checkin-view.ts
Language TypeScript22.5 KB557 lines
/*** The end-of-day note, rendered: the form one person fills in, and the log its* readers see.** What this file is careful about is not the same thing the rest of the board* is careful about. Every other surface turns five live sources into* numbers, and its hardest job is keeping an absent number from rendering as a* zero. Here there are no numbers at all. The hardest job is the same rule* pointed at prose: a day with no note, a day nobody was asked about, a store* that could not be read, and a store that was never set up are four different* facts, and the one that matters ("she has not written one since Tuesday")* is only true if the other three cannot be mistaken for it.** So each has its own words on the page, and none of them is silence.** Runtime-neutral (rule 7). The Worker imports this to stamp the block into a* pre-rendered page at serve time; the D1 access that feeds it lives in* apps/worker/src/checkin.ts, which is the only part that needs Worker types.*/import { MERIDIAN_TZ, calendarDate, localDay, localStamp } from '@meridian/ninety-client'import {CHECKIN_FIELD_CAP,CHECKIN_GRACE_UNTIL_HOUR,CHECKIN_HISTORY_DAYS,CHECKIN_OPENS_AT_HOUR,CHECKIN_QUESTIONS,CHECKIN_READERS,CHECKIN_WEEKDAYS_ONLY,canWriteCheckin,checkinAuthorFor,checkinExpectedFrom,readableCheckinAuthors,} from '../../../config/checkin.js'import { esc, join, map } from './html.js'import type { Viewer } from './viewer.js'/** One stored note. Mirrors the `checkin` table, camel-cased. */export interface CheckinNote {person: stringday: stringdid: stringunsure: stringneed: stringsubmittedAt: stringupdatedAt: stringrevisions: numberauthorEmail: string}/** One superseded version, from the append-only `checkin_revision` table. */export interface CheckinRevision {person: stringday: stringrevision: numberdid: stringunsure: stringneed: stringwrittenAt: string}/*** What came back from the store, and the three ways it can fail to be notes.** `unconfigured` and `failed` are different on purpose. The first means nobody* has created the D1 database or bound it yet: a deploy step, fixable in two* commands, and nothing has ever been written. The second means the store* exists and would not answer: rows probably do exist and cannot be shown.* Collapsing them into one "unavailable" would say the same sentence in the* one case where the reader's next action differs. Neither may ever be rendered as* an empty list; that is rule 1 and the prime invariant meeting.*/export type CheckinStore =| { state: 'ok'; notes: CheckinNote[]; earlier: CheckinRevision[]; truncated: boolean }| { state: 'unconfigured' }| { state: 'failed'; error: string }/** What the last submission did, echoed back through the redirect. */export type CheckinOutcome = 'saved' | 'updated' | 'unchanged' | 'empty' | 'stale' | 'failed'const OUTCOMES: readonly CheckinOutcome[] = ['saved','updated','unchanged','empty','stale','failed',]/** Narrow a query-string value, so only our own words reach the page. */export function parseOutcome(raw: string | null | undefined): CheckinOutcome | null {const v = (raw ?? '').trim()return (OUTCOMES as readonly string[]).includes(v) ? (v as CheckinOutcome) : null}// --- days ---------------------------------------------------------------------------/*** The hour of the day in Meridian time, 0-23.** `hourCycle: 'h23'` rather than `hour12: false`, which renders midnight as 24* in some ICU builds and would have made the grace window below silently never* fire on exactly one hour of the day.*/export function localHour(now: Date, tz: string = MERIDIAN_TZ): number {const part = new Intl.DateTimeFormat('en-US', { timeZone: tz, hour: 'numeric', hourCycle: 'h23' }).formatToParts(now).find((p) => p.type === 'hour')const n = Number(part?.value ?? NaN)return Number.isFinite(n) ? n % 24 : 0}/** The calendar day before this one. Pure string arithmetic, no timezone. */export function previousDay(day: string): string {const [y, m, d] = day.split('-').map(Number)const t = new Date(Date.UTC(y!, m! - 1, d!) - 86_400_000)return t.toISOString().slice(0, 10)}/*** Which day a note submitted now is about.** Before 4 AM Meridian time it is about the day that just ended, not the one that* just started. Somebody finishing at 00:30 would otherwise file two facts at* once, both wrong: an empty day behind them, which the log reports as missed,* and a note on a day they have not worked yet.** Every read and every write goes through this one function, so the day the* form claims and the day the row lands on cannot drift apart.*/export function checkinDay(now: Date, tz: string = MERIDIAN_TZ): string {const today = localDay(now, tz)return localHour(now, tz) < CHECKIN_GRACE_UNTIL_HOUR ? previousDay(today) : today}/** Is the form past its "end of day" mark, in Meridian time? */export function checkinIsOpen(now: Date, tz: string = MERIDIAN_TZ): boolean {const h = localHour(now, tz)// The small hours are the tail of yesterday's evening, so the form is open.return h >= CHECKIN_OPENS_AT_HOUR || h < CHECKIN_GRACE_UNTIL_HOUR}/** Monday-Friday. `day` is YYYY-MM-DD, read as a plain calendar date. */export function isWeekday(day: string): boolean {const [y, m, d] = day.split('-').map(Number)const dow = new Date(Date.UTC(y!, m! - 1, d!)).getUTCDay()return dow >= 1 && dow <= 5}/*** The days the log should account for: newest first, back to whichever came* later: the window, their start date, or the day the habit began.** The list is of days, not of notes, and that is the whole design. A list built* from the rows that exist can only show what was written, so the one thing* this feature is for, noticing that nothing was written, would be invisible* in it. See rule 9 in docs/ARCHITECTURE.md: collecting a thing and showing it* are not the same step, and the last one is the one that goes missing.*/export function expectedDays(through: string,from: string | null,days: number = CHECKIN_HISTORY_DAYS,weekdaysOnly: boolean = CHECKIN_WEEKDAYS_ONLY,): string[] {if (!from) return []const out: string[] = []let day = throughfor (let i = 0; i < days && day >= from; i++) {if (!weekdaysOnly || isWeekday(day)) out.push(day)day = previousDay(day)}return out}/*** The day range one person's log needs, and the row ceiling for it.** Exported so the query and the render cannot disagree. If the store fetched a* narrower window than the card lists, days with notes would render as "no* note": a missing row rendered as a missed day, which is the one thing this* feature must never say. One function, both callers.*/export function checkinWindow(person: string,through: string,): { from: string; limit: number } {const expected = expectedDays(through, checkinExpectedFrom(person))const from = expected[expected.length - 1] ?? through// Weekends inside the window can hold notes too, so the ceiling is the span// in calendar days, not the count of weekdays. A limit sized to the weekday// count would drop a Saturday note off the oldest end and show that day as// "no note".//// The +1 means `truncated` cannot fire while one-row-per-person-per-day// holds, and that is the point: if it ever does fire, the unique constraint// has been lost and the reader is told the list is short rather than being// handed a quietly clipped one (rule 5).return { from, limit: CHECKIN_HISTORY_DAYS + 1 }}// --- rendering ----------------------------------------------------------------------/** "left blank", never an empty line: an unanswered question is a fact. */const answerHtml = (text: string): string =>text.trim()? `<p class="ci-answer">${esc(text)}</p>`: `<p class="ci-answer ci-blank">left blank</p>`/** The store's own failure states, in the store's own words. Never an empty list. */function storeBanner(store: CheckinStore): string {if (store.state === 'unconfigured') {return `<p class="ci-banner ci-bad"><b>The note store is not set up.</b>This board has no D1 binding for <code>CHECKINS</code>, so nothing can be read and nothingcan be saved. That is a deploy step, not a missing note(see <code>apps/worker/migrations/0001_checkin.sql</code>).</p>`}if (store.state === 'failed') {// What happened, not why (rule 1). This says what the store said and// stops.return `<p class="ci-banner ci-bad"><b>The note store could not be read.</b>${esc(store.error)}, so this list is missing, not empty. Notes written earlier are stillin the database.</p>`}return store.truncated? `<p class="ci-banner">Showing the most recent ${CHECKIN_HISTORY_DAYS} days. There are oldernotes than these.</p>`: ''}/** The line that confirms what just happened to a submission. */function outcomeBanner(outcome: CheckinOutcome | null, day: string): string {if (!outcome) return ''const label = esc(calendarDate(day))switch (outcome) {case 'saved':return `<p class="ci-banner ci-good">Saved. That is ${label} written up.</p>`case 'updated':return `<p class="ci-banner ci-good">Updated. The earlier version is kept.</p>`case 'unchanged':return `<p class="ci-banner">Nothing had changed, so nothing was written.</p>`case 'empty':return `<p class="ci-banner ci-bad">All three boxes were empty, so nothing was saved:a blank note reads exactly like no note at all. Even “quiet day, nothing needed”is worth more than a blank.</p>`case 'stale':return `<p class="ci-banner ci-bad">That page was open from an earlier day, so nothing wassaved rather than filed against the wrong one. Reload and it will be ${label} again.</p>`case 'failed':return `<p class="ci-banner ci-bad">The note was <b>not</b> saved; the store refused thewrite. Copy what you typed somewhere before you reload.</p>`}}/** One question's box. `value` pre-fills an edit. */function questionField(q: (typeof CHECKIN_QUESTIONS)[number],value: string,autofocus: boolean,): string {return `<div class="ci-field"><label class="ci-q" for="ci-${q.id}">${esc(q.prompt)}</label><textarea class="ci-input" id="ci-${q.id}" name="${q.id}" rows="3"maxlength="${CHECKIN_FIELD_CAP}" placeholder="${esc(q.placeholder)}"${autofocus ? 'autofocus' : ''}>${esc(value)}</textarea></div>`}/*** The author's card: her form, plus what she has already written.** Folded before the hour, never locked. The note is for the end of the day,* so before 4 PM the card is shut and says so. It still* opens, and a submission at 2 PM is accepted: a form that refuses text* somebody has already typed is a form that throws it away, and there is no* version of this feature where losing what she wrote is the safe outcome.*/function authorCard(store: CheckinStore,day: string,now: Date,outcome: CheckinOutcome | null,): string {const today = store.state === 'ok' ? store.notes.find((n) => n.day === day) : undefinedconst open = checkinIsOpen(now)const hour12 = CHECKIN_OPENS_AT_HOUR % 12 || 12const openLabel = `${hour12}:00 ${CHECKIN_OPENS_AT_HOUR < 12 ? 'AM' : 'PM'}`const writable = store.state === 'ok'// A note typed after midnight is about yesterday, and it says so where she can// see it. Otherwise the only clue is a date she is not looking at.const backdated = day !== localDay(now)const mine =store.state === 'ok' ? store.notes.filter((n) => n.day !== day).slice(0, 5) : []return `<div class="card ci-card"><div class="rule"></div><div class="ci-head"><div><div class="eyebrow">End of day</div><h1 class="ci-title">Your three lines for ${esc(calendarDate(day))}</h1></div><span class="ci-chip ${today ? 'ci-done' : open ? 'ci-due' : ''}">${today ? 'written' : open ? 'not written yet' : `opens at ${esc(openLabel)}`}</span></div><div class="ci-body"><p class="ci-intro">What you did, what you are unsure about, what you need. It takes twominutes and it means nobody has to hover, and a question you write down tonight getsanswered tomorrow instead of in three weeks.${// Who reads it, on the form itself. Somebody answering "what I'm// unsure about" honestly is entitled to know exactly who sees it,// before they type rather than after.`Read by ${esc(CHECKIN_READERS.length === 1 ? 'one person' : `${CHECKIN_READERS.length} people`)}:${esc(CHECKIN_READERS.join(', '))}. Nobody else on the board can see this card at all.`}</p>${outcomeBanner(outcome, day)}${storeBanner(store)}${backdated? `<p class="ci-banner">It is past midnight, so this is <b>${esc(calendarDate(day))}</b>'snote: the day that just ended, not the one that just started.</p>`: ''}${writable? `<details class="ci-form-wrap" ${open || today ? 'open' : ''}><summary class="ci-summary">${today ? 'Edit tonight’s note' : open ? 'Write it' : 'Finishing early? Write it now'}</summary><form method="post" action="/checkin" class="ci-form"><input type="hidden" name="day" value="${esc(day)}">${map(CHECKIN_QUESTIONS, (q, i) =>questionField(q, today ? String(today[q.id]) : '', i === 0 && open && !today),)}<div class="ci-bar"><button type="submit" class="ci-save">${today ? 'Update the note' : 'Save the note'}</button><span class="ci-hint">${today? 'Edits are kept. The earlier version stays on the record and is visible to the same two people.': 'One box is enough if that is the honest answer. An all-blank note is not saved.'}</span></div></form></details>`: // The store could not be read. The form is still rendered: a read// failure is not a write failure, and hiding the boxes guarantees// the loss that showing them only risks.`<details class="ci-form-wrap" open><summary class="ci-summary">Write it anyway</summary><form method="post" action="/checkin" class="ci-form"><input type="hidden" name="day" value="${esc(day)}">${map(CHECKIN_QUESTIONS, (q) => questionField(q, '', false))}<div class="ci-bar"><button type="submit" class="ci-save">Save the note</button><span class="ci-hint">The store would not answer a read just now, so this may notsave either. Keep a copy of anything long until you see it land.</span></div></form></details>`}${mine.length? `<div class="ci-mine"><h3 class="ci-sub">What you wrote before</h3>${map(mine, (n) => noteBlock(n, []))}</div>`: ''}</div></div>`}/** One written note, with its questions as headings. */function noteBlock(note: CheckinNote, earlier: CheckinRevision[]): string {const prior = earlier.filter((r) => r.day === note.day && r.revision < note.revisions).sort((a, b) => b.revision - a.revision)return `<div class="ci-note"><div class="ci-note-head"><span class="ci-day">${esc(calendarDate(note.day))}</span><span class="ci-when">${esc(localStamp(note.submittedAt))}</span>${note.revisions > 1? `<span class="ci-chip">edited · ${note.revisions} versions</span>`: ''}</div>${map(CHECKIN_QUESTIONS, (q) => `<div class="ci-a"><div class="ci-a-label">${esc(q.label)}</div>${answerHtml(String(note[q.id]))}</div>`)}${prior.length? `<details class="ci-prior"><summary>What it said before</summary>${map(prior, (r) => `<div class="ci-note ci-old"><div class="ci-note-head"><span class="ci-day">version ${r.revision}</span><span class="ci-when">${esc(localStamp(r.writtenAt))}</span></div>${map(CHECKIN_QUESTIONS, (q) => `<div class="ci-a"><div class="ci-a-label">${esc(q.label)}</div>${answerHtml(String(r[q.id]))}</div>`)}</div>`)}</details>`: ''}</div>`}/*** The readers' card: one row per day, including the days with nothing in them.** The empty rows are the feature. A log of the notes that exist can say what* she wrote; only a log of the days can say she stopped writing on Tuesday,* which is what a reader is looking for.*/function readerCard(person: string, store: CheckinStore, day: string, now: Date): string {const from = checkinExpectedFrom(person)const notes = store.state === 'ok' ? store.notes : []const byDay = new Map(notes.map((n) => [n.day, n]))const earlier = store.state === 'ok' ? store.earlier : []// The days a note was expected: weekdays, back to the day the habit began.const expected = expectedDays(day, from)const oldest = expected[expected.length - 1] ?? day// Plus any day inside that window that has a note but was not expected: a// Saturday she worked. Fetched and then not shown is exactly the bug shape// rule 9 describes, and a weekend note is the one somebody most wants read.const extra = notes.map((n) => n.day).filter((d) => d <= day && d >= oldest && !expected.includes(d))const days = [...new Set([...expected, ...extra])].sort().reverse()// Today is not a missed day until the day is over. Before the form even// opens, "no note yet" is the correct and uninteresting answer.const pending = !byDay.has(day) && !checkinIsOpen(now)const written = days.filter((d) => byDay.has(d)).lengthconst missed = expected.filter((d) => !byDay.has(d) && !(d === day && pending)).lengthreturn `<div class="card ci-card"><div class="rule"></div><div class="ci-head"><div><div class="eyebrow">End of day · ${esc(person)}</div><h1 class="ci-title">What she did, what she is unsure about, what she needs</h1></div>${// The count and the list under it must account for each other (rule 9).// Every day in these two numbers is a row below.store.state === 'ok' && days.length? `<span class="ci-chip ${missed ? 'ci-due' : 'ci-done'}">${written} written · ${missed} weekday${missed === 1 ? '' : 's'} missed</span>`: ''}</div><div class="ci-body"><p class="ci-intro">Her own words, the evening she wrote them. Visible to you and${esc(CHECKIN_READERS.length - 1 === 1 ? 'one other person' : `${CHECKIN_READERS.length - 1} other people`)}and nobody else, including the people who can see her onboarding plan.</p>${storeBanner(store)}${store.state !== 'ok'? '' // The banner said what happened. A list here would be a claim.: !from? `<p class="ci-empty">${esc(person)} has no onboarding plan, so no note has beenasked for. This is not a run of missed days.</p>`: !days.length? `<p class="ci-empty">Nothing has been asked for yet. The daily note starts${esc(calendarDate(from))}.</p>`: map(days, (d) => {const note = byDay.get(d)if (note) return noteBlock(note, earlier)return `<div class="ci-note ci-none"><div class="ci-note-head"><span class="ci-day">${esc(calendarDate(d))}</span><span class="ci-when">${d === day && pending? 'not written yet; the day is not over': 'no note'}</span></div></div>`})}</div></div>`}/*** The whole block for one viewer: a form, a log, both, or nothing.** Nothing is the default. A viewer with no grant gets an empty string, and the* caller writes an empty string into the page: not a hidden div, not a* disabled card. Anything behind `hidden` is readable from view-source.*/export function checkinBlock(viewer: Viewer,stores: Record<string, CheckinStore>,now: Date = new Date(),outcome: CheckinOutcome | null = null,): string {const day = checkinDay(now)const own = checkinAuthorFor(viewer)const readable = readableCheckinAuthors(viewer)if (!readable.length && !own) return ''const missing: CheckinStore = { state: 'failed', error: 'no answer from the store' }return join([own && canWriteCheckin(viewer)? authorCard(stores[own] ?? missing, day, now, outcome): '',// A reader's own card is the form above, which already lists what she wrote....readable.filter((p) => p !== own).map((p) => readerCard(p, stores[p] ?? missing, day, now)),])}/*** The markers the Worker replaces at serve time.** A placeholder and not a rendered card, because Every page on this board is* pre-rendered by a cron and read back out of KV, so a card rendered at sync* time would show a form for a day that had not started and a log missing* everything written since. The theme stamp, the notes injection and the sync* line all solve this the same way.** The fallback between them is not empty, deliberately. `pnpm dashboard` writes* a file with no Worker behind it, and an empty div there is a feature that* silently is not there (rule 8). The text says which is which.*/export const CHECKIN_START = '<!--checkin:start-->'export const CHECKIN_END = '<!--checkin:end-->'export const checkinPlaceholder = (): string =>`${CHECKIN_START}<div class="card ci-card"><div class="rule"></div><div class="ci-body"><p class="ci-empty"><b>The end-of-day note lives on the live board.</b>This copy was rendered to a file, which has no way to read the note store or accept aform. Open the deployed board to write or read one.</p></div></div>${CHECKIN_END}`/*** Swap the placeholder for the real block. A page without the markers is* returned untouched, which is what a viewer with no grant gets.*/export function stampCheckin(html: string, block: string): string {const from = html.indexOf(CHECKIN_START)if (from === -1) return htmlconst to = html.indexOf(CHECKIN_END, from)if (to === -1) return htmlreturn html.slice(0, from) + block + html.slice(to + CHECKIN_END.length)}