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

types.ts

Language TypeScript18.3 KB506 lines

packages/ninety-client/src/types.ts

  1. /**
  2. * Schemas and normalized domain types.
  3. *
  4. * Field names below are verified against live responses from GET /teams and
  5. * POST /todos/query. Two things the live data shows that shape this file:
  6. *
  7. * 1. `createdBy` is a display name ("Ines Carvalho") sitting right next to
  8. * `createdByUserId`. That is a free id -> name pair on every single to-do,
  9. * and it cuts most of the GET /users/{id} traffic out of roster building.
  10. * 2. `teamName` is denormalized onto the to-do, so nothing needs to join
  11. * against /teams just to render a row.
  12. */
  13. import { z } from 'zod'
  14. import { MERIDIAN_TZ, dueLabel, localDay } from './dates.js'
  15. /** A person, after roster hydration. Never expose a bare id above the client. */
  16. export interface Person {
  17. id: string
  18. /** Falls back to "unknown user". Never a raw ObjectId. */
  19. name: string
  20. }
  21. export const UNKNOWN_PERSON = (id: string): Person => ({ id, name: 'unknown user' })
  22. export const UNASSIGNED: Person = { id: '', name: 'unassigned' }
  23. // --- raw (wire) schemas -----------------------------------------------------
  24. /**
  25. * Live shape confirmed. Still `.passthrough()` because Ninety may add fields,
  26. * and optional on everything Meridian's data does not exercise.
  27. */
  28. export const RawTodoSchema = z
  29. .object({
  30. _id: z.string(),
  31. userId: z.string().optional(),
  32. teamId: z.string().optional(),
  33. teamName: z.string().optional(),
  34. title: z.string().optional(),
  35. /** HTML, not plain text. See stripHtml(). */
  36. description: z.string().optional(),
  37. dueDate: z.string().nullable().optional(),
  38. originalDueDate: z.string().nullable().optional(),
  39. isPersonal: z.boolean().optional(),
  40. /** Display name of the creator. The free half of a roster entry. */
  41. createdBy: z.string().optional(),
  42. createdByUserId: z.string().optional(),
  43. createdDate: z.string().optional(),
  44. /** "Weekly", "Monthly", "Never", ... */
  45. repeat: z.string().nullable().optional(),
  46. /** Shared by every materialized instance of a recurring to-do. */
  47. seriesId: z.string().nullable().optional(),
  48. companyId: z.string().optional(),
  49. milestoneId: z.string().nullable().optional(),
  50. autoGenerated: z.boolean().optional(),
  51. dueDateChanged: z.boolean().optional(),
  52. comments: z.array(z.unknown()).optional(),
  53. archived: z.boolean().optional(),
  54. archivedDate: z.string().nullable().optional(),
  55. completed: z.boolean().optional(),
  56. completedDate: z.string().nullable().optional(),
  57. completedByUserId: z.string().nullable().optional(),
  58. deleted: z.boolean().optional(),
  59. followers: z.array(z.string()).optional(),
  60. ordinal: z.number().nullable().optional(),
  61. /**
  62. * Nullable, and that is not defensive typing: live records carry `null`
  63. * here as well as numbers, and nothing reads `_version`, so strictness on it
  64. * would only discard records (rule 4).
  65. *
  66. * On a record we do not control, prefer `.nullable().optional()` for
  67. * anything not actually consumed. Strictness
  68. * should defend the fields decisions are made from, not fields nobody reads.
  69. */
  70. _version: z.number().nullable().optional(),
  71. })
  72. .passthrough()
  73. export type RawTodo = z.infer<typeof RawTodoSchema>
  74. /**
  75. * Live shape confirmed. Note what is not here: any form of
  76. * membership. No users, no members, no userIds. The roster harvest in roster.ts
  77. * is required, not a fallback.
  78. */
  79. export const RawTeamSchema = z
  80. .object({
  81. _id: z.string(),
  82. name: z.string().optional(),
  83. companyId: z.string().optional(),
  84. /** Ninety models projects as teams. Usually not wanted on a team dashboard. */
  85. project: z.boolean().optional(),
  86. deleted: z.boolean().optional(),
  87. })
  88. .passthrough()
  89. export type RawTeam = z.infer<typeof RawTeamSchema>
  90. /**
  91. * GET /todos/company returns a different, thinner row than POST /todos/query:
  92. * exactly these nine fields on every row, verified against live data, with
  93. * nothing else.
  94. *
  95. * The two differences that will bite:
  96. *
  97. * 1. `id`, not `_id`. Every other endpoint uses `_id`. Normalize before use.
  98. * 2. No `completed`, `archived`, `deleted`, `isPersonal`, `seriesId`,
  99. * `milestoneId`, `createdByUserId`. So you cannot filter on completion
  100. * client-side from this payload, and you cannot collapse recurrence.
  101. *
  102. * (2) is fine in practice because the endpoint's own defaults already exclude
  103. * completed, archived, deleted, and personal items; the absent fields are
  104. * absent precisely because every row is open. But it means this is an index,
  105. * not a record. When you need the full shape, hydrate via GET /todos/{id},
  106. * which returns all 33 fields. See TodosResource.company()
  107. * vs companyDetailed().
  108. *
  109. * The compensation: `ownerFirstName` / `ownerLastName` ride along, so rendering
  110. * a name needs no roster join at all.
  111. */
  112. export const RawCompanyTodoSchema = z
  113. .object({
  114. id: z.string(),
  115. companyId: z.string().optional(),
  116. teamId: z.string().optional(),
  117. userId: z.string().optional(),
  118. title: z.string().optional(),
  119. /** HTML, same as the fat shape. See stripHtml(). */
  120. description: z.string().optional(),
  121. dueDate: z.string().nullable().optional(),
  122. ownerFirstName: z.string().optional(),
  123. ownerLastName: z.string().optional(),
  124. })
  125. .passthrough()
  126. export type RawCompanyTodo = z.infer<typeof RawCompanyTodoSchema>
  127. /**
  128. * Thin index row -> the fat wire shape, so one normalizer serves both endpoints.
  129. *
  130. * The `false` defaults are not guesses. GET /todos/company returns only open,
  131. * non-archived, non-deleted, non-personal to-dos, so those four values are known
  132. * even though the fields are absent. Everything genuinely unknowable (seriesId,
  133. * milestoneId, completedDate) stays null, and hydration is how you learn it.
  134. */
  135. export function companyTodoToRaw(row: RawCompanyTodo): RawTodo {
  136. const { id, ownerFirstName, ownerLastName, ...rest } = row
  137. return {
  138. ...rest,
  139. _id: id,
  140. completed: false,
  141. archived: false,
  142. deleted: false,
  143. isPersonal: false,
  144. } as RawTodo
  145. }
  146. /** "Hazel Okafor" from the inline owner fields, or null if neither is present. */
  147. export function companyOwnerName(row: RawCompanyTodo): string | null {
  148. const joined = [row.ownerFirstName, row.ownerLastName].filter(Boolean).join(' ').trim()
  149. return joined || null
  150. }
  151. /**
  152. * From GET /users, which returns all active users in the company with
  153. * `firstName` / `lastName`. Kept permissive because `GET /users/{id}` has not
  154. * been captured on its own and may differ.
  155. */
  156. export const RawUserSchema = z
  157. .object({
  158. _id: z.string().optional(),
  159. id: z.string().optional(),
  160. firstName: z.string().optional(),
  161. lastName: z.string().optional(),
  162. fullName: z.string().optional(),
  163. name: z.string().optional(),
  164. email: z.string().optional(),
  165. active: z.boolean().optional(),
  166. })
  167. .passthrough()
  168. export type RawUser = z.infer<typeof RawUserSchema>
  169. // --- normalized domain types ------------------------------------------------
  170. export interface Todo {
  171. id: string
  172. title: string
  173. /** HTML stripped. Safe for a model's context and for a text cell. */
  174. description: string
  175. /** Original markup, for a dashboard that wants to render it. */
  176. descriptionHtml: string
  177. /** Raw instant from Ninety. Correct for comparisons, wrong to slice for display. */
  178. dueDate: string | null
  179. /**
  180. * The calendar day the due date actually falls on, in Meridian's timezone.
  181. * "2026-08-07" for a wire value of "2026-08-08T04:59:59.999Z".
  182. * Display and grouping use this. See dates.ts for why.
  183. */
  184. dueDateLocal: string | null
  185. /** "Aug 7 (2 days overdue)". For humans and for models. */
  186. dueLabel: string
  187. completed: boolean
  188. completedDate: string | null
  189. archived: boolean
  190. deleted: boolean
  191. /** Hydrated from userId. Never a bare id. */
  192. owner: Person
  193. /** Hydrated from createdByUserId, usually free via the createdBy name. */
  194. createdBy: Person
  195. teamId: string | null
  196. /** Denormalized by Ninety. No join needed. */
  197. teamName: string | null
  198. isPersonal: boolean
  199. /** "Weekly" etc, or null for a one-off. */
  200. repeat: string | null
  201. /** Shared across every instance of a recurring to-do. See collapseSeries(). */
  202. seriesId: string | null
  203. /** Set when the to-do hangs off a rock milestone. */
  204. milestoneId: string | null
  205. followers: string[]
  206. createdAt: string | null
  207. raw: RawTodo
  208. }
  209. export interface Team {
  210. id: string
  211. name: string
  212. companyId: string | null
  213. /** Ninety models projects as teams. Filter these out of team views. */
  214. isProject: boolean
  215. deleted: boolean
  216. raw: RawTeam
  217. }
  218. // --- helpers ----------------------------------------------------------------
  219. /**
  220. * Ninety stores to-do descriptions as HTML, inline styles and all.
  221. *
  222. * Feeding that to a model burns context on markup, and dropping it into a page
  223. * unescaped is an injection. Strip to text; keep the original alongside for any
  224. * consumer that genuinely wants to render it (and can sanitize properly).
  225. */
  226. export function stripHtml(html: string): string {
  227. return html
  228. .replace(/<\s*(br|\/li|\/p|\/div|\/tr)\s*\/?>/gi, '\n')
  229. .replace(/<\s*li[^>]*>/gi, '- ')
  230. .replace(/<[^>]+>/g, '')
  231. .replace(/&nbsp;/gi, ' ')
  232. .replace(/&amp;/gi, '&')
  233. .replace(/&lt;/gi, '<')
  234. .replace(/&gt;/gi, '>')
  235. .replace(/&quot;/gi, '"')
  236. .replace(/&#39;/gi, "'")
  237. .replace(/[ \t]+/g, ' ')
  238. .replace(/\n{3,}/g, '\n\n')
  239. .trim()
  240. }
  241. export interface NormalizeOptions {
  242. /** Defaults to America/Chicago. */
  243. tz?: string
  244. /** Injectable for deterministic tests. */
  245. now?: Date
  246. }
  247. export function normalizeTodo(
  248. raw: RawTodo,
  249. resolve: (id: string | null | undefined) => Person,
  250. opts: NormalizeOptions = {},
  251. ): Todo {
  252. const html = raw.description ?? ''
  253. const tz = opts.tz ?? MERIDIAN_TZ
  254. const now = opts.now ?? new Date()
  255. const due = raw.dueDate ?? null
  256. return {
  257. id: raw._id,
  258. title: raw.title?.trim() || '(untitled)',
  259. description: stripHtml(html),
  260. descriptionHtml: html,
  261. dueDate: due,
  262. dueDateLocal: due ? localDay(due, tz) : null,
  263. dueLabel: dueLabel(due, now, tz),
  264. completed: raw.completed ?? false,
  265. completedDate: raw.completedDate ?? null,
  266. archived: raw.archived ?? false,
  267. deleted: raw.deleted ?? false,
  268. owner: resolve(raw.userId),
  269. createdBy: resolve(raw.createdByUserId),
  270. teamId: raw.teamId ?? null,
  271. teamName: raw.teamName ?? null,
  272. isPersonal: raw.isPersonal ?? false,
  273. repeat: raw.repeat && raw.repeat !== 'Never' ? raw.repeat : null,
  274. seriesId: raw.seriesId ?? null,
  275. milestoneId: raw.milestoneId ?? null,
  276. followers: raw.followers ?? [],
  277. createdAt: raw.createdDate ?? null,
  278. raw,
  279. }
  280. }
  281. export function normalizeTeam(raw: RawTeam): Team {
  282. return {
  283. id: raw._id,
  284. name: raw.name?.trim() || '(unnamed team)',
  285. companyId: raw.companyId ?? null,
  286. isProject: raw.project ?? false,
  287. deleted: raw.deleted ?? false,
  288. raw,
  289. }
  290. }
  291. export function normalizeUserName(raw: RawUser): string | null {
  292. const pick = (...v: (string | undefined)[]) => v.find((s) => typeof s === 'string' && s.length > 0) ?? null
  293. const full = pick(raw.fullName, raw.name)
  294. if (full) return full
  295. const joined = [raw.firstName, raw.lastName].filter(Boolean).join(' ').trim()
  296. return joined || pick(raw.email)
  297. }
  298. /**
  299. * Collapse recurring to-dos to one row per series.
  300. *
  301. * A weekly recurring to-do is materialized by Ninety as a separate to-do per
  302. * occurrence, all sharing a seriesId, so the same to-do can be open twice a
  303. * week apart.
  304. *
  305. * So a naive "count of open to-dos" counts one piece of work N times, and every
  306. * uncompleted past occurrence stays on the overdue list forever. On a dashboard
  307. * whose whole value is that people trust the number before the morning meeting, that is not
  308. * a cosmetic problem.
  309. *
  310. * Default keeps the earliest-due open occurrence, because that is the one that
  311. * is actually late. Undated items sort last. Non-recurring to-dos pass through.
  312. *
  313. * Keyed by series and owner. Recurrence duplicates a row for one person; it
  314. * never spreads one across several. Shared assignment does the opposite: the
  315. * same title on two or three different owners, and collapsing those would
  316. * erase who owes what, which is the
  317. * one thing the workload panel exists to show. Keying on seriesId alone would do
  318. * exactly that if Ninety ever files a shared item under a common series. Owner is
  319. * therefore part of the key, permanently, and not as a defensive maybe.
  320. *
  321. * For the shared-assignment case you want assignmentGroups(), not this.
  322. */
  323. export function collapseSeries(todos: Todo[], keep: 'earliest' | 'latest' = 'earliest'): Todo[] {
  324. const bySeries = new Map<string, Todo>()
  325. const out: Todo[] = []
  326. for (const t of todos) {
  327. if (!t.seriesId) {
  328. out.push(t)
  329. continue
  330. }
  331. // U+0000 as the separator, because it cannot occur in a Ninety id and so
  332. // cannot collide the way any printable delimiter could.
  333. //
  334. // Written as an escape, not a raw byte (rule 8). A literal 0x00 compiles
  335. // fine but makes `file` classify the source as binary, and grep then prints
  336. // nothing for it, with no error. The escape is the same string at runtime
  337. // (length 3, codePointAt(1) === 0). Do not "simplify" it back.
  338. const key = `${t.seriesId}\u0000${t.owner.id}`
  339. const held = bySeries.get(key)
  340. if (!held) {
  341. bySeries.set(key, t)
  342. continue
  343. }
  344. bySeries.set(key, preferred(held, t, keep))
  345. }
  346. return [...out, ...bySeries.values()]
  347. }
  348. /**
  349. * One commitment carried by several people, as opposed to one repeating for one
  350. * person. These are opposites and the dashboard must not confuse them.
  351. *
  352. * On live data, titles that appear more than once belong to distinct owners,
  353. * not to one owner repeating, so a headline count of rows ("31 assignments")
  354. * overstates the work ("24 commitments").
  355. *
  356. * Heuristic, and honestly so. Ninety exposes no link field joining the copies:
  357. * the index payload has none, and whether the hydrated records share a seriesId
  358. * or milestoneId is what sharedLinkAudit() is for. Until that says otherwise the
  359. * join is title + team, which will occasionally merge two genuinely different
  360. * items that happen to share a name. That is why `basis` is on every group:
  361. * surface it, do not bury it.
  362. *
  363. * Per-person views must keep every row. Only company-level counts dedupe.
  364. */
  365. export interface AssignmentGroup {
  366. /** Normalized join key. Not stable across title edits; do not persist it. */
  367. key: string
  368. title: string
  369. teamId: string | null
  370. todos: Todo[]
  371. owners: Person[]
  372. /** How the grouping was decided. 'title' is heuristic; say so in the UI. */
  373. basis: 'title'
  374. /** True when more than one person carries this commitment. */
  375. shared: boolean
  376. }
  377. export function assignmentGroups(todos: Todo[]): AssignmentGroup[] {
  378. const groups = new Map<string, AssignmentGroup>()
  379. for (const t of todos) {
  380. // U+0000 separator, as an escape rather than a raw byte -- see the note in
  381. // collapseSeries() above for why that distinction is load-bearing.
  382. const key = `${normalizeTitle(t.title)}\u0000${t.teamId ?? ''}`
  383. let g = groups.get(key)
  384. if (!g) {
  385. g = { key, title: t.title, teamId: t.teamId, todos: [], owners: [], basis: 'title', shared: false }
  386. groups.set(key, g)
  387. }
  388. g.todos.push(t)
  389. if (!g.owners.some((o) => o.id === t.owner.id)) g.owners.push(t.owner)
  390. g.shared = g.owners.length > 1
  391. }
  392. return [...groups.values()]
  393. }
  394. /**
  395. * The two numbers that belong next to each other on the page.
  396. *
  397. * `assignments` is how many rows exist; `commitments` is how many distinct
  398. * pieces of work they represent. Showing one without the other is how a count
  399. * becomes an argument in the morning meeting.
  400. */
  401. export function assignmentSpread(todos: Todo[]): {
  402. assignments: number
  403. commitments: number
  404. sharedCommitments: number
  405. maxOwners: number
  406. } {
  407. const groups = assignmentGroups(todos)
  408. return {
  409. assignments: todos.length,
  410. commitments: groups.length,
  411. sharedCommitments: groups.filter((g) => g.shared).length,
  412. maxOwners: groups.reduce((m, g) => Math.max(m, g.owners.length), 0),
  413. }
  414. }
  415. /**
  416. * Answers the open question: do shared copies share a link field, or is title
  417. * matching all we have?
  418. *
  419. * Run this against hydrated to-dos (companyDetailed), because the index payload
  420. * carries neither seriesId nor milestoneId. If `sharesSeriesId` or
  421. * `sharesMilestoneId` comes back true for every shared group, replace the title
  422. * heuristic in assignmentGroups() with that field and delete this function's
  423. * reason for existing. If both are false, the heuristic is the ceiling and the
  424. * UI should say "grouped by title" wherever it dedupes.
  425. */
  426. export function sharedLinkAudit(todos: Todo[]): {
  427. sharedGroups: number
  428. sharesSeriesId: number
  429. sharesMilestoneId: number
  430. linkable: boolean
  431. detail: { title: string; owners: number; seriesId: string | null; milestoneId: string | null }[]
  432. } {
  433. const shared = assignmentGroups(todos).filter((g) => g.shared)
  434. const detail = shared.map((g) => {
  435. const series = new Set(g.todos.map((t) => t.seriesId))
  436. const milestone = new Set(g.todos.map((t) => t.milestoneId))
  437. return {
  438. title: g.title,
  439. owners: g.owners.length,
  440. seriesId: series.size === 1 ? ([...series][0] ?? null) : null,
  441. milestoneId: milestone.size === 1 ? ([...milestone][0] ?? null) : null,
  442. }
  443. })
  444. const sharesSeriesId = detail.filter((d) => d.seriesId !== null).length
  445. const sharesMilestoneId = detail.filter((d) => d.milestoneId !== null).length
  446. return {
  447. sharedGroups: shared.length,
  448. sharesSeriesId,
  449. sharesMilestoneId,
  450. linkable: shared.length > 0 && (sharesSeriesId === shared.length || sharesMilestoneId === shared.length),
  451. detail,
  452. }
  453. }
  454. /** Whitespace and case only. Deliberately not fuzzy; near-matches must not merge. */
  455. function normalizeTitle(title: string): string {
  456. return title.replace(/\s+/g, ' ').trim().toLowerCase()
  457. }
  458. function preferred(a: Todo, b: Todo, keep: 'earliest' | 'latest'): Todo {
  459. const ta = a.dueDate ? Date.parse(a.dueDate) : Number.POSITIVE_INFINITY
  460. const tb = b.dueDate ? Date.parse(b.dueDate) : Number.POSITIVE_INFINITY
  461. if (ta === tb) return a
  462. if (keep === 'earliest') return ta < tb ? a : b
  463. const fa = Number.isFinite(ta) ? ta : Number.NEGATIVE_INFINITY
  464. const fb = Number.isFinite(tb) ? tb : Number.NEGATIVE_INFINITY
  465. return fa > fb ? a : b
  466. }
  467. /** How many extra rows recurrence is contributing. Worth logging on every sync. */
  468. export function seriesInflation(todos: Todo[]): { total: number; collapsed: number; duplicates: number } {
  469. const collapsed = collapseSeries(todos).length
  470. return { total: todos.length, collapsed, duplicates: todos.length - collapsed }
  471. }