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

client.ts

Language TypeScript6.8 KB175 lines

packages/ninety-client/src/client.ts

  1. /**
  2. * NinetyClient: the thing everything else imports.
  3. *
  4. * Read-only by design: write methods are deliberately absent. The board has no
  5. * write-back to Ninety (rule 12).
  6. */
  7. import { HttpClient, type HttpOptions } from './http.js'
  8. import { Roster, type RosterEntry, type RosterOptions } from './roster.js'
  9. import { TodosResource, type HydrationFailure } from './resources/todos.js'
  10. import { TeamsResource } from './resources/teams.js'
  11. import { assignmentSpread, seriesInflation, sharedLinkAudit, type Todo } from './types.js'
  12. import { MERIDIAN_TZ } from './dates.js'
  13. export interface NinetyClientOptions extends HttpOptions {
  14. /** Pinned roster entries, normally loaded from roster.json. */
  15. roster?: RosterEntry[]
  16. rosterOptions?: Omit<RosterOptions, 'seed'>
  17. /**
  18. * Timezone for date display and grouping. Defaults to America/Chicago.
  19. *
  20. * This is not cosmetic. Ninety serializes due dates as end-of-day local time
  21. * in UTC, so getting this wrong shifts every date by a day. See dates.ts.
  22. */
  23. tz?: string
  24. }
  25. export interface SyncSnapshot {
  26. todos: Todo[]
  27. /** When the sync ran. Put this on the page; polling means never live. */
  28. syncedAt: string
  29. roster: ReturnType<Roster['stats']>
  30. /** How many rows recurrence is contributing. Log it; it explains count drift. */
  31. recurrence: ReturnType<typeof seriesInflation>
  32. /** Ids that stayed "unknown user". Non-empty is a problem worth seeing. */
  33. unresolvedUsers: string[]
  34. }
  35. export interface CompanySnapshot {
  36. todos: Todo[]
  37. /** When the sync ran. Put this on the page; polling means never live. */
  38. syncedAt: string
  39. /** False means the thin index shape: no seriesId, milestoneId or date churn. */
  40. detailed: boolean
  41. /**
  42. * Assignments vs commitments. Show both. On live data a real share of rows are
  43. * one commitment shared between two or three people, so a headline counting
  44. * rows overstates the work and hides the fact that some of it is jointly
  45. * owned.
  46. */
  47. spread: ReturnType<typeof assignmentSpread>
  48. /** How many rows recurrence contributed. Log it; it explains count drift. */
  49. recurrence: ReturnType<typeof seriesInflation>
  50. /** null when detailed:false; the audit needs fields the index does not carry. */
  51. sharedLinks: ReturnType<typeof sharedLinkAudit> | null
  52. /** Rows that stayed on their thin version, with the reason. See HydrationFailure. */
  53. hydrationFailures: HydrationFailure[]
  54. roster: ReturnType<Roster['stats']>
  55. unresolvedUsers: string[]
  56. }
  57. export class NinetyClient {
  58. readonly http: HttpClient
  59. readonly roster: Roster
  60. readonly todos: TodosResource
  61. readonly teams: TeamsResource
  62. readonly tz: string
  63. constructor(opts: NinetyClientOptions) {
  64. this.http = new HttpClient(opts)
  65. this.roster = new Roster(this.http, { ...opts.rosterOptions, seed: opts.roster })
  66. this.tz = opts.tz ?? MERIDIAN_TZ
  67. this.todos = new TodosResource(this.http, this.roster, { tz: this.tz })
  68. this.teams = new TeamsResource(this.http)
  69. }
  70. /**
  71. * One open-to-do sweep with everything the dashboard needs to render and to
  72. * explain itself.
  73. *
  74. * Deliberately does not collapse recurring series; that is a presentation
  75. * decision. It does report how many duplicate rows recurrence produced, so a
  76. * count that looks wrong can be explained instead of argued about.
  77. */
  78. async snapshot(opts: { teamId?: string; now?: () => Date } = {}): Promise<SyncSnapshot> {
  79. const now = opts.now ?? (() => new Date())
  80. const query = opts.teamId ? { teamId: opts.teamId } : {}
  81. const todos = await this.todos.list(query)
  82. return {
  83. todos,
  84. syncedAt: now().toISOString(),
  85. roster: this.roster.stats(),
  86. recurrence: seriesInflation(todos),
  87. unresolvedUsers: this.roster.unresolved,
  88. }
  89. }
  90. /**
  91. * The company-wide sweep. This is what the dashboard and the morning briefing
  92. * actually run on.
  93. *
  94. * One token, every person, via GET /todos/company. Requires Owner or Admin in
  95. * Ninety. A 403 here means the role, not the code, and NinetyForbiddenError
  96. * says so.
  97. *
  98. * `detailed` defaults to true because the thin index row carries no seriesId,
  99. * milestoneId or dueDateChanged, and without those you cannot collapse
  100. * recurrence, link a to-do to its rock, or spot a date that has moved four
  101. * times. Pass false only when you want a fast headcount and nothing else.
  102. *
  103. * Like snapshot(), this deliberately does not collapse or dedupe. Both are
  104. * presentation decisions and both have a correct answer that depends on the
  105. * panel: per-person views keep every row, company counts dedupe. What it does
  106. * instead is hand you the numbers to explain either one.
  107. */
  108. async companySnapshot(
  109. opts: { detailed?: boolean; now?: () => Date; maxPages?: number } = {},
  110. ): Promise<CompanySnapshot> {
  111. const now = opts.now ?? (() => new Date())
  112. const detailed = opts.detailed ?? true
  113. const iterOpts = opts.maxPages === undefined ? {} : { maxPages: opts.maxPages }
  114. const { todos, hydrationFailures } = detailed
  115. ? await this.todos.companyDetailed(iterOpts)
  116. : { todos: await this.todos.company(iterOpts), hydrationFailures: [] as HydrationFailure[] }
  117. return {
  118. todos,
  119. syncedAt: now().toISOString(),
  120. detailed,
  121. spread: assignmentSpread(todos),
  122. recurrence: seriesInflation(todos),
  123. sharedLinks: detailed ? sharedLinkAudit(todos) : null,
  124. hydrationFailures,
  125. roster: this.roster.stats(),
  126. unresolvedUsers: this.roster.unresolved,
  127. }
  128. }
  129. }
  130. /**
  131. * Where a missing credential actually comes from.
  132. *
  133. * Naming the shell rather than the code is the point. There is no .env reader in
  134. * this repo, so "NINETY_TOKEN is not set" almost always means this shell never
  135. * sourced the profile (a new terminal, or one an editor spawned). The same
  136. * wording is in preflight() in apps/dashboard/src/main.ts.
  137. */
  138. const SHELL_HINT =
  139. 'Credentials come from the shell profile; this repo has no .env reader. ' +
  140. 'See credentials.example.sh, then open a NEW terminal; an existing one keeps ' +
  141. 'the old environment. Check what the shell has: env | grep NINETY'
  142. /** Build a client from environment variables. */
  143. export function clientFromEnv(
  144. env: Record<string, string | undefined>,
  145. roster?: RosterEntry[],
  146. ): NinetyClient {
  147. const token = env.NINETY_TOKEN
  148. if (!token) {
  149. // Points at the shell profile, never at a credentials file: nothing reads
  150. // one, and the message must not send people to create the thing the
  151. // credentials policy forbids.
  152. throw new Error(
  153. 'NINETY_TOKEN is not set. It must be a Personal Access Token belonging to a ' +
  154. 'company Owner or Admin. ' + SHELL_HINT,
  155. )
  156. }
  157. const opts: NinetyClientOptions = { token }
  158. if (env.NINETY_BASE_URL) opts.baseUrl = env.NINETY_BASE_URL
  159. if (env.NINETY_RPS) opts.rps = Number(env.NINETY_RPS)
  160. if (env.NINETY_DEBUG === 'true') opts.debug = true
  161. if (roster) opts.roster = roster
  162. return new NinetyClient(opts)
  163. }