SLOPSHOPPER

where-are-we

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

newbandguardtoastprompttimer
v0.1.0no licenseupdated 2026-10-03mrd1030/beastlyfactss/.claude/mods/where-are-we
A shopper browsing a rack in a slop shop
README

BeastlyFacts

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.

Tech stack

  • Frontend: Vite 6 + React 18, React Router, framer-motion
  • Styling: Tailwind CSS + shadcn/ui components
  • Content: MDX files in content/, compiled by @mdx-js/rollup
  • Data: plain JS modules in src/lib/data/ (facts, encyclopedia, Beastlypedia, guides, affiliate products, legal status)
  • Backend: Supabase for auth, favorites, push notifications, and the social feed queue; Stripe for care package checkout
  • Hosting: Cloudflare, with public/_worker.js serving the API routes, the RSS feeds, and the care package checkout and download endpoints
  • Prerendering: prerender.mjs plus scripts/generate-fact-pages.mjs, so every article and fact page is static HTML before hydration

Project structure

├── 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

Getting started

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.

Content checks

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:

CommandWhat it enforces
npm run check:linksevery article carries 1 to 2+ in-body internal links, to valid targets
npm run check:voicethe voice rules in docs/RULES.md, against scripts/voice-baseline.json
npm run check:costscost guides cover the required line items
npm run check:affiliateaffiliate articles pair affiliate: true with the disclosure
npm run check:relatednew articles are wired into relatedArticles.js, no dead slugs
npm run check:seoseoTitle and seoDescription length and presence
npm run check:imagesevery referenced image exists and has dimensions
npm run check:sourcessource URLs in Sources blocks still resolve
npm run check:numbersspecies numbers stay consistent across guides
npm run check:encyclopediaencyclopedia entries match their guides
npm run check:legal-syncthe legal map matches legalStatus.json
npm run check:link-depthinternal 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.

Writing content

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:

  • No em or en dashes, anywhere.
  • US spelling in prose, "grey" preferred.
  • Species guides keep their split structure (cost / handling / health / tank setup). They do not become long form pillar pages.
  • Never renumber facts. Removing one leaves an id gap, because favorites are keyed by id.
  • Never share one photo across multiple facts.
  • Every article needs 1 to 2+ in-body internal links, written by hand, never to the same species' sibling guides or its care guide hub.
  • Date an article the day it ships, never ahead; ship a batch 4 to 5 per day at most. Articles written ahead wait in content/_scheduled-*.
  • Dates use US Eastern, not the container clock.

MDX components

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 handling

Care packages

The 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.

Goals

  • High quality, trustworthy pet care information, researched from real sources
  • Excellent SEO and reading experience, with static HTML for every page
  • Transparent affiliate monetization and a small paid product line

Built with care for animals and the people who love them.

Source 2 files
hooks/register.tsx 479 lines
1import { 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}
479
types/index.d.ts 53 lines
1export 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