tracker.ts
Language TypeScript6.6 KB184 lines
import { parseCsv } from './csv.js'import { errorMessage } from '@meridian/shared'/*** One row of the Content Creation tracker, normalized.** `owners` is a list because the sheet has shared rows: "Nora & Sam" on a* photoshoot. A shared commitment counted once per person is three jobs where* there is one (see assignmentGroups in the Ninety client), so the shape* carries the truth and the caller decides how to count it.*/export interface TrackerTask {/** Stable within a sync: tab gid plus row number. The sheet has no ids. */id: string/** Which tab it came from, for the row's meta line. */tab: stringtitle: string/** As spelled in the sheet: first names, e.g. "Rafael". */owners: string[]/** Raw Status cell, trimmed. '' when the cell is blank. */status: string/** Free text: links, SKUs, references. Kept for the row subtitle. */notes: string/** MM/DD/YYYY as typed by a human, or null. Not parsed; see below. */deadline: string | nullsku: string | null/** True when more than one person is named on the row. */shared: boolean}export interface TrackerFetch {tasks: TrackerTask[]/** Tabs that answered, and what each contributed. */tabs: { label: string; gid: string; rows: number }[]/*** Tabs that failed, with the reason. A partial read is reported, not thrown:* three tabs of four is most of the picture, and losing all of it because one* tab was renamed would be the worse outcome. The caller must say so; this is* the same contract as `truncated` on the Plinth sweep.*/failedTabs: { label: string; gid: string; reason: string }[]}/** Column headers we need, matched case-insensitively and trimmed. */const WANT = {owner: ['owner'],task: ['task'],status: ['status'],notes: ['notes/links/resources', 'notes/links'],deadline: ['deadline'],sku: ['sku'],}function headerIndex(header: string[]): Record<keyof typeof WANT, number> {const norm = header.map((h) => h.trim().toLowerCase())const find = (names: string[]): number => norm.findIndex((h) => names.includes(h))return {owner: find(WANT.owner),task: find(WANT.task),status: find(WANT.status),notes: find(WANT.notes),deadline: find(WANT.deadline),sku: find(WANT.sku),}}/*** Split an Owner cell into people.** "Nora & Sam" is one row owned by two. Splitting on the separators the sheet* actually uses, rather than assuming one name per cell, is what keeps that row* from resolving to nobody and landing in the unowned bucket.*/export function splitOwners(cell: string): string[] {return cell.split(/\s*(?:&|\+|,|\band\b|\/)\s*/i).map((s) => s.trim()).filter(Boolean)}/*** A row is work if it names a task. Everything else in the tab is scaffolding.** The tracker's tabs are padded with dozens of blank rows carrying only a* trailing FALSE, which is what a spreadsheet looks like when someone dragged a* checkbox down a column. Counting those as untitled work would put 40 phantom* rows on the board, and counting them as anything at all would be reading* formatting as data.*/const isWork = (title: string, owner: string): boolean => title.trim() !== '' || owner.trim() !== ''export function parseTab(csv: string, tab: string, gid: string): TrackerTask[] {const rows = parseCsv(csv)if (!rows.length) return []// The header is not always row 0 and not always complete: Google's gviz export// drops leading empty header cells, so a tab whose first columns are dated// arrives with '' where 'Release Date' is. Find the row that actually names// Owner and Task rather than trusting a position.let headerRow = -1let idx: Record<keyof typeof WANT, number> | null = nullfor (let i = 0; i < Math.min(rows.length, 5); i++) {const candidate = headerIndex(rows[i]!)if (candidate.owner >= 0 && candidate.task >= 0) {headerRow = iidx = candidatebreak}}// No Owner/Task columns means this is not a work tab. Return nothing rather// than inventing rows from whatever the first columns happened to be.if (!idx || headerRow < 0) return []const out: TrackerTask[] = []for (let i = headerRow + 1; i < rows.length; i++) {const r = rows[i]!const cell = (n: number): string => (n >= 0 && n < r.length ? (r[n] ?? '').trim() : '')const title = cell(idx.task)const ownerCell = cell(idx.owner)if (!isWork(title, ownerCell)) continueconst owners = splitOwners(ownerCell)out.push({id: `${gid}:${i + 1}`,tab,title,owners,status: cell(idx.status),notes: cell(idx.notes),// Not parsed into a date. These are hand-typed and inconsistent in the// live sheet: "08/19/26" and "08/19/2026" and "8/21/2026" all appear.// Guessing a year from a two-digit one, or a locale from a slash, is how// you report something as overdue that is not. Shown as typed.deadline: cell(idx.deadline) || null,sku: cell(idx.sku) || null,shared: owners.length > 1,})}return out}/** Where a tab's CSV lives. Public document, no credential, no SDK. */export function tabCsvUrl(sheetId: string, gid: string): string {return `https://docs.google.com/spreadsheets/d/${sheetId}/gviz/tq?tqx=out:csv&headers=0&gid=${gid}`}/*** Read every listed tab.** Uses global `fetch` and nothing else, so this module is runtime-neutral and the* Worker can import it. No googleapis, no node:fs, no credential (rule 7).*/export async function fetchTracker(sheetId: string,tabs: { gid: string; label: string }[],fetchImpl: typeof fetch = fetch,): Promise<TrackerFetch> {const tasks: TrackerTask[] = []const ok: TrackerFetch['tabs'] = []const failed: TrackerFetch['failedTabs'] = []for (const t of tabs) {try {const res = await fetchImpl(tabCsvUrl(sheetId, t.gid))if (!res.ok) {// 401/403 here means the document's link sharing was tightened. Say// that, rather than "request failed". It is the one cause a reader can// act on, and it is observable from the status code rather than guessed.const hint =res.status === 401 || res.status === 403? ': the sheet is no longer readable by link, which is how this source is read at all': ''failed.push({ ...t, reason: `HTTP ${res.status}${hint}` })continue}const rows = parseTab(await res.text(), t.label, t.gid)tasks.push(...rows)ok.push({ label: t.label, gid: t.gid, rows: rows.length })} catch (err) {failed.push({ ...t, reason: errorMessage(err) })}}return { tasks, tabs: ok, failedTabs: failed }}