SLOPSHOPPER

next-lesson

Your lessons above Claude Code's prompt: what you teach now and next from a timetable file, and each lesson's tasks as a card of moving progress bars, one…

newpanebandguardcommandtoast
v0.1.0no licenseupdated 2026-10-19calimike74/lesson-card
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · next-lesson
│ ┃ 🟢 Today ✕ › fix the failing auth test and add an audit log call │ ┃ ╭──────────────────────────────────────────╮ │ ┃ │ No timetable at /Timetable.md: type │ ⏺ Read(src/auth.ts) │ ┃ │ /lessons timetable and the path of your │ ⎿ Read 6 lines │ ┃ │ timetable file. │ ⏺ Update(src/auth.ts) │ ┃ ╰──────────────────────────────────────────╯ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /lessons │ ⎿ next-lesson: No timetable at /Timetable.md: type /lessons timeta │ │ No timetable at /Timetable.md: type /lessons timetable and the path of your timetable file. ⟨Claude Code's own drawing⟩ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
No timetable at /Timetable.md: type /lessons timetable and the path of your timetable file. ⟨Claude Code's own drawing⟩
Pane · 🟢 Today
╭──────────────────────────────────────────────────────────╮ │ No timetable at /Timetable.md: type /lessons timetable │ │ and the path of your timetable file. │ ╰──────────────────────────────────────────────────────────╯
README

Lesson card for Claude Code

A Claude Code mod for teachers. Above your prompt it shows what you teach now and next, read from a timetable file. In a lesson whose plan Claude has handed it, each task is a progress bar on a dark card: the first starts with the lesson, each follows on as the one before runs out, and a soft chime sounds as one gives way to the next.

  • In the terminal the card is painted in coloured character cells.
  • In the Claude desktop app (and the phone) it is a picture: rounded bars, a dotted fill that brightens towards a chip riding at its head ("Quiz 2/5"), tick marks (one a mark for a quiz), and the percentage.
  • Nothing to press: ✓ ends a task early (the next starts), ▶ jumps to a task.
  • No clock times on the strip: it says where the day stands by class and period.

Install

At the prompt of a terminal session:

/plugin install next-lesson --marketplace calimike74/lesson-card

Answer y to add the marketplace, then choose a scope (the user scope keeps it in every session).

From a shell:

claude plugin install next-lesson --marketplace calimike74/lesson-card

In the desktop app, where /plugin does not run: click + next to the prompt box, then Plugins, Add marketplace with calimike74/lesson-card, then Add plugin.

Your timetable

The strip reads a Markdown file with four tables: Periods, Week, Term dates and One-off days. examples/Timetable.md is a complete example to copy and fill in. By default the strip looks for Timetable.md in your home folder; to use another file:

/lessons timetable ~/Documents/School/Timetable.md

Class names are your own ("Lower Sixth", "Year 10", "9B"); each class keeps one colour everywhere. Add each new term to the Term dates table: past the last date the strip asks for it rather than guessing.

Lesson plans

When Claude writes or changes a run sheet for one of your lessons, it hands the tasks to the strip with the mod's run tool: the class (as your timetable names it), the date, the period, and each task with a short title, a one-word label for its chip (Quiz, Starter, Essay), and its minutes, or for a quiz or exam question its marks (one mark, one minute). The mod tells Claude to do this itself; you can also just ask ("put Thursday's Year 10 plan on the lesson strip"). Each plan is checked against your timetable.

Commands

CommandWhat it does
/lessonsToday's lessons; /lessons tomorrow, /lessons fri for another day
/lessons tryA pretend three-minute lesson, to see and hear the card at work
/lessons sound chime / voice / offThe chime (default), a spoken line, or silence
/lessons timetable <file>Where your timetable is
/lessons hide / showTakes the strip away, or brings it back
/todayThe day as a tab: each lesson with its tasks

None of these reaches Claude or costs a turn.

Sound

The chime is the Mac's own Glass sound, played from macOS (nothing is shipped). /lessons sound voice has a voice say each task as it starts, using ElevenLabs: it needs your own ELEVENLABS_API_KEY in the environment and uses about 40 characters of your ElevenLabs allowance a line, each line made once and kept. Sound plays on macOS only.

What it reads and writes

Your timetable file, and its own folder ~/.claude/next-lesson/ (the plans it was handed, its settings, and what it last sounded). It sends nothing anywhere, unless you choose the voice, which sends each task's line to ElevenLabs once.

Source 6 files
hooks/register.tsx 906 lines
1// Next lesson: the line above the prompt saying what the teacher teaches now and next, read from their timetable file.
2// A lesson whose run sheet a planning window handed over (the run tool) becomes a dark card with a bar a task.
3// It runs by itself: the first task starts with the lesson and each follows on when the one before runs out,
4// with a soft chime (or Alice, or nothing: /lessons sound). ▶ jumps to a task, ✓ ends the one running.
5// /today opens the day as a tab; /lessons try runs a pretend lesson. None of it costs a turn.
6
7import { atom, read, update } from 'claude-code'
8import type { EngineInterface, Register } from 'claude-code'
9
10import type { Lesson, LessonRun, Play, RunTask } from '../types'
11import { CARD, DIM, GREEN, GREY_PILL, TEXT, header, hexOf, task as paintTask } from './card'
12import type { Seg, TaskLook } from './card'
13import { asRun, lessonKey, paceAt, runFor, slots } from './run'
14import type { Slot } from './run'
15import { cardSvg } from './svg'
16import type { SvgRow } from './svg'
17import {
18  DAYS,
19  addDays,
20  dateFrom,
21  dayLabel,
22  describeDay,
23  firstAfter,
24  isoOf,
25  lessonsOn,
26  longDay,
27  msOf,
28  parseTimetable,
29  periodLabel,
30  stripAt,
31} from './timetable'
32import type { Strip } from './timetable'
33
34const timetable = atom({ plugin: 'next-lesson', key: 'timetable' } as const, null)
35const problem = atom({ plugin: 'next-lesson', key: 'problem' } as const, '')
36const minute = atom({ plugin: 'next-lesson', key: 'minute' } as const, 0)
37const hidden = atom({ plugin: 'next-lesson', key: 'isHidden' } as const, false)
38const runsAtom = atom({ plugin: 'next-lesson', key: 'runs' } as const, [])
39const playAtom = atom({ plugin: 'next-lesson', key: 'play' } as const, null)
40const beat = atom({ plugin: 'next-lesson', key: 'beat' } as const, 0)
41const trialAtom = atom({ plugin: 'next-lesson', key: 'trial' } as const, null)
42
43// The timetable file, under the home folder until /lessons timetable names another (kept in the shared settings).
44const DEFAULT_TIMETABLE = 'Timetable.md'
45// Shared by every window: the run sheets, what was last sounded, the sound setting, and whether a lesson is on.
46const DIR = '.claude/next-lesson'
47const HIDDEN_KEY = 'isHidden'
48const TOOL = 'mcp__next-lesson__run'
49const PANE = 'today'
50const PANE_TITLE = '🟢 Today'
51const TICK_MS = 20 * 1000
52const FRAME_MS = 100 // the running bar's shimmer and fill
53const SOON_MS = 10 * 60 * 1000 // "Next" turns amber this close to a lesson
54const PREVIEW_MS = 15 * 60 * 1000 // a lesson's card shows this long before it starts
55const WARN_MS = 5 * 60 * 1000 // "Five minutes left", with the voice on
56const KEEP_RUN_DAYS = 14
57const TITLE_MAX = 28 // a task's title on its row, cut at a word, never with "…"
58const BUTTON_COLUMNS = 3
59// The Mac's own soft Glass chime, played from macOS itself so nothing of Apple's is shipped; chosen over a voice
60// for the classroom.
61const CHIME = '/System/Library/Sounds/Glass.aiff'
62const CHIME_VOLUME = '0.6'
63// Alice, a clear British ElevenLabs voice. Only with /lessons sound voice, and only with an ELEVENLABS_API_KEY.
64const VOICE_ID = 'Xb7hH8MSUJpSbSDYk0k2'
65const VOICE_MODEL = 'eleven_multilingual_v2'
66const FIVE_LEFT = 'Five minutes left.'
67const ALL_DONE = 'All tasks done.'
68const AMBER = '#E8A33D'
69const QUIET = '#888888'
70const DONE_BG = '#BFE5C8'
71const AHEAD_BG = '#E4E4E4'
72// Short names and colours for common class names; any other class gets its own name and a colour (classOf).
73const CLASSES: Record<string, { short: string; colour: string }> = {
74  'upper sixth': { short: 'U6', colour: '#8AA4E8' },
75  'lower sixth': { short: 'L6', colour: '#7FC8C8' },
76  'fifth form': { short: '5 Form', colour: '#A78BFA' },
77  'fourth form': { short: '4 Form', colour: '#9FD18B' },
78}
79
80type Sound = 'chime' | 'voice' | 'off'
81
82const GUIDE = [
83  "The teacher's lesson strip (the next-lesson mod): when you finish or change a run sheet for one of their lessons, also",
84  'hand it to the strip with mcp__next-lesson__run: the class as the timetable names it, the date, the period',
85  "(P7, P4+P5), and each task in order with a short title in the plan's words (28 characters at most), a one-word",
86  'label (Quiz, Starter, Essay, Listen), its minutes, or for a quiz or exam question its marks (one mark, one',
87  'minute), and one sentence of detail. In the lesson each task is a bar above their prompt, one after another.',
88].join(' ')
89
90const RUN_DESCRIPTION = [
91  "Hand a lesson's run sheet to the teacher's lesson strip. In that lesson each task is a bar on a card above the prompt;",
92  'the first starts with the lesson and each follows on as the one before runs out (a tick a mark for a quiz), with',
93  'a soft chime. Checked against their timetable: the class must be on it at that date and period. Handing over the',
94  'same lesson again replaces it.',
95].join(' ')
96
97// The surface's element table, as $.ui.resolve gives it; Raster is the terminal's alone, Svg the desktop's and phone's.
98type T = { Box: any; Button: any; Text: any; Raster?: any; Svg?: any }
99
100// A bar on screen that moves: what the frame timer repaints between redraws, and what it sounds when it fills.
101type LiveBar = { key: string; look: TaskLook; at: number; minutes: number; label: string; ticks: number; accent: number; soundId: string; line: string }
102let live: { requestId: string; bars: LiveBar[] } | null = null
103let home = ''
104// The lesson and task last seen by the tick, so a task that moves on redraws the card in every window.
105let lastPace = ''
106let timetableMtime = -1
107let runsMtime = -1
108
109export const register: Register = on => {
110  on('session.start', async ($, e, next) => {
111    const started = await next(e)
112    await $.command.register({
113      name: 'lessons',
114      description: 'Your lessons: /lessons for today, /lessons tomorrow or a day (fri), /lessons try (a pretend lesson), /lessons sound chime, voice or off, /lessons timetable <file>, /lessons hide or show',
115    })
116    await $.command.register({ name: 'today', description: "Today's lessons and their tasks, as a tab beside your clip list and panel" })
117    await $.tool.register({
118      name: 'run',
119      description: RUN_DESCRIPTION,
120      inputSchema: {
121        type: 'object',
122        properties: {
123          class: { type: 'string', description: 'As the timetable names it: "Lower Sixth", "Upper Sixth", "Fifth Form", "Fourth Form".' },
124          date: { type: 'string', description: '"2026-11-02" or "Mon 2 Nov".' },
125          period: { type: 'string', description: '"P7", or "P4+P5" for a double.' },
126          tasks: {
127            type: 'array',
128            description: 'In order.',
129            items: {
130              type: 'object',
131              properties: {
132                title: { type: 'string', description: "A few words, the plan's own, 28 characters at most: \"Era 1 from the front\"." },
133                label: { type: 'string', description: 'One word for the chip on its bar: "Quiz", "Starter", "Essay", "Listen".' },
134                minutes: { type: 'number', description: 'Its length; leave out for a quiz or question given in marks.' },
135                marks: { type: 'number', description: "A quiz or exam question's marks: a tick each, one mark one minute." },
136                start: { type: 'string', description: '"14:43" when the plan gives it.' },
137                detail: { type: 'string', description: 'One sentence: what the pupils do.' },
138              },
139              required: ['title'],
140            },
141          },
142        },
143        required: ['class', 'date', 'period', 'tasks'],
144      },
145    })
146    if ((await $.store.get(HIDDEN_KEY)) === true) await update($, hidden, () => true)
147    await load($)
148    const startedAt = Math.floor((await $.clock.now()) / 60000)
149    await update($, minute, () => startedAt)
150    $.clock.every(TICK_MS, () => void tick($))
151    $.clock.every(FRAME_MS, () => void frame($))
152
153    return started
154  })
155
156  on('prompt.compose', async ($, e, next) => {
157    const composed = await next(e)
158
159    return { ...composed, sections: [...composed.sections, { id: 'next-lesson:guide', text: GUIDE, scope: 'session' }] }
160  })
161
162  // Matched by name in the hook: the tool is this mod's own, registered above, so its type is not listed yet.
163  on('tool.call', async ($, e, next) => {
164    if (String(e.tool) !== TOOL) return next(e)
165    await load($)
166    const t = await read($, timetable)
167    if (!t) return { deny: (await read($, problem)) || 'The timetable could not be read.' }
168    const { run, why } = asRun(e as unknown as Record<string, unknown>, t, new Date(await $.clock.now()))
169    if (!run) return { deny: why ?? 'That run sheet could not be read.' }
170    const kept = await saveRun($, run)
171    // With the voice on, Alice's lines are made now, while nobody waits for them; the chime needs nothing made.
172    if ((await soundOf($)) === 'voice') void prepareVoice($, run)
173
174    return { result: `Handed to the lesson strip: ${run.cls} ${run.period}, ${longDay(run.date)}, ${run.tasks.length} tasks (${kept} lessons' run sheets kept).` }
175  })
176
177  on('command.run', { command: 'lessons' }, async ($, e) => {
178    const arg = e.args.trim().toLowerCase()
179    if (arg === 'hide' || arg === 'off' || arg === 'show' || arg === 'on') {
180      const isHidden = arg === 'hide' || arg === 'off'
181      await update($, hidden, () => isHidden)
182      await $.store.set(HIDDEN_KEY, isHidden)
183
184      return { text: isHidden ? 'Lesson strip hidden. /lessons show brings it back.' : 'Lesson strip shown above the prompt.' }
185    }
186    if (arg === 'try') {
187      const trial = await startTrial($)
188
189      return { text: `A pretend ${trial.lesson.cls} lesson is running on the card above your prompt: three tasks of a minute each, one after another, a chime as each gives way. ▶ jumps to a task, ✓ ends the one running.` }
190    }
191    if (/^timetable\b/.test(arg)) {
192      const given = e.args.trim().replace(/^timetable\s*/i, '').replace(/^["']|["']$/g, '')
193      if (given) {
194        await saveSettings($, { timetable: given })
195        timetableMtime = -1
196      }
197      await load($)
198      const t = await read($, timetable)
199      const path = await timetablePathOf($)
200      const count = t ? Object.values(t.week).reduce((n, day) => n + Object.keys(day).length, 0) : 0
201
202      return { text: t ? `Reading your timetable from ${path}: ${count} periods a week.` : (await read($, problem)) || `Nothing read from ${path}.` }
203    }
204    const soundAsked = /^(sound|voice)\b/.test(arg) ? soundIn(arg) : undefined
205    if (soundAsked) {
206      await saveSettings($, { sound: soundAsked })
207      const says: Record<Sound, string> = {
208        chime: 'A soft chime as each task gives way to the next.',
209        voice: 'Alice says each task as it starts, and "Five minutes left" near the end of a lesson.',
210        off: 'No sound: the card just moves on.',
211      }
212
213      return { text: `${says[soundAsked]} (/lessons sound chime, voice or off)` }
214    }
215    await load($)
216    const t = await read($, timetable)
217    if (!t) return { text: (await read($, problem)) || 'The timetable has not been read yet.' }
218    const now = new Date(await $.clock.now())
219    const day = dayAsked(arg, now)
220    if (!day) return { text: 'Type /lessons, /lessons tomorrow, a day (/lessons fri), /lessons try, /lessons sound chime, voice or off, /lessons timetable <file>, or /lessons hide.' }
221
222    return { text: describeDay(t, day) }
223  })
224
225  on('command.run', { command: 'today' }, async ($, e) => {
226    if (/^(close|hide)$/.test(e.args.trim().toLowerCase())) {
227      await $.ui.close({ id: PANE })
228
229      return { text: 'Today tab closed. /today opens it again.' }
230    }
231    await load($)
232    await $.ui.open({ id: PANE, title: PANE_TITLE, columns: 56, rows: 30 })
233
234    return { text: 'Today is open as a tab at the top, beside your clip list and panel. /today close shuts it.' }
235  })
236
237  // The band is shared: whatever another mod draws there (the build progress rows) stays, below this.
238  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
239    if (e.props.hasSurvey || (await read($, hidden))) return next(e)
240    const ui = $.ui.resolve(e)
241    const mine = await band($, ui, e.surface === 'terminal', e.requestId, e.props.bodyColumns ?? 100)
242    if (!mine) return next(e)
243    const below = await next(e)
244
245    return (
246      <ui.Box flexDirection="column">
247        {mine}
248        {below}
249      </ui.Box>
250    )
251  })
252
253  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => todayPane($, $.ui.resolve(e)))
254}
255
256// "sound chime", "sound voice", "sound off"; the older "voice on" and "voice off" still work.
257function soundIn(arg: string): Sound | undefined {
258  if (/\bvoice\b/.test(arg) && /\bon\b/.test(arg)) return 'voice'
259  if (/^voice\b/.test(arg) && /\boff\b/.test(arg)) return 'chime'
260  if (/\bchime\b/.test(arg)) return 'chime'
261  if (/\bvoice\b/.test(arg)) return 'voice'
262  if (/\b(off|quiet|none)\b/.test(arg)) return 'off'
263
264  return undefined
265}
266
267async function soundOf($: EngineInterface): Promise<Sound> {
268  const s = (await readShared($, 'settings.json')) as { sound?: Sound; voice?: boolean } | null
269  if (s?.sound === 'chime' || s?.sound === 'voice' || s?.sound === 'off') return s.sound
270
271  return s?.voice === false ? 'off' : 'chime'
272}
273
274// Every tick: a changed timetable or run sheet is read, the line moves on, the shared "lesson on" note is
275// written for the build progress mod, and (with the voice on) "Five minutes left" is said when due.
276async function tick($: EngineInterface) {
277  await load($)
278  const now = await $.clock.now()
279  const m = Math.floor(now / 60000)
280  if (m !== (await read($, minute))) await update($, minute, () => m)
281  const trial = await read($, trialAtom)
282  if (trial && now >= msOf(trial.lesson.date, trial.lesson.end)) await update($, trialAtom, () => null)
283  const t = await read($, timetable)
284  if (!t) return
285  const s = stripAt(t, new Date(now))
286  const lesson = s.kind === 'now' ? { cls: s.lesson.cls, period: periodLabel(s.lesson), until: s.lesson.end } : null
287  await writeShared($, 'now.json', { lesson, at: now })
288  if (s.kind === 'now') await warnNearEnd($, s.lesson, now)
289  const isTrial = trial !== null && now < msOf(trial.lesson.date, trial.lesson.end)
290  const focus = isTrial ? trial.lesson : s.kind === 'now' ? s.lesson : undefined
291  const run = isTrial ? trial.run : focus ? runFor(await read($, runsAtom), focus) : undefined
292  if (focus && run) await soundFollowOn($, focus, run, now)
293}
294
295// A task that has just run out by itself is sounded in from here too, in case a redraw on the minute took the
296// running bar off the frame timer before it saw the bar fill. The same id as the frame's, so it sounds once.
297async function soundFollowOn($: EngineInterface, l: Lesson, run: LessonRun, now: number) {
298  playCopy = await read($, playAtom)
299  const { current, play } = paceOf(l, run, now)
300  const seen = `${lessonKey(l)}|${current}`
301  if (seen !== lastPace) {
302    const isFirst = lastPace === ''
303    lastPace = seen
304    if (!isFirst) await update($, beat, b => b + 1)
305  }
306  if (current <= play.task || play.endedAt !== undefined) return
307  const ended = slots(run, play.task, play.at).find(x => x.i === current - 1)
308  if (!ended || now - ended.to > 60000) return
309  const next = run.tasks[current]
310  await announce($, `${lessonKey(l)}|after${current - 1}|${Math.round(ended.to / 1000)}`, next ? spokenLine(current, next) : ALL_DONE)
311}
312
313// Every frame: the running bar moves on (fill, chip, shimmer) by repainting its cells in place; when it fills,
314// the next task is sounded in and the card redraws with it running.
315async function frame($: EngineInterface) {
316  if (!live) return
317  const now = await $.clock.now()
318  for (const bar of live.bars) {
319    const fraction = (now - bar.at) / (bar.minutes * 60000)
320    if (fraction >= 1) {
321      live = null
322      await update($, beat, b => b + 1)
323      await announce($, bar.soundId, bar.line)
324
325      return
326    }
327    const look = { ...bar.look, ...running(bar, fraction), phase: Math.floor(now / 80) }
328    const done = await $.ui.blit({ requestId: live.requestId, key: bar.key, cells: paintTask(look), columns: look.width, rows: 1 }).catch(() => ({ deny: 'gone' }))
329    if ('deny' in done && done.deny) {
330      live = null
331
332      return
333    }
334  }
335}
336
337// "Five minutes left", with the voice on only: a chime then would sound like a task changing.
338async function warnNearEnd($: EngineInterface, l: Lesson, now: number) {
339  const end = msOf(l.date, l.end)
340  if (end - msOf(l.date, l.start) < 20 * 60000 || (await soundOf($)) !== 'voice') return
341  const at = end - WARN_MS
342  if (now < at || now - at >= 60000) return
343  await announce($, `${lessonKey(l)}|five`, FIVE_LEFT)
344}
345
346// A change in the lesson, sounded once across every window (the shared note is written first, so another
347// window finds it done): the chime, Alice saying the line, or nothing, as /lessons sound has it.
348async function announce($: EngineInterface, id: string, line: string) {
349  const sound = await soundOf($)
350  if (sound === 'off') return
351  const done = ((await readShared($, 'spoken.json')) as { ids?: string[] } | null)?.ids ?? []
352  if (done.includes(id)) return
353  await writeShared($, 'spoken.json', { ids: [...done, id].slice(-60) })
354  if (sound === 'chime') await $.process.run(['afplay', '-v', CHIME_VOLUME, CHIME], { timeoutMs: 10000 }).catch(() => undefined)
355  else await speak($, line)
356}
357
358// Plays a line in Alice's voice, making it first if it has not been made. With no file and no way to make one
359// (offline, or no key), nothing is said: a robotic stand-in in front of a class is worse than silence.
360async function speak($: EngineInterface, text: string) {
361  const file = await voiceFile($, text)
362  if (file) await $.process.run(['afplay', file], { timeoutMs: 30000 }).catch(() => undefined)
363  else $.ui.toast('Alice could not be reached (ElevenLabs), so that line was not said')
364}
365
366function spokenLine(i: number, k: RunTask): string {
367  return `Task ${i + 1}. ${plain(k.title)}.`
368}
369
370// Every line a run sheet will say, made ahead: each task as it starts, the five-minute warning, the end.
371async function prepareVoice($: EngineInterface, run: LessonRun) {
372  for (const text of [...run.tasks.map((k, i) => spokenLine(i, k)), FIVE_LEFT, ALL_DONE]) await voiceFile($, text)
373}
374
375// The line's sound file, kept by voice and words, made with ElevenLabs the first time (the key comes from the
376// ELEVENLABS_API_KEY the shell holds; it never sits in this file). About 40 characters of the account's allowance a line.
377async function voiceFile($: EngineInterface, text: string): Promise<string | undefined> {
378  const dir = `${await homeOf($)}/${DIR}/voice`
379  const path = `${dir}/${hashOf(`${VOICE_ID}|${VOICE_MODEL}|${text}`)}.mp3`
380  if (await $.fs.exists(path).catch(() => false)) return path
381  await $.process.run(['mkdir', '-p', dir]).catch(() => undefined)
382  const curl = [
383    'curl -s -o "$1" -w "%{http_code}" -X POST',
384    `"https://api.elevenlabs.io/v1/text-to-speech/${VOICE_ID}?output_format=mp3_44100_128"`,
385    '-H "xi-api-key: $ELEVENLABS_API_KEY" -H "Content-Type: application/json" --data-binary @-',
386  ].join(' ')
387  const made = await $.process
388    .run(['/bin/sh', '-c', curl, 'sh', path], { stdin: JSON.stringify({ text, model_id: VOICE_MODEL }), timeoutMs: 30000 })
389    .catch(() => null)
390  if (made?.stdout.trim() === '200') return path
391  await $.process.run(['rm', '-f', path]).catch(() => undefined)
392
393  return undefined
394}
395
396// A short name for a line's file: the same words in the same voice always find the same file.
397function hashOf(text: string): string {
398  let h = 5381
399  for (let i = 0; i < text.length; i += 1) h = ((h << 5) + h + text.charCodeAt(i)) >>> 0
400
401  return `${h.toString(36)}-${text.length}`
402}
403
404async function homeOf($: EngineInterface): Promise<string> {
405  if (home) return home
406  const root = $.plugin.root
407  const at = root.indexOf('/.claude/')
408  home = at >= 0 ? root.slice(0, at) : (await $.process.run(['/bin/sh', '-c', 'printf %s "$HOME"'])).stdout.trim()
409
410  return home
411}
412
413// The timetable file: the path /lessons timetable saved (absolute, ~/..., or under the home folder), else
414// Timetable.md in the home folder.
415async function timetablePathOf($: EngineInterface): Promise<string> {
416  const h = await homeOf($)
417  const saved = ((await readShared($, 'settings.json')) as { timetable?: string } | null)?.timetable?.trim()
418  const given = saved || DEFAULT_TIMETABLE
419  if (given.startsWith('/')) return given
420  if (given.startsWith('~/')) return `${h}/${given.slice(2)}`
421
422  return `${h}/${given}`
423}
424
425// Settings are shared by every window and kept together: a change to one keeps the others.
426async function saveSettings($: EngineInterface, change: Record<string, unknown>) {
427  const now = ((await readShared($, 'settings.json')) as Record<string, unknown> | null) ?? {}
428  await writeShared($, 'settings.json', { ...now, ...change })
429}
430
431// Reads the timetable and the run sheets when they are new or changed; a missing timetable says so in the strip.
432async function load($: EngineInterface) {
433  const path = await timetablePathOf($)
434  try {
435    const { mtimeMs } = await $.fs.stat(path)
436    if (mtimeMs !== timetableMtime) {
437      timetableMtime = mtimeMs
438      const parsed = parseTimetable(await $.fs.read(path))
439      const isUsable = parsed.periods.length > 0 && Object.keys(parsed.week).length > 0
440      await update($, timetable, () => (isUsable ? parsed : null))
441      await update($, problem, () => (isUsable ? '' : `No Periods or Week table in ${path}`))
442    }
443  } catch {
444    timetableMtime = -1
445    await update($, timetable, () => null)
446    await update($, problem, () => `No timetable at ${path}: type /lessons timetable and the path of your timetable file.`)
447  }
448  try {
449    const { mtimeMs } = await $.fs.stat(`${await homeOf($)}/${DIR}/runs.json`)
450    if (mtimeMs === runsMtime) return
451    runsMtime = mtimeMs
452    const runs = await readShared($, 'runs.json')
453    await update($, runsAtom, () => (Array.isArray(runs) ? (runs as LessonRun[]) : []))
454  } catch {
455    // No run sheets handed over yet.
456  }
457}
458
459// Keeps a lesson's run sheet in the shared file, in place of an earlier one for the same lesson; old ones go.
460async function saveRun($: EngineInterface, run: LessonRun): Promise<number> {
461  const disk = await readShared($, 'runs.json')
462  const cutoff = isoOf(addDays(new Date(await $.clock.now()), -KEEP_RUN_DAYS))
463  const others = (Array.isArray(disk) ? (disk as LessonRun[]) : []).filter(r => r.date >= cutoff && !sameLesson(r, run))
464  const runs = [...others, run].sort((a, b) => a.date.localeCompare(b.date))
465  await writeShared($, 'runs.json', runs)
466  await update($, runsAtom, () => runs)
467
468  return runs.length
469}
470
471function sameLesson(a: LessonRun, b: LessonRun): boolean {
472  return a.cls.toLowerCase() === b.cls.toLowerCase() && a.date === b.date && a.period === b.period
473}
474
475async function readShared($: EngineInterface, name: string): Promise<unknown> {
476  try {
477    return JSON.parse(await $.fs.read(`${await homeOf($)}/${DIR}/${name}`))
478  } catch {
479    return null
480  }
481}
482
483async function writeShared($: EngineInterface, name: string, value: unknown) {
484  const dir = `${await homeOf($)}/${DIR}`
485  try {
486    await $.fs.write(`${dir}/${name}`, JSON.stringify(value))
487  } catch {
488    await $.process.run(['mkdir', '-p', dir]).catch(() => undefined)
489    await $.fs.write(`${dir}/${name}`, JSON.stringify(value)).catch(() => undefined)
490  }
491}
492
493// The band: the card when a lesson with a run sheet is on (or about to be), else one plain line.
494async function band($: EngineInterface, ui: T, isTerminal: boolean, requestId: string, columns: number) {
495  const { Box, Text } = ui
496  await read($, minute)
497  await read($, beat)
498  const t = await read($, timetable)
499  if (!t) {
500    const why = await read($, problem)
501
502    return why ? <Box paddingX={1}><Text dimColor wrap="wrap">{why}</Text></Box> : null
503  }
504  const now = await $.clock.now()
505  const trial = await read($, trialAtom)
506  const isTrial = trial !== null && now < msOf(trial.lesson.date, trial.lesson.end)
507  const s: Strip = isTrial ? { kind: 'now', lesson: trial.lesson, leftMs: msOf(trial.lesson.date, trial.lesson.end) - now, rest: [] } : stripAt(t, new Date(now))
508  const focus = s.kind === 'now' ? s.lesson : s.kind === 'next' && s.inMs <= PREVIEW_MS ? s.lesson : undefined
509  const run = isTrial ? trial.run : focus ? runFor(await read($, runsAtom), focus) : undefined
510  playCopy = await read($, playAtom)
511  // The terminal paints the card in character cells; the desktop app and the phone draw it as a picture. (Elsewhere
512  // Raster exists only as an empty stand-in, so the surface decides, not the table.)
513  if (focus && run && run.tasks.length > 0 && isTerminal && ui.Raster) return card($, ui, requestId, columns, s.kind === 'now', focus, run, now)
514  if (focus && run && run.tasks.length > 0 && !isTerminal && ui.Svg) return pictureCard($, ui, s.kind === 'now', focus, run, now)
515  live = null
516
517  return (
518    <Box flexDirection="row" flexWrap="wrap" paddingX={1}>
519      {stripLine($, ui, s, new Date(now))}
520    </Box>
521  )
522}
523
524// What a task's bar shows at a fraction of its time: the chip, its colours, the percentage.
525function running(bar: { label: string; ticks: number; accent: number }, fraction: number): Partial<TaskLook> {
526  const f = Math.max(0, Math.min(1, fraction))
527
528  return { fraction: f, pill: `${bar.label} ${Math.min(bar.ticks, Math.floor(f * bar.ticks))}/${bar.ticks}`, pillBg: bar.accent, pillFg: 0x000000, percent: `${Math.floor(f * 100)}%` }
529}
530
531// Where a lesson is: from the task the teacher last jumped to (or the first, at the bell), each following on.
532function paceOf(l: Lesson, run: LessonRun, now: number): { current: number; slot?: Slot; fraction: number; play: Play } {
533  const play = playNow(l, run) ?? { key: lessonKey(l), task: 0, at: msOf(l.date, l.start) }
534  const pace = paceAt(run, slots(run, play.task, play.at, play.endedAt), play.task, now)
535
536  return { ...pace, play }
537}
538
539// The card: a header (now or next, the class, how far through), then the task running (or the first, waiting
540// for the lesson) and the next one. Each row's button sits at its right end, on the card's colour.
541function card($: EngineInterface, ui: T, requestId: string, columns: number, isNow: boolean, l: Lesson, run: LessonRun, now: number) {
542  const { Box, Button, Text } = ui
543  const Raster = ui.Raster
544  const key = lessonKey(l)
545  const width = Math.max(48, Math.min(columns - 2, 120))
546  const rowWidth = width - BUTTON_COLUMNS
547  const accent = hexOf(classOf(l.cls).colour)
548  const { current, slot, fraction } = paceOf(l, run, now)
549  const count = run.tasks.length
550  const isAllDone = current >= count
551  const shown = current < 0 ? [0] : isAllDone ? [count - 1] : current + 1 < count ? [current, current + 1] : [current]
552  const titles = shown.map(i => shortTitle(run.tasks[i]!.title))
553  const titleWidth = Math.max(...titles.map(x => x.length), 8)
554  const bars: LiveBar[] = []
555
556  const row = (i: number, n: number) => {
557    const k = run.tasks[i]!
558    const ticks = ticksOf(k)
559    const label = k.label || plain(k.title).split(/\s+/)[0] || 'Task'
560    const isRunning = i === current
561    const isDone = i < current
562    const base: TaskLook = { width: rowWidth, glyph: '○', glyphFg: DIM, title: titles[n]!, titleWidth, fraction: 0, ticks, pill: `${label} 0/${ticks}`, pillBg: GREY_PILL, pillFg: TEXT, accent, percent: '0%', phase: 0 }
563    const look: TaskLook = isDone
564      ? { ...base, glyph: '✓', glyphFg: GREEN, fraction: 1, pill: `✓ ${label} ${ticks}/${ticks}`, pillBg: GREEN, pillFg: 0x000000, percent: '100%' }
565      : isRunning
566        ? { ...base, glyph: '●', glyphFg: accent, ...running({ label, ticks, accent }, fraction), phase: Math.floor(now / 80) }
567        : base
568    const rasterKey = `bar-${i}`
569    if (isRunning && slot) {
570      const next = run.tasks[i + 1]
571      bars.push({
572        key: rasterKey,
573        look,
574        at: slot.from,
575        minutes: (slot.to - slot.from) / 60000,
576        label,
577        ticks,
578        accent,
579        soundId: `${key}|after${i}|${Math.round(slot.to / 1000)}`,
580        line: next ? spokenLine(i + 1, next) : ALL_DONE,
581      })
582    }
583    // ✓ ends the running task (the next starts); ▶ jumps to a task still to come; a done task has none.
584    const press = isRunning ? () => void finishTask($, l, run) : !isDone ? () => void jumpTo($, l, run, i) : undefined
585
586    return (
587      <Box key={`row-${i}`} flexDirection="row">
588        <Raster key={rasterKey} columns={rowWidth} rows={1} cells={paintTask(look)} />
589        {press ? (
590          <Button key={isRunning ? `finish-${i}` : `start-${i}`} plain onPress={press}>
591            <Text backgroundColor={hex(CARD)} color={hex(isRunning ? GREEN : TEXT)} bold>{isRunning ? ' ✓ ' : ' ▶ '}</Text>
592          </Button>
593        ) : (
594          <Text backgroundColor={hex(CARD)}>{' '.repeat(BUTTON_COLUMNS)}</Text>
595        )}
596      </Box>
597    )
598  }
599
600  const c = classOf(l.cls)
601  const left: Seg[] = [
602    { text: isNow ? '● Now  ' : '○ Next  ', fg: isNow ? accent : DIM },
603    { text: ` ${c.short} ${periodLabel(l)} `, fg: 0x000000, bg: accent },
604    // The class's full name, unless its chip already says it.
605    ...(c.short === l.cls ? [] : [{ text: `  ${l.cls}`, fg: TEXT }]),
606  ]
607  const right: Seg[] = isAllDone
608    ? [{ text: '✓ All tasks done', fg: GREEN }]
609    : current < 0
610      ? [{ text: `${count} tasks · they start with the lesson`, fg: DIM }]
611      : [{ text: `Task ${current + 1} of ${count}`, fg: TEXT }]
612  const rows = shown.map(row)
613  live = bars.length > 0 ? { requestId, bars } : null
614
615  return (
616    <Box flexDirection="column" paddingX={1}>
617      <Raster key="card-head" columns={width} rows={1} cells={header(width, left, right)} />
618      {rows}
619    </Box>
620  )
621}
622
623// The card as a picture where the surface draws SVG (the desktop app, the phone): rounded and shaded like the
624// desktop app's own panels, moving by itself; under it, buttons to end the running task or start the next now.
625function pictureCard($: EngineInterface, ui: T, isNow: boolean, l: Lesson, run: LessonRun, now: number) {
626  const { Box, Button } = ui
627  const Svg = ui.Svg
628  const { current, slot, fraction } = paceOf(l, run, now)
629  const count = run.tasks.length
630  const isAllDone = current >= count
631  const shown = current < 0 ? [0] : isAllDone ? [count - 1] : current + 1 < count ? [current, current + 1] : [current]
632  const labelOf = (k: RunTask) => k.label || plain(k.title).split(/\s+/)[0] || 'Task'
633  const rows: SvgRow[] = shown.map(i => {
634    const k = run.tasks[i]!
635    const isRunning = i === current && slot !== undefined
636
637    return {
638      state: i < current ? 'done' : isRunning ? 'running' : 'waiting',
639      title: shortTitle(k.title),
640      label: labelOf(k),
641      ticks: ticksOf(k),
642      fraction: isRunning ? fraction : i < current ? 1 : 0,
643      secondsLeft: isRunning ? (slot.to - now) / 1000 : 0,
644    }
645  })
646  const c = classOf(l.cls)
647  const right = isAllDone ? '✓ All tasks done' : current < 0 ? `${count} tasks · they start with the lesson` : `Task ${current + 1} of ${count}`
648  const source = cardSvg({ isNow, chip: `${c.short} ${periodLabel(l)}`, className: c.short === l.cls ? '' : l.cls, right, rightTone: isAllDone ? 'done' : current < 0 ? 'dim' : 'plain', accent: c.colour, rows })
649  const next = current < 0 ? 0 : current + 1
650  const buttons = [
651    ...(current >= 0 && !isAllDone ? [<Button key={`finish-${current}`} label={`✓ End ${labelOf(run.tasks[current]!)}`} onPress={() => void finishTask($, l, run)} />] : []),
652    ...(!isAllDone && next < count ? [<Button key={`start-${next}`} label={`▶ Start ${labelOf(run.tasks[next]!)} now`} onPress={() => void jumpTo($, l, run, next)} />] : []),
653  ]
654  live = null
655
656  return (
657    <Box flexDirection="column">
658      <Svg source={source} alt={`${l.cls} ${periodLabel(l)}: ${right}`} isInteractive />
659      {buttons.length > 0 ? <Box flexDirection="row">{buttons}</Box> : null}
660    </Box>
661  )
662}
663
664// The play on screen for this lesson, if the teacher has jumped or ended a task in it: a copy of the play atom, read by
665// each drawing before it builds its rows, since the row builder is not async.
666let playCopy: Play | null = null
667
668function playNow(l: Lesson, run: LessonRun): Play | null {
669  return playCopy && playCopy.key === lessonKey(l) && playCopy.task < run.tasks.length ? playCopy : null
670}
671
672// ▶: this task starts now, any before it are done, and the rest follow on from it.
673async function jumpTo($: EngineInterface, l: Lesson, run: LessonRun, i: number) {
674  const play: Play = { key: lessonKey(l), task: i, at: await $.clock.now() }
675  await setPlay($, play)
676  await announce($, `${play.key}|jump${i}|${Math.round(play.at / 1000)}`, spokenLine(i, run.tasks[i]!))
677}
678
679// ✓: the running task ends now and the next starts; the last one ending ends the lesson's tasks.
680async function finishTask($: EngineInterface, l: Lesson, run: LessonRun) {
681  const now = await $.clock.now()
682  const { current, slot } = paceOf(l, run, now)
683  if (current < 0 || current >= run.tasks.length || !slot) return
684  const key = lessonKey(l)
685  if (current + 1 < run.tasks.length) {
686    await setPlay($, { key, task: current + 1, at: now })
687    await announce($, `${key}|jump${current + 1}|${Math.round(now / 1000)}`, spokenLine(current + 1, run.tasks[current + 1]!))
688  } else {
689    // The last task's bar fills now: it starts where it started and ends here.
690    const play = playNow(l, run) ?? { key, task: 0, at: msOf(l.date, l.start) }
691    const ends = slots(run, play.task, play.at, play.endedAt)
692    const started = ends.find(s => s.i === current)?.from ?? slot.from
693    await setPlay($, { key, task: current, at: started, endedAt: now })
694    await announce($, `${key}|end|${Math.round(now / 1000)}`, ALL_DONE)
695  }
696}
697
698async function setPlay($: EngineInterface, play: Play) {
699  playCopy = play
700  live = null
701  await update($, playAtom, () => play)
702}
703
704// Ticks on a bar: a mark each for a quiz or question, else a minute each; past 30, a tick every five.
705function ticksOf(k: RunTask): number {
706  const n = k.marks ?? k.minutes
707
708  return n > 30 ? Math.ceil(n / 5) : Math.max(1, n)
709}
710
711// A title for its row: whole words up to 28 characters, never cut mid-word and never with "…".
712function shortTitle(title: string): string {
713  const words = plain(title).split(' ')
714  let out = ''
715  for (const w of words) {
716    const next = out ? `${out} ${w}` : w
717    if (next.length > TITLE_MAX) break
718    out = next
719  }
720
721  return out || plain(title).slice(0, TITLE_MAX)
722}
723
724function hex(n: number): string {
725  return `#${n.toString(16).padStart(6, '0')}`
726}
727
728// One class as a chip: its short name and period on its colour, black ink, readable on a white terminal.
729function chip(ui: T, l: Lesson, key: string) {
730  const { Text } = ui
731  const c = classOf(l.cls)
732
733  return [<Text key={`${key}-chip`} backgroundColor={c.colour} color="black" bold>{` ${c.short} ${periodLabel(l)} `}</Text>]
734}
735
736// The line when there is no card: where the day stands, by class and period, never by the clock.
737function stripLine($: EngineInterface, ui: T, s: Strip, now: Date) {
738  const { Button, Text } = ui
739  const gap = (key: string) => <Text key={key}>{'   '}</Text>
740  const then = (rest: Lesson[]) =>
741    rest.length === 0
742      ? []
743      : [<Text key="then" dimColor>{'   then '}</Text>, ...rest.flatMap((l, i) => [...(i > 0 ? [<Text key={`sep${i}`}> </Text>] : []), ...chip(ui, l, `r${i}`)])]
744  const nextDay = (l: Lesson | undefined, lastDate: string | undefined, lead: string) =>
745    l
746      ? [<Text key="lead" dimColor>{`${lead}${dayLabel(l.date, now)} `}</Text>, ...chip(ui, l, 'a')]
747      : [<Text key="lead" dimColor wrap="wrap">{`${lead}nothing in the term dates${lastDate ? ` after ${longDay(lastDate)}` : ''}: add the next term to the timetable`}</Text>]
748  const plan = (l: Lesson) => [gap('gap'), <Button key="plan" plain dimColor label="what's planned?" onPress={() => void askPlan($, l)} />]
749
750  if (s.kind === 'now') {
751    return [<Text key="state" color={classOf(s.lesson.cls).colour} bold>{'● Now '}</Text>, ...chip(ui, s.lesson, 'n'), ...then(s.rest), ...(s.after ? nextDay(s.after, undefined, '   next ') : [])]
752  }
753  if (s.kind === 'next') {
754    return [<Text key="state" color={s.inMs <= SOON_MS ? AMBER : QUIET} bold>{'○ Next '}</Text>, ...chip(ui, s.lesson, 'n'), ...then(s.rest), ...plan(s.lesson)]
755  }
756  if (s.kind === 'later') {
757    return [<Text key="state" color={QUIET} bold>{s.hadLessons ? '✓ Done for today' : '○ No lessons today'}</Text>, ...nextDay(s.after, s.lastDate, '   next '), ...(s.after ? plan(s.after) : [])]
758  }
759  if (s.kind === 'break') {
760    return [<Text key="state" color={QUIET} bold>{`☾ ${s.what}`}</Text>, ...nextDay(s.after, s.lastDate, '   back '), ...(s.after ? plan(s.after) : [])]
761  }
762
763  return [
764    <Text key="state" dimColor wrap="wrap">
765      {`Today is past the term dates in your timetable${s.lastDate ? ` (they end ${longDay(s.lastDate)})` : ''}: add the next term to its Term dates table.`}
766    </Text>,
767  ]
768}
769
770// The day as a tab: each lesson with its time, its class on its colour, and its run sheet's tasks as chips
771// (done green, the running one on the class colour, the rest grey). After the last lesson, or on a day without
772// any, it shows the next day that has lessons.
773async function todayPane($: EngineInterface, ui: T) {
774  const { Box, Button, Text } = ui
775  await read($, minute)
776  await read($, beat)
777  playCopy = await read($, playAtom)
778  const t = await read($, timetable)
779  if (!t) {
780    return (
781      <Box borderStyle="round" borderColor="gray" paddingX={1}>
782        <Text dimColor wrap="wrap">{(await read($, problem)) || 'Reading the timetable…'}</Text>
783      </Box>
784    )
785  }
786  const now = new Date(await $.clock.now())
787  const ms = now.getTime()
788  const today = lessonsOn(t, now)
789  const last = today[today.length - 1]
790  const isOver = !last || ms >= msOf(last.date, last.end)
791  const ahead = isOver ? firstAfter(t, now).lesson : undefined
792  const lessons = ahead ? lessonsOn(t, dateFrom(ahead.date)) : today
793  const dayName = ahead ? `${dayLabel(ahead.date, now) === 'tomorrow' ? 'Tomorrow' : 'Next'}, ${longDay(ahead.date)}` : `Today, ${longDay(isoOf(now))}`
794  const runs = await read($, runsAtom)
795
796  const block = (l: Lesson, i: number) => {
797    const from = msOf(l.date, l.start)
798    const to = msOf(l.date, l.end)
799    const state = ms >= to ? '✓' : ms >= from ? '●' : '○'
800    const run = runFor(runs, l)
801    const current = run ? paceOf(l, run, ms).current : -1
802
803    return (
804      <Box key={`lesson-${i}`} flexDirection="column" marginTop={1}>
805        <Box flexDirection="row" flexWrap="wrap">
806          <Text color={state === '●' ? classOf(l.cls).colour : QUIET} bold>{`${state} `}</Text>
807          <Text dimColor>{`${l.start} to ${l.end}  `}</Text>
808          {chip(ui, l, `c${i}`)}
809          {classOf(l.cls).short === l.cls ? null : <Text bold>{`  ${l.cls}`}</Text>}
810        </Box>
811        {run ? (
812          <Box flexDirection="row" flexWrap="wrap" paddingLeft={2}>
813            {run.tasks.map((k, j) => (
814              <Box key={`t${i}-${j}`} marginRight={1}>
815                <Text backgroundColor={j < current ? DONE_BG : j === current ? classOf(l.cls).colour : AHEAD_BG} color="black">
816                  {` ${j < current ? '✓ ' : ''}${j + 1} ${plain(k.title)} · ${k.marks ? `${k.marks} marks` : `${k.minutes} min`} `}
817                </Text>
818              </Box>
819            ))}
820          </Box>
821        ) : (
822          <Box flexDirection="row" flexWrap="wrap" paddingLeft={2}>
823            <Text dimColor>No run sheet handed over yet  </Text>
824            <Button key={`plan-${i}`} plain dimColor label="what's planned?" onPress={() => void askPlan($, l)} />
825          </Box>
826        )}
827      </Box>
828    )
829  }
830
831  return (
832    <Box flexDirection="column" borderStyle="round" borderColor="gray" paddingX={1}>
833      <Box flexDirection="row" justifyContent="space-between">
834        <Text bold><Text color="#5FB37A">◆</Text>{` ${dayName}`}</Text>
835        <Text dimColor>{clockOf(ms)}</Text>
836      </Box>
837      {lessons.length === 0 ? <Text dimColor wrap="wrap">{describeDay(t, now)}</Text> : lessons.map(block)}
838    </Box>
839  )
840}
841
842// /lessons try: a pretend Lower Sixth lesson starting now: a five-mark quiz, a listening task and the Hall,
843// a minute each, running one after another so the teacher can watch and hear it move on.
844async function startTrial($: EngineInterface): Promise<{ lesson: Lesson; run: LessonRun }> {
845  const now = await $.clock.now()
846  const date = isoOf(new Date(now))
847  const lesson: Lesson = { cls: 'Lower Sixth', from: 7, to: 7, start: clockOf(now), end: clockOf(now + 20 * 60000), date }
848  const run: LessonRun = {
849    cls: lesson.cls,
850    date,
851    period: periodLabel(lesson),
852    tasks: [
853      { title: 'Quiz: Signals and connectors', label: 'Quiz', marks: 5, minutes: 1, detail: 'Five marks, unannounced.' },
854      { title: 'Era 1 from the front', label: 'Listen', minutes: 1, detail: 'Each pupil listens and writes; no calling out.' },
855      { title: 'Set Hall 1', label: 'Hall', minutes: 1, detail: 'Each opens their own worksheet.' },
856    ],
857    savedAt: now,
858  }
859  await update($, trialAtom, () => ({ lesson, run }))
860  await setPlay($, { key: lessonKey(lesson), task: 0, at: now })
861  if ((await soundOf($)) === 'voice') void prepareVoice($, run)
862  await announce($, `${lessonKey(lesson)}|try|${Math.round(now / 1000)}`, spokenLine(0, run.tasks[0]!))
863
864  return { lesson, run }
865}
866
867// The words go in the prompt box for the teacher to check and send.
868async function askPlan($: EngineInterface, l: Lesson) {
869  const filled = await $.prompt.fill({ text: `What's planned for ${l.cls}, ${longDay(l.date)} ${periodLabel(l)}?`, mode: 'replace' })
870  $.ui.toast(filled.isFilled ? 'In your prompt: check it and press Enter' : 'Could not reach your prompt box')
871}
872
873// A class's chip: the short name and colour above where it has one, else its own name and a colour from the same
874// set, the same class always the same colour.
875const PALETTE = ['#8AA4E8', '#7FC8C8', '#A78BFA', '#9FD18B', '#E8C66A', '#E8A0C0']
876
877function classOf(cls: string): { short: string; colour: string } {
878  const known = CLASSES[cls.trim().toLowerCase()]
879  if (known) return known
880  let h = 0
881  for (const ch of cls) h = (h * 31 + ch.charCodeAt(0)) >>> 0
882
883  return { short: cls.trim(), colour: PALETTE[h % PALETTE.length]! }
884}
885
886// A title without the plan's markdown, as it reads on a chip or aloud.
887function plain(text: string): string {
888  return text.replace(/[*_`]/g, '').replace(/\s+/g, ' ').trim()
889}
890
891function clockOf(ms: number): string {
892  const d = new Date(ms)
893
894  return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
895}
896
897// "", "today", "tomorrow", or a weekday: the next such day after today (/lessons alone is today).
898function dayAsked(arg: string, now: Date): Date | undefined {
899  if (arg === '' || arg === 'today') return now
900  if (arg === 'tomorrow') return addDays(now, 1)
901  const i = DAYS.findIndex(d => arg.startsWith(d.toLowerCase()))
902  if (i < 0) return undefined
903
904  return addDays(now, (i - now.getDay() + 7) % 7 || 7)
905}
906
hooks/card.ts 194 lines
1// The lesson card, painted cell by cell for a Raster so it looks the same on a white terminal as on a
2// dark one: a dark card, a header line, and a row a task with a rounded track, a dotted
3// fill shading up to the task's colour, a tick a mark (or a minute), a rounded chip riding at the head of the
4// fill, and the percentage on the right. Pure: the same inputs paint the same cells, so tests can read them.
5
6export const DEFAULT = 0x01000000 // the terminal's own colour, outside the card
7export const CARD = 0x1d1d21
8export const TRACK = 0x41414a // clearly lighter than the card, so the empty bar reads
9export const TEXT = 0xececec
10export const DIM = 0x8e8e96
11export const GREEN = 0x5fbf7f
12export const GREY_PILL = 0x4a4a52
13const TICK = 0x74747e
14const BLACK = 0x000000
15const WHITE = 0xffffff
16
17export type Seg = { text: string; fg?: number; bg?: number }
18
19// One row of cells: [codePoint, foreground, background] each, packed as the Raster wants them.
20class Row {
21  readonly words: Uint32Array
22
23  constructor(readonly width: number, bg: number) {
24    this.words = new Uint32Array(width * 3)
25    for (let c = 0; c < width; c += 1) this.set(c, ' ', TEXT, bg)
26  }
27
28  set(c: number, ch: string, fg: number, bg?: number) {
29    if (c < 0 || c >= this.width) return
30    this.words[c * 3] = ch.codePointAt(0) ?? 32
31    this.words[c * 3 + 1] = fg
32    if (bg !== undefined) this.words[c * 3 + 2] = bg
33  }
34
35  bgAt(c: number): number {
36    return this.words[c * 3 + 2] ?? CARD
37  }
38
39  // Words from column c, cut at the row's end (titles are kept short where they are made, so nothing is cut here).
40  text(c: number, s: string, fg: number, bg?: number): number {
41    let at = c
42    for (const ch of s) this.set(at++, ch, fg, bg)
43
44    return at
45  }
46
47  encode(): string {
48    return base64(new Uint8Array(this.words.buffer))
49  }
50}
51
52// The header: segments from the left, segments flush right, on the card.
53export function header(width: number, left: Seg[], right: Seg[]): string {
54  const row = new Row(width, CARD)
55  // Column 1, the same as each task's dot below it, so the dots stand in one line.
56  let at = 1
57  for (const s of left) at = row.text(at, s.text, s.fg ?? TEXT, s.bg ?? CARD)
58  const rightLength = right.reduce((n, s) => n + [...s.text].length, 0)
59  at = Math.max(at + 2, width - 1 - rightLength)
60  for (const s of right) at = row.text(at, s.text, s.fg ?? TEXT, s.bg ?? CARD)
61
62  return row.encode()
63}
64
65export type TaskLook = {
66  width: number
67  glyph: string
68  glyphFg: number
69  title: string
70  titleWidth: number
71  // How far through, 0 to 1.
72  fraction: number
73  // Tick marks: one a mark, else one a minute.
74  ticks: number
75  pill: string
76  pillBg: number
77  pillFg: number
78  // The task's colour: the fill shades up to it.
79  accent: number
80  percent: string
81  // The shimmer's place, moving on each frame.
82  phase: number
83}
84
85// One task's row: glyph, title, the track with its fill, ticks and chip, then the percentage.
86export function task(look: TaskLook): string {
87  const { width } = look
88  const row = new Row(width, CARD)
89  row.set(1, look.glyph, look.glyphFg)
90  row.text(3, look.title, TEXT)
91  const start = 3 + look.titleWidth + 2
92  const end = width - 6 // the bar's last column + 1; five columns of percentage after
93  const inner = end - start - 2
94  if (inner < 4) return row.encode()
95  row.set(start, '◖', TRACK, CARD)
96  row.set(end - 1, '◗', TRACK, CARD)
97  for (let c = start + 1; c < end - 1; c += 1) row.set(c, ' ', TEXT, TRACK)
98  const head = start + 1 + Math.round(Math.max(0, Math.min(1, look.fraction)) * inner)
99  const span = Math.max(1, head - start - 1)
100  const shine = start + 1 + ((look.phase % (span + 12)) - 6)
101  for (let c = start + 1; c < head; c += 1) {
102    const t = (c - start) / span
103    const lit = Math.abs(c - shine) <= 2 ? 0.35 : 0
104    row.set(c, dots(c, t), mix(mix(TRACK, look.accent, 0.55 + 0.45 * t), WHITE, lit), TRACK)
105  }
106  for (let k = 1; k < look.ticks; k += 1) {
107    const c = start + 1 + Math.round((k / look.ticks) * inner)
108    if (c > start && c < end - 1) row.set(c, '│', c < head ? mix(look.accent, WHITE, 0.25) : TICK, TRACK)
109  }
110  // The chip rides with its right end at the head of the fill, kept inside the track.
111  const label = ` ${look.pill} `
112  const width0 = [...label].length + 2
113  const at = Math.max(start, Math.min(end - width0, head - width0 + 1))
114  row.set(at, '◖', look.pillBg, row.bgAt(at) === CARD ? CARD : TRACK)
115  row.text(at + 1, label, look.pillFg, look.pillBg)
116  row.set(at + width0 - 1, '◗', look.pillBg, row.bgAt(at + width0 - 1) === CARD ? CARD : TRACK)
117  const pct = look.percent.padStart(4)
118  row.text(width - 5, pct, DIM)
119
120  return row.encode()
121}
122
123// A braille cell of dots, denser toward the head: the same column always the same pattern, so only the shine moves.
124function dots(c: number, t: number): string {
125  let h = Math.imul(c + 1, 2654435761) >>> 0
126  const threshold = 2 + Math.round(t * 5)
127  let bits = 0
128  for (let d = 0; d < 8; d += 1) {
129    if ((h & 7) < threshold) bits |= 1 << d
130    h >>>= 3
131    if (h === 0) h = Math.imul(c + d + 7, 40503) >>> 0
132  }
133
134  return String.fromCodePoint(0x2800 + (bits || 0x40))
135}
136
137// Two colours mixed: `amount` of the way from a to b.
138export function mix(a: number, b: number, amount: number): number {
139  const ch = (shift: number) => {
140    const x = (a >> shift) & 255
141    const y = (b >> shift) & 255
142
143    return Math.round(x + (y - x) * amount) << shift
144  }
145
146  return ch(16) | ch(8) | ch(0)
147}
148
149export function hexOf(colour: string): number {
150  return parseInt(colour.replace('#', ''), 16)
151}
152
153// What a painted row says, for tests: its characters in order.
154export function textOf(cells: string): string {
155  const bytes = unbase64(cells)
156  const words = new Uint32Array(bytes.buffer, bytes.byteOffset, bytes.byteLength / 4)
157  let out = ''
158  for (let i = 0; i < words.length; i += 3) out += String.fromCodePoint(words[i]!)
159
160  return out
161}
162
163const ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
164
165function base64(bytes: Uint8Array): string {
166  let out = ''
167  for (let i = 0; i < bytes.length; i += 3) {
168    const n = (bytes[i]! << 16) | ((bytes[i + 1] ?? 0) << 8) | (bytes[i + 2] ?? 0)
169    out += ALPHABET[(n >> 18) & 63]! + ALPHABET[(n >> 12) & 63]!
170    out += i + 1 < bytes.length ? ALPHABET[(n >> 6) & 63]! : '='
171    out += i + 2 < bytes.length ? ALPHABET[n & 63]! : '='
172  }
173
174  return out
175}
176
177function unbase64(text: string): Uint8Array {
178  const clean = text.replace(/=+$/, '')
179  const out = new Uint8Array(Math.floor((clean.length * 3) / 4))
180  let n = 0
181  let bits = 0
182  let o = 0
183  for (const ch of clean) {
184    n = (n << 6) | ALPHABET.indexOf(ch)
185    bits += 6
186    if (bits >= 8) {
187      bits -= 8
188      out[o++] = (n >> bits) & 255
189    }
190  }
191
192  return out
193}
194
hooks/run.ts 91 lines
1// A lesson's run sheet: which lesson it belongs to, and the run tool's input checked against the timetable.
2// Pure, like timetable.ts.
3
4import type { Lesson, LessonRun, Timetable } from '../types'
5import { dateFrom, dateOf, isoOf, lessonsOn, periodLabel } from './timetable'
6
7export function keyOf(cls: string, date: string, period: string): string {
8  return `${cls.trim().toLowerCase()}|${date}|${period.trim().toUpperCase()}`
9}
10
11export function lessonKey(l: Lesson): string {
12  return keyOf(l.cls, l.date, periodLabel(l))
13}
14
15export function runFor(runs: readonly LessonRun[], l: Lesson): LessonRun | undefined {
16  return runs.find(r => keyOf(r.cls, r.date, r.period) === lessonKey(l))
17}
18
19// A run as a planning window hands it over, checked against the timetable: the lesson must be on it.
20export function asRun(input: Record<string, unknown>, t: Timetable, now: Date): { run?: LessonRun; why?: string } {
21  const text = (v: unknown) => (typeof v === 'string' ? v.trim() : '')
22  const cls = text(input.class)
23  const period = text(input.period).toUpperCase().replace(/\s+/g, '')
24  const date = dateIn(text(input.date), now)
25  if (!cls || !period || !date) return { why: 'Give class (as the timetable names it, e.g. "Lower Sixth"), date ("2026-11-02" or "Mon 2 Nov"), period ("P7" or "P4+P5") and tasks.' }
26  const day = lessonsOn(t, dateFrom(date))
27  const lesson = day.find(l => keyOf(l.cls, l.date, periodLabel(l)) === keyOf(cls, date, period))
28  if (!lesson) {
29    const has = day.map(l => `${l.cls} ${periodLabel(l)}`).join(', ') || 'no lessons'
30    return { why: `${cls} ${period} is not on the timetable for ${date}: that day has ${has}.` }
31  }
32  const tasks = (Array.isArray(input.tasks) ? (input.tasks as Record<string, unknown>[]) : [])
33    .map(k => {
34      const number = (v: unknown) => (typeof v === 'number' ? Math.round(v) : Number(text(v)) || 0)
35      const marks = number(k.marks) || undefined
36      const title = text(k.title)
37
38      return {
39        title,
40        // One mark, one minute (a common rule for papers and quizzes) when the plan gives marks and no minutes.
41        minutes: number(k.minutes) || marks || 0,
42        label: text(k.label).split(/\s+/)[0] || title.split(/[\s/]+/)[0] || 'Task',
43        marks,
44        start: /^\d{1,2}:\d{2}$/.test(text(k.start)) ? text(k.start).padStart(5, '0') : undefined,
45        detail: text(k.detail) || undefined,
46      }
47    })
48    .filter(k => k.title && k.minutes > 0)
49    .slice(0, 12)
50  if (tasks.length === 0) return { why: 'Give tasks: [{ title, minutes or marks, label?, start?, detail? }], in order.' }
51
52  return { run: { cls: lesson.cls, date, period: periodLabel(lesson), tasks, savedAt: now.getTime() } }
53}
54
55// "2026-11-02", or "Mon 2 Nov" / "2 November" in the year that makes it nearest to now.
56function dateIn(text: string, now: Date): string | undefined {
57  if (/^\d{4}-\d{2}-\d{2}$/.test(text)) return text
58  if (/\d{4}/.test(text)) return dateOf(text)
59  const guesses = [now.getFullYear() - 1, now.getFullYear(), now.getFullYear() + 1].flatMap(y => dateOf(`${text} ${y}`) ?? [])
60  const near = (iso: string) => Math.abs(dateFrom(iso).getTime() - dateFrom(isoOf(now)).getTime())
61
62  return guesses.sort((x, y) => near(x) - near(y))[0]
63}
64
65// When each task runs: the task the lesson started from (the first, at the bell, or the one the teacher jumped to)
66// and every task after it following on as the one before runs out. Tasks before it are done.
67export type Slot = { i: number; from: number; to: number }
68
69export function slots(run: LessonRun, from: number, at: number, endedAt?: number): Slot[] {
70  const out: Slot[] = []
71  let t = at
72  for (let i = from; i < run.tasks.length; i += 1) {
73    const to = i === from && endedAt !== undefined ? endedAt : t + run.tasks[i]!.minutes * 60000
74    out.push({ i, from: t, to })
75    t = to
76  }
77
78  return out
79}
80
81// Where the lesson is: the task running and how far through it, -1 before the first starts, past the last
82// (tasks.length) once every task is done.
83export function paceAt(run: LessonRun, all: readonly Slot[], from: number, now: number): { current: number; slot?: Slot; fraction: number } {
84  const first = all[0]
85  if (!first || now < first.from) return { current: first ? from - 1 : run.tasks.length, fraction: 0 }
86  const slot = all.find(s => now >= s.from && now < s.to)
87  if (!slot) return { current: run.tasks.length, fraction: 1 }
88
89  return { current: slot.i, slot, fraction: (now - slot.from) / Math.max(1, slot.to - slot.from) }
90}
91
hooks/svg.ts 196 lines
1// The lesson card as a picture, for the Claude desktop app (and the phone), which draw a mod's SVG: the look of
2// the desktop app's own panels. A dark rounded card; a row a task with a rounded track, a dotted fill shading up
3// to the task's colour, tick marks, a rounded chip riding at the head of the fill, and the percentage. The SVG
4// moves by itself (SMIL): the fill and chip slide to the end of the task's time, the count and percentage step
5// on, and a shine runs along the fill. Pure: tests read the markup.
6
7export type SvgRow = {
8  state: 'done' | 'running' | 'waiting'
9  title: string
10  label: string
11  ticks: number
12  // How far through when drawn, 0 to 1, and how many seconds are left of it (running only).
13  fraction: number
14  secondsLeft: number
15}
16
17export type SvgCard = {
18  isNow: boolean
19  chip: string
20  className: string
21  right: string
22  rightTone: 'done' | 'plain' | 'dim'
23  accent: string
24  rows: SvgRow[]
25}
26
27const W = 760
28const PAD = 16
29const HEAD = 40
30const ROW = 44
31const TRACK_X = 300
32const TRACK_END = W - 96
33const TRACK_W = TRACK_END - TRACK_X
34const TRACK_H = 26
35const CARD = '#1c1c1f'
36const TRACK = '#2c2c31'
37const TEXT = '#ececec'
38const DIM = '#8e8e96'
39const GREEN = '#5fbf7f'
40const GREY = '#4a4a52'
41const FONT = '-apple-system, BlinkMacSystemFont, "SF Pro Text", "Helvetica Neue", sans-serif'
42
43export function cardHeight(rows: number): number {
44  return PAD + HEAD + rows * ROW + 10
45}
46
47export function cardSvg(card: SvgCard): string {
48  const h = cardHeight(card.rows.length)
49  const parts: string[] = []
50  parts.push(`<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${h}" viewBox="0 0 ${W} ${h}" font-family='${FONT}'>`)
51  parts.push(defs(card))
52  parts.push(`<rect x="0.5" y="0.5" width="${W - 1}" height="${h - 1}" rx="16" fill="${CARD}" stroke="#2a2a2f"/>`)
53  parts.push(header(card))
54  card.rows.forEach((row, i) => parts.push(rowSvg(card, row, i)))
55  parts.push('</svg>')
56
57  return parts.join('')
58}
59
60// The dotted texture: a tile of squares of differing brightness, the same every time; and the shine.
61function defs(card: SvgCard): string {
62  const dots: string[] = []
63  let seed = 7
64  const random = () => {
65    seed = (Math.imul(seed, 1103515245) + 12345) >>> 0
66
67    return (seed >>> 8) / 16777216
68  }
69  for (let y = 0; y < 24; y += 3) {
70    for (let x = 0; x < 48; x += 3) {
71      const o = random()
72      if (o > 0.3) dots.push(`<rect x="${x}" y="${y}" width="2" height="2" fill="${card.accent}" opacity="${(0.25 + o * 0.75).toFixed(2)}"/>`)
73    }
74  }
75
76  return [
77    '<defs>',
78    `<pattern id="dots" width="48" height="24" patternUnits="userSpaceOnUse">${dots.join('')}</pattern>`,
79    `<pattern id="dotsDone" width="48" height="24" patternUnits="userSpaceOnUse">${dots.join('').replaceAll(card.accent, GREEN)}</pattern>`,
80    '<linearGradient id="fade" x1="0" x2="1" y1="0" y2="0"><stop offset="0" stop-color="#fff" stop-opacity="0.25"/><stop offset="1" stop-color="#fff" stop-opacity="1"/></linearGradient>',
81    '<mask id="fadeMask" maskContentUnits="objectBoundingBox"><rect width="1" height="1" fill="url(#fade)"/></mask>',
82    '<linearGradient id="shine" x1="0" x2="1" y1="0" y2="0"><stop offset="0" stop-color="#fff" stop-opacity="0"/><stop offset="0.5" stop-color="#fff" stop-opacity="0.35"/><stop offset="1" stop-color="#fff" stop-opacity="0"/></linearGradient>',
83    '</defs>',
84  ].join('')
85}
86
87function header(card: SvgCard): string {
88  const y = PAD + HEAD / 2
89  const chipW = textWidth(card.chip, 13) + 20
90  const tone = card.rightTone === 'done' ? GREEN : card.rightTone === 'dim' ? DIM : TEXT
91
92  return [
93    `<circle cx="24" cy="${y}" r="4.5" fill="${card.isNow ? card.accent : DIM}"/>`,
94    `<text x="38" y="${y + 5}" font-size="15" font-weight="600" fill="${card.isNow ? card.accent : DIM}">${card.isNow ? 'Now' : 'Next'}</text>`,
95    `<rect x="86" y="${y - 12}" width="${chipW}" height="24" rx="12" fill="${card.accent}"/>`,
96    `<text x="${86 + chipW / 2}" y="${y + 5}" font-size="13" font-weight="700" fill="#000" text-anchor="middle">${esc(card.chip)}</text>`,
97    `<text x="${86 + chipW + 12}" y="${y + 5}" font-size="15" fill="${TEXT}">${esc(card.className)}</text>`,
98    `<text x="${W - 24}" y="${y + 5}" font-size="14" fill="${tone}" text-anchor="end">${esc(card.right)}</text>`,
99  ].join('')
100}
101
102function rowSvg(card: SvgCard, row: SvgRow, i: number): string {
103  const cy = PAD + HEAD + i * ROW + ROW / 2
104  const ty = cy - TRACK_H / 2
105  const isDone = row.state === 'done'
106  const isRunning = row.state === 'running'
107  const f = isDone ? 1 : isRunning ? Math.max(0, Math.min(1, row.fraction)) : 0
108  const dur = isRunning ? Math.max(0.1, row.secondsLeft) : 0
109  const fillW = TRACK_W * f
110  const out: string[] = []
111  // The dot, then the title.
112  out.push(
113    isDone
114      ? `<text x="19" y="${cy + 5}" font-size="15" fill="${GREEN}">✓</text>`
115      : isRunning
116        ? `<circle cx="24" cy="${cy}" r="4.5" fill="${card.accent}"/>`
117        : `<circle cx="24" cy="${cy}" r="4" fill="none" stroke="${DIM}" stroke-width="1.5"/>`,
118  )
119  out.push(`<text x="38" y="${cy + 5}" font-size="15" fill="${TEXT}">${esc(row.title)}</text>`)
120  // The track.
121  out.push(`<rect x="${TRACK_X}" y="${ty}" width="${TRACK_W}" height="${TRACK_H}" rx="${TRACK_H / 2}" fill="${TRACK}"/>`)
122  // The fill: a tint and dots fading in from the left and brightest at the head, growing to the end of the
123  // task's time, inside the track's rounded shape; a shine runs along it while the task runs.
124  const clip = `track${i}`
125  const grow = isRunning ? `<animate attributeName="width" from="${fillW}" to="${TRACK_W}" dur="${dur}s" fill="freeze"/>` : ''
126  out.push(`<clipPath id="${clip}"><rect x="${TRACK_X}" y="${ty}" width="${TRACK_W}" height="${TRACK_H}" rx="${TRACK_H / 2}"/></clipPath>`)
127  if (f > 0 || isRunning) {
128    const colour = isDone ? GREEN : card.accent
129    out.push(
130      `<g clip-path="url(#${clip})">`,
131      `<rect x="${TRACK_X}" y="${ty}" width="${fillW}" height="${TRACK_H}" fill="${colour}" opacity="0.22" mask="url(#fadeMask)">${grow}</rect>`,
132      `<rect x="${TRACK_X}" y="${ty}" width="${fillW}" height="${TRACK_H}" fill="url(#${isDone ? 'dotsDone' : 'dots'})" mask="url(#fadeMask)">${grow}</rect>`,
133    )
134    if (isRunning) {
135      out.push(
136        `<clipPath id="grow${i}"><rect x="${TRACK_X}" y="${ty}" width="${fillW}" height="${TRACK_H}">${grow}</rect></clipPath>`,
137        `<g clip-path="url(#grow${i})"><rect x="${TRACK_X - 80}" y="${ty}" width="80" height="${TRACK_H}" fill="url(#shine)"><animate attributeName="x" from="${TRACK_X - 80}" to="${TRACK_END}" dur="2.6s" repeatCount="indefinite"/></rect></g>`,
138      )
139    }
140    out.push('</g>')
141  }
142  // A tick a mark (or a minute), lighter over the fill.
143  for (let k = 1; k < row.ticks; k += 1) {
144    const x = TRACK_X + (k / row.ticks) * TRACK_W
145    out.push(`<line x1="${x}" x2="${x}" y1="${cy - 6}" y2="${cy + 6}" stroke="${x <= TRACK_X + fillW ? 'rgba(255,255,255,0.55)' : '#4a4a52'}" stroke-width="1.5" stroke-linecap="round"/>`)
146  }
147  // The chip, riding with its right end at the head of the fill; its count stepping on with the ticks.
148  const labels = isDone
149    ? [{ text: `✓ ${row.label} ${row.ticks}/${row.ticks}`, from: 0, to: Infinity }]
150    : isRunning
151      ? steps(row.ticks, f, dur).map(s => ({ text: `${row.label} ${s.value}/${row.ticks}`, from: s.from, to: s.to }))
152      : [{ text: `${row.label} 0/${row.ticks}`, from: 0, to: Infinity }]
153  const chipW = Math.max(...labels.map(l => textWidth(l.text, 13))) + 26
154  const chipFill = isDone ? GREEN : isRunning ? card.accent : GREY
155  const chipInk = isDone || isRunning ? '#000' : TEXT
156  const x0 = Math.max(TRACK_X, Math.min(TRACK_END - chipW, TRACK_X + fillW - chipW))
157  const x1 = TRACK_END - chipW
158  const slide = isRunning ? `<animateTransform attributeName="transform" type="translate" from="0 0" to="${x1 - x0} 0" dur="${dur}s" fill="freeze"/>` : ''
159  out.push(`<g>${slide}<rect x="${x0}" y="${ty}" width="${chipW}" height="${TRACK_H}" rx="${TRACK_H / 2}" fill="${chipFill}"/>`)
160  for (const l of labels) out.push(timed(`<text x="${x0 + chipW / 2}" y="${cy + 5}" font-size="13" font-weight="700" fill="${chipInk}" text-anchor="middle">${esc(l.text)}`, l.from, l.to))
161  out.push('</g>')
162  // The percentage, stepping on a per cent at a time.
163  const pcts = isRunning ? steps(100, f, dur).map(s => ({ text: `${s.value}%`, from: s.from, to: s.to })) : [{ text: isDone ? '100%' : '0%', from: 0, to: Infinity }]
164  for (const p of pcts) out.push(timed(`<text x="${W - 24}" y="${cy + 5}" font-size="14" fill="${DIM}" text-anchor="end">${p.text}`, p.from, p.to))
165
166  return out.join('')
167}
168
169// The values a count passes through from where it is to its end, each with when (seconds from now) it shows.
170function steps(of: number, f: number, dur: number): { value: number; from: number; to: number }[] {
171  const now = Math.min(of, Math.floor(f * of))
172  const at = (v: number) => (f >= 1 ? 0 : Math.max(0, ((v / of - f) / (1 - f)) * dur))
173  const out: { value: number; from: number; to: number }[] = []
174  for (let v = now; v <= of; v += 1) out.push({ value: v, from: v === now ? 0 : at(v), to: v === of ? Infinity : at(v + 1) })
175
176  return out
177}
178
179// A text shown from one moment to another: hidden until it starts (unless it is the one showing now).
180function timed(open: string, from: number, to: number): string {
181  const show = from > 0 ? `<set attributeName="visibility" to="visible" begin="${from.toFixed(2)}s"/>` : ''
182  const hide = Number.isFinite(to) ? `<set attributeName="visibility" to="hidden" begin="${to.toFixed(2)}s"/>` : ''
183  const start = from > 0 ? open.replace('<text ', '<text visibility="hidden" ') : open
184
185  return `${start}${show}${hide}</text>`
186}
187
188// Close enough for laying out a chip: the system font's average width at a size.
189function textWidth(text: string, size: number): number {
190  return [...text].length * size * 0.6
191}
192
193function esc(text: string): string {
194  return text.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
195}
196
hooks/timetable.ts 237 lines
1// The timetable file read into the shape the strip needs, and the day worked out from it.
2// Pure: no engine calls, so the tests can check any day and time.
3
4import type { Lesson, Period, Timetable } from '../types'
5
6export const DAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'] as const
7const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']
8const LOOK_AHEAD_DAYS = 60
9
10// What the strip says at one moment: the lesson it is about, and what follows it.
11export type Strip =
12  | { kind: 'now'; lesson: Lesson; leftMs: number; rest: Lesson[]; after?: Lesson }
13  | { kind: 'next'; lesson: Lesson; inMs: number; rest: Lesson[] }
14  | { kind: 'later'; hadLessons: boolean; after?: Lesson; lastDate?: string }
15  | { kind: 'break'; what: string; after?: Lesson; lastDate?: string }
16  | { kind: 'unknown'; lastDate?: string }
17
18export function parseTimetable(md: string): Timetable {
19  const periods = table(md, 'Periods')
20    .slice(1)
21    .flatMap(([p = '', time = '']): Period[] => {
22      const span = /(\d{1,2}:\d{2}) to (\d{1,2}:\d{2})/.exec(time)
23      if (!/^\d+$/.test(p) || !span) return []
24      const wed = /Wednesday (\d{1,2}:\d{2})/.exec(time)
25
26      return [{ p: Number(p), start: hhmm(span[1]!), end: hhmm(span[2]!), ...(wed ? { wedStart: hhmm(wed[1]!) } : {}) }]
27    })
28  const [head = [], ...rows] = table(md, 'Week')
29  const week: Record<string, Record<string, string>> = {}
30  for (const row of rows) {
31    const p = row[0] ?? ''
32    if (!/^\d+$/.test(p)) continue
33    head.forEach((day, i) => {
34      const cls = row[i]
35      if (i > 0 && cls) (week[day] ??= {})[p] = cls
36    })
37  }
38  const oneOff = table(md, 'One-off days')
39    .slice(1)
40    .flatMap(([when = '', runs = '']) => {
41      const date = dateOf(when)
42      const slots = [...runs.matchAll(/\b(Mon|Tue|Wed|Thu|Fri|Sat) P(\d+)/g)].map(m => ({ day: m[1]!, p: Number(m[2]) }))
43
44      return date && slots.length > 0 ? [{ date, runs: slots }] : []
45    })
46  const terms = table(md, 'Term dates')
47    .slice(1)
48    .flatMap(([what = '', from = '', to = '', lessons = '']) => {
49      const [a, b] = [dateOf(from), dateOf(to)]
50
51      return what && a && b ? [{ what, from: a, to: b, hasLessons: /^y/i.test(lessons) }] : []
52    })
53
54  return { periods, week, oneOff, terms }
55}
56
57// The rows of the first table under a `## heading`, each as its cells; the |---| rule left out.
58function table(md: string, heading: string): string[][] {
59  const lines = md.split('\n')
60  const at = lines.findIndex(l => l.trim().toLowerCase() === `## ${heading.toLowerCase()}`)
61  if (at < 0) return []
62  const rows: string[][] = []
63  for (const line of lines.slice(at + 1)) {
64    if (line.startsWith('## ')) break
65    const t = line.trim()
66    if (t.startsWith('|')) rows.push(t.replace(/^\||\|$/g, '').split('|').map(c => c.trim()))
67    else if (rows.length > 0) break
68  }
69
70  return rows.filter(r => !r.every(c => /^:?-+:?$/.test(c)))
71}
72
73// "8 Sep 2026", "Tue 8 Sep 2026", "16 October 2026" as "2026-09-08".
74export function dateOf(text: string): string | undefined {
75  const m = /(\d{1,2}) (Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)[a-z]* (\d{4})/.exec(text)
76
77  return m ? isoOf(new Date(Number(m[3]), MONTHS.indexOf(m[2]!), Number(m[1]))) : undefined
78}
79
80export function isoOf(d: Date): string {
81  return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`
82}
83
84// Whether a day has lessons by the term dates: in a term, in a break (named), or past what the table knows.
85// With no term dates at all, every day is a teaching day, as the timetable alone would have it.
86export function dayKind(t: Timetable, iso: string): { kind: 'term' } | { kind: 'break'; what: string } | { kind: 'unknown' } {
87  if (t.terms.length === 0) return { kind: 'term' }
88  const covering = t.terms.filter(r => r.from <= iso && iso <= r.to)
89  const off = covering.find(r => !r.hasLessons)
90  if (off) return { kind: 'break', what: off.what }
91  if (covering.length > 0) return { kind: 'term' }
92  const first = t.terms.reduce((a, r) => (r.from < a ? r.from : a), t.terms[0]!.from)
93  const last = lastDate(t)!
94
95  return iso < first || iso > last ? { kind: 'unknown' } : { kind: 'break', what: 'Holiday' }
96}
97
98function lastDate(t: Timetable): string | undefined {
99  return t.terms.reduce<string | undefined>((a, r) => (a === undefined || r.to > a ? r.to : a), undefined)
100}
101
102// A day's lessons in order, a class running on into the next period joined into one double.
103export function lessonsOn(t: Timetable, d: Date): Lesson[] {
104  const iso = isoOf(d)
105  if (dayKind(t, iso).kind !== 'term') return []
106  const day = DAYS[d.getDay()]!
107  const special = t.oneOff.find(o => o.date === iso)
108  // A rotation day runs the listed slots as P1, P2, ... in that order.
109  const slots = special
110    ? special.runs.map((r, i) => ({ p: i + 1, cls: t.week[r.day]?.[String(r.p)] }))
111    : t.periods.map(per => ({ p: per.p, cls: t.week[day]?.[String(per.p)] }))
112  const out: Lesson[] = []
113  for (const slot of slots) {
114    const period = t.periods.find(x => x.p === slot.p)
115    if (!slot.cls || !period) continue
116    const last = out[out.length - 1]
117    if (last && last.cls === slot.cls && last.to === slot.p - 1) {
118      last.to = slot.p
119      last.end = period.end
120      continue
121    }
122    const start = day === 'Wed' && period.wedStart ? period.wedStart : period.start
123    out.push({ cls: slot.cls, from: slot.p, to: slot.p, start, end: period.end, date: iso })
124  }
125
126  return out
127}
128
129// The first lesson from the day after `d`, or the last date the term dates know when they run out first.
130export function firstAfter(t: Timetable, d: Date): { lesson?: Lesson; lastDate?: string } {
131  for (let i = 1; i <= LOOK_AHEAD_DAYS; i += 1) {
132    const day = addDays(d, i)
133    if (dayKind(t, isoOf(day)).kind === 'unknown') return { lastDate: lastDate(t) }
134    const lessons = lessonsOn(t, day)
135    if (lessons.length > 0) return { lesson: lessons[0] }
136  }
137
138  return {}
139}
140
141export function stripAt(t: Timetable, now: Date): Strip {
142  const kind = dayKind(t, isoOf(now))
143  if (kind.kind === 'unknown') return { kind: 'unknown', lastDate: lastDate(t) }
144  const ahead = firstAfter(t, now)
145  if (kind.kind === 'break') return { kind: 'break', what: kind.what, after: ahead.lesson, lastDate: ahead.lastDate }
146  const today = lessonsOn(t, now)
147  const ms = now.getTime()
148  const current = today.find(l => msOf(l.date, l.start) <= ms && ms < msOf(l.date, l.end))
149  if (current) {
150    const rest = today.slice(today.indexOf(current) + 1)
151
152    return { kind: 'now', lesson: current, leftMs: msOf(current.date, current.end) - ms, rest, after: rest.length === 0 ? ahead.lesson : undefined }
153  }
154  const coming = today.filter(l => msOf(l.date, l.start) > ms)
155  if (coming.length > 0) return { kind: 'next', lesson: coming[0]!, inMs: msOf(coming[0]!.date, coming[0]!.start) - ms, rest: coming.slice(1) }
156
157  return { kind: 'later', hadLessons: today.length > 0, after: ahead.lesson, lastDate: ahead.lastDate }
158}
159
160// A day's lessons in a sentence, for /lessons.
161export function describeDay(t: Timetable, d: Date): string {
162  const iso = isoOf(d)
163  const kind = dayKind(t, iso)
164  const name = longDay(iso)
165  if (kind.kind === 'unknown') return `${name} is past the term dates in your timetable: add the next term to its Term dates table.`
166  if (kind.kind === 'break') {
167    const { lesson } = firstAfter(t, d)
168    if (!lesson) return `${name}: ${kind.what}, no lessons.`
169    const back = lessonsOn(t, dateFrom(lesson.date))
170
171    return `${name}: ${kind.what}, no lessons. Back ${longDay(lesson.date)}: ${listOf(back)}.`
172  }
173  const lessons = lessonsOn(t, d)
174  if (lessons.length === 0) return `${name}: no lessons.`
175  const isRotation = t.oneOff.some(o => o.date === iso)
176
177  return `${name}${isRotation ? ' (rotation day)' : ''}: ${listOf(lessons)}.`
178}
179
180function listOf(lessons: Lesson[]): string {
181  return lessons.map(l => `${l.cls} ${periodLabel(l)} ${l.start} to ${l.end}`).join(' · ')
182}
183
184export function periodLabel(l: Lesson): string {
185  return l.from === l.to ? `P${l.from}` : `P${l.from}+P${l.to}`
186}
187
188// "today", "tomorrow", a weekday within the week, else "Mon 2 Nov".
189export function dayLabel(iso: string, today: Date): string {
190  if (iso === isoOf(today)) return 'today'
191  if (iso === isoOf(addDays(today, 1))) return 'tomorrow'
192  const days = Math.round((dateFrom(iso).getTime() - dateFrom(isoOf(today)).getTime()) / 86400000)
193
194  return days > 0 && days < 7 ? DAYS[dateFrom(iso).getDay()]! : longDay(iso)
195}
196
197export function longDay(iso: string): string {
198  const d = dateFrom(iso)
199
200  return `${DAYS[d.getDay()]} ${d.getDate()} ${MONTHS[d.getMonth()]}`
201}
202
203// "42 min", "1 h 05", "3 h".
204export function duration(ms: number): string {
205  const mins = Math.max(1, Math.ceil(ms / 60000))
206  if (mins < 60) return `${mins} min`
207  const rest = mins % 60
208
209  return rest === 0 ? `${Math.floor(mins / 60)} h` : `${Math.floor(mins / 60)} h ${pad(rest)}`
210}
211
212export function addDays(d: Date, n: number): Date {
213  return new Date(d.getFullYear(), d.getMonth(), d.getDate() + n)
214}
215
216export function dateFrom(iso: string): Date {
217  const [y, m, d] = iso.split('-').map(Number)
218
219  return new Date(y!, m! - 1, d!)
220}
221
222// Built from the parts rather than midnight plus minutes, so the day the clocks change is right too.
223export function msOf(iso: string, time: string): number {
224  const [y, mo, d] = iso.split('-').map(Number)
225  const [h, m] = time.split(':').map(Number)
226
227  return new Date(y!, mo! - 1, d!, h!, m!).getTime()
228}
229
230function hhmm(time: string): string {
231  return time.padStart(5, '0')
232}
233
234function pad(n: number): string {
235  return String(n).padStart(2, '0')
236}
237
types/index.d.ts 70 lines
1/** A lesson on one day: one period, or a double the same class runs on into. */
2export type Lesson = {
3  /** As the timetable names it: "Upper Sixth", "Supervised study", "Cover". */
4  cls: string
5  /** The first and last period, the same for a single. */
6  from: number
7  to: number
8  /** "HH:MM", local time. */
9  start: string
10  end: string
11  /** "YYYY-MM-DD". */
12  date: string
13}
14
15export type Period = { p: number; start: string; end: string; wedStart?: string }
16
17/** The timetable file as the strip reads it. */
18export type Timetable = {
19  periods: Period[]
20  /** Day ("Mon") to period ("4") to the class taught then. */
21  week: Record<string, Record<string, string>>
22  /** Rotation days: period n runs the lesson of the nth slot listed. */
23  oneOff: { date: string; runs: { day: string; p: number }[] }[]
24  /** Terms and breaks; a day inside a break has no lessons. */
25  terms: { what: string; from: string; to: string; hasLessons: boolean }[]
26}
27
28/** One task of a lesson's run sheet, as a planning window hands it over. */
29export type RunTask = {
30  title: string
31  minutes: number
32  /** One word for the chip on its bar: "Quiz", "Starter", "Essay". */
33  label?: string
34  /** A quiz or exam question's marks: a tick each on its bar (one mark, one minute). */
35  marks?: number
36  /** "HH:MM" when the plan gives it. */
37  start?: string
38  /** One sentence: what the pupils do. */
39  detail?: string
40}
41
42/** A lesson's run sheet: its tasks in order. */
43export type LessonRun = { cls: string; date: string; period: string; tasks: RunTask[]; savedAt: number }
44
45/** The task the teacher pressed ▶ on, when, and when they pressed ✓ if they ended it early: the lesson moves at his pace. */
46export type Play = { key: string; task: number; at: number; endedAt?: number }
47
48declare module 'claude-code' {
49  interface PluginState {
50    /**
51     * `timetable`: the file as last read; `problem`: why it could not be read; `minute`: the clock the strip
52     * redraws on; `isHidden`: /lessons hide; `runs`, `play`, `beat`, `trial`: the lesson's tasks, as below.
53     */
54    'next-lesson': {
55      timetable: Timetable | null
56      problem: string
57      minute: number
58      isHidden: boolean
59      /** The run sheets planning windows handed over, from the shared runs file. */
60      runs: LessonRun[]
61      /** The task running in the lesson on screen, or null before the teacher starts one. */
62      play: Play | null
63      /** Moves when a running task's bar fills, so the card redraws with its next task. */
64      beat: number
65      /** /lessons try: a pretend lesson of three one-minute tasks, to see and hear the strip at work. */
66      trial: { lesson: Lesson; run: LessonRun } | null
67    }
68  }
69}
70