types.ts
Language TypeScript18.3 KB506 lines
/*** Schemas and normalized domain types.** Field names below are verified against live responses from GET /teams and* POST /todos/query. Two things the live data shows that shape this file:** 1. `createdBy` is a display name ("Ines Carvalho") sitting right next to* `createdByUserId`. That is a free id -> name pair on every single to-do,* and it cuts most of the GET /users/{id} traffic out of roster building.* 2. `teamName` is denormalized onto the to-do, so nothing needs to join* against /teams just to render a row.*/import { z } from 'zod'import { MERIDIAN_TZ, dueLabel, localDay } from './dates.js'/** A person, after roster hydration. Never expose a bare id above the client. */export interface Person {id: string/** Falls back to "unknown user". Never a raw ObjectId. */name: string}export const UNKNOWN_PERSON = (id: string): Person => ({ id, name: 'unknown user' })export const UNASSIGNED: Person = { id: '', name: 'unassigned' }// --- raw (wire) schemas -----------------------------------------------------/*** Live shape confirmed. Still `.passthrough()` because Ninety may add fields,* and optional on everything Meridian's data does not exercise.*/export const RawTodoSchema = z.object({_id: z.string(),userId: z.string().optional(),teamId: z.string().optional(),teamName: z.string().optional(),title: z.string().optional(),/** HTML, not plain text. See stripHtml(). */description: z.string().optional(),dueDate: z.string().nullable().optional(),originalDueDate: z.string().nullable().optional(),isPersonal: z.boolean().optional(),/** Display name of the creator. The free half of a roster entry. */createdBy: z.string().optional(),createdByUserId: z.string().optional(),createdDate: z.string().optional(),/** "Weekly", "Monthly", "Never", ... */repeat: z.string().nullable().optional(),/** Shared by every materialized instance of a recurring to-do. */seriesId: z.string().nullable().optional(),companyId: z.string().optional(),milestoneId: z.string().nullable().optional(),autoGenerated: z.boolean().optional(),dueDateChanged: z.boolean().optional(),comments: z.array(z.unknown()).optional(),archived: z.boolean().optional(),archivedDate: z.string().nullable().optional(),completed: z.boolean().optional(),completedDate: z.string().nullable().optional(),completedByUserId: z.string().nullable().optional(),deleted: z.boolean().optional(),followers: z.array(z.string()).optional(),ordinal: z.number().nullable().optional(),/*** Nullable, and that is not defensive typing: live records carry `null`* here as well as numbers, and nothing reads `_version`, so strictness on it* would only discard records (rule 4).** On a record we do not control, prefer `.nullable().optional()` for* anything not actually consumed. Strictness* should defend the fields decisions are made from, not fields nobody reads.*/_version: z.number().nullable().optional(),}).passthrough()export type RawTodo = z.infer<typeof RawTodoSchema>/*** Live shape confirmed. Note what is not here: any form of* membership. No users, no members, no userIds. The roster harvest in roster.ts* is required, not a fallback.*/export const RawTeamSchema = z.object({_id: z.string(),name: z.string().optional(),companyId: z.string().optional(),/** Ninety models projects as teams. Usually not wanted on a team dashboard. */project: z.boolean().optional(),deleted: z.boolean().optional(),}).passthrough()export type RawTeam = z.infer<typeof RawTeamSchema>/*** GET /todos/company returns a different, thinner row than POST /todos/query:* exactly these nine fields on every row, verified against live data, with* nothing else.** The two differences that will bite:** 1. `id`, not `_id`. Every other endpoint uses `_id`. Normalize before use.* 2. No `completed`, `archived`, `deleted`, `isPersonal`, `seriesId`,* `milestoneId`, `createdByUserId`. So you cannot filter on completion* client-side from this payload, and you cannot collapse recurrence.** (2) is fine in practice because the endpoint's own defaults already exclude* completed, archived, deleted, and personal items; the absent fields are* absent precisely because every row is open. But it means this is an index,* not a record. When you need the full shape, hydrate via GET /todos/{id},* which returns all 33 fields. See TodosResource.company()* vs companyDetailed().** The compensation: `ownerFirstName` / `ownerLastName` ride along, so rendering* a name needs no roster join at all.*/export const RawCompanyTodoSchema = z.object({id: z.string(),companyId: z.string().optional(),teamId: z.string().optional(),userId: z.string().optional(),title: z.string().optional(),/** HTML, same as the fat shape. See stripHtml(). */description: z.string().optional(),dueDate: z.string().nullable().optional(),ownerFirstName: z.string().optional(),ownerLastName: z.string().optional(),}).passthrough()export type RawCompanyTodo = z.infer<typeof RawCompanyTodoSchema>/*** Thin index row -> the fat wire shape, so one normalizer serves both endpoints.** The `false` defaults are not guesses. GET /todos/company returns only open,* non-archived, non-deleted, non-personal to-dos, so those four values are known* even though the fields are absent. Everything genuinely unknowable (seriesId,* milestoneId, completedDate) stays null, and hydration is how you learn it.*/export function companyTodoToRaw(row: RawCompanyTodo): RawTodo {const { id, ownerFirstName, ownerLastName, ...rest } = rowreturn {...rest,_id: id,completed: false,archived: false,deleted: false,isPersonal: false,} as RawTodo}/** "Hazel Okafor" from the inline owner fields, or null if neither is present. */export function companyOwnerName(row: RawCompanyTodo): string | null {const joined = [row.ownerFirstName, row.ownerLastName].filter(Boolean).join(' ').trim()return joined || null}/*** From GET /users, which returns all active users in the company with* `firstName` / `lastName`. Kept permissive because `GET /users/{id}` has not* been captured on its own and may differ.*/export const RawUserSchema = z.object({_id: z.string().optional(),id: z.string().optional(),firstName: z.string().optional(),lastName: z.string().optional(),fullName: z.string().optional(),name: z.string().optional(),email: z.string().optional(),active: z.boolean().optional(),}).passthrough()export type RawUser = z.infer<typeof RawUserSchema>// --- normalized domain types ------------------------------------------------export interface Todo {id: stringtitle: string/** HTML stripped. Safe for a model's context and for a text cell. */description: string/** Original markup, for a dashboard that wants to render it. */descriptionHtml: string/** Raw instant from Ninety. Correct for comparisons, wrong to slice for display. */dueDate: string | null/*** The calendar day the due date actually falls on, in Meridian's timezone.* "2026-08-07" for a wire value of "2026-08-08T04:59:59.999Z".* Display and grouping use this. See dates.ts for why.*/dueDateLocal: string | null/** "Aug 7 (2 days overdue)". For humans and for models. */dueLabel: stringcompleted: booleancompletedDate: string | nullarchived: booleandeleted: boolean/** Hydrated from userId. Never a bare id. */owner: Person/** Hydrated from createdByUserId, usually free via the createdBy name. */createdBy: PersonteamId: string | null/** Denormalized by Ninety. No join needed. */teamName: string | nullisPersonal: boolean/** "Weekly" etc, or null for a one-off. */repeat: string | null/** Shared across every instance of a recurring to-do. See collapseSeries(). */seriesId: string | null/** Set when the to-do hangs off a rock milestone. */milestoneId: string | nullfollowers: string[]createdAt: string | nullraw: RawTodo}export interface Team {id: stringname: stringcompanyId: string | null/** Ninety models projects as teams. Filter these out of team views. */isProject: booleandeleted: booleanraw: RawTeam}// --- helpers ----------------------------------------------------------------/*** Ninety stores to-do descriptions as HTML, inline styles and all.** Feeding that to a model burns context on markup, and dropping it into a page* unescaped is an injection. Strip to text; keep the original alongside for any* consumer that genuinely wants to render it (and can sanitize properly).*/export function stripHtml(html: string): string {return html.replace(/<\s*(br|\/li|\/p|\/div|\/tr)\s*\/?>/gi, '\n').replace(/<\s*li[^>]*>/gi, '- ').replace(/<[^>]+>/g, '').replace(/ /gi, ' ').replace(/&/gi, '&').replace(/</gi, '<').replace(/>/gi, '>').replace(/"/gi, '"').replace(/'/gi, "'").replace(/[ \t]+/g, ' ').replace(/\n{3,}/g, '\n\n').trim()}export interface NormalizeOptions {/** Defaults to America/Chicago. */tz?: string/** Injectable for deterministic tests. */now?: Date}export function normalizeTodo(raw: RawTodo,resolve: (id: string | null | undefined) => Person,opts: NormalizeOptions = {},): Todo {const html = raw.description ?? ''const tz = opts.tz ?? MERIDIAN_TZconst now = opts.now ?? new Date()const due = raw.dueDate ?? nullreturn {id: raw._id,title: raw.title?.trim() || '(untitled)',description: stripHtml(html),descriptionHtml: html,dueDate: due,dueDateLocal: due ? localDay(due, tz) : null,dueLabel: dueLabel(due, now, tz),completed: raw.completed ?? false,completedDate: raw.completedDate ?? null,archived: raw.archived ?? false,deleted: raw.deleted ?? false,owner: resolve(raw.userId),createdBy: resolve(raw.createdByUserId),teamId: raw.teamId ?? null,teamName: raw.teamName ?? null,isPersonal: raw.isPersonal ?? false,repeat: raw.repeat && raw.repeat !== 'Never' ? raw.repeat : null,seriesId: raw.seriesId ?? null,milestoneId: raw.milestoneId ?? null,followers: raw.followers ?? [],createdAt: raw.createdDate ?? null,raw,}}export function normalizeTeam(raw: RawTeam): Team {return {id: raw._id,name: raw.name?.trim() || '(unnamed team)',companyId: raw.companyId ?? null,isProject: raw.project ?? false,deleted: raw.deleted ?? false,raw,}}export function normalizeUserName(raw: RawUser): string | null {const pick = (...v: (string | undefined)[]) => v.find((s) => typeof s === 'string' && s.length > 0) ?? nullconst full = pick(raw.fullName, raw.name)if (full) return fullconst joined = [raw.firstName, raw.lastName].filter(Boolean).join(' ').trim()return joined || pick(raw.email)}/*** Collapse recurring to-dos to one row per series.** A weekly recurring to-do is materialized by Ninety as a separate to-do per* occurrence, all sharing a seriesId, so the same to-do can be open twice a* week apart.** So a naive "count of open to-dos" counts one piece of work N times, and every* uncompleted past occurrence stays on the overdue list forever. On a dashboard* whose whole value is that people trust the number before the morning meeting, that is not* a cosmetic problem.** Default keeps the earliest-due open occurrence, because that is the one that* is actually late. Undated items sort last. Non-recurring to-dos pass through.** Keyed by series and owner. Recurrence duplicates a row for one person; it* never spreads one across several. Shared assignment does the opposite: the* same title on two or three different owners, and collapsing those would* erase who owes what, which is the* one thing the workload panel exists to show. Keying on seriesId alone would do* exactly that if Ninety ever files a shared item under a common series. Owner is* therefore part of the key, permanently, and not as a defensive maybe.** For the shared-assignment case you want assignmentGroups(), not this.*/export function collapseSeries(todos: Todo[], keep: 'earliest' | 'latest' = 'earliest'): Todo[] {const bySeries = new Map<string, Todo>()const out: Todo[] = []for (const t of todos) {if (!t.seriesId) {out.push(t)continue}// U+0000 as the separator, because it cannot occur in a Ninety id and so// cannot collide the way any printable delimiter could.//// Written as an escape, not a raw byte (rule 8). A literal 0x00 compiles// fine but makes `file` classify the source as binary, and grep then prints// nothing for it, with no error. The escape is the same string at runtime// (length 3, codePointAt(1) === 0). Do not "simplify" it back.const key = `${t.seriesId}\u0000${t.owner.id}`const held = bySeries.get(key)if (!held) {bySeries.set(key, t)continue}bySeries.set(key, preferred(held, t, keep))}return [...out, ...bySeries.values()]}/*** One commitment carried by several people, as opposed to one repeating for one* person. These are opposites and the dashboard must not confuse them.** On live data, titles that appear more than once belong to distinct owners,* not to one owner repeating, so a headline count of rows ("31 assignments")* overstates the work ("24 commitments").** Heuristic, and honestly so. Ninety exposes no link field joining the copies:* the index payload has none, and whether the hydrated records share a seriesId* or milestoneId is what sharedLinkAudit() is for. Until that says otherwise the* join is title + team, which will occasionally merge two genuinely different* items that happen to share a name. That is why `basis` is on every group:* surface it, do not bury it.** Per-person views must keep every row. Only company-level counts dedupe.*/export interface AssignmentGroup {/** Normalized join key. Not stable across title edits; do not persist it. */key: stringtitle: stringteamId: string | nulltodos: Todo[]owners: Person[]/** How the grouping was decided. 'title' is heuristic; say so in the UI. */basis: 'title'/** True when more than one person carries this commitment. */shared: boolean}export function assignmentGroups(todos: Todo[]): AssignmentGroup[] {const groups = new Map<string, AssignmentGroup>()for (const t of todos) {// U+0000 separator, as an escape rather than a raw byte -- see the note in// collapseSeries() above for why that distinction is load-bearing.const key = `${normalizeTitle(t.title)}\u0000${t.teamId ?? ''}`let g = groups.get(key)if (!g) {g = { key, title: t.title, teamId: t.teamId, todos: [], owners: [], basis: 'title', shared: false }groups.set(key, g)}g.todos.push(t)if (!g.owners.some((o) => o.id === t.owner.id)) g.owners.push(t.owner)g.shared = g.owners.length > 1}return [...groups.values()]}/*** The two numbers that belong next to each other on the page.** `assignments` is how many rows exist; `commitments` is how many distinct* pieces of work they represent. Showing one without the other is how a count* becomes an argument in the morning meeting.*/export function assignmentSpread(todos: Todo[]): {assignments: numbercommitments: numbersharedCommitments: numbermaxOwners: number} {const groups = assignmentGroups(todos)return {assignments: todos.length,commitments: groups.length,sharedCommitments: groups.filter((g) => g.shared).length,maxOwners: groups.reduce((m, g) => Math.max(m, g.owners.length), 0),}}/*** Answers the open question: do shared copies share a link field, or is title* matching all we have?** Run this against hydrated to-dos (companyDetailed), because the index payload* carries neither seriesId nor milestoneId. If `sharesSeriesId` or* `sharesMilestoneId` comes back true for every shared group, replace the title* heuristic in assignmentGroups() with that field and delete this function's* reason for existing. If both are false, the heuristic is the ceiling and the* UI should say "grouped by title" wherever it dedupes.*/export function sharedLinkAudit(todos: Todo[]): {sharedGroups: numbersharesSeriesId: numbersharesMilestoneId: numberlinkable: booleandetail: { title: string; owners: number; seriesId: string | null; milestoneId: string | null }[]} {const shared = assignmentGroups(todos).filter((g) => g.shared)const detail = shared.map((g) => {const series = new Set(g.todos.map((t) => t.seriesId))const milestone = new Set(g.todos.map((t) => t.milestoneId))return {title: g.title,owners: g.owners.length,seriesId: series.size === 1 ? ([...series][0] ?? null) : null,milestoneId: milestone.size === 1 ? ([...milestone][0] ?? null) : null,}})const sharesSeriesId = detail.filter((d) => d.seriesId !== null).lengthconst sharesMilestoneId = detail.filter((d) => d.milestoneId !== null).lengthreturn {sharedGroups: shared.length,sharesSeriesId,sharesMilestoneId,linkable: shared.length > 0 && (sharesSeriesId === shared.length || sharesMilestoneId === shared.length),detail,}}/** Whitespace and case only. Deliberately not fuzzy; near-matches must not merge. */function normalizeTitle(title: string): string {return title.replace(/\s+/g, ' ').trim().toLowerCase()}function preferred(a: Todo, b: Todo, keep: 'earliest' | 'latest'): Todo {const ta = a.dueDate ? Date.parse(a.dueDate) : Number.POSITIVE_INFINITYconst tb = b.dueDate ? Date.parse(b.dueDate) : Number.POSITIVE_INFINITYif (ta === tb) return aif (keep === 'earliest') return ta < tb ? a : bconst fa = Number.isFinite(ta) ? ta : Number.NEGATIVE_INFINITYconst fb = Number.isFinite(tb) ? tb : Number.NEGATIVE_INFINITYreturn fa > fb ? a : b}/** How many extra rows recurrence is contributing. Worth logging on every sync. */export function seriesInflation(todos: Todo[]): { total: number; collapsed: number; duplicates: number } {const collapsed = collapseSeries(todos).lengthreturn { total: todos.length, collapsed, duplicates: todos.length - collapsed }}