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…

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.
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.
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.
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.
| Command | What it does |
|---|---|
/lessons | Today's lessons; /lessons tomorrow, /lessons fri for another day |
/lessons try | A pretend three-minute lesson, to see and hear the card at work |
/lessons sound chime / voice / off | The chime (default), a spoken line, or silence |
/lessons timetable <file> | Where your timetable is |
/lessons hide / show | Takes the strip away, or brings it back |
/today | The day as a tab: each lesson with its tasks |
None of these reaches Claude or costs a turn.
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.
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.
hooks/register.tsx 906 lines1// 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}
906hooks/card.ts 194 lines1// 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}
194hooks/run.ts 91 lines1// 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}
91hooks/svg.ts 196 lines1// 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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"')
195}
196hooks/timetable.ts 237 lines1// 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}
237types/index.d.ts 70 lines1/** 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