// Pure helpers the dashboard and /browse share. NO `node:` import: the state // strip is rendered on the server but its labels are also what the e2e suite // compares against /browse's header, and one helper is how the two pages are // kept from disagreeing by a plural. import { PROJECT_STATES, type ProjectState } from "./project-types"; /** The header /browse prints and the dashboard's Projects panel repeats. */ export const projectsNote = (total: number, blocking: number, open: number) => `${total} projects · ${blocking} blocking · ${open} open`; export type StateRow = { kind: string; label: string; total: number; /** Every state, in PROJECT_STATES order, zero included -- the strip is proportional. */ segments: { state: ProjectState; n: number; href: string }[]; }; /** One row per kind: how many projects sit in each of the six states. */ export function stateRows( kinds: { id: string; label: string }[], projects: { kind: string; state: ProjectState }[], ): StateRow[] { return kinds.map((k) => { const mine = projects.filter((p) => p.kind === k.id); return { kind: k.id, label: k.label, total: mine.length, segments: PROJECT_STATES.map((state) => ({ state, n: mine.filter((p) => p.state === state).length, href: `/browse?kind=${encodeURIComponent(k.id)}&state=${state}`, })), }; }); } /** Thirty days: the age past which a recorded availability check is "old". */ export const SOURCES_STALE_DAYS = 30; /** * The Sources panel's three buckets, from each report project's summary * attributes (report.mjs writes `sources-checked-at`, `sources-dead`, * `cue-gaps`). Never checked / checked too long ago / something wrong. */ export function sourceBuckets }>( projects: T[], now = Date.now(), ): { never: T[]; old: T[]; bad: T[] } { const never: T[] = []; const old: T[] = []; const bad: T[] = []; for (const p of projects) { const a = p.attrs ?? {}; // Only kinds that report sources at all -- a song has no `sources` attr. if (!("sources" in a)) continue; if (Number(a.sources) === 0) continue; const at = Number(a["sources-checked-at"] ?? 0); if (!at) never.push(p); else if (now - at > SOURCES_STALE_DAYS * 86_400_000) old.push(p); if (Number(a["sources-dead"] ?? 0) > 0 || Number(a["sources-missing"] ?? 0) > 0 || Number(a["cue-gaps"] ?? 0) > 0) { bad.push(p); } } return { never, old, bad }; } export function fmtGB(bytes: number | null): string { if (bytes == null) return "–"; const gb = bytes / 1e9; return gb >= 100 ? `${gb.toFixed(0)} GB` : `${gb.toFixed(1)} GB`; }