mattrguerra/meridian-ops-boardmainPrivate on GitHub, published here in full

tracker.ts

Language TypeScript6.6 KB184 lines

packages/sheets-client/src/tracker.ts

  1. import { parseCsv } from './csv.js'
  2. import { errorMessage } from '@meridian/shared'
  3. /**
  4. * One row of the Content Creation tracker, normalized.
  5. *
  6. * `owners` is a list because the sheet has shared rows: "Nora & Sam" on a
  7. * photoshoot. A shared commitment counted once per person is three jobs where
  8. * there is one (see assignmentGroups in the Ninety client), so the shape
  9. * carries the truth and the caller decides how to count it.
  10. */
  11. export interface TrackerTask {
  12. /** Stable within a sync: tab gid plus row number. The sheet has no ids. */
  13. id: string
  14. /** Which tab it came from, for the row's meta line. */
  15. tab: string
  16. title: string
  17. /** As spelled in the sheet: first names, e.g. "Rafael". */
  18. owners: string[]
  19. /** Raw Status cell, trimmed. '' when the cell is blank. */
  20. status: string
  21. /** Free text: links, SKUs, references. Kept for the row subtitle. */
  22. notes: string
  23. /** MM/DD/YYYY as typed by a human, or null. Not parsed; see below. */
  24. deadline: string | null
  25. sku: string | null
  26. /** True when more than one person is named on the row. */
  27. shared: boolean
  28. }
  29. export interface TrackerFetch {
  30. tasks: TrackerTask[]
  31. /** Tabs that answered, and what each contributed. */
  32. tabs: { label: string; gid: string; rows: number }[]
  33. /**
  34. * Tabs that failed, with the reason. A partial read is reported, not thrown:
  35. * three tabs of four is most of the picture, and losing all of it because one
  36. * tab was renamed would be the worse outcome. The caller must say so; this is
  37. * the same contract as `truncated` on the Plinth sweep.
  38. */
  39. failedTabs: { label: string; gid: string; reason: string }[]
  40. }
  41. /** Column headers we need, matched case-insensitively and trimmed. */
  42. const WANT = {
  43. owner: ['owner'],
  44. task: ['task'],
  45. status: ['status'],
  46. notes: ['notes/links/resources', 'notes/links'],
  47. deadline: ['deadline'],
  48. sku: ['sku'],
  49. }
  50. function headerIndex(header: string[]): Record<keyof typeof WANT, number> {
  51. const norm = header.map((h) => h.trim().toLowerCase())
  52. const find = (names: string[]): number => norm.findIndex((h) => names.includes(h))
  53. return {
  54. owner: find(WANT.owner),
  55. task: find(WANT.task),
  56. status: find(WANT.status),
  57. notes: find(WANT.notes),
  58. deadline: find(WANT.deadline),
  59. sku: find(WANT.sku),
  60. }
  61. }
  62. /**
  63. * Split an Owner cell into people.
  64. *
  65. * "Nora & Sam" is one row owned by two. Splitting on the separators the sheet
  66. * actually uses, rather than assuming one name per cell, is what keeps that row
  67. * from resolving to nobody and landing in the unowned bucket.
  68. */
  69. export function splitOwners(cell: string): string[] {
  70. return cell
  71. .split(/\s*(?:&|\+|,|\band\b|\/)\s*/i)
  72. .map((s) => s.trim())
  73. .filter(Boolean)
  74. }
  75. /**
  76. * A row is work if it names a task. Everything else in the tab is scaffolding.
  77. *
  78. * The tracker's tabs are padded with dozens of blank rows carrying only a
  79. * trailing FALSE, which is what a spreadsheet looks like when someone dragged a
  80. * checkbox down a column. Counting those as untitled work would put 40 phantom
  81. * rows on the board, and counting them as anything at all would be reading
  82. * formatting as data.
  83. */
  84. const isWork = (title: string, owner: string): boolean => title.trim() !== '' || owner.trim() !== ''
  85. export function parseTab(csv: string, tab: string, gid: string): TrackerTask[] {
  86. const rows = parseCsv(csv)
  87. if (!rows.length) return []
  88. // The header is not always row 0 and not always complete: Google's gviz export
  89. // drops leading empty header cells, so a tab whose first columns are dated
  90. // arrives with '' where 'Release Date' is. Find the row that actually names
  91. // Owner and Task rather than trusting a position.
  92. let headerRow = -1
  93. let idx: Record<keyof typeof WANT, number> | null = null
  94. for (let i = 0; i < Math.min(rows.length, 5); i++) {
  95. const candidate = headerIndex(rows[i]!)
  96. if (candidate.owner >= 0 && candidate.task >= 0) {
  97. headerRow = i
  98. idx = candidate
  99. break
  100. }
  101. }
  102. // No Owner/Task columns means this is not a work tab. Return nothing rather
  103. // than inventing rows from whatever the first columns happened to be.
  104. if (!idx || headerRow < 0) return []
  105. const out: TrackerTask[] = []
  106. for (let i = headerRow + 1; i < rows.length; i++) {
  107. const r = rows[i]!
  108. const cell = (n: number): string => (n >= 0 && n < r.length ? (r[n] ?? '').trim() : '')
  109. const title = cell(idx.task)
  110. const ownerCell = cell(idx.owner)
  111. if (!isWork(title, ownerCell)) continue
  112. const owners = splitOwners(ownerCell)
  113. out.push({
  114. id: `${gid}:${i + 1}`,
  115. tab,
  116. title,
  117. owners,
  118. status: cell(idx.status),
  119. notes: cell(idx.notes),
  120. // Not parsed into a date. These are hand-typed and inconsistent in the
  121. // live sheet: "08/19/26" and "08/19/2026" and "8/21/2026" all appear.
  122. // Guessing a year from a two-digit one, or a locale from a slash, is how
  123. // you report something as overdue that is not. Shown as typed.
  124. deadline: cell(idx.deadline) || null,
  125. sku: cell(idx.sku) || null,
  126. shared: owners.length > 1,
  127. })
  128. }
  129. return out
  130. }
  131. /** Where a tab's CSV lives. Public document, no credential, no SDK. */
  132. export function tabCsvUrl(sheetId: string, gid: string): string {
  133. return `https://docs.google.com/spreadsheets/d/${sheetId}/gviz/tq?tqx=out:csv&headers=0&gid=${gid}`
  134. }
  135. /**
  136. * Read every listed tab.
  137. *
  138. * Uses global `fetch` and nothing else, so this module is runtime-neutral and the
  139. * Worker can import it. No googleapis, no node:fs, no credential (rule 7).
  140. */
  141. export async function fetchTracker(
  142. sheetId: string,
  143. tabs: { gid: string; label: string }[],
  144. fetchImpl: typeof fetch = fetch,
  145. ): Promise<TrackerFetch> {
  146. const tasks: TrackerTask[] = []
  147. const ok: TrackerFetch['tabs'] = []
  148. const failed: TrackerFetch['failedTabs'] = []
  149. for (const t of tabs) {
  150. try {
  151. const res = await fetchImpl(tabCsvUrl(sheetId, t.gid))
  152. if (!res.ok) {
  153. // 401/403 here means the document's link sharing was tightened. Say
  154. // that, rather than "request failed". It is the one cause a reader can
  155. // act on, and it is observable from the status code rather than guessed.
  156. const hint =
  157. res.status === 401 || res.status === 403
  158. ? ': the sheet is no longer readable by link, which is how this source is read at all'
  159. : ''
  160. failed.push({ ...t, reason: `HTTP ${res.status}${hint}` })
  161. continue
  162. }
  163. const rows = parseTab(await res.text(), t.label, t.gid)
  164. tasks.push(...rows)
  165. ok.push({ label: t.label, gid: t.gid, rows: rows.length })
  166. } catch (err) {
  167. failed.push({ ...t, reason: errorMessage(err) })
  168. }
  169. }
  170. return { tasks, tabs: ok, failedTabs: failed }
  171. }