A card above the prompt: Done / In progress / Waiting on your OK, kept by Claude and saved per project across sessions.

BeastlyFacts is a content site with honest, practical, well sourced information about pets and animals, weighted toward reptiles, amphibians, small mammals, and invertebrates. It ships as a prerendered React app on Cloudflare, with every article stored as MDX in this repo.
New session here? Read READMEFIRST.md for current state and what to work on, CLAUDE.md for the working rules, and docs/RULES.md before writing or editing any content. This file is the map of the codebase; those three win over it.
content/, compiled by @mdx-js/rollupsrc/lib/data/ (facts, encyclopedia, Beastlypedia, guides, affiliate products, legal status)public/_worker.js serving the API routes, the RSS feeds, and the care package checkout and download endpointsprerender.mjs plus scripts/generate-fact-pages.mjs, so every article and fact page is static HTML before hydration├── content/ # every article, as MDX
│ ├── guides/ # species guides (cost / handling / health / tank setup / feeding / enrichment)
│ ├── fun-facts/ # numbered list articles
│ ├── blog/ # standalone posts
│ ├── short-story/ # the Chronicles
│ └── CAREPACKAGE Guides/ # printable care package sources and PDFs
├── src/
│ ├── components/
│ │ ├── mdx/ # components usable inside article bodies
│ │ ├── blog/ # article page furniture (Deep Dive, TOC, FAQ)
│ │ ├── beastlypedia/ encyclopedia/ guides/ home/ pack/ legal/
│ │ ├── layout/ shared/ ui/
│ ├── pages/ # route components
│ ├── lib/
│ │ ├── data/ # the content data layer
│ │ ├── generated/ # build output, do not hand edit
│ │ └── seo/ hooks/ utils/
│ └── api/supabaseClient.js
├── scripts/ # content checks and index generators
├── docs/ # rulebook, plans, runbooks, logs
├── supabase/ # SQL schema and edge functions
├── templates/ # MDX starter templates
├── tools/ # image audit, keyword finder, legal research
└── public/ # static assets and _worker.js
npm install
npm run dev
dev regenerates thumbnails and the article, guide, encyclopedia, Beastlypedia, and legal indexes before starting Vite, so a new MDX file shows up without extra steps.
Supabase is optional locally. Without VITE_SUPABASE_URL and VITE_SUPABASE_ANON_KEY, auth and favorites degrade gracefully and everything else runs.
npm run build runs the full gauntlet before Vite ever starts, so a content mistake fails the build instead of shipping. Each check also runs on its own:
| Command | What it enforces |
|---|---|
npm run check:links | every article carries 1 to 2+ in-body internal links, to valid targets |
npm run check:voice | the voice rules in docs/RULES.md, against scripts/voice-baseline.json |
npm run check:costs | cost guides cover the required line items |
npm run check:affiliate | affiliate articles pair affiliate: true with the disclosure |
npm run check:related | new articles are wired into relatedArticles.js, no dead slugs |
npm run check:seo | seoTitle and seoDescription length and presence |
npm run check:images | every referenced image exists and has dimensions |
npm run check:sources | source URLs in Sources blocks still resolve |
npm run check:numbers | species numbers stay consistent across guides |
npm run check:encyclopedia | encyclopedia entries match their guides |
npm run check:legal-sync | the legal map matches legalStatus.json |
npm run check:link-depth | internal link depth across the whole site |
npm run lint and npm run typecheck cover the code side.
Content and infographic work stops at the image and internal-link checks. Do not run npm run build unless you have a reason to.
Start from templates/GUIDE_TEMPLATE.mdx or templates/FUN_FACT_TEMPLATE.mdx and read templates/README.md for the frontmatter schema. The frontmatter is the structured data: BlogPosting, BreadcrumbList, and FAQPage JSON-LD are all generated from it, so never hand write a schema block in an article.
The non-negotiables, in full in docs/RULES.md:
content/_scheduled-*.Located in src/components/mdx/:
<Figure /> images with captions and alt text<FunFact /> highlighted callouts<KeyTakeaway /> the one thing a reader should leave with<ProsCons /> pros and cons blocks<ComparisonTable /> comparison tables<AffiliateLink /> and <AffiliateDisclosure /> affiliate links and the disclosure that has to accompany them<Sources /> and <AlsoConsulted /> references<VetDisclaimer /> and <LegalDisclaimer /> for medical and legal content<CarePackageBlock /> care package promo block<MdxLink /> and <DemotedH1 /> internal link and heading handlingThe printable PDF packages sold through Stripe. Sources live in content/CAREPACKAGE Guides/source/*.html, one self contained HTML file per package rendered to PDF with headless Chrome. The older care-packages/ folder is superseded; see its README before touching it. Checkout, the Stripe webhook, and signed downloads are handled in public/_worker.js.
Built with care for animals and the people who love them.
hooks/register.tsx 479 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, RenderElement } from 'claude-code'
3
4import type { Archived, Armed, Backup, Card, Ledger } from '../types'
5
6const EMPTY: Card = { done: [], doing: [], next: [], waiting: [], updatedAt: null }
7const EMPTY_LEDGER: Ledger = { doneAt: {}, archived: [], undo: null }
8const card = atom({ plugin: 'where-are-we', key: 'card' } as const, EMPTY)
9const ledger = atom({ plugin: 'where-are-we', key: 'ledger' } as const, EMPTY_LEDGER)
10const picked = atom({ plugin: 'where-are-we', key: 'picked' } as const, [] as string[])
11const armed = atom({ plugin: 'where-are-we', key: 'armed' } as const, null as Armed | null)
12// Hide folds the card to one line of counts with a Show button. Kept in the
13// mod's store per repo, so it holds across sessions on this machine.
14const isHidden = atom({ plugin: 'where-are-we', key: 'isHidden' } as const, false)
15
16// Archive, Archive all done and Reset take two presses: the first turns the
17// button into "Press again", which lapses after CONFIRM_MS.
18const CONFIRM_MS = 5000
19// How many backups the backups file keeps, newest first.
20const MAX_BACKUPS = 20
21
22// The card lives in a plain JSON file in the project, written by the model
23// with its ordinary Write or Edit tool. A tool registered through
24// $.tool.register would be cleaner, but on Windows that tool's loopback MCP
25// server answers its first request and garbles every one after it
26// (InvalidHTTPResponse on tools/list), so the tool never reaches the session.
27// Reported to Anthropic 2026-10-03; until it is fixed, the file is the channel.
28const FILE = '.where-are-we.json'
29// The mod's own file beside it: when each Done item first appeared, the
30// archived tasks, and what Undo puts back. Untracked like the card, so a
31// button press never touches git or lands on whichever branch is checked out.
32const LEDGER_FILE = '.where-are-we-archive.json'
33// A copy of both taken before every Archive, Reset and Undo, so even an Undo
34// pressed by mistake can be walked back. Untracked too.
35const BACKUP_FILE = '.where-are-we-backups.json'
36// The tracked, human-readable copies, written only when the owner asks.
37const SNAPSHOT = 'WHERE_ARE_WE.md'
38const ARCHIVE_DOC = 'archive/docs-completed/WHERE_ARE_WE_COMPLETED_<YYYY-MM-DD>.md'
39const MAX_DONE = 30
40// Rows drawn per section before the rest fold into "+N more", so a long list
41// cannot push the conversation off the screen.
42const SHOWN_DONE = 6
43const SHOWN_ROWS = 6
44
45const list = (v: unknown) =>
46 Array.isArray(v) ? v.filter((x): x is string => typeof x === 'string' && x.trim() !== '').map(x => x.trim()) : []
47
48// Forward slashes, no trailing slash: Windows paths arrive either way.
49const slashes = (p: string) => {
50 const s = p.split('\\').join('/')
51 return s.endsWith('/') ? s.slice(0, -1) : s
52}
53const norm = (p: string) => slashes(p).toLowerCase()
54
55// One card per repository, not per folder: a session in a git worktree
56// (bf-store, .claude/worktrees/...) uses the main checkout's card, so every
57// branch in flight lands on the same list. A worktree's `.git` is a file
58// reading `gitdir: <main>/.git/worktrees/<name>`; the main checkout's is a
59// directory, which fs.read rejects, and then the session root is the answer.
60let home: string | undefined
61
62async function homeDir($: EngineInterface) {
63 if (home) return home
64 const root = slashes(await $.session.root())
65 let found = root
66 try {
67 const line = (await $.fs.read(`${root}/.git`)).trim()
68 const gitdir = line.startsWith('gitdir:') ? slashes(line.slice('gitdir:'.length).trim()) : ''
69 const at = gitdir.indexOf('/.git/worktrees/')
70 if (at > 0) found = gitdir.slice(0, at)
71 } catch {
72 found = root
73 }
74 home = found
75 return home
76}
77
78const cardPath = async ($: EngineInterface) => `${await homeDir($)}/${FILE}`
79const ledgerPath = async ($: EngineInterface) => `${await homeDir($)}/${LEDGER_FILE}`
80const backupPath = async ($: EngineInterface) => `${await homeDir($)}/${BACKUP_FILE}`
81
82// The card file, or the archive file when a session restores a backup.
83async function isCardWrite($: EngineInterface, filePath: string, ran: { deny?: unknown; isError?: boolean }) {
84 const target = norm(filePath)
85 const isOurs = target === norm(await cardPath($)) || target === norm(await ledgerPath($))
86 return isOurs && ran.deny === undefined && ran.isError !== true
87}
88
89function parseCard(text: string): Card {
90 const raw = JSON.parse(text) as Record<string, unknown>
91 return {
92 done: list(raw.done).slice(-MAX_DONE),
93 doing: list(raw.doing),
94 next: list(raw.next),
95 waiting: list(raw.waiting),
96 updatedAt: typeof raw.updatedAt === 'number' ? raw.updatedAt : null,
97 }
98}
99
100function parseDoneAt(v: unknown): Record<string, number> {
101 const out: Record<string, number> = {}
102 if (v && typeof v === 'object') {
103 for (const [k, n] of Object.entries(v as Record<string, unknown>)) if (typeof n === 'number') out[k] = n
104 }
105 return out
106}
107
108function parseArchived(v: unknown): Archived[] {
109 if (!Array.isArray(v)) return []
110 return v.flatMap(x => {
111 const a = x as Record<string, unknown>
112 if (typeof a?.text !== 'string' || typeof a.archivedAt !== 'number') return []
113 return [{
114 text: a.text,
115 doneAt: typeof a.doneAt === 'number' ? a.doneAt : null,
116 archivedAt: a.archivedAt,
117 doneAtText: typeof a.doneAtText === 'string' ? a.doneAtText : 'unknown',
118 archivedAtText: typeof a.archivedAtText === 'string' ? a.archivedAtText : full(a.archivedAt),
119 }]
120 })
121}
122
123function parseLedger(text: string): Ledger {
124 const raw = JSON.parse(text) as Record<string, unknown>
125 const u = raw.undo as Record<string, unknown> | null | undefined
126 return {
127 doneAt: parseDoneAt(raw.doneAt),
128 archived: parseArchived(raw.archived),
129 undo: u && typeof u === 'object' && u.card
130 ? { card: parseCard(JSON.stringify(u.card)), doneAt: parseDoneAt(u.doneAt), archived: parseArchived(u.archived) }
131 : null,
132 }
133}
134
135const EASTERN = { timeZone: 'America/New_York' } as const
136
137// The owner's clock is US Eastern; written bare, no zone label.
138function stamp(ms: number) {
139 try {
140 const day = (d: Date) => d.toLocaleDateString('en-US', EASTERN)
141 const at = new Date(ms)
142 const time = at.toLocaleTimeString('en-US', { ...EASTERN, hour: 'numeric', minute: '2-digit' })
143 return day(at) === day(new Date()) ? time : `${at.toLocaleDateString('en-US', { ...EASTERN, month: 'short', day: 'numeric' })}, ${time}`
144 } catch {
145 return ''
146 }
147}
148
149// A full date and time for the archive, "Oct 3, 2026, 7:41 PM".
150function full(ms: number) {
151 try {
152 const at = new Date(ms)
153 const date = at.toLocaleDateString('en-US', { ...EASTERN, month: 'short', day: 'numeric', year: 'numeric' })
154 const time = at.toLocaleTimeString('en-US', { ...EASTERN, hour: 'numeric', minute: '2-digit' })
155 return `${date}, ${time}`
156 } catch {
157 return String(ms)
158 }
159}
160
161async function readOr<T>($: EngineInterface, path: string, parse: (t: string) => T, fallback: T) {
162 try {
163 return parse(await $.fs.read(path))
164 } catch {
165 return fallback
166 }
167}
168
169// Reads both files. Archived text stays off Done, and every Done item gets
170// the time it was first seen there, which is what the archive records as
171// "done". A missing or unreadable file is empty, never a failed session.
172async function load($: EngineInterface, fromWrite = false) {
173 const now = await $.clock.now()
174 let c = await readOr($, await cardPath($), parseCard, EMPTY)
175 const l = await readOr($, await ledgerPath($), parseLedger, EMPTY_LEDGER)
176
177 const gone = new Set(l.archived.map(a => a.text))
178 c = { ...c, done: c.done.filter(t => !gone.has(t)) }
179 if (fromWrite) c = { ...c, updatedAt: now }
180
181 const firstSeen = fromWrite ? now : (c.updatedAt ?? now)
182 const doneAt: Record<string, number> = {}
183 for (const t of c.done) doneAt[t] = l.doneAt[t] ?? firstSeen
184 const isChanged =
185 Object.keys(doneAt).length !== Object.keys(l.doneAt).length || c.done.some(t => l.doneAt[t] === undefined)
186 const nextLedger = { ...l, doneAt }
187 if (isChanged) await $.fs.write(await ledgerPath($), `${JSON.stringify(nextLedger, null, 2)}\n`)
188
189 await update($, card, () => c)
190 await update($, ledger, () => nextLedger)
191 await update($, picked, p => p.filter(t => c.done.includes(t)))
192}
193
194// The buttons write both files, so they stay the one source of truth.
195async function commit($: EngineInterface, c: Card, l: Ledger) {
196 await $.fs.write(await cardPath($), `${JSON.stringify(c, null, 2)}\n`)
197 await $.fs.write(await ledgerPath($), `${JSON.stringify(l, null, 2)}\n`)
198 await update($, card, () => c)
199 await update($, ledger, () => l)
200 await update($, picked, p => p.filter(t => c.done.includes(t)))
201}
202
203// Copies the card and the ledger, as they stand, to the top of the backups
204// file before a button changes them. A broken backups file starts over.
205async function backup($: EngineInterface, reason: string) {
206 const c = await read($, card)
207 const l = await read($, ledger)
208 const now = await $.clock.now()
209 const path = await backupPath($)
210 const old = await readOr($, path, t => (JSON.parse(t) as Backup[]).filter(b => b && typeof b.at === 'number'), [] as Backup[])
211 const entry: Backup = { at: now, atText: full(now), reason, card: c, doneAt: l.doneAt, archived: l.archived }
212 await $.fs.write(path, `${JSON.stringify([entry, ...old].slice(0, MAX_BACKUPS), null, 2)}\n`)
213}
214
215// First press arms the button; a second press within CONFIRM_MS runs it.
216async function confirmThen($: EngineInterface, key: string, run: () => Promise<void>) {
217 const a = await read($, armed)
218 const now = await $.clock.now()
219 if (a && a.key === key && now - a.at < CONFIRM_MS) {
220 await update($, armed, () => null)
221 await run()
222 return
223 }
224 await update($, armed, () => ({ key, at: now }))
225 await $.clock.after(CONFIRM_MS, () => {
226 void update($, armed, x => (x && x.key === key && x.at === now ? null : x))
227 })
228}
229
230async function archive($: EngineInterface, which: 'picked' | 'all') {
231 const c = await read($, card)
232 const l = await read($, ledger)
233 const p = await read($, picked)
234 const chosen = which === 'all' ? c.done : c.done.filter(t => p.includes(t))
235 if (chosen.length === 0) return
236
237 await backup($, which === 'all' ? 'Archive all done' : `Archive ${chosen.length}`)
238 const now = await $.clock.now()
239 const entries: Archived[] = chosen.map(text => {
240 const at = l.doneAt[text] ?? null
241 return { text, doneAt: at, archivedAt: now, doneAtText: at === null ? 'unknown' : full(at), archivedAtText: full(now) }
242 })
243 const doneAt = { ...l.doneAt }
244 for (const t of chosen) delete doneAt[t]
245
246 await commit(
247 $,
248 { ...c, done: c.done.filter(t => !chosen.includes(t)), updatedAt: now },
249 { doneAt, archived: [...l.archived, ...entries], undo: { card: c, doneAt: l.doneAt, archived: l.archived } },
250 )
251 await update($, picked, () => [])
252 await $.ui.toast(`Archived ${chosen.length} ${chosen.length === 1 ? 'task' : 'tasks'}. Undo is on the card.`)
253}
254
255async function reset($: EngineInterface) {
256 await backup($, 'Reset')
257 const c = await read($, card)
258 const l = await read($, ledger)
259 const now = await $.clock.now()
260 await commit($, { ...EMPTY, updatedAt: now }, {
261 doneAt: {},
262 archived: l.archived,
263 undo: { card: c, doneAt: l.doneAt, archived: l.archived },
264 })
265 await $.ui.toast('Card cleared. Undo is on the card.')
266}
267
268async function undo($: EngineInterface) {
269 const l = await read($, ledger)
270 if (!l.undo) return
271 await backup($, 'Undo')
272 const now = await $.clock.now()
273 await commit($, { ...l.undo.card, updatedAt: now }, { doneAt: l.undo.doneAt, archived: l.undo.archived, undo: null })
274}
275
276function summary(c: Card, path: string, archivePath: string, backupsPath: string) {
277 const line = (items: string[]) => (items.length ? items.join('; ') : 'nothing')
278 return [
279 'Where-are-we card (shown to the owner above the prompt, saved across sessions):',
280 `Done: ${line(c.done.slice(-8))}`,
281 `In progress: ${line(c.doing)}`,
282 `Up next: ${line(c.next)}`,
283 `Waiting on owner's OK: ${line(c.waiting)}`,
284 `Keep it current by writing ${path} with the Write tool whenever an item starts, finishes, or comes to need`,
285 'the owner\'s OK (Fable reviews, merges, pushes to main, anything you must not start unasked).',
286 'The file is JSON: {"done": [...], "doing": [...], "next": [...], "waiting": [...]}, each list complete',
287 '(done oldest first), items short noun phrases under 60 characters, no em or en dashes. Write the whole file.',
288 '"doing" (In progress) holds ONLY what is being worked on this moment, usually one item. Everything planned',
289 'but not started goes in "next" (Up next), in the order it will be done; move the first of it into "doing"',
290 'when you start it. A batch of many items is one in "doing" and the rest queued in "next".',
291 `The owner archives Done items with the card's buttons; the mod keeps them in ${archivePath}.`,
292 'An archived item never goes back on the card or into the snapshot; repeat work gets a distinct name.',
293 `Before every Archive, Reset and Undo the mod copies the card and that file into ${backupsPath}`,
294 '(newest first, each with atText and reason). Edit the archive file only to restore a backup, and only when the',
295 'owner asks ("undo the card clear", "restore the card from <time>"): write the backup\'s card into the card',
296 'file and {"doneAt", "archived", "undo": null} into the archive file. Never edit the backups file.',
297 `Both files are untracked. ${SNAPSHOT} is the card's tracked snapshot and ${ARCHIVE_DOC}`,
298 '(one file; create it with today\'s date only if none exists) is the archive\'s; both change only on request.',
299 `Both are only ever COMBINED, never overwritten: add what is missing, drop nothing. In ${SNAPSHOT}, an item`,
300 'in two states takes Done over In progress over Waiting over Up next, archived items are left out, and the',
301 'newer "Saved <date time, US Eastern>" line is kept. Sections: Done, In progress, Up next, Waiting on your OK.',
302 'In the archive doc, batches go newest first, each headed by its archivedAtText, one line per task:',
303 '"<task> (done <doneAtText>)"; skip a task already listed in the same batch.',
304 `"Save the card to git": combine the card into ${SNAPSHOT} and the archive file into the archive doc, commit`,
305 'only those two files on main with [CI Skip] in the message, push main.',
306 `"Load the card from git": pull, then combine ${SNAPSHOT} into the card file.`,
307 'When a branch\'s work is finished and it changes what the site builds (not only docs or mods), ask the owner',
308 `whether to combine the card into ${SNAPSHOT} on that branch before it merges. A merge conflict in either`,
309 'tracked file is resolved the same way: combine both sides, drop nothing.',
310 ].join('\n')
311}
312
313const hiddenKey = async ($: EngineInterface) => `hidden:${norm(await homeDir($))}`
314
315async function setHidden($: EngineInterface, value: boolean) {
316 await update($, isHidden, () => value)
317 await $.store.set(await hiddenKey($), value)
318}
319
320export const register: Register = on => {
321 on('session.start', async ($, e, next) => {
322 await load($)
323 try {
324 const saved = await $.store.get(await hiddenKey($))
325 await update($, isHidden, () => saved === true)
326 } catch {
327 await update($, isHidden, () => false)
328 }
329 return next(e)
330 })
331
332 on('prompt.submit', async ($, e, next) => {
333 const c = await read($, card)
334 const text = summary(c, await cardPath($), await ledgerPath($), await backupPath($))
335 return next({ ...e, context: [...(e.context ?? []), text] })
336 })
337
338 // After a successful Write or Edit of the card file, redraw from the file.
339 on('tool.call', { tool: 'Write' }, async ($, e, next) => {
340 const ran = await next(e)
341 if (await isCardWrite($, String(e.file_path ?? ''), ran)) await load($, true)
342 return ran
343 })
344
345 on('tool.call', { tool: 'Edit' }, async ($, e, next) => {
346 const ran = await next(e)
347 if (await isCardWrite($, String(e.file_path ?? ''), ran)) await load($, true)
348 return ran
349 })
350
351 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
352 const c = await read($, card)
353 const l = await read($, ledger)
354 const p = await read($, picked)
355 const a = await read($, armed)
356 const hidden = await read($, isHidden)
357 const below = await next(e)
358 const hasItems = c.done.length + c.doing.length + c.next.length + c.waiting.length > 0
359
360 // An empty card still shows while Undo can bring something back.
361 if (e.props.hasSurvey || (!hasItems && !l.undo)) {
362 return below
363 }
364
365 const { Box, Button, Text } = $.ui.resolve(e)
366 const stack = (top: RenderElement) =>
367 below ? (
368 <Box flexDirection="column">
369 {top}
370 {below}
371 </Box>
372 ) : (
373 top
374 )
375
376 // Hidden: one line of counts, and Show to open the card again.
377 if (hidden) {
378 const counts = `${c.done.length} done · ${c.doing.length} in progress · ${c.next.length} up next · ${c.waiting.length} waiting`
379 return stack(
380 <Box borderStyle="round" borderColor="magenta" paddingX={1}>
381 <Text color="magenta" bold>
382 Where are we{' '}
383 </Text>
384 <Text dimColor>{`${counts} `}</Text>
385 <Button key="show" variant="primary" label="Show" onPress={() => setHidden($, false)} />
386 </Box>,
387 )
388 }
389 const shown = c.done.slice(-SHOWN_DONE)
390 const earlier = c.done.length - shown.length
391 const when = c.updatedAt === null ? '' : stamp(c.updatedAt)
392 const toggle = (t: string) => update($, picked, x => (x.includes(t) ? x.filter(y => y !== t) : [...x, t]))
393
394 // `mark` of null numbers the rows instead, for the queue's order.
395 const section = (label: string, color: string, items: string[], mark: string | null) =>
396 items.length === 0 ? null : (
397 <Box key={label} flexDirection="column">
398 <Text color={color} bold>
399 {label}
400 </Text>
401 {items.slice(0, SHOWN_ROWS).map((item, i) => (
402 <Text key={`${label}${i}`} wrap="truncate-end">
403 {' '}
404 {mark ?? `${i + 1}.`} {item}
405 </Text>
406 ))}
407 {items.length > SHOWN_ROWS ? <Text dimColor> +{items.length - SHOWN_ROWS} more</Text> : null}
408 </Box>
409 )
410
411 // Done rows are checkboxes: press one to tick it, then Archive the ticked.
412 const done =
413 shown.length === 0 ? null : (
414 <Box key="Done" flexDirection="column">
415 <Text color="green" bold>
416 Done
417 </Text>
418 {earlier > 0 ? <Text dimColor> +{earlier} done earlier (Archive all done takes them too)</Text> : null}
419 {shown.map((item, i) => (
420 <Box key={`done${i}`}>
421 <Text> </Text>
422 <Button
423 key={`pick${i}`}
424 plain
425 label={`${p.includes(item) ? '☑' : '☐'} ${item}`}
426 onPress={() => toggle(item)}
427 />
428 </Box>
429 ))}
430 </Box>
431 )
432
433 const gap = (k: string) => <Text key={k}> </Text>
434 // A button armed by its first press says so until it lapses.
435 const labelFor = (key: string, label: string) => (a && a.key === key ? `Press again: ${label}` : label)
436
437 const mine = (
438 <Box flexDirection="column" borderStyle="round" borderColor="magenta" paddingX={1}>
439 <Box>
440 <Text color="magenta" bold>
441 Where are we{' '}
442 </Text>
443 <Text dimColor>{when ? `updated ${when} ` : ''}</Text>
444 {p.length > 0 ? (
445 <Button
446 key="archive-picked"
447 variant="primary"
448 label={labelFor('archive-picked', `Archive ${p.length}`)}
449 onPress={() => confirmThen($, 'archive-picked', () => archive($, 'picked'))}
450 />
451 ) : null}
452 {p.length > 0 ? gap('g1') : null}
453 {c.done.length > 0 ? (
454 <Button
455 key="archive-all"
456 label={labelFor('archive-all', 'Archive all done')}
457 onPress={() => confirmThen($, 'archive-all', () => archive($, 'all'))}
458 />
459 ) : null}
460 {c.done.length > 0 ? gap('g2') : null}
461 {hasItems ? (
462 <Button key="reset" label={labelFor('reset', 'Reset')} onPress={() => confirmThen($, 'reset', () => reset($))} />
463 ) : null}
464 {hasItems && l.undo ? gap('g3') : null}
465 {l.undo ? <Button key="undo" label="Undo" onPress={() => undo($)} /> : null}
466 {gap('g4')}
467 <Button key="hide" label="Hide" onPress={() => setHidden($, true)} />
468 </Box>
469 {done}
470 {section('In progress', 'blue', c.doing, '›')}
471 {section('Up next', 'cyan', c.next, null)}
472 {section('Waiting on your OK', 'red', c.waiting, '?')}
473 </Box>
474 )
475
476 return stack(mine)
477 })
478}
479types/index.d.ts 53 lines1export type Card = {
2 done: string[]
3 doing: string[]
4 // The queue: work lined up after what is in progress, in order.
5 next: string[]
6 waiting: string[]
7 updatedAt: number | null
8}
9
10// One archived task. The text fields are the times already written out in
11// US Eastern, so the archive doc can copy them as they are.
12export type Archived = {
13 text: string
14 doneAt: number | null
15 archivedAt: number
16 doneAtText: string
17 archivedAtText: string
18}
19
20// What Undo puts back: the card and the ledger as they were before the last
21// Archive or Reset.
22export type Undo = {
23 card: Card
24 doneAt: Record<string, number>
25 archived: Archived[]
26}
27
28export type Ledger = {
29 doneAt: Record<string, number>
30 archived: Archived[]
31 undo: Undo | null
32}
33
34// A copy of the card and the archive ledger taken just before an Archive,
35// Reset or Undo, kept newest first in the backups file.
36export type Backup = {
37 at: number
38 atText: string
39 reason: string
40 card: Card
41 doneAt: Record<string, number>
42 archived: Archived[]
43}
44
45// A button pressed once and waiting for its confirming second press.
46export type Armed = { key: string; at: number }
47
48declare module 'claude-code' {
49 interface PluginState {
50 'where-are-we': { card: Card; ledger: Ledger; picked: string[]; armed: Armed | null; isHidden: boolean }
51 }
52}
53