checkin.ts
Language TypeScript12.3 KB292 lines
/*** The end-of-day note: three questions, one person writing, a few people reading.** A short written note at the end of each day: three lines covering what was* done, what is unclear and what is needed. A new starter gets a daily* check-in without anyone hovering, and confusion surfaces on day two instead* of week three.** So the thing being built is a habit, not a report. Three boxes, no scoring,* no status field, nothing that turns into a metric. What it produces is prose* a person typed, shown to the people responsible for her, on the day she* typed it.** It lives on the Onboarding tab, which means it inherits that tab's toggle:* set a plan `active: false` in config/onboarding.ts and the tab disappears,* and with it that person's form and the readers' log. The rows stay in D1* and come back when the plan comes back. That coupling is deliberate (the note* is part of being new here) but it is a real consequence, so it is written* down in both files rather than discovered later.** A config file for the same reason as config/onboarding.ts and* config/inbox.ts: who may read a colleague's daily note is an access-control* fact, and access-control facts are human edits that diff and deploy, never* something derived from another list (rules 13 and 14). `seesLeadership` is* deliberately not what gates the reader side here: everyone with it sees the* onboarding plan, but only the people listed below read the notes. A reader* list derived from a different permission is exactly the widening-by-analogy* that rule 13 warns about.** Runtime-neutral: no `node:` imports, because the Worker reads this (rule 7).*/import { MERIDIAN_PEOPLE, personFromEmail } from './meridian-people.js'import { ONBOARDING } from './onboarding.js'import type { Viewer } from '../apps/dashboard/src/viewer.js'/*** The three questions.** `id` is a database column name and a form field name, so it is fixed. The* `label` and `prompt` are the wording and may be edited freely: they are what* she reads, not what anything joins on. Changing an `id` means a migration.*/export interface CheckinQuestion {id: 'did' | 'unsure' | 'need'/** The short form, used as the column heading in the readers' log. */label: string/** The question itself, above the box. */prompt: stringplaceholder: string}export const CHECKIN_QUESTIONS: readonly CheckinQuestion[] = [{id: 'did',label: 'What I did',prompt: 'What did you do today?',placeholder: 'The leads, the orders, the calls: whatever the day was actually spent on.',},{id: 'unsure',label: "What I'm unsure about",prompt: "What are you unsure about?",placeholder:'Anything you had to guess at, or did and then wondered about afterwards. This is the ' +'box that matters most. It is the whole reason this exists.',},{id: 'need',label: 'What I need',prompt: 'What do you need?',placeholder: 'An answer, an access, a walkthrough, a decision. Or “nothing today”.',},] as const/*** Who is asked to write one. Keyed by the address Access asserts.** Written by hand. This is not "everyone with an onboarding plan"* and not "everyone new". It is a list, the same way INBOX_READERS is a list,* so a second new hire gets a daily note when somebody decides they should and* writes the line, and not by inheriting a rule nobody revisited.** The value is the roster display name the rows are filed under, and it must* match `config/meridian-people.ts` exactly, checked at load below. It is stored* alongside the address rather than derived from it at read time, because the* address is a role mailbox that changes hands: `accounts@` has had more than* one holder already. A future holder of that mailbox signs in as the same* address and must get their own rows, not inherit a history with someone* else's name on it. See the `person` column comment in the migration.*/export const CHECKIN_AUTHORS: Record<string, { person: string }> = {'accounts@meridiandisplay.example': { person: 'Rosa Galván' },}/*** Who may read them, on their own dashboards only.** A list, not a role. It overlaps `seesLeadership` but is not the same set,* and deriving it from that permission would add readers without anybody* deciding to. When a grant request could mean either this list or the tab,* ask; the two are different permissions. The notes say what somebody is unsure about on day two,* and the point of writing them honestly is knowing exactly who is reading,* so every name here is a human edit, and the author is told who they are on* the card she types into.** An author reads their own notes without being listed here; see* `canReadCheckinsOf`. Nobody needs a grant to read their own words back.*/export const CHECKIN_READERS: string[] = ['hello@meridiandisplay.example', // Sam Whitaker'frontdesk@meridiandisplay.example', // Theo Bassett'projects@meridiandisplay.example', // Ines Carvalho]/*** The local hour the note "opens", and what that does and does not mean.** It is a prompt, not a lock. Before this hour the card is folded shut and says* the note is for the end of the day; after it, the card is open with the boxes* ready. Either way a submission is accepted, because a form that refuses the* text somebody has already typed is a form that loses it, and somebody* finishing at 2 PM has finished their day.** 16:00 America/Chicago. Everything on this board is Meridian time (MERIDIAN_TZ), and* that is the clock the day boundary uses too.*/export const CHECKIN_OPENS_AT_HOUR = 16/*** A note written after midnight still belongs to the day that just ended.** Before this local hour, the form writes yesterday's row and says so on the* page. Without it, somebody finishing at 00:30 files an empty day behind them* and a duplicate-looking one in front, and the reader's log shows a gap on a* day that was actually worked.*/export const CHECKIN_GRACE_UNTIL_HOUR = 4/** Per-answer ceiling. Three lines were asked for; this is the runaway guard. */export const CHECKIN_FIELD_CAP = 4000/*** The first day a note was asked for. Nothing before this is a missed day.** The readers' log lists days, and a day with no note says so ("she has not* written one since Tuesday"). That makes the* empty days load-bearing, and it makes this date load-bearing with them.* The author's plan can start before the habit was asked for. A log that* reported such a day as one she failed to write would be reporting a failure* against a rule that did not exist yet: never-asked rendered as didn't-write,* which breaks the prime invariant.** A person who joins later starts at their own start date instead; the log uses* whichever is later. See `checkinExpectedFrom`.*/export const CHECKIN_ASKED_FROM = '2026-09-15'/*** How far back the readers' log goes, in calendar days.** The log lists days, not notes: a day with no note is a row that says so* (see `expectedDays`). A list of only the notes that exist cannot say "she* has not written one since Tuesday".*/export const CHECKIN_HISTORY_DAYS = 21/*** Weekends are not expected, and are not reported as missed.** The board's own morning cron runs Monday-Friday for the same reason. A* weekend worked still stores and still shows; this controls which empty days* are called out, not which days can hold a note.*/export const CHECKIN_WEEKDAYS_ONLY = true/*** The first day this person could have been expected to write one: the later of* their start date and the day the habit began.** Null when there is no plan for them, which the load-time check below makes* impossible for an author, so a null here means a person who is read about* but never wrote, and the log renders that as having nothing to expect rather* than as a run of missed days.*/export function checkinExpectedFrom(person: string): string | null {const plan = ONBOARDING.find((p) => p.person === person)if (!plan) return null// Both are YYYY-MM-DD, so a string comparison is a date comparison.return plan.startedOn > CHECKIN_ASKED_FROM ? plan.startedOn : CHECKIN_ASKED_FROM}/** The roster name whose notes this viewer writes, or null. */export function checkinAuthorFor(viewer: Viewer): string | null {const key = (viewer.email ?? '').trim().toLowerCase()if (!key) return nullreturn CHECKIN_AUTHORS[key]?.person ?? null}/** May this viewer submit an end-of-day note? */export const canWriteCheckin = (viewer: Viewer): boolean => checkinAuthorFor(viewer) !== null/*** Whose notes this viewer may read. Empty means none, and no markup at all.** Two ways in, and no third: you are on the reader list, or they are your own* words. Both are checked against the identity Access asserted, never against* anything in a request.*/export function readableCheckinAuthors(viewer: Viewer): string[] {const key = (viewer.email ?? '').trim().toLowerCase()if (!key) return []const all = [...new Set(Object.values(CHECKIN_AUTHORS).map((a) => a.person))]if (CHECKIN_READERS.includes(key)) return allconst own = checkinAuthorFor(viewer)return own ? [own] : []}/** May this viewer read this person's notes? */export function canReadCheckinsOf(viewer: Viewer, person: string): boolean {return readableCheckinAuthors(viewer).includes(person)}/** Is there anything here for this viewer: a form, a log, or both? */export const hasCheckin = (viewer: Viewer): boolean =>canWriteCheckin(viewer) || readableCheckinAuthors(viewer).length > 0/*** Load-time invariants. Every one of these is the failure rule 13 names: a line* that looks written, reviewed and intended, and does nothing at all.*/const rosterNames = new Set(MERIDIAN_PEOPLE.map((p) => p.name))for (const [email, grant] of Object.entries(CHECKIN_AUTHORS)) {if (email !== email.trim().toLowerCase()) {throw new Error(`config/checkin.ts: author key "${email}" is not lowercased. Access asserts addresses ` +`lowercased and this map is looked up by exact key, so this grant would never match.`,)}if (!rosterNames.has(grant.person)) {throw new Error(`config/checkin.ts: "${grant.person}" is not a name in MERIDIAN_PEOPLE. Notes are filed under ` +`the roster name, so this would write rows nobody can be shown.`,)}// The address must be that person's. Filing one name against a mailbox// somebody else signs in as would put their words under that name.const who = personFromEmail(email)if (who?.name !== grant.person) {throw new Error(`config/checkin.ts: ${email} belongs to ${who?.name ?? 'nobody on the roster'}, not to ` +`"${grant.person}". The address and the name must be the same person.`,)}// The form lives on the Onboarding tab, so a plan is what makes it reachable.// Without this check, an author with no plan is a daily habit nobody was ever// asked for and nothing anywhere would say so.if (!ONBOARDING.some((p) => p.person === grant.person)) {throw new Error(`config/checkin.ts: "${grant.person}" has no plan in config/onboarding.ts. The end-of-day ` +`note is rendered on the Onboarding tab, so with no plan there is no tab and no form.`,)}}if (!CHECKIN_READERS.length) {throw new Error(`config/checkin.ts: CHECKIN_READERS is empty. Asking somebody to write a daily note that ` +`nobody is shown is worse than not asking. Say who reads it, or remove the authors.`,)}for (const email of CHECKIN_READERS) {if (email !== email.trim().toLowerCase()) {throw new Error(`config/checkin.ts: reader "${email}" is not lowercased, so the grant would never match.`,)}}if (!/^\d{4}-\d{2}-\d{2}$/.test(CHECKIN_ASKED_FROM)) {throw new Error(`config/checkin.ts: CHECKIN_ASKED_FROM must be YYYY-MM-DD. It is compared against day ` +`strings as text, so a different shape would silently sort wrong instead of failing.`,)}if (CHECKIN_OPENS_AT_HOUR < 0 || CHECKIN_OPENS_AT_HOUR > 23) {throw new Error(`config/checkin.ts: CHECKIN_OPENS_AT_HOUR must be an hour of the day.`)}if (CHECKIN_GRACE_UNTIL_HOUR < 0 || CHECKIN_GRACE_UNTIL_HOUR >= CHECKIN_OPENS_AT_HOUR) {throw new Error(`config/checkin.ts: CHECKIN_GRACE_UNTIL_HOUR must fall before CHECKIN_OPENS_AT_HOUR, or the ` +`form would back-date a note written during the working day.`,)}