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

checkin.ts

Language TypeScript12.3 KB292 lines

config/checkin.ts

  1. /**
  2. * The end-of-day note: three questions, one person writing, a few people reading.
  3. *
  4. * A short written note at the end of each day: three lines covering what was
  5. * done, what is unclear and what is needed. A new starter gets a daily
  6. * check-in without anyone hovering, and confusion surfaces on day two instead
  7. * of week three.
  8. *
  9. * So the thing being built is a habit, not a report. Three boxes, no scoring,
  10. * no status field, nothing that turns into a metric. What it produces is prose
  11. * a person typed, shown to the people responsible for her, on the day she
  12. * typed it.
  13. *
  14. * It lives on the Onboarding tab, which means it inherits that tab's toggle:
  15. * set a plan `active: false` in config/onboarding.ts and the tab disappears,
  16. * and with it that person's form and the readers' log. The rows stay in D1
  17. * and come back when the plan comes back. That coupling is deliberate (the note
  18. * is part of being new here) but it is a real consequence, so it is written
  19. * down in both files rather than discovered later.
  20. *
  21. * A config file for the same reason as config/onboarding.ts and
  22. * config/inbox.ts: who may read a colleague's daily note is an access-control
  23. * fact, and access-control facts are human edits that diff and deploy, never
  24. * something derived from another list (rules 13 and 14). `seesLeadership` is
  25. * deliberately not what gates the reader side here: everyone with it sees the
  26. * onboarding plan, but only the people listed below read the notes. A reader
  27. * list derived from a different permission is exactly the widening-by-analogy
  28. * that rule 13 warns about.
  29. *
  30. * Runtime-neutral: no `node:` imports, because the Worker reads this (rule 7).
  31. */
  32. import { MERIDIAN_PEOPLE, personFromEmail } from './meridian-people.js'
  33. import { ONBOARDING } from './onboarding.js'
  34. import type { Viewer } from '../apps/dashboard/src/viewer.js'
  35. /**
  36. * The three questions.
  37. *
  38. * `id` is a database column name and a form field name, so it is fixed. The
  39. * `label` and `prompt` are the wording and may be edited freely: they are what
  40. * she reads, not what anything joins on. Changing an `id` means a migration.
  41. */
  42. export interface CheckinQuestion {
  43. id: 'did' | 'unsure' | 'need'
  44. /** The short form, used as the column heading in the readers' log. */
  45. label: string
  46. /** The question itself, above the box. */
  47. prompt: string
  48. placeholder: string
  49. }
  50. export const CHECKIN_QUESTIONS: readonly CheckinQuestion[] = [
  51. {
  52. id: 'did',
  53. label: 'What I did',
  54. prompt: 'What did you do today?',
  55. placeholder: 'The leads, the orders, the calls: whatever the day was actually spent on.',
  56. },
  57. {
  58. id: 'unsure',
  59. label: "What I'm unsure about",
  60. prompt: "What are you unsure about?",
  61. placeholder:
  62. 'Anything you had to guess at, or did and then wondered about afterwards. This is the ' +
  63. 'box that matters most. It is the whole reason this exists.',
  64. },
  65. {
  66. id: 'need',
  67. label: 'What I need',
  68. prompt: 'What do you need?',
  69. placeholder: 'An answer, an access, a walkthrough, a decision. Or “nothing today”.',
  70. },
  71. ] as const
  72. /**
  73. * Who is asked to write one. Keyed by the address Access asserts.
  74. *
  75. * Written by hand. This is not "everyone with an onboarding plan"
  76. * and not "everyone new". It is a list, the same way INBOX_READERS is a list,
  77. * so a second new hire gets a daily note when somebody decides they should and
  78. * writes the line, and not by inheriting a rule nobody revisited.
  79. *
  80. * The value is the roster display name the rows are filed under, and it must
  81. * match `config/meridian-people.ts` exactly, checked at load below. It is stored
  82. * alongside the address rather than derived from it at read time, because the
  83. * address is a role mailbox that changes hands: `accounts@` has had more than
  84. * one holder already. A future holder of that mailbox signs in as the same
  85. * address and must get their own rows, not inherit a history with someone
  86. * else's name on it. See the `person` column comment in the migration.
  87. */
  88. export const CHECKIN_AUTHORS: Record<string, { person: string }> = {
  89. 'accounts@meridiandisplay.example': { person: 'Rosa Galván' },
  90. }
  91. /**
  92. * Who may read them, on their own dashboards only.
  93. *
  94. * A list, not a role. It overlaps `seesLeadership` but is not the same set,
  95. * and deriving it from that permission would add readers without anybody
  96. * deciding to. When a grant request could mean either this list or the tab,
  97. * ask; the two are different permissions. The notes say what somebody is unsure about on day two,
  98. * and the point of writing them honestly is knowing exactly who is reading,
  99. * so every name here is a human edit, and the author is told who they are on
  100. * the card she types into.
  101. *
  102. * An author reads their own notes without being listed here; see
  103. * `canReadCheckinsOf`. Nobody needs a grant to read their own words back.
  104. */
  105. export const CHECKIN_READERS: string[] = [
  106. 'hello@meridiandisplay.example', // Sam Whitaker
  107. 'frontdesk@meridiandisplay.example', // Theo Bassett
  108. 'projects@meridiandisplay.example', // Ines Carvalho
  109. ]
  110. /**
  111. * The local hour the note "opens", and what that does and does not mean.
  112. *
  113. * It is a prompt, not a lock. Before this hour the card is folded shut and says
  114. * the note is for the end of the day; after it, the card is open with the boxes
  115. * ready. Either way a submission is accepted, because a form that refuses the
  116. * text somebody has already typed is a form that loses it, and somebody
  117. * finishing at 2 PM has finished their day.
  118. *
  119. * 16:00 America/Chicago. Everything on this board is Meridian time (MERIDIAN_TZ), and
  120. * that is the clock the day boundary uses too.
  121. */
  122. export const CHECKIN_OPENS_AT_HOUR = 16
  123. /**
  124. * A note written after midnight still belongs to the day that just ended.
  125. *
  126. * Before this local hour, the form writes yesterday's row and says so on the
  127. * page. Without it, somebody finishing at 00:30 files an empty day behind them
  128. * and a duplicate-looking one in front, and the reader's log shows a gap on a
  129. * day that was actually worked.
  130. */
  131. export const CHECKIN_GRACE_UNTIL_HOUR = 4
  132. /** Per-answer ceiling. Three lines were asked for; this is the runaway guard. */
  133. export const CHECKIN_FIELD_CAP = 4000
  134. /**
  135. * The first day a note was asked for. Nothing before this is a missed day.
  136. *
  137. * The readers' log lists days, and a day with no note says so ("she has not
  138. * written one since Tuesday"). That makes the
  139. * empty days load-bearing, and it makes this date load-bearing with them.
  140. * The author's plan can start before the habit was asked for. A log that
  141. * reported such a day as one she failed to write would be reporting a failure
  142. * against a rule that did not exist yet: never-asked rendered as didn't-write,
  143. * which breaks the prime invariant.
  144. *
  145. * A person who joins later starts at their own start date instead; the log uses
  146. * whichever is later. See `checkinExpectedFrom`.
  147. */
  148. export const CHECKIN_ASKED_FROM = '2026-09-15'
  149. /**
  150. * How far back the readers' log goes, in calendar days.
  151. *
  152. * The log lists days, not notes: a day with no note is a row that says so
  153. * (see `expectedDays`). A list of only the notes that exist cannot say "she
  154. * has not written one since Tuesday".
  155. */
  156. export const CHECKIN_HISTORY_DAYS = 21
  157. /**
  158. * Weekends are not expected, and are not reported as missed.
  159. *
  160. * The board's own morning cron runs Monday-Friday for the same reason. A
  161. * weekend worked still stores and still shows; this controls which empty days
  162. * are called out, not which days can hold a note.
  163. */
  164. export const CHECKIN_WEEKDAYS_ONLY = true
  165. /**
  166. * The first day this person could have been expected to write one: the later of
  167. * their start date and the day the habit began.
  168. *
  169. * Null when there is no plan for them, which the load-time check below makes
  170. * impossible for an author, so a null here means a person who is read about
  171. * but never wrote, and the log renders that as having nothing to expect rather
  172. * than as a run of missed days.
  173. */
  174. export function checkinExpectedFrom(person: string): string | null {
  175. const plan = ONBOARDING.find((p) => p.person === person)
  176. if (!plan) return null
  177. // Both are YYYY-MM-DD, so a string comparison is a date comparison.
  178. return plan.startedOn > CHECKIN_ASKED_FROM ? plan.startedOn : CHECKIN_ASKED_FROM
  179. }
  180. /** The roster name whose notes this viewer writes, or null. */
  181. export function checkinAuthorFor(viewer: Viewer): string | null {
  182. const key = (viewer.email ?? '').trim().toLowerCase()
  183. if (!key) return null
  184. return CHECKIN_AUTHORS[key]?.person ?? null
  185. }
  186. /** May this viewer submit an end-of-day note? */
  187. export const canWriteCheckin = (viewer: Viewer): boolean => checkinAuthorFor(viewer) !== null
  188. /**
  189. * Whose notes this viewer may read. Empty means none, and no markup at all.
  190. *
  191. * Two ways in, and no third: you are on the reader list, or they are your own
  192. * words. Both are checked against the identity Access asserted, never against
  193. * anything in a request.
  194. */
  195. export function readableCheckinAuthors(viewer: Viewer): string[] {
  196. const key = (viewer.email ?? '').trim().toLowerCase()
  197. if (!key) return []
  198. const all = [...new Set(Object.values(CHECKIN_AUTHORS).map((a) => a.person))]
  199. if (CHECKIN_READERS.includes(key)) return all
  200. const own = checkinAuthorFor(viewer)
  201. return own ? [own] : []
  202. }
  203. /** May this viewer read this person's notes? */
  204. export function canReadCheckinsOf(viewer: Viewer, person: string): boolean {
  205. return readableCheckinAuthors(viewer).includes(person)
  206. }
  207. /** Is there anything here for this viewer: a form, a log, or both? */
  208. export const hasCheckin = (viewer: Viewer): boolean =>
  209. canWriteCheckin(viewer) || readableCheckinAuthors(viewer).length > 0
  210. /**
  211. * Load-time invariants. Every one of these is the failure rule 13 names: a line
  212. * that looks written, reviewed and intended, and does nothing at all.
  213. */
  214. const rosterNames = new Set(MERIDIAN_PEOPLE.map((p) => p.name))
  215. for (const [email, grant] of Object.entries(CHECKIN_AUTHORS)) {
  216. if (email !== email.trim().toLowerCase()) {
  217. throw new Error(
  218. `config/checkin.ts: author key "${email}" is not lowercased. Access asserts addresses ` +
  219. `lowercased and this map is looked up by exact key, so this grant would never match.`,
  220. )
  221. }
  222. if (!rosterNames.has(grant.person)) {
  223. throw new Error(
  224. `config/checkin.ts: "${grant.person}" is not a name in MERIDIAN_PEOPLE. Notes are filed under ` +
  225. `the roster name, so this would write rows nobody can be shown.`,
  226. )
  227. }
  228. // The address must be that person's. Filing one name against a mailbox
  229. // somebody else signs in as would put their words under that name.
  230. const who = personFromEmail(email)
  231. if (who?.name !== grant.person) {
  232. throw new Error(
  233. `config/checkin.ts: ${email} belongs to ${who?.name ?? 'nobody on the roster'}, not to ` +
  234. `"${grant.person}". The address and the name must be the same person.`,
  235. )
  236. }
  237. // The form lives on the Onboarding tab, so a plan is what makes it reachable.
  238. // Without this check, an author with no plan is a daily habit nobody was ever
  239. // asked for and nothing anywhere would say so.
  240. if (!ONBOARDING.some((p) => p.person === grant.person)) {
  241. throw new Error(
  242. `config/checkin.ts: "${grant.person}" has no plan in config/onboarding.ts. The end-of-day ` +
  243. `note is rendered on the Onboarding tab, so with no plan there is no tab and no form.`,
  244. )
  245. }
  246. }
  247. if (!CHECKIN_READERS.length) {
  248. throw new Error(
  249. `config/checkin.ts: CHECKIN_READERS is empty. Asking somebody to write a daily note that ` +
  250. `nobody is shown is worse than not asking. Say who reads it, or remove the authors.`,
  251. )
  252. }
  253. for (const email of CHECKIN_READERS) {
  254. if (email !== email.trim().toLowerCase()) {
  255. throw new Error(
  256. `config/checkin.ts: reader "${email}" is not lowercased, so the grant would never match.`,
  257. )
  258. }
  259. }
  260. if (!/^\d{4}-\d{2}-\d{2}$/.test(CHECKIN_ASKED_FROM)) {
  261. throw new Error(
  262. `config/checkin.ts: CHECKIN_ASKED_FROM must be YYYY-MM-DD. It is compared against day ` +
  263. `strings as text, so a different shape would silently sort wrong instead of failing.`,
  264. )
  265. }
  266. if (CHECKIN_OPENS_AT_HOUR < 0 || CHECKIN_OPENS_AT_HOUR > 23) {
  267. throw new Error(`config/checkin.ts: CHECKIN_OPENS_AT_HOUR must be an hour of the day.`)
  268. }
  269. if (CHECKIN_GRACE_UNTIL_HOUR < 0 || CHECKIN_GRACE_UNTIL_HOUR >= CHECKIN_OPENS_AT_HOUR) {
  270. throw new Error(
  271. `config/checkin.ts: CHECKIN_GRACE_UNTIL_HOUR must fall before CHECKIN_OPENS_AT_HOUR, or the ` +
  272. `form would back-date a note written during the working day.`,
  273. )
  274. }