own-list-view.ts
Language TypeScript23.4 KB568 lines
/*** The private list, rendered: the lines one person logs for themselves.** What this file is careful about: every other surface on this board turns five* live sources into numbers, and its hardest job is keeping an absent number* from rendering as a zero. This one has exactly one reader (its author) and* two hard jobs instead.** The first is that "empty" means something different here. Everywhere else an* empty list is a claim about the world, and the prime invariant exists so that* "we looked and there is nothing" cannot be confused with "no source covers* this" or "the fetch failed". Here an empty list is a claim about the owner:* it means they have not written anything. That is a true zero and must read as* one, while a store that is missing or unreadable must still read as neither.* Four facts, four sentences, and none of them silence.** The second is that none of it may reach anybody else. The pages this board* serves are pre-rendered per viewer by a cron and read back from KV; this block* is stamped into the viewer's own page at serve time, so no private text is* ever written to KV at all. `ownListBlock` re-derives the person from the* viewer and refuses to render a store belonging to anybody else.** It is also why "overdue" is computed here and now. A pre-rendered overdue* flag would be decided by the 6 a.m. cron, would be wrong by lunchtime, and* would be silently wrong, which is the worst kind. Every load re-decides.** Runtime-neutral (rule 7). The Worker imports this to stamp the block into a* pre-rendered page; the D1 access that feeds it lives in* apps/worker/src/own-list.ts, which is the only part that needs Worker types.*/import { MERIDIAN_TZ, calendarDate, localDay, localStamp } from '@meridian/ninety-client'import {OWN_OPEN_CAP,OWN_PRIORITIES,OWN_PRIORITY_LABEL,OWN_TEXT_CAP,type OwnPriority,hasOwnList,ownListPersonFor,ownPriorityOrder,} from '../../../config/own-list.js'import { esc, join, map } from './html.js'import { parseWorkKey, workKeyLabel } from './work-key.js'import type { Viewer } from './viewer.js'/** One stored line. Mirrors the `own_item` table, camel-cased. */export interface OwnItem {id: numbertext: string/** YYYY-MM-DD, or null for "no date", which is not the same as overdue. */due: string | nullpriority: OwnPriority/** `order:MD2639582` / `task:41837`, or null. */linkKey: string | nullcreatedAt: stringupdatedAt: stringedits: number/** ISO 8601 UTC when it was completed, or null while it is open. */doneAt: string | null}/*** What a read of the store came back with.** `unconfigured` and `failed` stay distinct for the same reason they do in* CheckinStore: collapsing them into one "unavailable" would print the same* sentence in the two cases where the next action differs: one is a deploy* step, the other is a database that answered and refused.*/export type OwnStore =| {state: 'ok'/** Whose list this is. Checked against the viewer before anything renders. */person: stringopen: OwnItem[]done: OwnItem[]/** More open lines exist than were fetched (rule 5). Should be impossible. */truncated: boolean/** Completed lines beyond the folded block's window. */doneHidden: number}| { state: 'unconfigured' }| { state: 'failed'; error: string }export type OwnOutcome =| 'added'| 'edited'| 'unchanged'| 'completed'| 'reopened'| 'empty'| 'toolong'| 'badaction'| 'badid'| 'baddate'| 'badlink'| 'badpriority'| 'notfound'| 'stale'| 'full'| 'failed'const OUTCOMES: readonly OwnOutcome[] = ['added','edited','unchanged','completed','reopened','empty','toolong','badaction','badid','baddate','badlink','badpriority','notfound','stale','full','failed',]/*** Narrow a query-string value to an outcome, or null.** An allow-list rather than a cast, so only our own words reach the page; the* value arrives in a URL anybody can type. Anything else renders no banner at* all, which is the safe answer: a banner is a claim about what just happened.*/export function parseOwnOutcome(raw: string | null | undefined): OwnOutcome | null {const v = (raw ?? '').trim()return (OUTCOMES as readonly string[]).includes(v) ? (v as OwnOutcome) : null}// --- dates --------------------------------------------------------------------------export type DueState = 'overdue' | 'today' | 'later' | 'none'/*** Where a line sits relative to today, in Meridian time.** A line with no date is `none` and never becomes `overdue`. That distinction is* the prime invariant on this surface: "I did not give this a date" and "this* was due on Friday" are different facts, and only one of them is a reproach.*/export function dueState(due: string | null, today: string): DueState {if (!due) return 'none'if (due < today) return 'overdue'if (due === today) return 'today'return 'later'}/*** Open lines in the order the card reads them.** Dated before undated, soonest first, priority breaking ties, then id so the* order is stable across loads. The store's SQL asks for the same order; this* function is what the card actually trusts, because a page whose grouping and* whose sort came from two different definitions would eventually disagree with* itself and there would be no way to tell which was right.*/export function sortOpen(items: OwnItem[]): OwnItem[] {return [...items].sort((a, b) => {if ((a.due === null) !== (b.due === null)) return a.due === null ? 1 : -1if (a.due && b.due && a.due !== b.due) return a.due < b.due ? -1 : 1const p = ownPriorityOrder(a.priority) - ownPriorityOrder(b.priority)return p !== 0 ? p : a.id - b.id})}// --- rendering ----------------------------------------------------------------------const GROUPS: { state: DueState; label: string }[] = [{ state: 'overdue', label: 'Overdue' },{ state: 'today', label: 'Due today' },{ state: 'later', label: 'Dated' },{ state: 'none', label: 'No date' },]/** The store's own words and no diagnosis (rule 1). */function storeBanner(store: OwnStore): string {if (store.state === 'unconfigured') {return `<p class="own-banner own-bad"><b>Your own list is not set up on this board.</b>There is no D1 binding for <code>CHECKINS</code>, so nothing can be read and nothing canbe saved. That is a deploy step, not an empty list. See<code>apps/worker/migrations/0002_own_list.sql</code>.</p>`}if (store.state === 'failed') {return `<p class="own-banner own-bad"><b>Your own list could not be read.</b>${esc(store.error)}. So this is missing, not empty. Anything you have added isstill in the database.</p>`}if (store.truncated) {// Rule 5: what we have, plus the fact that there was more. This should be// unreachable (the add route refuses past the cap), so it says so rather// than presenting a clipped list as a whole one.return `<p class="own-banner own-bad">Showing the first ${OWN_OPEN_CAP} open lines. There aremore than that, which should not be possible: the add form refuses past the cap. Ifyou are seeing this, tell Sam.</p>`}return ''}function outcomeBanner(outcome: OwnOutcome | null): string {if (!outcome) return ''switch (outcome) {case 'added':return `<p class="own-banner own-good">Added. It stays on your list until you complete it.</p>`case 'edited':return `<p class="own-banner own-good">Changed.</p>`case 'unchanged':return `<p class="own-banner">Nothing had changed, so nothing was written.</p>`case 'completed':return `<p class="own-banner own-good">Done. It is folded under <b>Done</b> below.Completing something never throws it away, and reopening is one click.</p>`case 'reopened':return `<p class="own-banner own-good">Back on your open list.</p>`case 'empty':return `<p class="own-banner own-bad">A line with no text is a bullet you cannot act onlater, so nothing was saved. Even three words is enough.</p>`case 'toolong':return `<p class="own-banner own-bad">That was longer than ${OWN_TEXT_CAP} characters, sonothing was saved rather than saving part of it. Shorten it and try again; yourtext is still in the box if you go back.</p>`case 'full':return `<p class="own-banner own-bad">Your list is at its cap of open lines, so this was<b>not</b> added. Complete something first; nothing was trimmed to make room.</p>`case 'notfound':return `<p class="own-banner own-bad">That line is not on your list. It may have beenchanged from another tab. Nothing was altered.</p>`case 'stale':return `<p class="own-banner own-bad">That page was open from before this line last changed,so nothing was done rather than done to the wrong version. Reload and try again.</p>`case 'baddate':return `<p class="own-banner own-bad">That date was not a real one, so nothing was saved.A line can have no date at all, which is different from being overdue.</p>`case 'badlink':return `<p class="own-banner own-bad">A link has to be an order number or a task id, like<code>MD2639582</code> or <code>task:41837</code>. Nothing was saved.</p>`case 'badpriority':case 'badaction':case 'badid':return `<p class="own-banner own-bad">That form did not arrive in a shape this pagerecognises, so nothing was changed. Reload and try again.</p>`case 'failed':return `<p class="own-banner own-bad">It was <b>not</b> saved: the store refused thewrite. Copy what you typed somewhere before you reload.</p>`}}/** The link chip: the id the rest of the board already uses for that work. */function linkChip(linkKey: string | null): string {const parsed = parseWorkKey(linkKey)if (!parsed) return ''// A label, not an anchor. The board has no per-order page to link to, and a// link that scrolled somewhere plausible but wrong is worse than a label.return `<span class="own-link">${esc(workKeyLabel(parsed))}</span>`}function priorityChip(p: OwnPriority): string {// Normal is the default and says nothing; only the two ends earn a chip.if (p === 'normal') return ''return `<span class="own-pri own-pri-${esc(p)}">${esc(OWN_PRIORITY_LABEL[p])}</span>`}function dueLabel(item: OwnItem, today: string): string {if (!item.due) return `<span class="own-when own-nodate">no date</span>`const state = dueState(item.due, today)const word = state === 'overdue' ? 'was due' : state === 'today' ? 'due today' : 'due'const shown = state === 'today' ? '' : ` ${esc(calendarDate(item.due))}`return `<span class="own-when own-when-${state}">${word}${shown}</span>`}/** The hidden fields every mutation form carries. */function rowTokens(item: OwnItem): string {return `<input type="hidden" name="id" value="${esc(String(item.id))}"><input type="hidden" name="seen" value="${esc(item.updatedAt)}">`}function openRow(item: OwnItem, today: string, writable: boolean): string {const state = dueState(item.due, today)return `<li class="own-row own-row-${state}"><div class="own-row-main"><p class="own-text">${esc(item.text)}</p><div class="own-meta">${dueLabel(item, today)}${priorityChip(item.priority)}${linkChip(item.linkKey)}${item.edits ? `<span class="own-edited">edited</span>` : ''}</div></div>${writable? `<div class="own-row-acts"><form method="post" action="/own" class="own-act">${rowTokens(item)}<input type="hidden" name="action" value="done"><button type="submit" class="own-btn">Done</button></form><details class="own-edit"><summary class="own-btn own-btn-quiet">Edit</summary>${itemForm(item, today, 'edit')}</details></div>`: ''}</li>`}function doneRow(item: OwnItem, writable: boolean): string {return `<li class="own-row own-row-done"><div class="own-row-main"><p class="own-text">${esc(item.text)}</p><div class="own-meta"><span class="own-when">done ${esc(item.doneAt ? localStamp(item.doneAt) : '')} CT</span>${linkChip(item.linkKey)}</div></div>${writable? `<div class="own-row-acts"><form method="post" action="/own" class="own-act">${rowTokens(item)}<input type="hidden" name="action" value="reopen"><button type="submit" class="own-btn own-btn-quiet">Reopen</button></form></div>`: ''}</li>`}/*** The add form, and the edit form, which are the same fields.** One function because they must stay the same fields: an edit box missing a* control the add box has would silently drop that value on every edit.*/function itemForm(item: OwnItem | null, today: string, mode: 'add' | 'edit'): string {const id = mode === 'add' ? 'own-new' : `own-edit-${item?.id ?? 0}`const parsed = parseWorkKey(item?.linkKey ?? null)return `<form method="post" action="/own" class="own-form"><input type="hidden" name="action" value="${mode === 'add' ? 'add' : 'edit'}">${item ? rowTokens(item) : ''}<label class="own-label" for="${esc(id)}-text">What needs doing</label><textarea id="${esc(id)}-text" name="text" class="own-input" rows="2"maxlength="${OWN_TEXT_CAP}"placeholder="Call the gym back about the 10 ft backdrop proof">${esc(item?.text ?? '')}</textarea><div class="own-form-row"><span class="own-field"><label class="own-label" for="${esc(id)}-due">Due</label><input id="${esc(id)}-due" type="date" name="due" class="own-date"value="${esc(item?.due ?? '')}"></span><span class="own-field"><label class="own-label" for="${esc(id)}-pri">Priority</label><select id="${esc(id)}-pri" name="priority" class="own-select">${map(OWN_PRIORITIES,(p) =>`<option value="${esc(p)}"${(item?.priority ?? 'normal') === p ? ' selected' : ''}>${esc(OWN_PRIORITY_LABEL[p])}</option>`,)}</select></span><span class="own-field"><label class="own-label" for="${esc(id)}-link">Order or task <span class="own-opt">optional</span></label><input id="${esc(id)}-link" type="text" name="link" class="own-text-in"value="${esc(parsed?.key ?? '')}" placeholder="MD2639582"></span></div><div class="own-form-acts"><button type="submit" class="own-btn own-btn-go">${mode === 'add' ? 'Add to my list' : 'Save changes'}</button>${mode === 'add'? `<span class="own-hint">Today is ${esc(calendarDate(today))}. Leave the date blank if it does not have one.</span>`: ''}</div></form>`}/*** The reconciliation sentence: what the numbers above mean and what they do not.** Rule 9, and the version of it that actually bites on this card. The risk here* is not a number above an empty list; it is a reader seeing "on my board 4" at* the top of the card and "3 open" here and having no way to know whether the 3* is inside the 4. It is not, and saying so is one sentence.*/function reconciliation(open: number, overdue: number, done: number, loadIsNull: boolean): string {const bits = [`${open} open, and ${open === 1 ? 'it is' : 'all of them are'} listed above.`,overdue? `${overdue} of ${open === 1 ? 'them' : 'those'} ${overdue === 1 ? 'is' : 'are'} past its date and counted in both numbers.`: '',done ? `${done} completed ${done === 1 ? 'line is' : 'lines are'} folded below and in neither.` : '',loadIsNull? `“On my board” above reads – because no source covers your workindividually. Your own list is not a source, and adding to it does not change that.`: `Nothing here is counted in “on my board” at the top of this card:that number is orders, tasks, to-dos and tracker items. This list is yours and is nota source.`,]return `<p class="own-reconcile">${bits.filter(Boolean).join(' ')}</p>`}/*** The whole block, stamped over the placeholder at serve time.** Nothing is the default. A viewer with no list, or a store belonging to* somebody else, gets an empty string, not a hidden div. Hidden markup is not* hidden, which is why the board renders a variant per viewer at all.*/export function ownListBlock(viewer: Viewer,store: OwnStore,now: Date = new Date(),outcome: OwnOutcome | null = null,loadIsNull = false,): string {const person = ownListPersonFor(viewer)if (!person || !hasOwnList(viewer)) return ''// The store must be the one that was asked for. This cannot happen through// any current call path (the Worker reads with this same name), and it is// checked anyway, because the cost of the refactor that breaks it is one// person reading another's private list, and the cost of the check is a// string comparison.if (store.state === 'ok' && store.person !== person) {return card(person,'',`<p class="own-banner own-bad"><b>Your own list could not be shown.</b> The board read alist that is not yours and refused to render it. Nothing was disclosed and nothing waschanged. Tell Sam.</p>`,)}const today = localDay(now, MERIDIAN_TZ)const writable = store.state !== 'unconfigured'const open = store.state === 'ok' ? sortOpen(store.open) : []const done = store.state === 'ok' ? store.done : []const overdue = open.filter((i) => dueState(i.due, today) === 'overdue').lengthconst chip =store.state === 'ok'? `<span class="own-chip ${overdue ? 'own-chip-due' : ''}">${open.length} open${overdue ? ` · ${overdue} overdue` : ''}</span>`: // Not a zero. A store that could not be read has no count, and printing// "0 open" here would be the prime invariant broken on the one surface// where the reader is the only person who could notice.`<span class="own-chip own-chip-dark">not read</span>`const body = join([outcomeBanner(outcome),storeBanner(store),store.state !== 'ok'? // The banner said what happened. A list here would be a claim.'': open.length? join([...GROUPS.map((g) => {const rows = open.filter((i) => dueState(i.due, today) === g.state)if (!rows.length) return ''return `<div class="own-group"><div class="own-group-head">${esc(g.label)} <span class="own-group-n">${rows.length}</span></div><ul class="own-rows">${map(rows, (i) => openRow(i, today, writable))}</ul></div>`}),reconciliation(open.length, overdue, done.length, loadIsNull),]): done.length? `<p class="own-empty">Nothing open. ${done.length} completed${done.length === 1 ? 'line is' : 'lines are'} kept below; completingsomething never removes it.</p>`: // A true zero, and it must read as one. Everywhere else on this board// an empty list is a claim about the world and has to be kept apart// from "not tracked"; here it is a claim about the owner, and saying// so is what stops it reading like every other absence on the page.`<p class="own-empty"><b>Nothing on your own list.</b> This one is empty because youhave not added anything yet. It is the only list on this card that is notread from a source, so an empty one means exactly that.</p>`,writable? `<details class="own-add"${open.length || done.length ? '' : ' open'}><summary class="own-btn own-btn-go">Add a line</summary>${store.state === 'failed'? `<p class="own-hint own-hint-warn">The store would not answer a read just now, sothis may not save either. Keep a copy of anything long until you see it land.</p>`: ''}${itemForm(null, today, 'add')}</details>`: // No form at all without a binding: a box that could only lose what was// typed into it is worse than no box, which is the same call `bootNotes`// makes when the notes tag is absent.'',done.length? `<details class="own-done"><summary class="own-btn own-btn-quiet">Done (${done.length})</summary><ul class="own-rows">${map(done, (i) => doneRow(i, writable))}</ul>${store.state === 'ok' && store.doneHidden? `<p class="own-hint">${store.doneHidden} older completed${store.doneHidden === 1 ? 'line is' : 'lines are'} kept in the database and notlisted here. Nothing was deleted.</p>`: ''}</details>`: '',])return card(person, chip, body)}function card(person: string, chip: string, body: string): string {return `<div class="own-wrap"><div class="own-head"><div class="own-title">My own list</div>${chip}</div><p class="own-sub">Only you. Nobody else who signs into this board can read these:there is no reader list and no leadership view, and no count from here appears on anyshared page. Nothing here chases you either: it is waiting when you open the board, andthe dates are checked fresh each time you load it.</p>${body}<p class="own-foot">Filed under ${esc(person)}.</p></div>`}// --- markers ------------------------------------------------------------------------/*** Stamped into a pre-rendered page at serve time, like the theme, the notes* JSON, the sync line and the end-of-day note.** It has to be stamped rather than rendered for two independent reasons, and* the second is the one that matters: a line added a minute ago must show on* the next load without waiting for a cron, and no private text may ever be* written into the KV page at all. What sits in KV is the placeholder below.*/export const OWN_START = '<!--ownlist:start-->'export const OWN_END = '<!--ownlist:end-->'/*** Not empty, and rule 8 is why. An empty div here is a feature that silently is* not there: visible in a variant sitting in KV before the Worker stamps it,* in a variant dumped to a file, and above all in a deploy where the Worker* stopped stamping. The last sentence is for whoever is looking at one of those* and needs to know at a glance that no private text is in it.*/export const ownListPlaceholder = (): string =>`${OWN_START}<div class="own-wrap"><p class="own-empty"><b>Your own list lives on the liveboard.</b> This copy was rendered to a file, which has no way to read your list or acceptthe form. Open the deployed board to add a line.<b>Nothing of yours is in this file.</b></p></div>${OWN_END}`/** Swap the placeholder for the real block. A page without markers is untouched. */export function stampOwnList(html: string, block: string): string {const from = html.indexOf(OWN_START)if (from === -1) return htmlconst to = html.indexOf(OWN_END, from)if (to === -1) return htmlreturn html.slice(0, from) + block + html.slice(to + OWN_END.length)}