Band row showing the workflow phase this session is in, elapsed against your own median, an own-pace ETA, tokens this phase and review rounds

One row above the prompt, in any session that has a phase row in the sink (see What feeds it). In the desktop app it is drawn: the eight phases as a bar, the current one as a pill with the next one ghosted after it, and the phase's Clawd mascot at the end, walking while the phase is open:
Past the phase's median the row turns yellow (red past twice it):
Done, the bar fills and the Clawd throws a party:
One Clawd per phase, plus between phases and done:
In the terminal the row is text:
#12345 full ◆◆◆◆◈◇◇◇ 5 Implement 23m/41m · ~2h10m left · 184k · R6×2
#12345 full — the ticket this session's latest phase row names, and the tier: from its Work Item note (? when none). Every tier draws eight cells; the tier is a label.23m/41m — time in the phase / your own median for it, from paired enter/exit rows across every ticket. 23m/— with no median. Green under the median, yellow to twice it, red past that; grey with no median. In the terminal the colour covers the title, the cells and the phase; the stats after it are dim. On the desktop the title is ordinary text and the colour drives the dot before it, the bar's dots and the current-phase pill; the stats are grey.~2h10m left — the medians of the phases ahead plus what the current one's median still allows; shown only when every summed phase has pairs from two tickets, else ETA — (the basis is in /phase).184k — this session's new tokens since the phase began (uncached input, output and cache writes; the cached context read back at each step is left out), subagents included, kept across a restart. The step whose Bash call writes the boundary lands wholly on one side; that is noise.R6×2 — a phase entered again after it was left (by an exit, or by entering another phase). A resume, or a second session joining an open phase, is not a round. Several phases list in phase order, space-joined: R5×3 R6×2.between phases after an exit with nothing open; done 3h0m once the ticket's latest row is an exit of Phase 8, with the total from the first enter to that exit. A later enter reopens the ticket. No token figure shows between phases or when done (only an open phase has a tally): between phases · ~1h5m left, done 3h0m · R6×2. In the terminal done is grey; the desktop draws it green.A ticket is in one phase at a time. An enter opens its phase and leaves every other open phase, whichever session held it (a workflow may move to a new session at its seams, so a dead session's unanswered enter is the common case, not an error). An enter on a phase that is already open is a resume: the phase keeps its original start. An exit closes the phase for everyone. An exit with nothing open is an orphan: counted in /phase, no effect on the band.
Desktop drawing. The same row as an eight-segment bar, after a coloured dot and the title. On a narrow pane the stats column goes first, then the Clawd, then the title's share shrinks. The bar is one length in every phase: the stats sit in a fixed-width column (between phases, its text leads it). Done is the one exception: nothing follows it that could move the bar again, so the column shrinks to the done 3h0m text (plus any rounds) and the bar runs the rest of the way to it. The current phase is a solid chevron whose right edge is always its own boundary; its label is centred, a name too long for the segment stretches the chevron back over earlier phases as far as it needs (never forward), and with no room at all only the number shows. The next phase is a ghost chevron with its name (or number), from 5 % visible as the phase starts to 55 % at the median (12 % with no median). Worked phases are a dither that runs right up to the chevron, dense and bold behind it and thinning further back, so earlier phases fade away as the ticket moves on; the tail reaches further in later phases (about 2 segments in phase 2, 4.5 in phase 8). Each dot blinks on one of twelve rhythms (a shimmer, a slow fade nearly out, a quick blink), and a ragged pulse runs back from the chevron, barely dimming the dots nearest it. Done is the celebration: the whole bar fills with dots at one density whatever was entered (no tail, no fade, no boundary ticks), every dot flashing on its own beat (twelve rhythms × sixteen starts, so no two patches of the bar flash in step), and bright sparks popping all over the field; under reduced motion the dots sit lit and the sparks stay hidden. The row ends in the pixel Clawd holding a prop for the state (hooks/mascot.ts, original pixel art): 1 a wizard hat, 2 a sweeping magnifier, 3 a clipboard ticking off, 4 a bubbling flask, 5 a tapping hammer, 6 reading glasses with scanning eyes, 7 a launching rocket, 8 a checkered flag waving at the finish line, thinking dots between phases, and a party hat with confetti (tinted green) when done. It walks while a phase is open. A phase entered in the last 4 s slides in from the one before. Every motion stops under reduced motion.
List this mod first in CLAUDE_CODE_PLUGIN_DIRS to draw it under the other rows: the first mod listed draws at the bottom of the band. The row is laid out to fit 80 columns in its longest normal state (two round markers, a day-long elapsed, a megatoken tally); a narrower pane wraps it.
/phase prints every phase with its enters, exits, rounds, median and sample counts, the ETA basis, the token tally and the skipped-row count. /phase hide / /phase show turn the row off and on; tracking continues while hidden.
The mod reads the sink (default ~/.claude/metrics/ticket-phase-tokens.jsonl) and the notes folder's Work Items/ notes (In Flight, In Testing, Backlog, the root, then every folder under Done/, first match wins); it writes nothing but its own $.store keys. Paths are userConfig (sink, vault; ~ expands to your home). It re-reads when a Bash call whose command contains ticket-phase.sh lands, at each finished turn, and once a minute when the sink's or the note's mtime moved (or a ticket with no note gains one). Medians pair each exit with its own session's enter first, so interleaved sessions measure their own stretches.
Tests: claude plugin test . (91 cases) and python3 tests/mutate.py (63 mutants, each must be detected).
Nothing draws until something appends phase rows to the sink, a JSONL file (userConfig.sink, default ~/.claude/metrics/ticket-phase-tokens.jsonl). This repo ships no writer: a workflow script (yours) appends one line per phase boundary. Name it ticket-phase.sh, or have its command line contain that string, and the row refreshes as soon as the Bash call that runs it lands; otherwise it catches up at the next finished turn or minute tick.
Each line is one JSON object. The mod reads only these five fields; anything else on the line is ignored:
| Field | Type | Meaning |
|---|---|---|
ts | string | When the boundary happened; anything Date.parse accepts (ISO 8601 with a zone, e.g. 2026-01-05T14:03:00Z). |
session | string, non-empty | The Claude Code session id. The row shows the ticket named by this session's newest row. |
ticket | string or number | The ticket id; a number is read as its string. |
phase | integer 1–8, or a string of one | The phase the boundary belongs to. |
event | "enter" or "exit" | Entering or leaving that phase. |
A line that is not JSON, or misses or mistypes any of the five, is skipped and counted in /phase. The tier is not a row field and the rows carry no tokens: the token figure is counted by the mod itself from the session's own API responses while a phase is open.
{"ts":"2026-01-05T14:03:00Z","session":"0b7c2d5e-1f4a-4c1e-9a7d-3e2b6f8a9c10","ticket":"12345","phase":5,"event":"enter"}
Notes folder (optional). The tier label comes from a markdown note named <ticket> - <title>.md (e.g. 12345 - Dark mode toggle.md) under <vault>/Work Items/, searched in In Flight/, In Testing/, Backlog/, Work Items/ itself, then each folder under Done/ (first match wins). The note's YAML frontmatter needs a tier: field with a single alphabetic word (tier: full, read case-insensitively); without a note, or without the field, the tier shows ?. userConfig.vault (the key is vault for historical reasons) sets the notes folder (default ~/Documents/Obsidian Vault, Obsidian's own first-run vault path; any folder with the same layout works).
/phase shows both: 6k new · 206k processed. A tally stored before 0.3.0 counts its new tokens from the upgrade.tail -c, one byte more so a window that opens on a whole row keeps it) and drops a cut first row; /phase says the history is the newest part. Output the engine cut short is not taken as the history. A row's order is now its place among rows with the same timestamp, so a phase's tally keeps its identity as old rows leave the window; tallies stored before 0.4.0 restart once.ticket-phase.sh Bash call), not every minute. A search is claimed before it walks the folders, so two refreshes at once walk them once.hooks/register.tsx 575 lines1import type { EngineInterface, PluginOptions, Register } from 'claude-code'
2
3import type { TicketPhaseReading, TicketPhaseTally } from '../types'
4import type { RowColor } from './row'
5import { ROW_H, rowSvg } from './row'
6import {
7 PHASES,
8 aheadOf,
9 cellsOf,
10 etaOf,
11 parseRows,
12 statsOf,
13 tierOf,
14 tokensShort,
15 until,
16 walk,
17} from './phases'
18
19const TICK = 60_000
20const READING = { plugin: 'ticket-phase', key: 'reading' } as const
21const HIDDEN = { plugin: 'ticket-phase', key: 'hidden' } as const
22const SINK_MTIME = { plugin: 'ticket-phase', key: 'sinkMtimeMs' } as const
23const HIDE = 'hidden'
24const DEFAULT_SINK = '~/.claude/metrics/ticket-phase-tokens.jsonl'
25const DEFAULT_VAULT = '~/Documents/Obsidian Vault'
26
27/** `~` at the front of a configured path stands for the home directory. */
28export const expandHome = (path: string, home: string): string =>
29 path === '~' ? home : path.startsWith('~/') ? `${home}${path.slice(1)}` : path
30
31const pathsOf = async ($: EngineInterface, options: PluginOptions) => {
32 const home = (await $.env.get('HOME')) ?? ''
33 const sink = typeof options.sink === 'string' && options.sink !== '' ? options.sink : DEFAULT_SINK
34 const vault =
35 typeof options.vault === 'string' && options.vault !== '' ? options.vault : DEFAULT_VAULT
36
37 return { sink: expandHome(sink, home), vault: expandHome(vault, home) }
38}
39
40const NOTE_PATH = { plugin: 'ticket-phase', key: 'notePath' } as const
41/** Where a ticket's note may sit, first match wins; '' is the Work Items root. */
42const FOLDERS = ['In Flight', 'In Testing', 'Backlog', ''] as const
43
44const matches = (name: string, ticket: string): boolean =>
45 name.startsWith(`${ticket} - `) && name.endsWith('.md')
46
47/** The Work Item note for the ticket, searched in folder precedence; null when none. */
48const findNote = async (
49 $: EngineInterface,
50 vault: string,
51 ticket: string,
52): Promise<string | null> => {
53 const root = `${vault}/Work Items`
54 const dirs: string[] = FOLDERS.map(one => (one === '' ? root : `${root}/${one}`))
55
56 try {
57 for (const sprint of await $.fs.list(`${root}/Done`)) {
58 if (sprint.kind === 'dir') {
59 dirs.push(`${root}/Done/${sprint.name}`)
60 }
61 }
62 } catch {
63 // No Done folder: nothing more to search.
64 }
65
66 for (const dir of dirs) {
67 try {
68 const hit = (await $.fs.list(dir)).find(
69 one => one.kind === 'file' && matches(one.name, ticket),
70 )
71
72 if (hit !== undefined) {
73 return `${dir}/${hit.name}`
74 }
75 } catch {
76 // A folder that is not there is simply not searched.
77 }
78 }
79
80 return null
81}
82
83/** The tier for the ticket from its note, with the path cached in $.state. */
84const tierFor = async (
85 $: EngineInterface,
86 vault: string,
87 ticket: string,
88): Promise<{ tier: string; mtimeMs: number | null }> => {
89 const { value: cached = null } = await $.state.get(NOTE_PATH)
90 const base = cached === null ? '' : cached.slice(cached.lastIndexOf('/') + 1)
91 let path = cached !== null && matches(base, ticket) ? cached : null
92
93 for (let attempt = 0; attempt < 2; attempt += 1) {
94 if (path === null) {
95 // A ticket with no note is looked for at most every NOTE_SEARCH_MS, whatever asks.
96 const now = await $.clock.now()
97
98 if (noteSearched !== null && noteSearched.ticket === ticket && now - noteSearched.at < NOTE_SEARCH_MS) {
99 break
100 }
101
102 // claimed before the walk, so a refresh overlapping this one does not walk again
103 const claim = { ticket, at: now }
104 noteSearched = claim
105 path = await findNote($, vault, ticket)
106
107 if (path !== null && noteSearched === claim) {
108 noteSearched = null
109 }
110 }
111
112 if (path === null) {
113 break
114 }
115
116 // The stat comes first: a write between the two calls then leaves an older
117 // stamp behind, and the next tick re-reads rather than missing the change.
118 const mtimeMs = await $.fs.stat(path).then(one => one.mtimeMs, () => null)
119
120 try {
121 const text = await $.fs.read(path)
122 await $.state.set(NOTE_PATH, path)
123
124 return { tier: tierOf(text) ?? '?', mtimeMs }
125 } catch {
126 // The note moved or was renamed: forget the path and look again once.
127 path = null
128 }
129 }
130
131 await $.state.set(NOTE_PATH, null)
132
133 return { tier: '?', mtimeMs: null }
134}
135
136/** The most of the sink one refresh reads: under $.fs.read's 4 MiB, with room to spare. */
137const SINK_READ_CAP = 3 * 1024 * 1024
138/** A ticket with no Work Item note is looked for again this often, not every tick. */
139const NOTE_SEARCH_MS = 10 * 60_000
140/** Tallies of phases entered longer ago than this are pruned at session start. */
141const TALLY_KEEP_MS = 30 * 86_400_000
142/** When the note for a ticket was last looked for and not found. */
143let noteSearched: { ticket: string; at: number } | null = null
144
145const TALLY = 'tokens:'
146/** The tally updates in flight, chained so they apply one at a time. */
147let tallying: Promise<void> = Promise.resolve()
148
149const isTally = (value: unknown): value is TicketPhaseTally =>
150 typeof value === 'object' &&
151 value !== null &&
152 typeof (value as TicketPhaseTally).tokens === 'number' &&
153 typeof (value as TicketPhaseTally).phase === 'number' &&
154 typeof (value as TicketPhaseTally).enteredAt === 'number' &&
155 typeof (value as TicketPhaseTally).enteredOrder === 'number' &&
156 typeof (value as TicketPhaseTally).ticket === 'string'
157
158/**
159 * The tally that belongs to the reading's current phase: the stored one when
160 * it is for the same ticket, phase and enter, else a fresh zero. Null when
161 * there is no current phase.
162 */
163const tallyFor = async (
164 $: EngineInterface,
165 reading: TicketPhaseReading,
166): Promise<TicketPhaseTally | null> => {
167 const { state, ticket } = reading
168
169 if (state.current === null || state.enteredAt === null || state.enteredOrder === null) {
170 return null
171 }
172
173 const stored = await $.store.get(`${TALLY}${await $.session.id()}`)
174 const same =
175 isTally(stored) &&
176 stored.ticket === ticket &&
177 stored.phase === state.current &&
178 stored.enteredAt === state.enteredAt &&
179 stored.enteredOrder === state.enteredOrder
180
181 return same
182 ? stored
183 : {
184 ticket,
185 phase: state.current,
186 enteredAt: state.enteredAt,
187 enteredOrder: state.enteredOrder,
188 tokens: 0,
189 fresh: 0,
190 }
191}
192
193/**
194 * Reads the sink, finds this session's ticket, and publishes its phase state,
195 * the own-pace medians and the tier to draw. A sink that cannot be read clears
196 * the reading, so nothing stale is drawn over it.
197 */
198const refresh = async ($: EngineInterface, options: PluginOptions): Promise<void> => {
199 const { sink, vault } = await pathsOf($, options)
200 const now = await $.clock.now()
201 let text: string
202 let sinkMtimeMs: number
203
204 // The stat comes first (see tierFor): a write between the two calls is then
205 // caught by the next tick instead of being acknowledged unread.
206 let isPartial = false
207
208 try {
209 const st = await $.fs.stat(sink)
210 sinkMtimeMs = st.mtimeMs
211
212 if (st.size > SINK_READ_CAP) {
213 // Past what one read may copy (4 MiB): the newest part, from its first whole row.
214 // One byte more than the window: when that byte is a newline the window opens on a
215 // whole row and keeps it; otherwise the first, cut row is dropped. Output the engine
216 // cut short (a sink growing during the read) is not taken as the history.
217 const r = await $.process.run(['tail', '-c', String(SINK_READ_CAP + 1), sink], { timeoutMs: 10_000 })
218
219 if (r.exitCode !== 0 || r.isStdoutTruncated) {
220 throw new Error('tail failed')
221 }
222
223 text = r.stdout.slice(r.stdout.indexOf('\n') + 1)
224 isPartial = true
225 } else {
226 text = await $.fs.read(sink)
227 }
228 } catch {
229 await $.state.set(READING, null)
230 await $.state.set(SINK_MTIME, null)
231
232 return
233 }
234
235 await $.state.set(SINK_MTIME, sinkMtimeMs)
236
237 const { rows, skipped } = parseRows(text)
238 const me = await $.session.id()
239 const own = rows.filter(row => row.session === me)
240 const mine = own[own.length - 1]
241
242 if (mine === undefined) {
243 await $.state.set(READING, null)
244
245 return
246 }
247
248 const state = walk(rows.filter(row => row.ticket === mine.ticket))
249 const note = await tierFor($, vault, mine.ticket)
250 const reading: TicketPhaseReading = {
251 ticket: mine.ticket,
252 tier: note.tier,
253 state,
254 stats: statsOf(rows),
255 skipped,
256 sinkMtimeMs,
257 noteMtimeMs: note.mtimeMs,
258 at: now,
259 ...(isPartial ? { isPartial: true as const } : {}),
260 }
261 await $.state.set(READING, reading)
262 await $.state.set(HIDDEN, (await $.store.get(HIDE)) === true)
263}
264
265/** Whether the sink or the ticket's note changed since the reading was taken. */
266const moved = async ($: EngineInterface, options: PluginOptions): Promise<boolean> => {
267 const { value: reading } = await $.state.get(READING)
268 const { value: known = null } = await $.state.get(SINK_MTIME)
269 const { sink, vault } = await pathsOf($, options)
270
271 try {
272 // Unknown (never read, or the last read failed) counts as moved: the tick retries.
273 if (known === null || (await $.fs.stat(sink)).mtimeMs !== known) {
274 return true
275 }
276 } catch {
277 return known !== null
278 }
279
280 if (reading === null || reading === undefined) {
281 return false
282 }
283
284 const { value: notePath = null } = await $.state.get(NOTE_PATH)
285
286 if (notePath === null) {
287 // No note yet: look for one, so a note written after the first reading is noticed,
288 // but not on every tick (it walks several folders): every NOTE_SEARCH_MS.
289 const now = await $.clock.now()
290
291 if (noteSearched !== null && noteSearched.ticket === reading.ticket && now - noteSearched.at < NOTE_SEARCH_MS) {
292 return false
293 }
294
295 const claim = { ticket: reading.ticket, at: now }
296 noteSearched = claim
297 const found = (await findNote($, vault, reading.ticket)) !== null
298
299 if (found && noteSearched === claim) {
300 noteSearched = null
301 }
302
303 return found
304 }
305
306 try {
307 return (await $.fs.stat(notePath)).mtimeMs !== reading.noteMtimeMs
308 } catch {
309 return true
310 }
311}
312
313/** Tallies of phases entered more than TALLY_KEEP_MS ago: one store key per session that tracked a phase. */
314const pruneTallies = async ($: EngineInterface): Promise<void> => {
315 const now = await $.clock.now()
316
317 for (const key of await $.store.keys()) {
318 if (!key.startsWith(TALLY)) {
319 continue
320 }
321
322 const value = await $.store.get(key)
323
324 // By last activity: a phase can stay open for weeks while its session keeps counting.
325 if (isTally(value) && now - (value.at ?? value.enteredAt) > TALLY_KEEP_MS) {
326 await $.store.delete(key)
327 }
328 }
329}
330
331export const register: Register = (on, options) => {
332 on('session.start', async ($, e, next) => {
333 await $.command.register({
334 name: 'phase',
335 description:
336 'Ticket phase band: /phase shows the phases, medians and token tally; /phase hide and /phase show turn the row off and on',
337 })
338 // An idle session follows another session's boundaries within a minute.
339 $.clock.every(TICK, async () => {
340 if (await moved($, options)) {
341 await refresh($, options)
342 }
343
344 $.ui.invalidate('ui.render')
345 })
346 await refresh($, options)
347 await pruneTallies($).catch(() => undefined)
348
349 return next(e)
350 })
351
352 // The boundary lands in the same turn: the skill's own call moves the band.
353 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
354 const out = await next(e)
355 const command = (e as { command?: unknown }).command
356
357 if (typeof command === 'string' && command.includes('ticket-phase.sh')) {
358 await refresh($, options).catch(() => undefined)
359 }
360
361 return out
362 })
363
364 on('turn.complete', async ($, e, next) => {
365 const answer = await next(e)
366
367 if (e.agentId === undefined) {
368 await refresh($, options)
369 }
370
371 return answer
372 })
373
374 // Every model step, this session's own and its subagents', lands on the open phase.
375 on('turn.step', async function* ($, e, next) {
376 const response = yield* next(e)
377 const usage = response.usage
378
379 if (usage === null || usage === undefined) {
380 return response
381 }
382
383 // fresh: what the step added (uncached input, output, cache writes). The cached context
384 // read back on every step is in `spent` only, so a warm 200k context does not look like
385 // 200k of work per step.
386 const fresh = (usage.input_tokens ?? 0) + (usage.output_tokens ?? 0) + (usage.cache_creation_input_tokens ?? 0)
387 const spent = fresh + (usage.cache_read_input_tokens ?? 0)
388
389 if (!Number.isFinite(spent)) {
390 return response
391 }
392
393 // Steps can overlap (a subagent's beside the main loop's): the read-add-write
394 // runs one at a time so neither overwrites the other's addition.
395 tallying = tallying
396 .then(async () => {
397 const { value: reading } = await $.state.get(READING)
398
399 if (reading === null || reading === undefined) {
400 return
401 }
402
403 const tally = await tallyFor($, reading)
404
405 if (tally !== null) {
406 await $.store.set(`${TALLY}${await $.session.id()}`, {
407 ...tally,
408 tokens: tally.tokens + spent,
409 at: await $.clock.now(),
410 fresh: (tally.fresh ?? 0) + fresh,
411 })
412 }
413 })
414 .catch(() => undefined)
415 await tallying
416
417 return response
418 })
419
420 on('command.run', { command: 'phase' }, async ($, e) => {
421 const args = e.args.trim()
422
423 if (args === 'hide' || args === 'show') {
424 await $.store.set(HIDE, args === 'hide')
425 await $.state.set(HIDDEN, args === 'hide')
426
427 return {
428 text:
429 args === 'hide'
430 ? 'Phase row hidden; phases are still tracked. /phase show brings it back.'
431 : 'Phase row shown.',
432 }
433 }
434
435 await refresh($, options)
436 const { value: reading } = await $.state.get(READING)
437
438 if (reading === null || reading === undefined) {
439 return {
440 text: 'No phase row for this session: the band appears once /ticket runs ticket-phase.sh here.',
441 }
442 }
443
444 const now = await $.clock.now()
445 const { state, stats } = reading
446 const { value: notePath = null } = await $.state.get(NOTE_PATH)
447 const tally = await tallyFor($, reading)
448 const lines = [`#${reading.ticket} · tier ${reading.tier}${notePath === null ? '' : ` · ${notePath}`}`]
449
450 for (let p = 1; p <= 8; p += 1) {
451 const info = state.phases[p]!
452 const one = stats[p]!
453 const when =
454 p === state.current && state.enteredAt !== null
455 ? `open ${until(now - state.enteredAt)}`
456 : info.enters === 0
457 ? 'not entered'
458 : `${info.enters} in / ${info.exits} out${info.rounds > 1 ? ` · ${info.rounds} rounds` : ''}`
459 const pace = one.median === null ? 'no median' : `med ${until(one.median)}`
460 lines.push(
461 `${p} ${PHASES[p]} · ${when} · ${pace} · ${one.pairs} pair${one.pairs === 1 ? '' : 's'} · ${one.tickets} ticket${one.tickets === 1 ? '' : 's'}`,
462 )
463 }
464
465 const eta = state.isDone ? null : etaOf(state, stats, now)
466 lines.push(
467 state.isDone
468 ? 'done'
469 : eta === null
470 ? `ETA needs pairs from 2 tickets for every phase ahead (${aheadOf(state).join(', ')})`
471 : `ETA ~${until(eta)} from own medians`,
472 )
473 lines.push(
474 tally === null
475 ? 'tokens: none (no open phase)'
476 : `tokens this phase: ${tokensShort(tally.fresh ?? 0)} new · ${tokensShort(tally.tokens)} processed (this session's steps, subagents included; processed adds the cached context read back each step)`,
477 )
478 lines.push(`${reading.skipped} row${reading.skipped === 1 ? '' : 's'} skipped`)
479 if (reading.isPartial === true) {
480 lines.push('history: the newest 3 MiB of the sink (it is past what one read may copy); older rows are left out of the medians')
481 }
482
483 return { text: lines.join('\n') }
484 })
485
486 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
487 const beneath = await next(e)
488 const { value: isHidden = false } = await $.state.get(HIDDEN)
489 const { value: reading } = await $.state.get(READING)
490
491 if (e.props.hasSurvey || isHidden || reading === null || reading === undefined) {
492 return beneath
493 }
494
495 const now = await $.clock.now()
496 const { state, stats } = reading
497 const current = state.current
498 const medianMs = current === null ? null : (stats[current]?.median ?? null)
499 const elapsed = current === null || state.enteredAt === null ? 0 : now - state.enteredAt
500 const color: RowColor =
501 current === null || medianMs === null
502 ? 'gray'
503 : elapsed < medianMs
504 ? 'green'
505 : elapsed <= 2 * medianMs
506 ? 'yellow'
507 : 'red'
508 const pill =
509 current !== null
510 ? `${current} ${PHASES[current]}`
511 : state.isDone && state.firstEnterAt !== null && state.lastExitAt !== null
512 ? `done ${until(state.lastExitAt - state.firstEnterAt)}`
513 : 'between phases'
514 const pace = current !== null ? ` ${until(elapsed)}/${medianMs === null ? '—' : until(medianMs)}` : ''
515 const phase = `${pill}${pace}`
516 const eta = state.isDone ? null : etaOf(state, stats, now)
517 const left = state.isDone ? '' : eta === null ? ' · ETA —' : ` · ~${until(eta)} left`
518 const rounds = Object.entries(state.phases)
519 .filter(([, one]) => one.rounds >= 2)
520 .map(([p, one]) => `R${p}×${one.rounds}`)
521 .join(' ')
522 const tally = await tallyFor($, reading)
523 const tok = tally === null ? '' : ` · ${tokensShort(tally.fresh ?? 0)}`
524 const tail = `${left}${tok}${rounds === '' ? '' : ` · ${rounds}`}`
525 const title = `#${reading.ticket} ${reading.tier}`
526 const cells = cellsOf(state)
527 // One string for both surfaces: the terminal row, and the desktop drawing's alt.
528 const text = `${title} ${cells} ${phase}${tail}`
529 const { Box, Text } = $.ui.resolve(e)
530
531 if (e.surface === 'desktop') {
532 const { Svg } = $.ui.resolve({ ...e, surface: 'desktop' })
533 const W = Math.max(180, Math.min(1600, (e.props.bodyColumns || 100) * 8 - 96))
534 // The stats after the pill: the pace, then the ETA, tokens and rounds, as dim text.
535 const statsText = `${pace.trim()}${tail}`.replace(/^ · /, '')
536 const source = rowSvg(
537 {
538 title,
539 cells,
540 current,
541 pill,
542 next: current !== null && current < 8 ? `${current + 1} ${PHASES[current + 1]}` : '',
543 color,
544 stats: statsText,
545 progress: current === null || medianMs === null || medianMs <= 0 ? null : elapsed / medianMs,
546 enteredAgoMs: current === null || state.enteredAt === null ? null : elapsed,
547 isDone: state.isDone,
548 },
549 W,
550 )
551
552 return (
553 <Box flexDirection="column">
554 {beneath}
555 <Svg source={source} alt={text} width={W} height={ROW_H} />
556 </Box>
557 )
558 }
559
560 return (
561 <Box flexDirection="column">
562 {beneath}
563 <Box>
564 <Text color={color} bold>
565 {`${title} `}
566 </Text>
567 <Text color={color}>{cells}</Text>
568 <Text color={color} bold>{` ${phase}`}</Text>
569 <Text dimColor>{tail}</Text>
570 </Box>
571 </Box>
572 )
573 })
574}
575hooks/row.ts 439 lines1import type { MascotKey } from './mascot'
2import { MASCOT_CSS, MASCOT_H, MASCOT_W, mascot } from './mascot'
3
4export type RowColor = 'green' | 'yellow' | 'red' | 'gray'
5
6export type RowInput = {
7 /** `#12345 full` */
8 title: string
9 /** cellsOf(state): eight of ◆ ◈ ◇ */
10 cells: string
11 current: number | null
12 /** `5 Implement`, `between phases`, `done 3h0m` */
13 pill: string
14 /** The next phase's label for its ghost (`6 Review`); '' when there is no next phase. */
15 next: string
16 color: RowColor
17 /** `23m/41m · ~2h10m left · 184k · R6×2`, dim; '' when nothing */
18 stats: string
19 /** Time in the current phase over its median; null with no median. Drives the ghost's strength. */
20 progress: number | null
21 /** How long ago the current phase was entered; the slide-in plays while this is short. */
22 enteredAgoMs: number | null
23 isDone: boolean
24}
25
26export const ROW_H = 22
27/** The mascot's slot at the row's end: the GIF plus a little air. */
28const MASCOT_BOX = MASCOT_W + 2
29export const BAR_MIN = 96
30const BAR_H = 16
31const CELL = 3
32/** How far a phase's arrow edge points into the next, and the gap between the two. */
33const ARROW = 5
34const GAP = 1.5
35/** Label padding inside a chevron, both sides together. */
36const PAD = 24
37/** The labels are bold: they run this much wider than the regular-weight estimate. */
38const BOLD = 1.1
39const MIN_W = 180
40const MAX_W = 1600
41/** The stats column is as wide as this, whatever the stats say, so the bar keeps one length. */
42const STATS_REF = '1h57m/30m · ~15h46m left · 971k · R6×2'
43/** The slide-in on entering a phase, and how long after the enter a redraw still shows it. */
44const SLIDE_S = 0.55
45const SLIDE_WINDOW_MS = 4000
46/** The ghost of the next phase: barely there as the phase starts, this strong at the median. */
47const GHOST_MIN = 0.05
48const GHOST_MAX = 0.55
49const GHOST_NO_MEDIAN = 0.12
50/**
51 * The tail falls off with distance behind the chevron, in segments: nearly solid and bold right
52 * behind it, thinning to a few faint dots a reach back. The reach grows with the phase, from
53 * about 2 segments in phase 2 to about 4.5 in phase 8: later phases trail a longer tail.
54 */
55const reachFor = (index: number): number => 1.4 + 0.45 * index
56/** The pulse's front is ragged by up to this many seconds a column. */
57const PULSE_JITTER = 0.5
58/**
59 * Each dot blinks on one of these rhythms, picked by noise: a soft shimmer, a slow fade almost
60 * out and back, or a quick blink. [keyframes, duration s, delay s]
61 */
62const TWINKLES: readonly (readonly [string, number, number])[] = [
63 ['ptw', 2.2, -0.0], ['ptx', 3.7, -1.9], ['pty', 2.9, -0.4], ['ptw', 1.7, -1.1],
64 ['ptx', 4.6, -3.2], ['ptw', 3.1, -2.6], ['pty', 1.9, -1.5], ['ptx', 2.6, -0.8],
65 ['ptw', 2.7, -2.0], ['pty', 3.4, -2.9], ['ptx', 5.3, -4.4], ['ptw', 1.4, -0.3],
66]
67const TWINKLE_CSS =
68 TWINKLES.map(([name, dur, delay], k) => `.t${k}{animation:${name} ${dur}s ease-in-out ${delay}s infinite}`).join('') +
69 '@keyframes ptw{0%,100%{opacity:1}50%{opacity:.55}}' +
70 '@keyframes ptx{0%,100%{opacity:1}40%,55%{opacity:.08}}' +
71 '@keyframes pty{0%,82%,100%{opacity:1}88%{opacity:.15}}'
72const TWINKLE_CLASSES = TWINKLES.map((_, k) => `.t${k}`).join(',')
73/** The tail's pulse: one sweep every PULSE_S seconds, travelling back PULSE_SPEED px a second. */
74const PULSE_S = 2.4
75const PULSE_SPEED = 160
76/**
77 * Done: the whole bar fills with dots at one density, every dot flashing on its own beat, and bright
78 * sparks popping across the field. A dot takes a rhythm (fK: keyframes and duration) and a start
79 * (dJ: delay), both picked by noise, so no two patches of the bar ever flash in step. [keyframes, duration s]
80 */
81const DONE_DENSITY = 0.8
82/** The share of the field's dots that also carry a spark. */
83const DONE_SPARKS = 0.16
84const FLASHES: readonly (readonly [string, number])[] = [
85 ['pfa', 1.3], ['pfb', 2.1], ['pfa', 0.9], ['pfc', 1.7], ['pfb', 1.1], ['pfa', 2.6],
86 ['pfc', 1.5], ['pfb', 0.8], ['pfa', 1.9], ['pfc', 2.3], ['pfb', 1.4], ['pfa', 1.0],
87]
88/** The sparks' beats: a pop from nothing to full and back, on durations that never line up. */
89const SPARKS: readonly number[] = [1.1, 1.7, 2.3, 1.3, 2.9, 1.9, 3.3, 1.5, 2.1, 2.7, 1.2, 3.1]
90/** The starts, in seconds back into the beat; the delay classes come last so they beat the shorthands. */
91const STARTS: readonly number[] = [0, 0.31, 0.57, 0.92, 1.13, 1.48, 1.71, 2.04, 2.26, 2.59, 2.83, 3.17, 3.42, 3.76, 3.99, 4.33]
92const SPARK = '#e6fff0'
93const DONE_CLASSES = [...FLASHES.map((_, k) => `.f${k}`), ...SPARKS.map((_, k) => `.s${k}`)].join(',')
94const DONE_CSS =
95 FLASHES.map(([name, dur], k) => `.f${k}{animation:${name} ${dur}s linear infinite}`).join('') +
96 SPARKS.map((dur, k) => `.s${k}{animation:psk ${dur}s linear infinite}`).join('') +
97 STARTS.map((delay, j) => `.d${j}{animation-delay:-${delay}s}`).join('') +
98 '.sp{opacity:0}' +
99 '@keyframes pfa{0%,100%{opacity:.4}5%,12%{opacity:1}}' +
100 '@keyframes pfb{0%,100%{opacity:.5}8%{opacity:1}22%{opacity:.5}}' +
101 '@keyframes pfc{0%,100%{opacity:.35}3%,7%{opacity:1}14%,18%{opacity:1}}' +
102 '@keyframes psk{0%,100%{opacity:0}3%,9%{opacity:1}}' +
103 `@media (prefers-reduced-motion: reduce){${DONE_CLASSES}{animation:none}}`
104const FONT = "-apple-system,BlinkMacSystemFont,'SF Pro Text','Segoe UI',sans-serif"
105const COLORS: Record<RowColor, string> = { green: '#3B9C5F', yellow: '#C9A227', red: '#D0453F', gray: '#9a9a96' }
106const DONE = '#5fbf8f'
107
108const xml = (s: string): string =>
109 s.replace(/[&<>"]/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"' })[c] ?? c)
110
111const charEm = (ch: string): number => (/[\s.,:;'|!il1()[\]·]/.test(ch) ? 0.3 : /[A-Z#mw@%]/.test(ch) ? 0.72 : 0.56)
112const textWidth = (s: string, size: number): number => [...s].reduce((w, ch) => w + charEm(ch) * size, 0)
113const fitText = (s: string, size: number, maxW: number): string => {
114 if (textWidth(s, size) <= maxW) {
115 return s
116 }
117
118 let out = ''
119
120 for (const ch of s) {
121 if (textWidth(`${out}${ch}…`, size) > maxW) {
122 break
123 }
124
125 out += ch
126 }
127
128 return `${out}…`
129}
130
131/** Deterministic noise so the dither does not shimmer between redraws. */
132const noise = (x: number, y: number): number => {
133 const s = Math.sin(x * 12.9898 + y * 78.233) * 43758.5453
134
135 return s - Math.floor(s)
136}
137
138
139/**
140 * What fits. The bar's length depends on the row's width and the title alone, never on
141 * the phase or the stats: the stats get a fixed column. When the bar would go under
142 * BAR_MIN the stats column goes first, then the Clawd, then the title's share drops
143 * from 40 % to 25 %. Done is the one exception: nothing follows it that could move the
144 * bar again, so the column shrinks to its own text and the bar takes the rest.
145 */
146export const layout = (input: Pick<RowInput, 'title'> & Partial<Pick<RowInput, 'isDone' | 'pill' | 'stats'>>, W0: number) => {
147 const W = Math.max(MIN_W, Math.min(MAX_W, W0))
148 const doneText = input.isDone === true ? [input.pill ?? '', input.stats ?? ''].filter(one => one !== '').join(' · ') : null
149 const statsW = doneText === null
150 ? Math.round(textWidth(STATS_REF, 12.5)) + 10
151 : Math.round(textWidth(doneText, 12.5)) + 10
152 let share = 0.4
153 let showStats = true
154 let showCrab = true
155 const measure = (): { title: string; barX: number; barW: number } => {
156 const title = fitText(input.title, 13, Math.max(40, W * share))
157 const barX = Math.round(16 + textWidth(title, 13) + 12)
158 const barW = W - barX - (showStats ? statsW : 0) - 10 - (showCrab ? MASCOT_BOX + 6 : 0)
159
160 return { title, barX, barW }
161 }
162 let m = measure()
163
164 if (m.barW < BAR_MIN && showStats) {
165 showStats = false
166 m = measure()
167 }
168
169 if (m.barW < BAR_MIN && showCrab) {
170 showCrab = false
171 m = measure()
172 }
173
174 if (m.barW < BAR_MIN) {
175 share = 0.25
176 m = measure()
177 }
178
179 return { W, title: m.title, barX: m.barX, barW: Math.max(BAR_MIN, m.barW), showStats, showCrab, statsW }
180}
181
182/**
183 * One phase as a chevron from x0 to x1: notched on the left where the phase before
184 * points in (a chevron starting at the bar's start is flat, and the bar's round end
185 * rounds it), pointed on the right; the last phase ends flat, so the bar's round end
186 * closes the cycle.
187 */
188const chevron = (x0: number, x1: number, isFirst: boolean, isLast: boolean): string => {
189 const m = BAR_H / 2
190 const left = isFirst ? [[x0, BAR_H], [x0, 0]] : [[x0 + GAP, BAR_H], [x0 + GAP + ARROW, m], [x0 + GAP, 0]]
191 const right = isLast ? [[x1, 0], [x1, BAR_H]] : [[x1, 0], [x1 + ARROW, m], [x1, BAR_H]]
192
193 return [...left, ...right].map(([x, y]) => `${x!.toFixed(1)},${y}`).join(' ')
194}
195
196/** The room a label takes in a chevron starting at x0: its padded text, plus the notch unless flat. */
197const roomFor = (label: string, isFlat: boolean): number => textWidth(label, 11) * BOLD + PAD + (isFlat ? 0 : GAP + ARROW)
198
199/**
200 * The current phase's chevron. Its right edge is its own phase's boundary, always: a label
201 * that does not fit its segment stretches the chevron back over the phases before, as far
202 * as it needs, and never forward. A label that does not fit even from the bar's start
203 * shows the phase number alone.
204 */
205export const pillOf = (index: number, pill: string, current: number, seg: number) => {
206 const x1 = (index + 1) * seg
207 const own = index * seg
208
209 if (roomFor(pill, index === 0) <= seg) {
210 return { x0: own, x1, label: pill }
211 }
212
213 const back = x1 - roomFor(pill, false)
214
215 if (back >= 0) {
216 return { x0: back, x1, label: pill }
217 }
218
219 if (roomFor(pill, true) <= x1) {
220 return { x0: 0, x1, label: pill }
221 }
222
223 return { x0: own, x1, label: String(current) }
224}
225
226/** The next phase's label in its ghost: the name when it fits the segment, else the number. */
227const ghostLabel = (next: string, seg: number, isFlat: boolean): string =>
228 roomFor(next, isFlat) <= seg ? next : next.split(' ')[0] ?? next
229
230/**
231 * The worked phases as a dither, densest and boldest right behind the current phase and
232 * thinning out further back, with a pulse running from the current phase back. Each column is one group: its
233 * fill-opacity is the fade, its animation delay (growing with distance) is the pulse.
234 */
235const tail = (cells: string[], seg: number, color: string, anchor: number, reach: number): { clips: string[]; parts: string[] } => {
236 const clips: string[] = []
237 const parts: string[] = []
238 const rows = Math.floor(BAR_H / CELL)
239
240 cells.forEach((cell, i) => {
241 if (cell !== '◆') {
242 return
243 }
244
245 const x0 = i * seg
246 const columns: string[] = []
247
248 // One dot grid across the whole bar; each phase takes the columns from its start through its
249 // arrow tip, and its chevron clips them, so the dots run right up to the next phase's edge.
250 for (let c = Math.max(0, Math.floor((x0 - 1) / CELL)); c * CELL + 1 < x0 + seg + ARROW; c += 1) {
251 const x = c * CELL + 1
252 // Segments behind the chevron; 0 right behind it.
253 const behind = Math.max(0, anchor - x) / seg
254 const falloff = Math.exp(-behind / reach)
255 const density = 0.06 + 0.9 * falloff
256 const dots: string[] = []
257
258 for (let r = 0; r < rows; r += 1) {
259 if (noise(c, r) < density) {
260 dots.push(`<rect class="t${Math.floor(noise(r + 31, c) * TWINKLES.length)}" x="${x.toFixed(1)}" y="${r * CELL + 1}" width="2" height="2"/>`)
261 }
262 }
263
264 if (dots.length > 0) {
265 const fade = 0.2 + 0.8 * falloff
266 // The pulse dims the far tail more than the dots right behind the chevron, which stay bold.
267 const band = behind < 1 ? 'w0' : behind < 2.5 ? 'w1' : 'w2'
268 // The pulse leaves the current phase and travels back at PULSE_SPEED, its front ragged by
269 // up to PULSE_JITTER s; a negative delay puts every column in its steady place from the first frame.
270 const lag = (anchor - x) / PULSE_SPEED + (noise(c, 97) - 0.5) * PULSE_JITTER
271 const delay = (-(PULSE_S - (((lag % PULSE_S) + PULSE_S) % PULSE_S))).toFixed(2)
272 columns.push(`<g class="w ${band}" fill-opacity="${fade.toFixed(2)}" style="animation-delay:${delay}s">${dots.join('')}</g>`)
273 }
274 }
275
276 clips.push(`<clipPath id="cs${i}"><polygon points="${chevron(x0, x0 + seg, i === 0, i === 7)}"/></clipPath>`)
277 parts.push(`<g class="dots" data-cell="${i + 1}" fill="${color}" clip-path="url(#cs${i})">${columns.join('')}</g>`)
278 })
279
280 return { clips, parts }
281}
282
283/**
284 * The done field: dots over the whole bar at one density (no tail, no fade), each on a rhythm and a
285 * start of its own, and a bright spark over some of them. One group each; the bar's round end clips them.
286 */
287const field = (barW: number, color: string): string => {
288 const rows = Math.floor(BAR_H / CELL)
289 // One path per rhythm-and-start pair: every dot on the same beat is one element, so the
290 // field stays well under the source cap at the widest row.
291 const dots = new Map<string, string[]>()
292 const sparks = new Map<string, string[]>()
293 const add = (into: Map<string, string[]>, classes: string, d: string): void => {
294 const list = into.get(classes) ?? []
295 list.push(d)
296 into.set(classes, list)
297 }
298
299 for (let c = 0; c * CELL + 1 < barW; c += 1) {
300 const x = c * CELL + 1
301
302 for (let r = 0; r < rows; r += 1) {
303 if (noise(c, r) >= DONE_DENSITY) {
304 continue
305 }
306
307 const f = Math.floor(noise(r + 31, c) * FLASHES.length)
308 const d = Math.floor(noise(c + 53, r + 7) * STARTS.length)
309 add(dots, `f${f} d${d}`, `M${x} ${r * CELL + 1}h2v2h-2z`)
310
311 if (noise(r + 11, c + 89) < DONE_SPARKS) {
312 const k = Math.floor(noise(c + 5, r + 67) * SPARKS.length)
313 const start = Math.floor(noise(r + 41, c + 19) * STARTS.length)
314 add(sparks, `sp s${k} d${start}`, `M${x - 0.5} ${r * CELL + 0.5}h3v3h-3z`)
315 }
316 }
317 }
318
319 const paths = (of: Map<string, string[]>): string =>
320 [...of].map(([classes, ds]) => `<path class="${classes}" d="${ds.join('')}"/>`).join('')
321
322 return `<g class="dots done" fill="${color}">${paths(dots)}</g><g class="sparks" fill="${SPARK}">${paths(sparks)}</g>`
323}
324
325/** The mascot for the row's state: the current phase's, else waiting between phases, else done. */
326export const mascotKey = (input: Pick<RowInput, 'current' | 'isDone'>): MascotKey =>
327 input.current !== null && input.current >= 1 && input.current <= 8 ? (String(input.current) as MascotKey) : input.isDone ? 'done' : 'between'
328
329const mascotOf = (input: RowInput, x: number, y: number): string =>
330 mascot(mascotKey(input), x, y, { isWalking: input.current !== null, isDone: input.isDone })
331
332const label = (x: number, text: string, fill: string, extra = ''): string =>
333 `<text${extra} x="${x.toFixed(1)}" y="${BAR_H / 2 + 4}" text-anchor="middle" font-family="${FONT}" font-size="11" font-weight="600" fill="${fill}">${xml(text)}</text>`
334
335/** One SVG row: dot, title, the eight-segment bar with the current chevron and the next one's ghost, the stats, the Clawd. */
336export const rowSvg = (input: RowInput, W0: number): string => {
337 const l = layout(input, W0)
338 const color = input.isDone ? DONE : COLORS[input.color]
339 const y0 = (ROW_H - BAR_H) / 2
340 const seg = l.barW / 8
341 const cells = [...input.cells].slice(0, 8)
342 const at = cells.indexOf('◈')
343 const pill = at >= 0 && input.current !== null ? pillOf(at, input.pill, input.current, seg) : null
344 const lastWorked = cells.lastIndexOf('◆')
345 // The tail is boldest where it meets the chevron, or at its own end with no phase open.
346 const anchor = pill !== null ? pill.x0 + GAP : (lastWorked + 1) * seg
347 // Done fills the whole bar whatever was entered: the celebration is the ticket's, not a phase's.
348 const { clips, parts } = input.isDone
349 ? { clips: [], parts: [field(l.barW, color)] }
350 : tail(cells, seg, color, anchor, reachFor(at >= 0 ? at : lastWorked + 1))
351 const hasGhost = at >= 0 && at < 7 && input.next !== ''
352 let css = ''
353
354 parts.unshift(...cells.map((_, i) => `<rect class="seg" x="${(i * seg).toFixed(1)}" y="0" width="${seg.toFixed(1)}" height="${BAR_H}" fill="none"/>`))
355
356 if (hasGhost) {
357 const gx0 = (at + 1) * seg
358 const strength = input.progress === null ? GHOST_NO_MEDIAN : GHOST_MIN + (GHOST_MAX - GHOST_MIN) * Math.max(0, Math.min(1, input.progress))
359 parts.push(
360 `<g class="ghost" opacity="${strength.toFixed(3)}">` +
361 `<polygon points="${chevron(gx0, gx0 + seg, false, at + 1 === 7)}" fill="${color}"/>` +
362 label(gx0 + (GAP + ARROW + seg) / 2, ghostLabel(input.next, seg, false), '#ffffff') +
363 `</g>`,
364 )
365 }
366
367 // An arrow edge marks every phase boundary, drawn where a chevron's arrow sits (x to x + ARROW),
368 // except where the current chevron or the ghost already draws one.
369 for (let b = 1; b < 8; b += 1) {
370 const x = b * seg
371
372 if (input.isDone) {
373 // A full bar is one field: no boundaries.
374 break
375 }
376
377 if (pill !== null && x >= pill.x0 - 1 && x <= pill.x1 + ARROW + 1) {
378 continue
379 }
380
381 if (hasGhost && b === at + 2) {
382 continue
383 }
384
385 parts.push(`<polyline class="tick" points="${x.toFixed(1)},3 ${(x + ARROW).toFixed(1)},${BAR_H / 2} ${x.toFixed(1)},${BAR_H - 3}"/>`)
386 }
387
388 if (pill !== null) {
389 const isFlat = pill.x0 <= 0.5
390 const inset = isFlat ? 0 : GAP + ARROW
391 // A fresh enter slides the chevron in from the phase before; a negative delay puts a later
392 // redraw at the right point of the slide, past its end once the window closes.
393 const isSliding = input.enteredAgoMs !== null && input.enteredAgoMs < SLIDE_WINDOW_MS && at > 0
394
395 if (isSliding) {
396 const ago = (-(input.enteredAgoMs ?? 0) / 1000).toFixed(2)
397 css =
398 `@keyframes ps{from{transform:translateX(-${seg.toFixed(1)}px)}to{transform:none}}@keyframes pf{0%,60%{opacity:0}100%{opacity:1}}` +
399 `.slide{animation:ps ${SLIDE_S}s cubic-bezier(.2,.8,.2,1) ${ago}s both}.slide text{animation:pf ${SLIDE_S}s linear ${ago}s both}` +
400 `@media (prefers-reduced-motion: reduce){.slide,.slide text{animation:none}}`
401 }
402
403 parts.push(
404 `<g${isSliding ? ' class="slide"' : ''}>` +
405 `<polygon class="pill" points="${chevron(pill.x0, pill.x1, isFlat, at === 7)}" fill="${color}"/>` +
406 label((pill.x0 + inset + pill.x1) / 2, pill.label, '#ffffff') +
407 `</g>`,
408 )
409 }
410
411 // Between phases and done, the phase text leads the stats column.
412 const statsText = input.current === null ? [input.pill, input.stats].filter(one => one !== '').join(' · ') : input.stats
413 const statsX = l.W - (l.showCrab ? MASCOT_BOX + 6 : 0)
414 const stats = l.showStats && statsText !== ''
415 ? `<text class="m" x="${statsX}" y="${ROW_H / 2 + 4.5}" text-anchor="end" font-family="${FONT}" font-size="12.5" font-variant-numeric="tabular-nums">${xml(fitText(statsText, 12.5, l.statsW - 10))}</text>`
416 : ''
417 const sprite = l.showCrab
418 ? mascotOf(input, l.W - MASCOT_W, (ROW_H - MASCOT_H) / 2)
419 : ''
420
421 return (
422 `<svg xmlns="http://www.w3.org/2000/svg" width="${l.W}" height="${ROW_H}" viewBox="0 0 ${l.W} ${ROW_H}">` +
423 `<style>` +
424 `.t{fill:#1f1f1f}.m{fill:#8a8a8a}.k{fill:#e4e4e2}.tick{fill:none;stroke:#b4b4b0;stroke-width:1.5;stroke-linecap:round;stroke-linejoin:round}` +
425 `@media (prefers-color-scheme: dark){.t{fill:#ececec}.m{fill:#9a9a9a}.k{fill:#2c2c2c}.tick{stroke:#5a5a5a}}` +
426 `${TWINKLE_CSS}` +
427 `.w{animation:pw2 ${PULSE_S}s ease-out infinite both}.w0{animation-name:pw0}.w1{animation-name:pw1}` +
428 `@keyframes pw0{0%{opacity:1}22%,100%{opacity:.85}}@keyframes pw1{0%{opacity:1}22%,100%{opacity:.62}}@keyframes pw2{0%{opacity:1}22%,100%{opacity:.45}}` +
429 `${css}${input.isDone ? DONE_CSS : ''}` +
430 `@media (prefers-reduced-motion: reduce){${TWINKLE_CLASSES},.w{animation:none}}` +
431 `${MASCOT_CSS}</style>` +
432 `<defs><clipPath id="pc"><rect x="0" y="0" width="${l.barW}" height="${BAR_H}" rx="${BAR_H / 2}"/></clipPath>${clips.join('')}</defs>` +
433 `<circle cx="5" cy="${ROW_H / 2}" r="4" fill="${color}"/>` +
434 `<text class="t" x="16" y="${ROW_H / 2 + 4.5}" font-family="${FONT}" font-size="13" font-weight="500">${xml(l.title)}</text>` +
435 `<g transform="translate(${l.barX},${y0})"><rect class="k" width="${l.barW}" height="${BAR_H}" rx="${BAR_H / 2}"/><g clip-path="url(#pc)">${parts.join('')}</g></g>` +
436 `${stats}${sprite}</svg>`
437 )
438}
439hooks/phases.ts 377 lines1import type { PhaseInfo, PhaseRow, PhaseStats, TicketPhases } from '../types'
2
3const isEvent = (value: unknown): value is PhaseRow['event'] =>
4 value === 'enter' || value === 'exit'
5
6/**
7 * Reads the sink's lines into rows: the stamp becomes epoch ms, the phase a
8 * number 1–8. A line that fails any of that is skipped and counted. Rows come
9 * back in time order; equal stamps keep the file's order.
10 */
11export const parseRows = (text: string): { rows: PhaseRow[]; skipped: number } => {
12 const rows: PhaseRow[] = []
13 let skipped = 0
14 // A row's order is its place among the rows with the same stamp, not in the whole file:
15 // when only the newest part of the sink is read, rows before it drop out, and an order
16 // counted from the start of the text would shift (and with it a tally's identity).
17 const ties = new Map<number, number>()
18
19 for (const raw of text.split('\n')) {
20 if (raw.trim() === '') {
21 continue
22 }
23
24 let one: unknown
25 try {
26 one = JSON.parse(raw)
27 } catch {
28 skipped += 1
29 continue
30 }
31
32 if (typeof one !== 'object' || one === null) {
33 skipped += 1
34 continue
35 }
36
37 const { ts, session, ticket, phase, event } = one as Record<string, unknown>
38 const at = typeof ts === 'string' ? Date.parse(ts) : Number.NaN
39 const n =
40 typeof phase === 'number' ? phase : typeof phase === 'string' ? Number(phase) : Number.NaN
41
42 if (
43 !Number.isFinite(at) ||
44 typeof session !== 'string' ||
45 session === '' ||
46 (typeof ticket !== 'string' && typeof ticket !== 'number') ||
47 !Number.isInteger(n) ||
48 n < 1 ||
49 n > 8 ||
50 !isEvent(event)
51 ) {
52 skipped += 1
53 continue
54 }
55
56 const order = (ties.get(at) ?? 0) + 1
57 ties.set(at, order)
58 rows.push({ at, session, ticket: String(ticket), phase: n, event, order })
59 }
60
61 rows.sort((a, b) => a.at - b.at || a.order - b.order)
62
63 return { rows, skipped }
64}
65
66export const LAST_PHASE = 8
67/** Canonical phase names, index 1–8 (index 0 unused). */
68export const PHASES: readonly string[] = [
69 '',
70 'Recall',
71 'Research',
72 'Plan',
73 'Test',
74 'Implement',
75 'Review',
76 'Ship',
77 'Compound',
78]
79
80const blank = (): PhaseInfo => ({
81 enters: 0,
82 exits: 0,
83 closed: 0,
84 left: 0,
85 rounds: 1,
86 open: [],
87 openedAt: null,
88 openedOrder: null,
89})
90
91/**
92 * Walks one ticket's rows into its phase state. A ticket is in one phase at a
93 * time: an enter opens its phase and leaves every other open phase (a skipped
94 * exit, whoever's it was); an enter on a phase already open is a resume that
95 * keeps the phase's start; an exit closes the phase for everyone; an exit with
96 * nothing open is an orphan that changes no state.
97 */
98export const walk = (rows: readonly PhaseRow[]): TicketPhases => {
99 const phases: Record<number, PhaseInfo> = {}
100 const sessions = new Set<string>()
101 let firstEnterAt: number | null = null
102 let lastExitAt: number | null = null
103 let lastPhase: number | null = null
104 let lastEvent: PhaseRow['event'] | null = null
105
106 for (let p = 1; p <= LAST_PHASE; p += 1) {
107 phases[p] = blank()
108 }
109
110 for (const row of rows) {
111 const info = phases[row.phase]!
112 sessions.add(row.session)
113 lastPhase = row.phase
114 lastEvent = row.event
115
116 if (row.event === 'enter') {
117 info.enters += 1
118 firstEnterAt ??= row.at
119
120 if (info.open.length > 0) {
121 if (!info.open.includes(row.session)) {
122 info.open.push(row.session)
123 }
124
125 continue
126 }
127
128 for (let q = 1; q <= LAST_PHASE; q += 1) {
129 const other = phases[q]!
130
131 if (q !== row.phase && other.open.length > 0) {
132 other.open = []
133 other.openedAt = null
134 other.openedOrder = null
135 other.left += 1
136 }
137 }
138
139 if (info.left > 0) {
140 info.rounds += 1
141 }
142
143 info.open.push(row.session)
144 info.openedAt = row.at
145 info.openedOrder = row.order
146 continue
147 }
148
149 info.exits += 1
150
151 if (info.open.length === 0) {
152 continue
153 }
154
155 info.open = []
156 info.openedAt = null
157 info.openedOrder = null
158 info.closed += 1
159 info.left += 1
160 lastExitAt = row.at
161 }
162
163 let current: number | null = null
164 let enteredAt: number | null = null
165 let enteredOrder: number | null = null
166
167 for (let p = 1; p <= LAST_PHASE; p += 1) {
168 const { openedAt, openedOrder } = phases[p]!
169
170 if (
171 openedAt !== null &&
172 (enteredAt === null ||
173 openedAt > enteredAt ||
174 (openedAt === enteredAt && (openedOrder ?? 0) > (enteredOrder ?? 0)))
175 ) {
176 current = p
177 enteredAt = openedAt
178 enteredOrder = openedOrder
179 }
180 }
181
182 const last = phases[LAST_PHASE]!
183 const isDone =
184 current === null && lastPhase === LAST_PHASE && lastEvent === 'exit' && last.closed > 0
185
186 return {
187 ticket: rows[0]?.ticket ?? '',
188 phases,
189 current,
190 enteredAt,
191 enteredOrder,
192 lastPhase,
193 lastEvent,
194 isDone,
195 firstEnterAt,
196 lastExitAt,
197 sessions: [...sessions],
198 }
199}
200
201/** The middle value; null under two values, since one sample is no pace. */
202export const median = (values: readonly number[]): number | null => {
203 if (values.length < 2) {
204 return null
205 }
206
207 const sorted = [...values].sort((a, b) => a - b)
208 const mid = Math.floor(sorted.length / 2)
209
210 return sorted.length % 2 === 1 ? sorted[mid]! : (sorted[mid - 1]! + sorted[mid]!) / 2
211}
212
213/**
214 * Own pace per phase over every ticket: each exit pairs with its own session's
215 * most recent still-unpaired enter, else the most recent of any session, so a
216 * resumed phase yields one sample, interleaved sessions measure their own
217 * stretches, and an orphan exit yields none.
218 */
219export const statsOf = (rows: readonly PhaseRow[]): Record<number, PhaseStats> => {
220 const open = new Map<string, { at: number; session: string }[]>()
221 const durations: Record<number, { ms: number; ticket: string }[]> = {}
222
223 for (const row of rows) {
224 const key = `${row.ticket}:${row.phase}`
225 const stack = open.get(key) ?? []
226
227 if (row.event === 'enter') {
228 stack.push({ at: row.at, session: row.session })
229 open.set(key, stack)
230 continue
231 }
232
233 let index = stack.length - 1
234
235 for (let i = stack.length - 1; i >= 0; i -= 1) {
236 if (stack[i]!.session === row.session) {
237 index = i
238 break
239 }
240 }
241
242 const [entered] = index >= 0 ? stack.splice(index, 1) : []
243
244 if (entered === undefined) {
245 continue
246 }
247
248 ;(durations[row.phase] ??= []).push({ ms: row.at - entered.at, ticket: row.ticket })
249 }
250
251 const stats: Record<number, PhaseStats> = {}
252
253 for (let p = 1; p <= LAST_PHASE; p += 1) {
254 const pairs = durations[p] ?? []
255 stats[p] = {
256 median: median(pairs.map(one => one.ms)),
257 pairs: pairs.length,
258 tickets: new Set(pairs.map(one => one.ticket)).size,
259 }
260 }
261
262 return stats
263}
264
265/** The canonical phases still ahead: above the current one, or above the latest row's. */
266export const aheadOf = (state: TicketPhases): number[] => {
267 const from = state.current ?? state.lastPhase ?? 0
268 const ahead: number[] = []
269
270 for (let p = from + 1; p <= LAST_PHASE; p += 1) {
271 ahead.push(p)
272 }
273
274 return ahead
275}
276
277/**
278 * Time left at own pace: the medians of the phases ahead, plus what the current
279 * phase's median still allows. Null until every summed phase has pairs from two
280 * distinct tickets.
281 */
282export const etaOf = (
283 state: TicketPhases,
284 stats: Record<number, PhaseStats>,
285 now: number,
286): number | null => {
287 const summed = [...aheadOf(state), ...(state.current === null ? [] : [state.current])]
288 let ms = 0
289
290 for (const p of summed) {
291 const one = stats[p]
292
293 if (one === undefined || one.median === null || one.tickets < 2) {
294 return null
295 }
296
297 ms +=
298 p === state.current && state.enteredAt !== null
299 ? Math.max(0, one.median - (now - state.enteredAt))
300 : one.median
301 }
302
303 return ms
304}
305
306/** A duration for the row: minutes under an hour, then h+m, then d+h. */
307export const until = (ms: number): string => {
308 const minutes = Math.max(0, Math.floor(ms / 60_000))
309 const days = Math.floor(minutes / 1440)
310 const hours = Math.floor((minutes % 1440) / 60)
311
312 if (days > 0) {
313 return `${days}d${hours}h`
314 }
315
316 return hours > 0 ? `${hours}h${minutes % 60}m` : `${minutes}m`
317}
318
319/** A token count for the row: thousands as k, millions to one decimal, each carrying up. */
320export const tokensShort = (n: number): string => {
321 if (n < 1000) {
322 return `${n}`
323 }
324
325 const k = Math.round(n / 1000)
326
327 if (k < 1000) {
328 return `${k}k`
329 }
330
331 const m = Math.round(n / 100_000) / 10
332
333 return m >= 1000 ? `${Math.round(n / 1_000_000)}M` : `${m.toFixed(1)}M`
334}
335
336/**
337 * The eight cells: below the current phase ◆ where the phase was entered, ◈
338 * the current, ◇ everything above it whatever its history. With no current
339 * phase, ◆ wherever a phase was entered.
340 */
341export const cellsOf = (state: TicketPhases): string => {
342 let out = ''
343
344 for (let p = 1; p <= LAST_PHASE; p += 1) {
345 const entered = state.phases[p]!.enters > 0
346
347 if (state.current === null) {
348 out += entered ? '◆' : '◇'
349 } else if (p === state.current) {
350 out += '◈'
351 } else if (p < state.current) {
352 out += entered ? '◆' : '◇'
353 } else {
354 out += '◇'
355 }
356 }
357
358 return out
359}
360
361/** The `tier:` value inside the leading frontmatter block, or null. */
362export const tierOf = (text: string): string | null => {
363 if (!text.startsWith('---')) {
364 return null
365 }
366
367 const end = text.indexOf('\n---', 3)
368
369 if (end === -1) {
370 return null
371 }
372
373 const match = /^tier:\s*([A-Za-z]+)\s*$/m.exec(text.slice(0, end))
374
375 return match === null ? null : match[1]!.toLowerCase()
376}
377hooks/mascot.ts 269 lines1/**
2 * The row's mascot: the pixel Clawd (a clay slab with two dark eyes, side nubs for arms and
3 * four stubby legs) holding a prop for the phase. Original pixel art on a 25 x 16 cell grid,
4 * one <rect> per cell run; all motion is CSS, so a redraw never moves a pixel, and reduced
5 * motion stops it.
6 *
7 * Cell layout: arms at columns 0-2 and 17-19, body at 3-16 on rows 5-13, hats on rows 0-4,
8 * legs on rows 14-15. A raised right arm sits three rows higher; props reach columns 17-24.
9 */
10
11export type MascotKey = '1' | '2' | '3' | '4' | '5' | '6' | '7' | '8' | 'between' | 'done'
12
13const CLAY = '#E0805A'
14const SHADE = '#C4603F'
15const LIGHT = '#F2A684'
16const INK = '#1F1E1D'
17const WHITE = '#F8F6F1'
18const DONE_BODY = '#5fbf8f'
19const DONE_SHADE = '#3f9f6f'
20const DONE_LIGHT = '#8fd9b3'
21const UNIT = 1.5
22const COLS = 25
23const ROWS = 16
24const BODY_X = 3
25const BODY_Y = 5
26/** How the cell grid is drawn: this scale makes it 21 px tall in the 22 px row. */
27const SCALE = 0.88
28
29/** The mascot's box in the row, px. */
30export const MASCOT_W = Math.ceil(COLS * UNIT * SCALE)
31export const MASCOT_H = Math.ceil(ROWS * UNIT * SCALE)
32
33/** What each mascot is, for the alt text and the tests. */
34export const MASCOT_NAMES: Record<MascotKey, string> = {
35 '1': 'wizard-hat',
36 '2': 'magnifier',
37 '3': 'clipboard',
38 '4': 'flask',
39 '5': 'hammer',
40 '6': 'reading-glasses',
41 '7': 'rocket',
42 '8': 'checkered-flag',
43 between: 'thinking-dots',
44 done: 'party',
45}
46
47type Cells = Map<string, string[]>
48
49/** One cell grid in groups: `put` adds a run of cells to a group, `stamp` a picture from rows of characters. */
50const grid = () => {
51 const groups: Cells = new Map()
52 const put = (x: number, y: number, w: number, h: number, color: string, group = 'bd'): void => {
53 const list = groups.get(group) ?? []
54 // Ink outlines on props turn light in dark mode (`ik`); the eyes and the flag's squares stay dark.
55 const tone = color === INK && group !== 'ey' && !group.startsWith('flag') ? ' class="ik"' : ''
56 list.push(`<rect${tone} x="${x * UNIT}" y="${y * UNIT}" width="${w * UNIT}" height="${h * UNIT}" fill="${color}"/>`)
57 groups.set(group, list)
58 }
59 const stamp = (x: number, y: number, rows: readonly string[], map: Record<string, string>, group = 'bd'): void => {
60 rows.forEach((row, dy) => {
61 let dx = 0
62
63 while (dx < row.length) {
64 const ch = row[dx]!
65 const color = map[ch]
66 let end = dx + 1
67
68 while (end < row.length && row[end] === ch) {
69 end += 1
70 }
71
72 if (color !== undefined) {
73 put(x + dx, y + dy, end - dx, 1, color, group)
74 }
75
76 dx = end
77 }
78 })
79 }
80
81 return { groups, put, stamp }
82}
83
84/** The bare Clawd: slab with a light brow and a dark base, eyes, smile, arms, legs. */
85const body = (g: ReturnType<typeof grid>, isDone: boolean, raisedArm: boolean): void => {
86 const [b, s, l] = isDone ? [DONE_BODY, DONE_SHADE, DONE_LIGHT] : [CLAY, SHADE, LIGHT]
87 g.stamp(BODY_X, BODY_Y, ['llllllllllllll', ...Array<string>(7).fill('bbbbbbbbbbbbbb'), 'ssssssssssssss'], { l, b, s })
88 // Eyes: two 2 x 1 dark bars, their own group so they can blink (and scan, reading).
89 g.put(BODY_X + 2, BODY_Y + 3, 2, 1, INK, 'ey')
90 g.put(BODY_X + 10, BODY_Y + 3, 2, 1, INK, 'ey')
91 // Smile.
92 g.put(BODY_X + 5, BODY_Y + 5, 1, 1, WHITE)
93 g.put(BODY_X + 8, BODY_Y + 5, 1, 1, WHITE)
94 g.put(BODY_X + 6, BODY_Y + 6, 2, 1, WHITE)
95 // Arms: 3 x 3 nubs at the sides; the right one lifts to hold a prop.
96 g.put(0, BODY_Y + 5, 3, 3, b)
97 g.put(0, BODY_Y + 7, 3, 1, s)
98 g.put(17, BODY_Y + (raisedArm ? 2 : 5), 3, 3, b, raisedArm ? 'arm' : 'bd')
99 g.put(17, BODY_Y + (raisedArm ? 4 : 7), 3, 1, s, raisedArm ? 'arm' : 'bd')
100 // Legs: `la` steps while `lb` plants, then the other way.
101 for (const [col, group] of [[1, 'la'], [4, 'lb'], [9, 'la'], [12, 'lb']] as const) {
102 g.put(BODY_X + col, BODY_Y + 9, 1, 1, b, group)
103 g.put(BODY_X + col, BODY_Y + 10, 1, 1, s, group)
104 }
105}
106
107/** Each phase's prop, drawn over the body. Returns whether the right arm is raised to hold it. */
108const PROPS: Record<MascotKey, { raised: boolean; draw: (g: ReturnType<typeof grid>) => void }> = {
109 // Recall: a wizard hat with a gold star, and two sparkles.
110 '1': {
111 raised: false,
112 draw: g => {
113 g.stamp(4, 0, [' pp ', ' ppl ', ' pppypp ', ' ppppppp ', ' dddddddddd '], { p: '#8A60D6', l: '#B99AEF', y: '#F2C14E', d: '#5A3699' }, 'hat')
114 g.stamp(17, 1, [' y ', 'ywy', ' y '], { y: '#F2C14E', w: WHITE }, 'sp')
115 g.put(2, 2, 1, 1, '#8A60D6', 'sp2')
116 },
117 },
118 // Research: a magnifier held up, sweeping.
119 '2': {
120 raised: true,
121 draw: g => {
122 g.stamp(18, 1, [' kkk ', 'kcwwk', 'kcwck', 'kccck', ' kkk ', ' nk ', ' nn ', ' nn '], { k: INK, c: '#9DD2E8', w: WHITE, n: '#9A6A3A' }, 'it')
123 },
124 },
125 // Plan: a clipboard whose three items tick off in turn.
126 '3': {
127 raised: true,
128 draw: g => {
129 g.stamp(18, 1, [' kk ', 'bwkkwb', 'bwwwnb', 'bwwwwb', 'bwwwnb', 'bwwwwb', 'bwwwnb', 'bwwwwb', 'bbbbbb'], { k: INK, b: '#AD7845', w: WHITE, n: '#8A8A8A' }, 'it')
130 for (const [y, group] of [[3, 'tk1'], [5, 'tk2'], [7, 'tk3']] as const) {
131 g.stamp(19, y, ['g g', ' gg'], { g: '#27834C' }, group)
132 }
133 },
134 },
135 // Test: a flask of green liquid, bubbles rising out of it.
136 '4': {
137 raised: true,
138 draw: g => {
139 g.stamp(18, 2, [' gggg ', ' cw ', ' cw ', ' kcck ', 'kqqqqk', 'kqwqqk', 'kqqqqk', ' kkkk '], { g: '#A8B5BE', k: INK, c: '#BFE3F2', q: '#5CCB8A', w: WHITE }, 'it')
140 g.put(20, 4, 1, 1, '#5CCB8A', 'bu1')
141 g.put(21, 3, 1, 1, '#5CCB8A', 'bu2')
142 g.put(22, 8, 1, 1, WHITE, 'foam')
143 },
144 },
145 // Implement: a hammer tapping, a spark at each tap.
146 '5': {
147 raised: true,
148 draw: g => {
149 g.stamp(16, 3, ['llllk', 'kkkkk', ' nn ', ' nn ', ' nn ', ' nn ', ' nn '], { l: '#A8B5BE', k: '#56626D', n: '#AD7845' }, 'it')
150 g.stamp(21, 8, [' lll', ' kk ', 'kkkk'], { l: '#A8B5BE', k: INK }, 'anvil')
151 g.put(23, 7, 1, 1, '#E9B63D', 'sp')
152 g.put(24, 6, 1, 1, '#E9B63D', 'sp2')
153 },
154 },
155 // Review: reading glasses with light lenses; the eyes, drawn over them, scan the lines.
156 '6': {
157 raised: false,
158 draw: g => {
159 g.stamp(BODY_X, BODY_Y + 1, [' kkkkk kkkkk ', 'kkccckkkkkccckk', ' kccck kccck ', ' kkkkk kkkkk '], { k: INK, c: '#DCEFF7' }, 'gl')
160 },
161 },
162 // Ship: a rocket on the pad beside, launching and coming round again.
163 '7': {
164 raised: false,
165 draw: g => {
166 g.stamp(19, 5, [' r ', ' rwr ', ' www ', ' wbw ', ' www ', 'rwwwr', 'rkwkr'], { r: '#D95650', w: WHITE, b: '#4C8DF6', k: '#A8B5BE' }, 'rk')
167 g.stamp(20, 12, ['oyo', ' y ', ' o '], { o: '#EF843A', y: '#FFE08A' }, 'fl')
168 g.put(18, 14, 2, 1, '#A8B5BE', 'sm1')
169 g.put(22, 14, 2, 1, '#A8B5BE', 'sm2')
170 g.put(18, 15, 6, 1, '#8A8A8A')
171 },
172 },
173 // Compound: the checkered flag at the finish line, waving.
174 '8': {
175 raised: true,
176 draw: g => {
177 g.put(19, 0, 1, 8, INK, 'pole')
178 g.stamp(20, 0, ['kwkwk', 'wkwkw', 'kwkwk', 'wkwkw'], { k: INK, w: WHITE }, 'flag')
179 },
180 },
181 // Between phases: thinking dots above the head.
182 between: {
183 raised: false,
184 draw: g => {
185 g.put(6, 2, 2, 2, '#8A8A8A', 'd1')
186 g.put(10, 2, 2, 2, '#8A8A8A', 'd2')
187 g.put(14, 2, 2, 2, '#8A8A8A', 'd3')
188 },
189 },
190 // Done: a party hat and confetti.
191 done: {
192 raised: false,
193 draw: g => {
194 g.stamp(6, 0, [' y ', ' r ', ' rbr ', ' ryr ', ' bbbbb '], { y: '#F2C14E', r: '#E56761', b: '#4C8DF6' }, 'hat')
195 g.put(1, 1, 1, 2, '#4C8DF6', 'cf1')
196 g.put(17, 0, 2, 1, '#E9B63D', 'cf2')
197 g.put(22, 2, 1, 2, '#E56761', 'cf3')
198 g.put(14, 1, 1, 1, '#8A60D6', 'cf4')
199 },
200 },
201}
202
203/**
204 * Motion: legs step and the body bobs while a phase is open; eyes blink; each prop moves its
205 * own way (the flag waves, the rocket launches, the ticks tick, the bubbles rise).
206 */
207export const MASCOT_CSS =
208 '.mz g{transform-box:fill-box}' +
209 '.mz.run .la{animation:mstep .9s steps(1) infinite}.mz.run .lb{animation:mstep .9s steps(1) infinite -.45s}.mz.run .bd{animation:mbob .9s steps(1) infinite}' +
210 '.mz .ey{transform-origin:50% 50%;animation:mblink 4s steps(1) infinite}' +
211 '.mz-wizard-hat .sp,.mz-wizard-hat .sp2{animation:mtw 2.4s steps(1) infinite}.mz-wizard-hat .sp2{animation-delay:-1.2s}' +
212 '.mz-magnifier .it{transform-origin:30% 100%;animation:msweep 2.4s steps(1) infinite}' +
213 '.mz-clipboard .tk1,.mz-clipboard .tk2,.mz-clipboard .tk3{animation:mtick 3.6s steps(1) infinite}.mz-clipboard .tk2{animation-name:mtick2}.mz-clipboard .tk3{animation-name:mtick3}' +
214 '.mz-flask .bu1{animation:mbub 1.8s steps(1) infinite}.mz-flask .bu2{animation:mbub 1.8s steps(1) infinite -.9s}.mz-flask .foam{animation:mfoam 1.8s steps(1) infinite}' +
215 '.mz-hammer.run .bd,.mz-rocket.run .bd{animation:none}' +
216 '.mz-hammer .it{transform-origin:60% 100%;animation:mtap 1.5s steps(1) infinite}.mz-hammer .arm{animation:mgrip 1.5s steps(1) infinite}.mz-hammer .sp,.mz-hammer .sp2{animation:mimpact 1.5s steps(1) infinite}.mz-hammer .sp2{animation-name:mimpact2}' +
217 '.mz-reading-glasses .scan{animation:mscan 3.2s steps(1) infinite}' +
218 '.mz-rocket .rk{animation:mlaunch 3.6s steps(1) infinite}.mz-rocket .fl{transform-origin:50% 0;animation:mflick .24s steps(1) infinite}.mz-rocket .sm1,.mz-rocket .sm2{animation:msmoke 3.6s steps(1) infinite}.mz-rocket .sm2{animation-name:msmoke2}' +
219 '.mz-checkered-flag .flag{transform-origin:0 50%;animation:mwave .7s ease-in-out infinite}' +
220 '.mz-thinking-dots .d1,.mz-thinking-dots .d2,.mz-thinking-dots .d3{animation:mdot 2.4s steps(1) infinite}.mz-thinking-dots .d2{animation-delay:-1.6s}.mz-thinking-dots .d3{animation-delay:-.8s}' +
221 '.mz-party .cf1,.mz-party .cf2,.mz-party .cf3,.mz-party .cf4{animation:mconfetti 3.2s steps(1) infinite}.mz-party .cf2{animation-delay:-.8s}.mz-party .cf3{animation-delay:-1.6s}.mz-party .cf4{animation-delay:-2.4s}' +
222 '@keyframes mstep{0%,39%,70%,100%{transform:translateY(0)}40%,69%{transform:translateY(-1.5px)}}@keyframes mbob{0%,39%,70%,100%{transform:translateY(0)}40%,69%{transform:translateY(1.5px)}}' +
223 '@keyframes mblink{0%,92%,100%{transform:scaleY(1)}94%,98%{transform:scaleY(.2)}}' +
224 '@keyframes mtw{0%,24%,70%,100%{opacity:0}25%,69%{opacity:1}}' +
225 '@keyframes msweep{0%,19%,80%,100%{transform:translate(0,0)}20%,39%{transform:translate(-1.5px,1.5px)}40%,59%{transform:translate(0,1.5px)}60%,79%{transform:translate(1.5px,0)}}' +
226 '@keyframes mtick{0%,14%{opacity:0}15%,100%{opacity:1}}@keyframes mtick2{0%,34%{opacity:0}35%,100%{opacity:1}}@keyframes mtick3{0%,54%{opacity:0}55%,100%{opacity:1}}' +
227 '@keyframes mbub{0%,19%{transform:translateY(0);opacity:0}20%,39%{transform:translateY(-1.5px);opacity:1}40%,59%{transform:translateY(-3px);opacity:1}60%,79%{transform:translateY(-4.5px);opacity:1}80%,100%{transform:translateY(-4.5px);opacity:0}}' +
228 '@keyframes mfoam{0%,49%{opacity:1}50%,100%{opacity:0}}' +
229 '@keyframes mtap{0%,14%,80%,100%{transform:rotate(0)}15%,29%{transform:rotate(-12deg)}30%,39%{transform:rotate(-20deg)}40%,49%{transform:translateY(1.5px) rotate(35deg)}50%,59%{transform:rotate(12deg)}60%,79%{transform:rotate(-5deg)}}' +
230 '@keyframes mgrip{0%,39%,60%,100%{transform:translateY(0)}40%,59%{transform:translateY(1.5px)}}' +
231 '@keyframes mimpact{0%,39%,55%,100%{opacity:0}40%,54%{opacity:1}}@keyframes mimpact2{0%,44%,60%,100%{opacity:0}45%,59%{opacity:1}}' +
232 '@keyframes mscan{0%,19%,90%,100%{transform:translateX(0)}20%,44%{transform:translateX(-.75px)}45%,69%{transform:translateX(0)}70%,89%{transform:translateX(.75px)}}' +
233 '@keyframes mlaunch{0%,11%,92%,100%{transform:translateY(0);opacity:1}12%,23%{transform:translateY(1.5px);opacity:1}24%,31%{transform:translateY(0);opacity:1}32%,39%{transform:translateY(-1.5px);opacity:1}40%,47%{transform:translateY(-4.5px);opacity:1}48%,55%{transform:translateY(-9px);opacity:1}56%,63%{transform:translateY(-15px);opacity:1}64%,71%{transform:translateY(-21px);opacity:1}72%,91%{transform:translateY(-24px);opacity:0}}' +
234 '@keyframes mflick{0%,49%{transform:scaleY(.65)}50%,100%{transform:scaleY(1)}}' +
235 '@keyframes msmoke{0%,23%,48%,100%{transform:translateX(0);opacity:0}24%,35%{transform:translateX(0);opacity:1}36%,47%{transform:translateX(-1.5px);opacity:.5}}@keyframes msmoke2{0%,23%,48%,100%{transform:translateX(0);opacity:0}24%,35%{transform:translateX(0);opacity:1}36%,47%{transform:translateX(1.5px);opacity:.5}}' +
236 '@keyframes mwave{0%,100%{transform:skewY(0) scaleX(1)}50%{transform:skewY(12deg) scaleX(.85)}}' +
237 '@keyframes mdot{0%,32%{transform:translateY(-1.5px);opacity:1}33%,100%{transform:translateY(0);opacity:.35}}' +
238 '@keyframes mconfetti{0%,19%{transform:translate(0,0);opacity:1}20%,39%{transform:translate(1.5px,1.5px);opacity:1}40%,59%{transform:translate(0,3px);opacity:1}60%,79%{transform:translate(1.5px,4.5px);opacity:.6}80%,100%{transform:translate(0,6px);opacity:0}}' +
239 '@media (prefers-color-scheme: dark){.mz .ik{fill:#D9D6CF}}' +
240 '@media (prefers-reduced-motion: reduce){.mz,.mz g,.mz rect{animation:none!important}}'
241
242/**
243 * The mascot for a row state as an SVG `<g>` at (x, y): its prop, the body, and whether it
244 * walks (only while a phase is open). Props over the body draw after it.
245 */
246export const mascot = (key: MascotKey, x: number, y: number, opts: { isWalking: boolean; isDone: boolean }): string => {
247 const g = grid()
248 const prop = PROPS[key]
249 body(g, opts.isDone, prop.raised)
250 prop.draw(g)
251 // Nest independent motions: launch carries exhaust, blink rides scan, the flag rides its pole.
252 const group = (name: string): string => {
253 if (!g.groups.has(name)) return ''
254 const children = name === 'rk' ? ['fl'] : name === 'pole' ? ['flag'] : []
255 const svg = `<g class="${name}">${g.groups.get(name)!.join('')}${children.map(group).join('')}</g>`
256 return name === 'ey' && key === '6' ? `<g class="scan">${svg}</g>` : svg
257 }
258 // Legs outside the bobbing body; everything else rides the bob.
259 // The eyes draw last, so they show through a prop's lenses.
260 const nested = new Set(['bd', 'la', 'lb', 'ey', 'fl', 'flag'])
261 const riders = [...g.groups.keys()].filter(name => !nested.has(name)).concat(g.groups.has('ey') ? ['ey'] : [])
262 const bodyGroup = `<g class="bd">${(g.groups.get('bd') ?? []).join('')}${riders.map(group).join('')}</g>`
263
264 return (
265 `<g class="mascot" data-mascot="${MASCOT_NAMES[key]}" transform="translate(${x},${y}) scale(${SCALE})" shape-rendering="crispEdges">` +
266 `<g class="mz mz-${MASCOT_NAMES[key]}${opts.isWalking ? ' run' : ''}">${bodyGroup}${group('la')}${group('lb')}</g></g>`
267 )
268}
269types/index.d.ts 97 lines1/** One sink row after parsing: the stamp as epoch ms, the file position as `order`. */
2export type PhaseRow = {
3 at: number
4 session: string
5 ticket: string
6 phase: number
7 event: 'enter' | 'exit'
8 order: number
9}
10
11/** What one phase of a ticket has seen across every session. */
12export type PhaseInfo = {
13 enters: number
14 exits: number
15 /** Exits that closed an open enter; the rest of `exits` were orphans. */
16 closed: number
17 /** Times the phase was left: closed by an exit, or by an enter of another phase. */
18 left: number
19 /** 1 + enters after the phase was left; a resume or a second session is not a round. */
20 rounds: number
21 /** Sessions with an unanswered enter, oldest first. */
22 open: string[]
23 /** The enter that opened the phase for the ticket, ms; null when not open. */
24 openedAt: number | null
25 /** That enter's row order: tells a same-second re-entry from the one before. */
26 openedOrder: number | null
27}
28
29/** The phase state of one ticket, walked from its rows in time order. */
30export type TicketPhases = {
31 ticket: string
32 phases: Record<number, PhaseInfo>
33 /** The open phase whose enter is the latest; null between phases or when done. */
34 current: number | null
35 enteredAt: number | null
36 enteredOrder: number | null
37 /** The phase and event of the ticket's latest row; null with no rows. */
38 lastPhase: number | null
39 lastEvent: 'enter' | 'exit' | null
40 isDone: boolean
41 firstEnterAt: number | null
42 lastExitAt: number | null
43 /** Every session that wrote a row for the ticket. */
44 sessions: string[]
45}
46
47/** Own pace for one phase over every ticket's paired enter/exit. */
48export type PhaseStats = {
49 /** Median duration in ms; null under two pairs. */
50 median: number | null
51 pairs: number
52 /** Distinct tickets among the pairs: the ETA's gate. */
53 tickets: number
54}
55
56export type TicketPhaseReading = {
57 ticket: string
58 /** `small`, `medium`, `full`, or `?`. */
59 tier: string
60 state: TicketPhases
61 stats: Record<number, PhaseStats>
62 skipped: number
63 sinkMtimeMs: number
64 noteMtimeMs: number | null
65 /** When the reading was taken, ms. */
66 at: number
67 /** Set when the sink was past the read limit and only its newest part was read. */
68 isPartial?: true
69}
70
71/** Tokens this session has spent in the current phase, kept in $.store across restarts. */
72export type TicketPhaseTally = {
73 ticket: string
74 phase: number
75 enteredAt: number
76 enteredOrder: number
77 /** Every step's four counts, cached context included. */
78 tokens: number
79 /** What the steps added: uncached input, output and cache writes; absent on a tally stored before 0.3.0. */
80 fresh?: number
81 /** When a step last added to it: pruning goes by this, not by when the phase began. */
82 at?: number
83}
84
85declare module 'claude-code' {
86 interface PluginState {
87 'ticket-phase': {
88 reading: TicketPhaseReading | null
89 /** The sink's mtime at the last read, whether or not it held a row of ours; null after a failed read. */
90 sinkMtimeMs: number | null
91 /** The Work Item note found for the ticket, cached between refreshes. */
92 notePath: string | null
93 hidden: boolean
94 }
95 }
96}
97