A pomodoro timer on the prompt's hint line whose break lands while Claude works, with buttons beside it, a history and a report: /pomodoro start

A pomodoro timer on the prompt's hint line in Claude Code. It runs on the wall clock like any other, with one difference: the break is timed to land while Claude works, when you have a wait ahead of you anyway.
Type /pomodoro start and the round counts down at the end of the hint line under the prompt:
> _
? for shortcuts · 🍅 18:42 · 2/4
claude plugin marketplace add barisdemirhan/claude-mods
claude plugin install pomodoro@claude-mods
Restart Claude Code, then run /pomodoro start.
The same two steps work from inside a session with /plugin marketplace add barisdemirhan/claude-mods and /plugin install pomodoro@claude-mods.
claude-mods is one marketplace for all of these mods, so its first line is needed once for the lot.
claude-pomodoroNothing has to change. This repository is still a marketplace of its own, and pomodoro@claude-pomodoro goes on getting updates.
Moving to claude-mods is a new install as Claude Code sees it, and it starts with an empty store: without your rounds and the running timer. To bring them along, copy the store's file to its new name before you install, with no Claude Code session open:
cp -R ~/.claude/plugins/store ~/claude-store-backup
cp ~/.claude/plugins/store/pomodoro_claude-pomodoro-69a776f141b5.json ~/.claude/plugins/store/pomodoro_claude-mods-cf0e3c48f8c2.json
claude plugin marketplace add barisdemirhan/claude-mods
claude plugin install pomodoro@claude-mods
claude plugin uninstall pomodoro@claude-pomodoro
The two file names are where Claude Code 2.1.288 keeps a mod's store. They are Claude Code's own and may change with it. The first line keeps a copy of every store in ~/claude-store-backup, to put back if the move goes wrong; delete it once the mod shows what it showed before. Uninstalling leaves the old store's file where it is. Keep one of the two installs, not both: with both on, every hook runs twice.
| Command | What it does | ||
|---|---|---|---|
/pomodoro start [what #tag] | Begins a set at focus round 1, for what you name with any #tags. On a running pomodoro it runs a paused timer on, or begins the round a finished break waits on. Plain /pomodoro begins one the same way, and says where the timer stands once one is on | ||
/pomodoro pause | Holds the timer where it is | ||
/pomodoro resume | Runs a paused timer on | ||
/pomodoro extend [minutes] | Gives the phase 5 more minutes, or as many as you say | ||
/pomodoro finish | Ends the focus round now and counts it with the time it ran, then the break begins | ||
/pomodoro skip | Goes to the next phase now. A focus round skipped before its end is not counted, though its focus time is | ||
/pomodoro stop | Ends the pomodoro | ||
/pomodoro note [what #tag] | Says what the rounds are for from now on. With nothing after it, clears it | ||
/pomodoro undo | Takes back the last change of phase, within five minutes: a stop, a skip, a finish, or a break that began, with the round it counted | ||
/pomodoro stats | Today's rounds and focus time, against your daily goal if you set one; your days in a row and the best run; your rounds in all; and the last seven days by project and tag, with Claude's share | ||
/pomodoro report | Opens a pane with the same, drawn: the last seven days as bars, twelve weeks as a heat map, the hours you focus in, and your top projects and tags | ||
/pomodoro log [day] | One day's rounds, one to a line: when, how long, what for, where, and Claude's part. today with no word, or yesterday, or a date as 2026-10-02 | ||
| `/pomodoro export [json\ | csv\ | ical] [file]` | Every round kept, as CSV with no word. To the clipboard, or to the file you name (~/focus.ics) |
/pomodoro sound | Turns the sounds off or on. /pomodoro sound on and /pomodoro sound off say which. Every open session follows within a second | ||
/pomodoro controls | Opens or closes the timer's row of buttons. /pomodoro controls on and /pomodoro controls off say which. Every open session follows within a second | ||
/pomodoro close | Takes it all out of sight and hearing, in every open session within a second: the timer on the hint line and its row of buttons, the report, the toasts and the sounds. Unlike /pomodoro stop, the pomodoro runs on. /pomodoro exit and /pomodoro quit do the same; /pomodoro open brings it all back as it was, and so do /pomodoro start, /pomodoro resume and /pomodoro controls on |
A set is four focus rounds of 25 minutes with a 5 minute break after each, and a 15 minute break after the fourth. Once started it runs round after round until you stop it.
Where there is a pointer to press with, the terminal's fullscreen layout or the desktop app, the timer moves to a row of its own right under the hint line, with buttons beside it that do the same as the commands:
? for shortcuts
🍅 18:42 · 2/4 [ ❚❚ pause ] [ +5m ] [ ✓ finish ] [ ■ stop ] [ ● sound ] [ report ] [ × ]
The row always begins with 🍅, so it reads as the pomodoro's: 🍅 pomodoro with none on, 🍅 ☕ 4:12 on a break. The buttons follow where the timer stands: ▶ start with none on, ▶ resume when paused, ▶ break for a round that ran out, ▶ focus during a break, and ↶ undo for five minutes after a change of phase. The one most likely wanted comes first; the rest are drawn dim. × closes the row, in every session, and the timer goes back to the end of the hint line; /pomodoro controls opens it again. The Control row setting shows it always, only while a pomodoro is on, or never. On the terminal's main screen, which has no pointer, there is no row and the commands do it all.
The row sits right under the hint line, over the rows other mods draw there, and leaves what they draw over the line where it is.
| On the hint line | It means |
|---|---|
🍅 18:42 · 2/4 | Focus round 2 of 4, 18:42 left |
🍅 break due · 2/4 | The round ran out and waits for Claude to start working |
☕ 4:12 | A break, 4:12 left |
☕ break over | The break ran out and the next round waits for you to come back |
🍅 paused 18:42 · 2/4 | Paused |
break due and the break starts with your next prompt, so you step away as Claude gets to work. If no prompt comes within five minutes, the break starts anyway. The minutes past the round's end count as focus.break over, and the next focus round starts with the next prompt you send. A prompt from a schedule, a background task or another session is not you, and does not start it.Each focus round is kept with when it began and ended, how long it was focused, whether it was done or stopped early, what you said it was for and its tags, the repositories or folders you worked in during it (by name), Claude's working time and your prompts. A round stopped before a minute of focus is not kept. Rounds older than 400 days are folded into their days' totals.
The stats from an earlier version of the mod, a total per day, carry over as days with no rounds listed.
All of these are in Claude Code's /config menu, under the plugin's name:
| Setting | Default | What it does |
|---|---|---|
| Focus minutes | 25 | |
| Break minutes | 5 | |
| Long break minutes | 15 | |
| Rounds | 4 | Focus rounds in a set, the long break after the last |
| Focus starts | prompt | After a break: prompt waits for your next prompt, auto starts the round at once. A round auto started that sees no prompt, command or turn of Claude's is taken as nobody being there, and ends uncounted |
| Break starts | claude | After a focus round: claude waits up to five minutes for Claude to start working, auto starts the break at once |
| Daily goal | 0 | Rounds a day you aim for, shown in the stats and told with a toast when met. 0 for none |
| Hint style | full | full shows 2/4, dots shows ●●○○, minimal the clock alone |
| Control row | always | The timer's row of buttons where there is a pointer: always, running (only while a pomodoro is on) or off |
| Volume | 50 | How loud the sounds play, 0 to 100 |
| Spoken announcements | off | Says each change of phase aloud as well, with the system voice |
| Open Pomodoro files | off | See below |
| Hook scripts | off | See below |
| Tools for Claude | off | See below |
A change of length applies from the next phase on.
openpomodoro-cli and the tools around it keep a pomodoro in ~/.pomodoro, in the Open Pomodoro format. Two settings, both off until you turn them on, let this mod share it:
~/.pomodoro/current while a focus round runs and clears it when the round ends, so a tmux status line or anything else that reads it shows this timer. The format has no pause, so a resumed round's end moves later by the time it sat paused. Each round done goes into ~/.pomodoro/history with the time it ran, in place of the line the CLI wrote for a round it began; a round stopped early or taken back with /pomodoro undo has its line removed, as the CLI's cancel does. A pomodoro pomodoro start began while none is on here shows up here within five seconds. Finishing or cancelling it with the CLI is not followed: end it here. Breaks are not part of the format, so they stay in the mod.~/.pomodoro/hooks/ as openpomodoro-cli does: start when a focus round begins, break when a break begins, and stop when a phase ends, a break included when it runs out with the next round waiting on you. Each gets POMODORO_EVENT, POMODORO_PHASE, POMODORO_ROUND, POMODORO_ROUNDS, POMODORO_MINUTES, POMODORO_DESCRIPTION, POMODORO_TAGS and POMODORO_DIRECTORY, and ten seconds to run. Unlike the CLI's, these run when a phase runs out on its own too. A script that turns on macOS Focus could be: #!/bin/sh
# ~/.pomodoro/hooks/start
shortcuts run "Focus On"
Only the session that changes the phase writes the files and runs the scripts, so they happen once however many sessions are open.
With Tools for Claude on, Claude can call two tools: stats, which reads where the timer stands, the stats and one day's rounds, and timer, which acts on the timer as /pomodoro does. Ask "how much did I focus this week, and on what?" or "start a pomodoro for the auth tests". Claude Code asks your permission for them as for any tool, and their descriptions take a little of every prompt's context, which is why they are off by default.
/pomodoro does not show up after installing, the switch may still be off for you.The mod registers one slash command, adds one label to the hint line under the prompt, or where there is a pointer a row under it with the label and its buttons, and draws the report pane when you open it. It never changes a prompt, a tool call or a tool's result.
What it reads. Of Claude's work, only when a turn starts and ends and how long it ran, and when Claude Code notifies you that it waits on a permission or a question. Of your prompts, only where each came from, to tell yours from a schedule's or another session's. It reads nothing of a prompt's text, a tool call or an answer. It asks Claude Code for the name of the repository or folder a session works in, to keep it with the rounds.
What it sends. Nothing. It makes no network request, and has no server, no account and no analytics.
What reaches Claude. What /pomodoro answers is a row of the conversation, as any command's output is, and Claude reads it with the rest: /pomodoro stats and /pomodoro log show your rounds' labels, tags and project names. With Tools for Claude on, what the stats tool answers goes into the conversation the same way. It is off until you turn it on.
What it keeps. These, in the plugin's own Claude Code store, one JSON file under ~/.claude/plugins/store/ on your disk: the timer (its phase, when it began, how long it runs, and what the rounds are for), your rounds and a count of their changes, what the running round gathered so far in each session, the rounds lately ended, when you last sent a prompt, what /pomodoro undo would take back, whether the sound is off, whether the row of buttons is closed, and whether you closed it all with /pomodoro close.
Sound. It plays two short sounds from its own sounds/ folder, through Claude Code's player, and with spoken announcements on, speaks through the system voice.
Files and processes. Out of the box it reads no files, writes none and runs no processes. Only these do, each when you ask for it:
/pomodoro export with a file name writes that file. Without one it copies to the clipboard.HOME to find ~/.pomodoro, reads and writes current and history there, and nothing else.~/.pomodoro/hooks/ named start, stop and break, if they are there.PRIVACY.md is the same as a privacy policy, with what reaches Claude and how to take your data off.
Its hooks are in hooks/register.tsx, with the report pane's drawing in hooks/report.tsx:
session.start registers the /pomodoro command (and with Tools for Claude on, the two tools), and starts the one-second tick that reads the shared timer, then passes the event on unchanged.command.run answers only the /pomodoro command. Other commands never reach it.prompt.submit reads where a prompt came from; one of yours begins a round that waits on you and is counted into the round. It passes the prompt on unchanged.turn.start notes that Claude is working and begins a break that was due, then passes the event on unchanged.turn.complete and classic.Notification note Claude's working time and show the toast during a break: the first when Claude's turn ends, the second when Claude Code notifies you that it waits on you. Both pass the event on unchanged and decide nothing.tool.call, with Tools for Claude on, answers only the mod's own two tools.ui.render adds the timer's label to the end of the hint line, after what the line already holds, and on the desktop app to the mode labels beside it; the hint itself and the other labels stay as they are. Where there is a pointer it draws the timer's row under the line instead, keeping what other mods drew. It also draws the report pane, and only that pane.The files under tests/ run only under claude plugin test, and are never loaded in a session.
git clone https://github.com/barisdemirhan/claude-pomodoro
claude plugin validate claude-pomodoro
claude plugin test claude-pomodoro
claude --plugin-dir claude-pomodoro
hooks/register.tsx holds the hooks that tie the mod to Claude Code, and every call it makes on Claude Code: the engine follows $ into no function of another file. The other files are plain functions it calls:
hooks/timer.ts: the timer itself, told from the clock alone.hooks/history.ts: the rounds kept, and the stats and log told from them.hooks/insights.ts and hooks/report.tsx: the report pane's numbers and its drawing.hooks/export.ts: the rounds as JSON, CSV and iCalendar.hooks/openpomodoro.ts and hooks/automation.ts: the Open Pomodoro format, and the paths, variables and file contents the two Open Pomodoro settings need.hooks/tools.ts: the tools Claude reads, as it reads them.hooks/values.ts: readers for what the store hands back.From the same marketplace, claude-mods:
MIT
hooks/register.tsx 1563 lines1import { atom, read, update } from 'claude-code'
2import type {
3 EngineInterface,
4 Register,
5 RenderElement,
6 RenderNode,
7 ToastOptions,
8} from 'claude-code'
9
10import {
11 currentTextOf,
12 historyWith,
13 historyWithout,
14 hookEnvOf,
15 hookPathOf,
16 pomodoroDirOf,
17 readCurrentText,
18} from './automation'
19import type { HookContext, PomodoroEvent } from './automation'
20import { EXPORT_FORMATS, exportText } from './export'
21import {
22 dayOf,
23 dayTotals,
24 intentOf,
25 intentText,
26 logText,
27 plural,
28 recorded,
29 removed,
30 spanText,
31 statsText,
32 toHistory,
33} from './history'
34import type { History, Round, RoundStatus } from './history'
35import { entryOfRound } from './openpomodoro'
36import type { OpenPomodoro } from './openpomodoro'
37import { REPORT_OPEN, REPORT_PANE, registerReport } from './report'
38import {
39 IDLE,
40 clockText,
41 extended,
42 focusOf,
43 focused,
44 intended,
45 isPaused,
46 labelOf,
47 leftOf,
48 minutesText,
49 paused,
50 pausedOf,
51 planOf,
52 rested,
53 resumed,
54 returned,
55 roundText,
56 skipped,
57 statusText,
58 stepped,
59 toTimer,
60} from './timer'
61import type { Plan, Timer } from './timer'
62import { ACTIONS, TOOLS } from './tools'
63import { field, isRecord, toCount, toWords } from './values'
64
65// What Claude did in this session while the person was on a break.
66type Away = { turns: number; asks: number }
67// What this session knows that the shared timer does not.
68type Session = {
69 // The session's own id, naming its tally in the store.
70 id: string
71 isWorking: boolean
72 // When Claude's turn here began: 0 while it is not working.
73 turnAt: number
74 // The focus round the turn began in, and how long that round had sat
75 // paused then: what the turn's time inside the round leaves out.
76 turnRound: number
77 turnPausedMs: number
78 // The timer as this session last saw it.
79 seen: Timer | undefined
80 // The history's revision as this session last drew it.
81 revision: number
82 // The repository or folder this session works in, by name.
83 project: string
84 away: Away
85}
86// What a focus round gathers in one session while it runs, each session
87// under a key of its own: counting a prompt never writes over a phase or a
88// count another session wrote.
89type Tally = {
90 start: number
91 prompts: number
92 claudeMs: number
93 projects: string[]
94 // When a turn of Claude's that still runs began in this round: 0 for none.
95 turnAt: number
96}
97// How the person wants to hear it.
98type Sound = { gain: number; isSpoken: boolean }
99// What the person let the mod do outside its own store: keep Open
100// Pomodoro's files in `~/.pomodoro`, and run the hook scripts kept there.
101type Outside = { isMirrored: boolean; isScripted: boolean }
102type Ways = { sound: Sound; outside: Outside }
103// When the buttons under the hint line show: always, only while a pomodoro
104// is on, or never.
105type ControlRow = 'always' | 'running' | 'off'
106// One of the timer's buttons: the `/pomodoro` word it says, and its label.
107type Control = { verb: string; label: string }
108// The switches every session shares, each named as the store keeps it.
109type Switches = { isMuted: boolean; areControlsOpen: boolean; isClosed: boolean }
110
111const TIMER = 'timer'
112const HISTORY = 'history'
113// The stats before the history was kept: each day's totals alone.
114const STATS = 'stats'
115const MUTED = 'isMuted'
116// Whether the timer's row of buttons is open: it is until closed.
117const CONTROLS_OPEN = 'areControlsOpen'
118// Whether the person closed the pomodoro out of sight and hearing: the timer,
119// its row, the report, its toasts and its sounds. A pomodoro on runs on.
120const CLOSED = 'isClosed'
121// When the person last did something, in any session.
122const ACTIVE = 'activeAt'
123// Each session's tally is under this, then the session's id.
124const TALLY = 'tally.'
125const UNDO = 'undo'
126// The focus rounds lately ended, so a `~/.pomodoro/current` left behind
127// brings none of them back in any session.
128const ENDED = 'ended'
129const MAX_ENDED = 20
130// Counts the history's changes, so each session's open report draws again.
131const REVISION = 'revision'
132const TICK_MS = 1000
133// With no pomodoro on, the store is read this many ticks apart: how soon one
134// started in another session shows here.
135const IDLE_TICKS = 5
136const TOAST_MS = 8000
137const MINUTE_MS = 60_000
138// A round stopped before this much focus is not worth a line in the history.
139const MIN_ROUND_MS = MINUTE_MS
140// How long after a change `/pomodoro undo` can still take it back.
141const UNDO_MS = 5 * MINUTE_MS
142const MAX_EXTEND_MINUTES = 120
143// How long a hook script of the person's may run.
144const SCRIPT_MS = 10_000
145// The notices Claude Code sends when it waits on the person's answer.
146const WAITING = ['permission_prompt', 'elicitation_dialog']
147// Where a prompt the person sent themselves comes from.
148const PERSON = ['composer', 'bridge']
149const CLIPS = { break: 'sounds/break.wav', focus: 'sounds/focus.wav' } as const
150const USAGE =
151 'Usage: /pomodoro [start [what #tag]], pause, resume, skip, finish, stop, extend [minutes], note [what #tag], undo, stats, report, log [today|yesterday|YYYY-MM-DD], export [json|csv|ical] [file], sound [on|off], controls [on|off], close or open.'
152const TIMER_VERBS = ['', 'start', 'pause', 'resume', 'skip', 'finish', 'stop', 'extend', 'note', 'undo']
153// The verbs that take no words after them.
154const BARE_VERBS = ['', 'pause', 'skip', 'finish', 'stop', 'undo']
155const SWITCH: Readonly<Record<string, boolean>> = { on: true, off: false }
156// Other words for closing it all out of sight.
157const CLOSE_WORDS = ['close', 'exit', 'quit']
158const CONTROL_ROWS: readonly ControlRow[] = ['always', 'running', 'off']
159const NOBODY: Away = { turns: 0, asks: 0 }
160
161// Bumped each time a round is recorded, so an open report pane draws again.
162const historyVersion = atom(
163 { plugin: 'pomodoro', key: 'historyVersion' } as const,
164 0,
165)
166const label = atom({ plugin: 'pomodoro', key: 'label' } as const, '')
167// The shared switches as this session last read them, what it draws and
168// plays by: another session's change reaches it at the next tick.
169const switches = atom(
170 { plugin: 'pomodoro', key: 'switches' } as const,
171 { isMuted: false, areControlsOpen: true, isClosed: false },
172)
173
174const controlRowOf = (options: Readonly<Record<string, unknown>>): ControlRow =>
175 CONTROL_ROWS.find((row) => row === options.controls) ?? 'always'
176
177const outsideOf = (options: Readonly<Record<string, unknown>>): Outside => ({
178 isMirrored: options.openPomodoro === true,
179 isScripted: options.scriptHooks === true,
180})
181
182const soundOf = (options: Readonly<Record<string, unknown>>): Sound => ({
183 gain:
184 typeof options.volume === 'number' && Number.isFinite(options.volume)
185 ? Math.min(Math.max(options.volume, 0), 100) / 100
186 : 0.5,
187 isSpoken: options.voice === true,
188})
189
190const ring = async (
191 $: EngineInterface,
192 sound: Sound,
193 clip: keyof typeof CLIPS,
194 words: string,
195): Promise<void> => {
196 const { isMuted, isClosed } = await read($, switches)
197
198 if (isMuted || isClosed) {
199 return
200 }
201
202 // A machine with no player has no sound; the timer runs on without it.
203 await $.audio.play({ asset: CLIPS[clip] }, { gain: sound.gain }).catch(() => undefined)
204
205 if (sound.isSpoken) {
206 await $.audio.speak(words).catch(() => undefined)
207 }
208}
209
210/** Shows a toast, unless the person closed the pomodoro out of sight. */
211const toasted = async (
212 $: EngineInterface,
213 text: string,
214 options?: ToastOptions,
215): Promise<void> => {
216 if (!(await read($, switches)).isClosed) {
217 $.ui.toast(text, options)
218 }
219}
220
221/**
222 * What to say of a break that begins: its length, and that now is the time.
223 * A toast is a box forty cells wide, so each of these stays one line of it.
224 */
225const breakText = (
226 before: Timer,
227 after: Timer,
228 plan: Plan,
229 isWorking: boolean,
230): string => {
231 const done = `${roundText(before, plan)} done`
232 const minutes = minutesText(after.lengthMs)
233
234 return isWorking
235 ? `Take ${minutes} while Claude works · ${done}`
236 : `Take a ${minutes} minute break · ${done}`
237}
238
239const focusText = (timer: Timer, plan: Plan): string =>
240 `Break over · focus ${roundText(timer, plan)} is on`
241
242const dueText = (timer: Timer, plan: Plan): string =>
243 `Break's over · focus ${roundText(timer, plan)} is next`
244
245const awayText = ({ turns, asks }: Away): string => {
246 const parts = [
247 turns > 0 ? `${plural(turns, 'turn')} done` : '',
248 asks > 0 ? `${plural(asks, 'ask')} for you` : '',
249 ].filter((part) => part !== '')
250
251 return parts.length === 0 ? '' : `Meanwhile: ${parts.join(' · ')}`
252}
253
254/** Says what Claude did here while the person was away, and starts over. */
255const welcomed = async ($: EngineInterface, session: Session): Promise<void> => {
256 const text = awayText(session.away)
257
258 session.away = NOBODY
259
260 if (text !== '') {
261 await toasted($, text, { timeoutMs: TOAST_MS })
262 }
263}
264
265const historyOf = async ($: EngineInterface): Promise<History> =>
266 toHistory(await $.store.get(HISTORY), await $.store.get(STATS))
267
268const toTally = (value: unknown, start: number): Tally =>
269 isRecord(value) && toCount(field(value, 'start')) === start
270 ? {
271 start,
272 prompts: toCount(field(value, 'prompts')),
273 claudeMs: toCount(field(value, 'claudeMs')),
274 projects: toWords(field(value, 'projects')),
275 turnAt: toCount(field(value, 'turnAt')),
276 }
277 : { start, prompts: 0, claudeMs: 0, projects: [], turnAt: 0 }
278
279const tallyKey = (session: Session): string => `${TALLY}${session.id}`
280
281const toStarts = (value: unknown): number[] =>
282 Array.isArray(value) ? value.map(toCount).filter((start) => start > 0) : []
283
284/**
285 * How long Claude's turn here has worked inside the focus round so far, the
286 * round's pauses since the turn began left out: 0 with no turn running.
287 */
288const workingOf = (session: Session, timer: Timer, now: number): number => {
289 if (session.turnAt === 0) {
290 return 0
291 }
292
293 const pausedMs =
294 pausedOf(timer, now) - (session.turnRound === timer.beganAt ? session.turnPausedMs : 0)
295
296 return Math.max(now - Math.max(session.turnAt, timer.beganAt) - pausedMs, 0)
297}
298
299/**
300 * What every session gathered in the focus round `timer` is, summed: a turn
301 * of Claude's still running in another session counted up to now.
302 */
303const talliesOf = async (
304 $: EngineInterface,
305 session: Session,
306 timer: Timer,
307 now: number,
308): Promise<Tally> => {
309 const start = timer.beganAt
310 const own = tallyKey(session)
311 const keys = (await $.store.keys()).filter((key) => key.startsWith(TALLY))
312 let sum: Tally = toTally(undefined, start)
313
314 for (const key of keys) {
315 const tally = toTally(await $.store.get(key), start)
316 const running =
317 key === own || tally.turnAt === 0 ? 0 : Math.max(now - Math.max(tally.turnAt, start), 0)
318
319 sum = {
320 ...sum,
321 prompts: sum.prompts + tally.prompts,
322 claudeMs: sum.claudeMs + tally.claudeMs + running,
323 projects: [...new Set([...sum.projects, ...tally.projects])],
324 }
325 }
326
327 return { ...sum, claudeMs: sum.claudeMs + workingOf(session, timer, now) }
328}
329
330/** Drops every session's tally of a round that is over: the one that began at `start`, or before. */
331const cleared = async ($: EngineInterface, start: number): Promise<void> => {
332 const keys = (await $.store.keys()).filter((key) => key.startsWith(TALLY))
333
334 for (const key of keys) {
335 const value = await $.store.get(key)
336
337 if (!isRecord(value) || toCount(field(value, 'start')) <= start) {
338 await $.store.delete(key)
339 }
340 }
341}
342
343/** Marks the focus round that began at `start` as ended, for every session. */
344const buried = async ($: EngineInterface, start: number): Promise<void> => {
345 const ended = toStarts(await $.store.get(ENDED)).filter((kept) => kept !== start)
346
347 await $.store.set(ENDED, [...ended, start].slice(-MAX_ENDED))
348}
349
350/** Keeps the history, and counts the change so every open report draws again. */
351const saved = async (
352 $: EngineInterface,
353 session: Session,
354 history: History,
355): Promise<void> => {
356 const revision = toCount(await $.store.get(REVISION)) + 1
357
358 await $.store.set(HISTORY, history)
359 await $.store.set(REVISION, revision)
360 session.revision = revision
361 await update($, historyVersion, (version) => version + 1)
362}
363
364/**
365 * Writes `after` in place of `before`, unless another session changed the
366 * timer since `before` was read: then nothing is written, and the change and
367 * all it sets going are that session's. The store has no compare-and-set,
368 * so this narrows the race to the moment between the two calls.
369 */
370const swapped = async (
371 $: EngineInterface,
372 before: Timer,
373 after: Timer,
374): Promise<boolean> => {
375 const now = toTimer(await $.store.get(TIMER))
376
377 if (JSON.stringify(now) !== JSON.stringify(before)) {
378 return false
379 }
380
381 await $.store.set(TIMER, after)
382
383 return true
384}
385
386/** Draws the timer's label again, if the timer reads otherwise by now. */
387const shown = async (
388 $: EngineInterface,
389 timer: Timer,
390 plan: Plan,
391): Promise<void> => {
392 const text = labelOf(timer, await $.clock.now(), plan)
393
394 if ((await read($, label)) !== text) {
395 await update($, label, () => text)
396 }
397}
398
399/** Keeps `timer` as the one every session reads, and shows it here. */
400const turned = async (
401 $: EngineInterface,
402 session: Session,
403 plan: Plan,
404 timer: Timer,
405): Promise<void> => {
406 await $.store.set(TIMER, timer)
407 session.seen = timer
408 await shown($, timer, plan)
409}
410
411/**
412 * Writes the focus round `timer` was into the history, with what it
413 * gathered while it ran: none for a stop too short to keep.
414 */
415const archived = async (
416 $: EngineInterface,
417 session: Session,
418 timer: Timer,
419 now: number,
420 status: RoundStatus,
421): Promise<Round | undefined> => {
422 const focusMs = Math.round(focusOf(timer, now))
423
424 if (timer.phase !== 'focus') {
425 return undefined
426 }
427
428 await buried($, timer.beganAt)
429
430 if (status === 'stopped' && focusMs < MIN_ROUND_MS) {
431 await cleared($, timer.beganAt)
432
433 return undefined
434 }
435
436 const tally = await talliesOf($, session, timer, now)
437 const round: Round = {
438 start: timer.beganAt,
439 end: now,
440 plannedMs: timer.lengthMs,
441 focusMs,
442 status,
443 label: timer.label,
444 tags: timer.tags,
445 projects:
446 tally.projects.length > 0 || session.project === ''
447 ? tally.projects
448 : [session.project],
449 claudeMs: Math.min(focusMs, tally.claudeMs),
450 prompts: tally.prompts,
451 }
452
453 await saved($, session, recorded(await historyOf($), round, now))
454 await cleared($, timer.beganAt)
455
456 return round
457}
458
459/**
460 * Keeps what `/pomodoro undo` takes back: the timer before, the round
461 * written, and which phase the change made, so that an undo after anything
462 * else changed the phase takes nothing back.
463 */
464const remembered = async (
465 $: EngineInterface,
466 before: Timer,
467 after: Timer,
468 now: number,
469 round?: Round,
470): Promise<void> =>
471 $.store.set(UNDO, {
472 timer: before,
473 phase: after.phase,
474 beganAt: after.beganAt,
475 at: now,
476 start: round?.start ?? 0,
477 })
478
479/** Tells the person the day's goal is met, on the round that met it. */
480const goalMet = async (
481 $: EngineInterface,
482 plan: Plan,
483 round: Round | undefined,
484 now: number,
485): Promise<void> => {
486 if (plan.dailyGoal === 0 || round?.status !== 'done') {
487 return
488 }
489
490 const today = dayTotals(await historyOf($))[dayOf(now)]
491
492 if (today?.rounds === plan.dailyGoal) {
493 await toasted($, `Daily goal reached · ${plural(plan.dailyGoal, 'round')} 🎯`, {
494 timeoutMs: TOAST_MS,
495 })
496 }
497}
498
499/** The `~/.pomodoro` folder the Open Pomodoro tools share: none without a home. */
500const sharedDir = async ($: EngineInterface): Promise<string | undefined> => {
501 const home = await $.env.get('HOME')
502
503 return home === undefined || home === '' ? undefined : pomodoroDirOf(home)
504}
505
506/** A focus round as Open Pomodoro's `current` file holds it. */
507const entryOf = (timer: Timer): OpenPomodoro => ({
508 start: timer.beganAt,
509 // Open Pomodoro has no pause: the round ends that much later instead.
510 minutes: Math.max(1, Math.round((timer.lengthMs + timer.startedAt - timer.beganAt) / MINUTE_MS)),
511 description: timer.label,
512 tags: timer.tags,
513})
514
515/**
516 * What a change of the timer is to Open Pomodoro's hooks, in their order:
517 * the phase that ends stops, and the one that begins starts or breaks.
518 */
519const eventsOf = (before: Timer, after: Timer): PomodoroEvent[] => {
520 if (before.phase === after.phase && before.beganAt === after.beganAt) {
521 // A break that ran out stops there, though its round waits for the person.
522 return before.phase === 'break' && !before.isDue && after.isDue ? ['stop'] : []
523 }
524
525 const hasStopped = before.phase === 'idle' || (before.phase === 'break' && before.isDue)
526 const ends: PomodoroEvent[] = hasStopped ? [] : ['stop']
527 const begins: PomodoroEvent[] =
528 after.phase === 'focus' ? ['start'] : after.phase === 'break' ? ['break'] : []
529
530 return [...ends, ...begins]
531}
532
533const contextOf = (timer: Timer, plan: Plan): HookContext => ({
534 phase: timer.phase,
535 // A break's round is the one just done; the long break's, the set's last.
536 round:
537 timer.phase === 'focus' ? timer.round + 1 : timer.round === 0 ? plan.rounds : timer.round,
538 rounds: plan.rounds,
539 minutes: timer.phase === 'idle' ? 0 : Math.round(timer.lengthMs / MINUTE_MS),
540 description: timer.label,
541 tags: timer.tags,
542})
543
544/** Runs the person's `~/.pomodoro/hooks/<event>` script, if they keep one. */
545const scripted = async (
546 $: EngineInterface,
547 dir: string,
548 event: PomodoroEvent,
549 context: HookContext,
550): Promise<void> => {
551 const path = hookPathOf(dir, event)
552 const kind = (await $.fs.stat(path).catch(() => undefined))?.kind
553
554 if (kind !== 'file') {
555 return
556 }
557
558 const ran = await $.process
559 .run([path], { env: hookEnvOf(dir, event, context), timeoutMs: SCRIPT_MS })
560 .catch(() => undefined)
561
562 if (ran?.exitCode !== 0) {
563 $.ui.log(
564 `pomodoro: hooks/${event} ${ran === undefined ? 'did not run' : `exited with ${ran.exitCode}`}`,
565 { to: 'debug' },
566 )
567 }
568}
569
570/**
571 * Tells the world outside the store of a change this session made to the
572 * timer: Open Pomodoro's `current` and `history` files, then the person's
573 * hook scripts. Nothing here can stop the timer: a failure is logged.
574 */
575const announced = async (
576 $: EngineInterface,
577 plan: Plan,
578 outside: Outside,
579 before: Timer,
580 after: Timer,
581 round: Round | undefined,
582 removed: number,
583): Promise<void> => {
584 const dir = await sharedDir($)
585
586 if (dir === undefined) {
587 return
588 }
589
590 // A round that ended undone is cancelled, as the CLI cancels one: a line
591 // the tool that began it wrote goes. So does a round an undo took back.
592 const hasEnded =
593 before.phase === 'focus' && (after.phase !== 'focus' || after.beganAt !== before.beganAt)
594 const gone = removed > 0 ? removed : hasEnded && round?.status !== 'done' ? before.beganAt : 0
595
596 try {
597 if (outside.isMirrored && (round?.status === 'done' || gone > 0)) {
598 const kept = await $.fs.read(`${dir}/history`).catch(() => '')
599 const text =
600 round?.status === 'done'
601 ? historyWith(kept, entryOfRound(round))
602 : historyWithout(kept, gone)
603
604 if (text !== undefined) {
605 await $.fs.write(`${dir}/history`, text)
606 }
607 }
608
609 if (outside.isMirrored && (after.phase === 'focus' || before.phase === 'focus')) {
610 const entry = after.phase === 'focus' ? entryOf(after) : undefined
611 await $.fs.write(`${dir}/current`, currentTextOf(entry))
612 }
613
614 if (outside.isScripted) {
615 for (const event of eventsOf(before, after)) {
616 await scripted($, dir, event, contextOf(after, plan))
617 }
618 }
619 } catch (error) {
620 $.ui.log(`pomodoro: could not keep ${dir}: ${String(error)}`, { to: 'debug' })
621 }
622}
623
624/** Sends word of a change on, once the change itself is kept. */
625const told = (
626 $: EngineInterface,
627 plan: Plan,
628 outside: Outside,
629 before: Timer,
630 after: Timer,
631 round?: Round,
632 removed = 0,
633): void => {
634 if (outside.isMirrored || outside.isScripted) {
635 $.clock.after(1, () => {
636 void announced($, plan, outside, before, after, round, removed)
637 })
638 }
639}
640
641/**
642 * A pomodoro another Open Pomodoro tool began, read from `~/.pomodoro/current`
643 * while none is on here: a focus round still running, and not one lately
644 * ended here or the history holds.
645 */
646const picked = async (
647 $: EngineInterface,
648 plan: Plan,
649 now: number,
650): Promise<Timer | undefined> => {
651 const dir = await sharedDir($)
652 const text = dir === undefined ? '' : await $.fs.read(`${dir}/current`).catch(() => '')
653 const entry = readCurrentText(text)
654
655 if (entry === undefined || toStarts(await $.store.get(ENDED)).includes(entry.start)) {
656 return undefined
657 }
658
659 const lengthMs = entry.minutes * MINUTE_MS
660 const isKept = (await historyOf($)).rounds.some((round) => round.start === entry.start)
661
662 if (isKept || entry.start > now || entry.start + lengthMs <= now) {
663 return undefined
664 }
665
666 return {
667 ...focused(plan, entry.start, { round: 0, label: entry.description, tags: entry.tags }),
668 lengthMs,
669 }
670}
671
672/**
673 * Brings this session up to the shared timer. A phase that ran out is
674 * turned here, with a toast and a sound; one another session turned is
675 * told with the toast alone, the sound being that session's to play.
676 */
677const synced = async (
678 $: EngineInterface,
679 session: Session,
680 plan: Plan,
681 { sound, outside }: Ways,
682): Promise<void> => {
683 const now = await $.clock.now()
684 const stored = toTimer(await $.store.get(TIMER))
685 const found =
686 outside.isMirrored && stored.phase === 'idle'
687 ? await picked($, plan, now)
688 : undefined
689
690 if (found !== undefined && (await swapped($, stored, found))) {
691 await toasted($, 'Picked up a pomodoro from ~/.pomodoro')
692 }
693
694 const revision = toCount(await $.store.get(REVISION))
695
696 if (revision !== session.revision) {
697 session.revision = revision
698 await update($, historyVersion, (version) => version + 1)
699 }
700
701 const before = toTimer(await $.store.get(TIMER))
702 const activeAt = toCount(await $.store.get(ACTIVE))
703 const moment = { now, isWorking: session.isWorking, activeAt }
704 const { timer, event } = stepped(before, moment, plan)
705 const was = session.seen
706
707 // Another session turned it first: the next tick shows what it made.
708 if (event !== 'none' && !(await swapped($, before, timer))) {
709 return
710 }
711
712 if (event === 'break') {
713 const round = await archived($, session, before, now, 'done')
714 await remembered($, before, timer, now, round)
715 told($, plan, outside, before, timer, round)
716 await toasted($, breakText(before, timer, plan, session.isWorking), {
717 timeoutMs: TOAST_MS,
718 })
719 session.away = NOBODY
720 void ring($, sound, 'break', `Take a ${minutesText(timer.lengthMs)} minute break.`)
721 await goalMet($, plan, round, now)
722 } else if (event === 'due') {
723 told($, plan, outside, before, timer)
724 await toasted($, dueText(timer, plan), { timeoutMs: TOAST_MS })
725 void ring($, sound, 'focus', 'The break is over.')
726 } else if (event === 'focus') {
727 await remembered($, before, timer, now)
728 told($, plan, outside, before, timer)
729 await toasted($, focusText(timer, plan), { timeoutMs: TOAST_MS })
730 await welcomed($, session)
731 void ring($, sound, 'focus', `Focus round ${timer.round + 1}.`)
732 } else if (event === 'stale') {
733 if (before.phase === 'focus') {
734 await buried($, before.beganAt)
735 }
736
737 told($, plan, outside, before, timer)
738 await toasted($, 'Pomodoro stopped · nobody was here')
739 } else if (was !== undefined && timer.beganAt > was.beganAt) {
740 // Another session turned the phase; an undo, going back, is no news.
741 if (was.phase === 'focus' && timer.phase === 'break') {
742 await toasted($, breakText(was, timer, plan, session.isWorking), {
743 timeoutMs: TOAST_MS,
744 })
745 session.away = NOBODY
746 } else if (was.phase === 'break' && timer.phase === 'focus') {
747 await toasted($, focusText(timer, plan), { timeoutMs: TOAST_MS })
748 await welcomed($, session)
749 }
750 } else if (was?.phase === 'break' && !was.isDue && timer.isDue) {
751 await toasted($, dueText(timer, plan), { timeoutMs: TOAST_MS })
752 }
753
754 session.seen = timer
755 await shown($, timer, plan)
756}
757
758/** Counts a prompt of the person's into the focus round it came in. */
759const tallied = async (
760 $: EngineInterface,
761 session: Session,
762 timer: Timer,
763): Promise<void> => {
764 const key = tallyKey(session)
765 const tally = toTally(await $.store.get(key), timer.beganAt)
766 const isNew = session.project !== '' && !tally.projects.includes(session.project)
767
768 await $.store.set(key, {
769 ...tally,
770 prompts: tally.prompts + 1,
771 projects: isNew ? [...tally.projects, session.project] : tally.projects,
772 })
773}
774
775/**
776 * The person sent a prompt: they are here. A break that ran out and waits
777 * for them turns to focus.
778 */
779const arrived = async (
780 $: EngineInterface,
781 session: Session,
782 plan: Plan,
783 outside: Outside,
784): Promise<void> => {
785 const now = await $.clock.now()
786 const timer = toTimer(await $.store.get(TIMER))
787 const back = returned(timer, plan, now)
788
789 await $.store.set(ACTIVE, now)
790
791 if (back !== undefined) {
792 await turned($, session, plan, back)
793 await remembered($, timer, back, now)
794 told($, plan, outside, timer, back)
795 await toasted($, `Welcome back · focus ${roundText(back, plan)} is on`, {
796 timeoutMs: TOAST_MS,
797 })
798 await welcomed($, session)
799 await tallied($, session, back)
800 } else if (timer.phase === 'focus' && !isPaused(timer)) {
801 await tallied($, session, timer)
802 }
803}
804
805/** Notes in this session's tally that a turn of Claude's began in the focus round. */
806const began = async ($: EngineInterface, session: Session): Promise<void> => {
807 const now = await $.clock.now()
808 const timer = toTimer(await $.store.get(TIMER))
809 const isFocus = timer.phase === 'focus'
810
811 session.turnAt = now
812 session.turnRound = isFocus ? timer.beganAt : 0
813 session.turnPausedMs = isFocus ? pausedOf(timer, now) : 0
814
815 if (isFocus) {
816 const key = tallyKey(session)
817 await $.store.set(key, { ...toTally(await $.store.get(key), timer.beganAt), turnAt: now })
818 }
819}
820
821/**
822 * Adds the part of Claude's turn that fell inside the focus round, its
823 * pauses left out. A round that ended while the turn ran counted it then.
824 */
825const worked = async ($: EngineInterface, session: Session): Promise<void> => {
826 const now = await $.clock.now()
827 const timer = toTimer(await $.store.get(TIMER))
828 const inside = workingOf(session, timer, now)
829
830 session.turnAt = 0
831
832 if (timer.phase !== 'focus') {
833 return
834 }
835
836 const key = tallyKey(session)
837 const tally = toTally(await $.store.get(key), timer.beganAt)
838
839 await $.store.set(key, { ...tally, claudeMs: tally.claudeMs + inside, turnAt: 0 })
840}
841
842/** During a break, says that Claude asks for the person and what is left. */
843const called = async (
844 $: EngineInterface,
845 session: Session,
846 what: string,
847 kind: keyof Away,
848): Promise<void> => {
849 const timer = toTimer(await $.store.get(TIMER))
850 const left = leftOf(timer, await $.clock.now())
851
852 if (timer.phase !== 'break' || isPaused(timer)) {
853 return
854 }
855
856 session.away = { ...session.away, [kind]: session.away[kind] + 1 }
857 await toasted(
858 $,
859 `${what} · ${left > 0 ? `${clockText(left)} of break left` : 'the break is over'}`,
860 )
861}
862
863/** The switches as the store keeps them for every session: each unset one as it begins. */
864const switchesOf = async ($: EngineInterface): Promise<Switches> => ({
865 isMuted: (await $.store.get(MUTED)) === true,
866 areControlsOpen: (await $.store.get(CONTROLS_OPEN)) !== false,
867 isClosed: (await $.store.get(CLOSED)) === true,
868})
869
870/**
871 * Keeps a change of the switches for every session, and takes it up here at
872 * once; the other sessions take it up at their next tick. Closed is the timer
873 * and its row away together: a change that opens the row again leaves the
874 * pomodoro closed no longer.
875 */
876const switched = async ($: EngineInterface, change: Partial<Switches>): Promise<void> => {
877 const changed =
878 change.isClosed === undefined && change.areControlsOpen === true
879 ? { ...change, isClosed: false }
880 : change
881
882 for (const [key, value] of Object.entries(changed)) {
883 await $.store.set(key, value)
884 }
885
886 await update($, switches, (now) => ({ ...now, ...changed }))
887}
888
889const areSame = (one: Switches, other: Switches): boolean =>
890 one.isMuted === other.isMuted &&
891 one.areControlsOpen === other.areControlsOpen &&
892 one.isClosed === other.isClosed
893
894/**
895 * Takes up the switches another session changed: the sound, the row of
896 * buttons, and the pomodoro closed, which closes the report here too.
897 */
898const followed = async ($: EngineInterface): Promise<void> => {
899 const held = await read($, switches)
900 const kept = await switchesOf($)
901
902 if (areSame(held, kept)) {
903 return
904 }
905
906 // A switch made here in the meantime stands: the next tick reads it back.
907 const now = await update($, switches, (seen) => (areSame(seen, held) ? kept : seen))
908
909 if (now.isClosed && !held.isClosed) {
910 await $.ui.close({ id: REPORT_PANE }).catch(() => undefined)
911 }
912}
913
914/** `/pomodoro sound [on|off]`: the word's way, or the other way with no word. */
915const soundText = async ($: EngineInterface, word: string): Promise<string> => {
916 const isOn = word === '' ? (await read($, switches)).isMuted : SWITCH[word]
917
918 if (isOn === undefined) {
919 return USAGE
920 }
921
922 await switched($, { isMuted: !isOn })
923
924 return `Pomodoro sound is ${isOn ? 'on' : 'off'}.`
925}
926
927/** The day `/pomodoro log` names: today with no word. */
928const dayFrom = (word: string, now: number): string | undefined => {
929 if (word === '' || word === 'today') {
930 return dayOf(now)
931 }
932
933 if (word === 'yesterday') {
934 return dayOf(now, 1)
935 }
936
937 return /^\d{4}-\d{2}-\d{2}$/.test(word) ? word : undefined
938}
939
940/**
941 * What `/pomodoro undo` would take back now: the timer it holds now, the one
942 * before the last change and the round that change wrote. Nothing once five
943 * minutes have passed or the phase changed again.
944 */
945const undoOf = async (
946 $: EngineInterface,
947 now: number,
948): Promise<{ was: Timer; timer: Timer; start: number } | undefined> => {
949 const kept = await $.store.get(UNDO)
950 const was = toTimer(await $.store.get(TIMER))
951
952 if (
953 !isRecord(kept) ||
954 now - toCount(field(kept, 'at')) > UNDO_MS ||
955 field(kept, 'phase') !== was.phase ||
956 toCount(field(kept, 'beganAt')) !== was.beganAt
957 ) {
958 return undefined
959 }
960
961 return { was, timer: toTimer(field(kept, 'timer')), start: toCount(field(kept, 'start')) }
962}
963
964/** `/pomodoro undo`: the timer as it was before the last change, if lately. */
965const undone = async (
966 $: EngineInterface,
967 session: Session,
968 plan: Plan,
969 outside: Outside,
970 now: number,
971): Promise<string> => {
972 const undo = await undoOf($, now)
973
974 if (undo === undefined) {
975 return 'Pomodoro: nothing to undo.'
976 }
977
978 const { was, timer, start } = undo
979
980 if (start > 0) {
981 await saved($, session, removed(await historyOf($), start))
982 }
983
984 await $.store.set(UNDO, null)
985 await turned($, session, plan, timer)
986 told($, plan, outside, was, timer, undefined, start)
987
988 return `Undid the last change. ${statusText(timer, now, plan)}`
989}
990
991/**
992 * `tree` with `row` right under the hint line. The engine draws its line
993 * over a tree's first row, and another mod's drawing may hold the line's
994 * place first with rows of its own after it, or draw over the line from a
995 * box of its own: `row` goes in right after that place, so what the others
996 * drew stays where it was, under it or over the line.
997 */
998const under = (tree: RenderNode, row: RenderElement): RenderElement => {
999 if (typeof tree === 'string' || tree.type !== 'Box' || tree.children === undefined) {
1000 return { type: 'Box', props: { flexDirection: 'column' }, children: [tree, row] }
1001 }
1002
1003 const [first, ...rest] = tree.children
1004 const isLine = typeof first === 'string' || first?.type !== 'Box'
1005
1006 if (first === undefined) {
1007 return { ...tree, children: [row] }
1008 }
1009
1010 if (tree.props?.flexDirection === 'column' && isLine) {
1011 return { ...tree, children: [first, row, ...rest] }
1012 }
1013
1014 return { ...tree, children: [under(first, row), ...rest] }
1015}
1016
1017/**
1018 * `/pomodoro controls [on|off]`: the word's way, or the other way with no
1019 * word. Opened, the row brings the pomodoro back if it was closed.
1020 */
1021const controlsText = async ($: EngineInterface, word: string): Promise<string> => {
1022 const { areControlsOpen, isClosed } = await read($, switches)
1023 const isOn = word === '' ? !areControlsOpen || isClosed : SWITCH[word]
1024
1025 if (isOn === undefined) {
1026 return USAGE
1027 }
1028
1029 await switched($, { areControlsOpen: isOn })
1030
1031 return `Pomodoro controls are ${isOn ? 'on' : 'off'}. They show where there is a pointer: the fullscreen terminal and the desktop app.`
1032}
1033
1034/**
1035 * `/pomodoro close`: the timer out of sight wherever it shows, its row and
1036 * the report with it, and its toasts and sounds kept back, where `controls
1037 * off` leaves the timer on the hint line. A pomodoro on runs on, and
1038 * `/pomodoro open`, `start` or `resume` brings it all back as it was. The
1039 * other sessions follow at their next tick.
1040 */
1041const closedText = async ($: EngineInterface): Promise<string> => {
1042 await switched($, { isClosed: true })
1043 await $.ui.close({ id: REPORT_PANE }).catch(() => undefined)
1044
1045 return 'Pomodoro is closed: the timer, its buttons, the report, its toasts and its sounds are away, and a pomodoro on runs on. /pomodoro open, start or resume brings them back.'
1046}
1047
1048/**
1049 * The timer's buttons for where it stands, the one most likely wanted first:
1050 * the row draws the rest dim.
1051 */
1052const controlsOf = (timer: Timer, now: number): Control[] => {
1053 const stop = { verb: 'stop', label: '■ stop' }
1054 const more = { verb: 'extend', label: '+5m' }
1055 const pause = { verb: 'pause', label: '❚❚ pause' }
1056
1057 if (timer.phase === 'idle') {
1058 return [{ verb: 'start', label: '▶ start' }]
1059 }
1060
1061 if (isPaused(timer)) {
1062 return [{ verb: 'resume', label: '▶ resume' }, stop]
1063 }
1064
1065 const isOver = leftOf(timer, now) <= 0
1066
1067 if (timer.phase === 'break') {
1068 return isOver
1069 ? [{ verb: 'start', label: '▶ focus' }, stop]
1070 : [{ verb: 'skip', label: '▶ focus' }, more, pause, stop]
1071 }
1072
1073 return isOver
1074 ? [{ verb: 'finish', label: '▶ break' }, more, stop]
1075 : [pause, more, { verb: 'finish', label: '✓ finish' }, stop]
1076}
1077
1078const beganText = (timer: Timer, plan: Plan): string => {
1079 const intent = intentText(timer)
1080 const what = `${minutesText(plan.focusMs)} minutes of focus, round 1/${plan.rounds}${intent === '' ? '' : ` for ${intent}`}`
1081
1082 return plan.breakStart === 'claude'
1083 ? `Pomodoro is on: ${what}. The break toast comes while Claude works.`
1084 : `Pomodoro is on: ${what}.`
1085}
1086
1087/** Every `/pomodoro` word that acts on the timer; answers what it did. */
1088const commanded = async (
1089 $: EngineInterface,
1090 session: Session,
1091 plan: Plan,
1092 outside: Outside,
1093 verb: string,
1094 text: string,
1095): Promise<string> => {
1096 const now = await $.clock.now()
1097 const timer = toTimer(await $.store.get(TIMER))
1098 const intent = intentOf(text)
1099
1100 await $.store.set(ACTIVE, now)
1101
1102 if (verb === 'undo') {
1103 return undone($, session, plan, outside, now)
1104 }
1105
1106 if (timer.phase === 'idle') {
1107 if (verb !== '' && verb !== 'start') {
1108 return statusText(timer, now, plan)
1109 }
1110
1111 const begun = focused(plan, now, { round: 0, ...intent })
1112 await turned($, session, plan, begun)
1113 told($, plan, outside, timer, begun)
1114
1115 return beganText(begun, plan)
1116 }
1117
1118 if (verb === '') {
1119 return statusText(timer, now, plan)
1120 }
1121
1122 if (verb === 'start' || verb === 'resume') {
1123 const held = text === '' ? timer : intended(timer, intent)
1124 const back = returned(held, plan, now)
1125 const running = back ?? (isPaused(held) ? resumed(held, now) : held)
1126 await turned($, session, plan, running)
1127
1128 if (back !== undefined) {
1129 await remembered($, timer, back, now)
1130 }
1131
1132 told($, plan, outside, timer, running)
1133
1134 return statusText(running, now, plan)
1135 }
1136
1137 if (verb === 'pause') {
1138 const held = isPaused(timer) ? timer : paused(timer, now)
1139 await turned($, session, plan, held)
1140
1141 return statusText(held, now, plan)
1142 }
1143
1144 if (verb === 'note') {
1145 const noted = intended(timer, intent)
1146 await turned($, session, plan, noted)
1147 told($, plan, outside, timer, noted)
1148
1149 return text === ''
1150 ? 'Pomodoro: the note is cleared.'
1151 : `Pomodoro: the rounds are for ${intentText(intent)} now.`
1152 }
1153
1154 if (verb === 'extend') {
1155 const minutes = text === '' ? 5 : Number(text)
1156
1157 if (!Number.isInteger(minutes) || minutes < 1 || minutes > MAX_EXTEND_MINUTES) {
1158 return `Usage: /pomodoro extend [minutes], 1 to ${MAX_EXTEND_MINUTES}.`
1159 }
1160
1161 const longer = extended(timer, now, minutes * MINUTE_MS)
1162 await turned($, session, plan, longer)
1163 told($, plan, outside, timer, longer)
1164
1165 return `Added ${plural(minutes, 'minute')}. ${statusText(longer, now, plan)}`
1166 }
1167
1168 // A round that ran its length is done, whatever ends it, paused or not.
1169 const status: RoundStatus =
1170 timer.phase === 'focus' && leftOf(timer, now) <= 0 ? 'done' : 'stopped'
1171
1172 if (verb === 'finish' && timer.phase === 'focus') {
1173 const next = rested(timer, plan, now)
1174 const round = await archived($, session, timer, now, 'done')
1175 await turned($, session, plan, next)
1176 await remembered($, timer, next, now, round)
1177 told($, plan, outside, timer, next, round)
1178 await goalMet($, plan, round, now)
1179
1180 return `Finished focus ${roundText(timer, plan)} after ${spanText(focusOf(timer, now))}: take a ${minutesText(next.lengthMs)} minute break.`
1181 }
1182
1183 if (verb === 'skip' || verb === 'finish') {
1184 const next = skipped(timer, plan, now)
1185 const round = await archived($, session, timer, now, status)
1186 await turned($, session, plan, next)
1187 await remembered($, timer, next, now, round)
1188 told($, plan, outside, timer, next, round)
1189
1190 if (next.phase === 'focus') {
1191 return `Skipped the break: focus ${roundText(next, plan)} is on.`
1192 }
1193
1194 return status === 'done'
1195 ? `Focus ${roundText(timer, plan)} is done: take a ${minutesText(next.lengthMs)} minute break.`
1196 : `Skipped to a ${minutesText(next.lengthMs)} minute break. The round is not counted.`
1197 }
1198
1199 const round = await archived($, session, timer, now, status)
1200 await turned($, session, plan, IDLE)hooks/automation.ts 79 lines1import type { OpenPomodoro } from './openpomodoro'
2import { formatEntry, parseEntry } from './openpomodoro'
3
4export type PomodoroEvent = 'start' | 'stop' | 'break'
5export type HookContext = {
6 phase: 'focus' | 'break' | 'idle'
7 round: number
8 rounds: number
9 minutes: number
10 description: string
11 tags: string[]
12}
13
14/** The Open Pomodoro directory under the person's home. */
15export const pomodoroDirOf = (home: string): string =>
16 `${home.replace(/\/+$/, '')}/.pomodoro`
17
18export const hookPathOf = (directory: string, event: PomodoroEvent): string =>
19 `${directory}/hooks/${event}`
20
21/** The variables a hook sees, with the phase that begins and its intent. */
22export const hookEnvOf = (
23 directory: string,
24 event: PomodoroEvent,
25 context: HookContext,
26): Record<string, string> => ({
27 POMODORO_EVENT: event,
28 POMODORO_PHASE: context.phase,
29 POMODORO_ROUND: String(context.round),
30 POMODORO_ROUNDS: String(context.rounds),
31 POMODORO_MINUTES: String(context.minutes),
32 POMODORO_DESCRIPTION: context.description,
33 POMODORO_TAGS: context.tags.join(','),
34 POMODORO_DIRECTORY: directory,
35})
36
37/** An entry followed by a newline, or the empty file the CLI clears with. */
38export const currentTextOf = (entry: OpenPomodoro | undefined): string =>
39 entry ? `${formatEntry(entry)}\n` : ''
40
41/** The current file's first line, absent when it is empty or unreadable. */
42export const readCurrentText = (text: string): OpenPomodoro | undefined =>
43 parseEntry(text.split(/\r\n|\r|\n/)[0] ?? '')
44
45/**
46 * The history file with `entry` in it: in place of a line of the same start,
47 * which a round another tool began has from its start, or added at the end.
48 * The other lines stay as they were; undefined when nothing changes.
49 */
50export const historyWith = (
51 existing: string,
52 entry: OpenPomodoro,
53): string | undefined => {
54 const line = formatEntry(entry)
55 const lines = existing.split(/\r\n|\r|\n/)
56 const at = lines.findIndex((kept) => parseEntry(kept)?.start === entry.start)
57
58 if (at >= 0) {
59 return lines[at] === line
60 ? undefined
61 : lines.map((kept, index) => (index === at ? line : kept)).join('\n')
62 }
63
64 const separator = existing === '' || /[\r\n]$/.test(existing) ? '' : '\n'
65
66 return `${existing}${separator}${line}\n`
67}
68
69/** The history file without the round that began at `start`: undefined when it has none. */
70export const historyWithout = (
71 existing: string,
72 start: number,
73): string | undefined => {
74 const lines = existing.split(/\r\n|\r|\n/)
75 const kept = lines.filter((line) => parseEntry(line)?.start !== start)
76
77 return kept.length === lines.length ? undefined : kept.join('\n')
78}
79hooks/export.ts 102 lines1import type { Round } from './history'
2import { timestampOf } from './openpomodoro'
3
4export type ExportFormat = 'json' | 'csv' | 'ical'
5export const EXPORT_FORMATS: readonly ExportFormat[] = ['json', 'csv', 'ical']
6
7const minutesOf = (ms: number): number => Math.round(ms / 6000) / 10
8
9const recordOf = (round: Round) => ({
10 start: timestampOf(round.start),
11 end: timestampOf(round.end),
12 focusMinutes: minutesOf(round.focusMs),
13 plannedMinutes: minutesOf(round.plannedMs),
14 status: round.status,
15 label: round.label,
16 tags: round.tags,
17 projects: round.projects,
18 claudeMinutes: minutesOf(round.claudeMs),
19 prompts: round.prompts,
20})
21
22const csvField = (value: string | number): string => {
23 const text = String(value)
24
25 return /[,"\r\n]/.test(text) ? `"${text.replaceAll('"', '""')}"` : text
26}
27
28const csvText = (rounds: readonly Round[]): string => {
29 const header = 'start,end,focus_minutes,planned_minutes,status,label,tags,projects,claude_minutes,prompts'
30 const lines = rounds.map((round) => {
31 const record = recordOf(round)
32
33 return [
34 record.start, record.end, record.focusMinutes, record.plannedMinutes,
35 record.status, record.label, record.tags.join(';'), record.projects.join(';'),
36 record.claudeMinutes, record.prompts,
37 ].map(csvField).join(',')
38 })
39
40 return [header, ...lines, ''].join('\r\n')
41}
42
43const utcTime = (at: number): string =>
44 new Date(at).toISOString().replace(/[-:]/g, '').replace(/\.\d{3}Z$/, 'Z')
45
46const icalText = (text: string): string =>
47 text.replaceAll('\\', '\\\\')
48 .replace(/\r\n|\r|\n/g, '\\n')
49 .replaceAll(';', '\\;')
50 .replaceAll(',', '\\,')
51
52/** Content lines of at most 75 bytes, keeping every Unicode character whole. */
53const folded = (line: string): string => {
54 const encoder = new TextEncoder()
55 const lines: string[] = []
56 let part = ''
57 let bytes = 0
58
59 for (const character of line) {
60 const size = encoder.encode(character).length
61
62 if (bytes + size > 75) {
63 lines.push(part)
64 part = ' '
65 bytes = 1
66 }
67
68 part += character
69 bytes += size
70 }
71
72 return [...lines, part].join('\r\n')
73}
74
75const calendarText = (rounds: readonly Round[]): string => {
76 const events = rounds.flatMap((round) => [
77 'BEGIN:VEVENT',
78 `UID:${round.start}@claude-pomodoro`,
79 `DTSTAMP:${utcTime(round.end)}`,
80 `DTSTART:${utcTime(round.start)}`,
81 `DTEND:${utcTime(round.end)}`,
82 `SUMMARY:${icalText(`🍅 ${round.label || 'Focus'}${round.status === 'stopped' ? ' (stopped)' : ''}`)}`,
83 ...(round.tags.length > 0 ? [`CATEGORIES:${round.tags.map(icalText).join(',')}`] : []),
84 `DESCRIPTION:${icalText(`Projects: ${round.projects.join(', ')}\nFocus minutes: ${minutesOf(round.focusMs)}\nClaude minutes: ${minutesOf(round.claudeMs)}`)}`,
85 'END:VEVENT',
86 ])
87
88 return [
89 'BEGIN:VCALENDAR', 'VERSION:2.0', 'PRODID:-//claude-pomodoro//EN',
90 ...events, 'END:VCALENDAR', '',
91 ].map(folded).join('\r\n')
92}
93
94/** The rounds as a document another app can read. */
95export const exportText = (rounds: readonly Round[], format: ExportFormat): string => {
96 if (format === 'json') {
97 return JSON.stringify(rounds.map(recordOf), null, 2)
98 }
99
100 return format === 'csv' ? csvText(rounds) : calendarText(rounds)
101}
102hooks/history.ts 387 lines1// The focus rounds a person has done, one record each, kept in the plugin's
2// store beside the timer. Every number the stats give is told from these. A
3// round is named by when it began, so one that two sessions both turned is
4// still one round.
5
6import { field, isRecord, toCount, toText, toWords } from './values'
7
8export type RoundStatus = 'done' | 'stopped'
9export type Round = {
10 /** When the round began, unmoved by pauses: the round's name. */
11 start: number
12 end: number
13 plannedMs: number
14 /** How long it was focused: its run with the pauses left out. */
15 focusMs: number
16 /** `done` ran out or was finished; `stopped` was stopped or skipped first. */
17 status: RoundStatus
18 label: string
19 /** Its tags, without the `#`. */
20 tags: string[]
21 /** The repositories or folders worked in during it, by name. */
22 projects: string[]
23 /** About how long Claude worked inside it. */
24 claudeMs: number
25 prompts: number
26}
27export type Day = { rounds: number; focusMs: number }
28export type History = {
29 /** Oldest first, one per start. */
30 rounds: Round[]
31 /** Days kept as totals alone: from before rounds were kept, or folded. */
32 days: Record<string, Day>
33}
34export type Intent = { label: string; tags: string[] }
35
36const MINUTE_MS = 60_000
37const DAY_MS = 24 * 60 * MINUTE_MS
38// Rounds older than this are kept as their days' totals alone: the store is
39// one JSON file of 4 MiB in all.
40export const KEEP_DAYS = 400
41const MAX_ROUNDS = 8_000
42const DAY_KEY = /^\d{4}-\d{2}-\d{2}$/
43const TAG = /^#[^#\s]+$/
44
45export const EMPTY: History = { rounds: [], days: {} }
46const NO_DAY: Day = { rounds: 0, focusMs: 0 }
47
48/** The day `at` falls on where the person is, as `2026-10-02`. */
49export const dayOf = (at: number, daysBack = 0): string => {
50 const moment = new Date(at)
51 const date = new Date(
52 moment.getFullYear(),
53 moment.getMonth(),
54 moment.getDate() - daysBack,
55 )
56 const month = String(date.getMonth() + 1).padStart(2, '0')
57 const day = String(date.getDate()).padStart(2, '0')
58
59 return `${date.getFullYear()}-${month}-${day}`
60}
61
62/** Noon of a `2026-10-02` day where the person is: safe from either end. */
63const noonOf = (day: string): number => {
64 const [year = 0, month = 1, date = 1] = day.split('-').map(Number)
65
66 return new Date(year, month - 1, date, 12).getTime()
67}
68
69const toDay = (value: unknown): Day =>
70 isRecord(value)
71 ? {
72 rounds: toCount(field(value, 'rounds')),
73 focusMs: toCount(field(value, 'focusMs')),
74 }
75 : NO_DAY
76
77const toDays = (value: unknown): Record<string, Day> =>
78 isRecord(value)
79 ? Object.fromEntries(
80 Object.entries(value)
81 .filter(([day]) => DAY_KEY.test(day))
82 .map(([day, kept]) => [day, toDay(kept)]),
83 )
84 : {}
85
86const toRound = (value: unknown): Round | undefined => {
87 if (!isRecord(value)) {
88 return undefined
89 }
90
91 const start = toCount(field(value, 'start'))
92 const end = toCount(field(value, 'end'))
93
94 if (start === 0 || end < start) {
95 return undefined
96 }
97
98 return {
99 start,
100 end,
101 plannedMs: toCount(field(value, 'plannedMs')),
102 focusMs: toCount(field(value, 'focusMs')),
103 status: field(value, 'status') === 'stopped' ? 'stopped' : 'done',
104 label: toText(field(value, 'label')),
105 tags: toWords(field(value, 'tags')),
106 projects: toWords(field(value, 'projects')),
107 claudeMs: toCount(field(value, 'claudeMs')),
108 prompts: toCount(field(value, 'prompts')),
109 }
110}
111
112/** One round per start, the later record kept, oldest first. */
113const sorted = (rounds: readonly Round[]): Round[] =>
114 [...new Map(rounds.map((round) => [round.start, round])).values()].sort(
115 (a, b) => a.start - b.start,
116 )
117
118/**
119 * The history as the store keeps it. The stats before it kept each day's
120 * totals alone: given those, they carry over as days with no rounds.
121 */
122export const toHistory = (stored: unknown, legacy?: unknown): History => {
123 if (isRecord(stored)) {
124 const rounds = field(stored, 'rounds')
125
126 return {
127 rounds: sorted(
128 Array.isArray(rounds)
129 ? rounds.map(toRound).filter((round): round is Round => round !== undefined)
130 : [],
131 ),
132 days: toDays(field(stored, 'days')),
133 }
134 }
135
136 return isRecord(legacy)
137 ? { rounds: [], days: toDays(field(legacy, 'days')) }
138 : EMPTY
139}
140
141const added = (days: Record<string, Day>, round: Round): void => {
142 const day = dayOf(round.end)
143 const kept = days[day] ?? NO_DAY
144
145 days[day] = {
146 rounds: kept.rounds + (round.status === 'done' ? 1 : 0),
147 focusMs: kept.focusMs + round.focusMs,
148 }
149}
150
151/** Rounds past `KEEP_DAYS`, or past the most the store holds, as day totals. */
152const folded = (history: History, now: number): History => {
153 const oldest = now - KEEP_DAYS * DAY_MS
154 const extra = history.rounds.length - MAX_ROUNDS
155 const isOld = (round: Round, index: number): boolean =>
156 round.start < oldest || index < extra
157 const old = history.rounds.filter(isOld)
158
159 if (old.length === 0) {
160 return history
161 }
162
163 const days = { ...history.days }
164 old.forEach((round) => added(days, round))
165
166 return {
167 rounds: history.rounds.filter((round, index) => !isOld(round, index)),
168 days,
169 }
170}
171
172/** The history with `round` in it, in place of any of the same start. */
173export const recorded = (
174 history: History,
175 round: Round,
176 now: number,
177): History =>
178 folded({ ...history, rounds: sorted([...history.rounds, round]) }, now)
179
180/** The history without the round that began at `start`. */
181export const removed = (history: History, start: number): History => ({
182 ...history,
183 rounds: history.rounds.filter((round) => round.start !== start),
184})
185
186/**
187 * Each day's rounds and focus, by the day a round ended: a round counts once
188 * done, while the focus of a stopped one counts too.
189 */
190export const dayTotals = (history: History): Record<string, Day> => {
191 const days = { ...history.days }
192 history.rounds.forEach((round) => added(days, round))
193
194 return days
195}
196
197export const totalOf = (days: Readonly<Record<string, Day>>): Day =>
198 Object.values(days).reduce(
199 (sum, day) => ({
200 rounds: sum.rounds + day.rounds,
201 focusMs: sum.focusMs + day.focusMs,
202 }),
203 NO_DAY,
204 )
205
206/** The rounds that ended on `day`, oldest first. */
207export const roundsOn = (history: History, day: string): Round[] =>
208 history.rounds.filter((round) => dayOf(round.end) === day)
209
210/**
211 * Days in a row with a focus round, up to today: a day that has none yet
212 * does not break the row before it.
213 */
214export const streakOf = (
215 days: Readonly<Record<string, Day>>,
216 now: number,
217): number => {
218 const has = (daysBack: number): boolean =>
219 (days[dayOf(now, daysBack)]?.rounds ?? 0) > 0
220 const first = has(0) ? 0 : 1
221 let streak = 0
222
223 while (has(first + streak)) {
224 streak += 1
225 }
226
227 return streak
228}
229
230/** The most days in a row there ever was a focus round. */
231export const bestStreakOf = (days: Readonly<Record<string, Day>>): number => {
232 let best = 0
233 let run = 0
234 let last = ''
235
236 Object.keys(days)
237 .filter((day) => (days[day]?.rounds ?? 0) > 0)
238 .sort()
239 .forEach((day) => {
240 run = last !== '' && dayOf(noonOf(last), -1) === day ? run + 1 : 1
241 best = Math.max(best, run)
242 last = day
243 })
244
245 return best
246}
247
248/** `write the tests #auth #api` as a label and its tags. */
249export const intentOf = (text: string): Intent => {
250 const words = text.trim().split(/\s+/).filter((word) => word !== '')
251 const tags = words
252 .filter((word) => TAG.test(word))
253 .map((word) => word.slice(1).toLowerCase())
254
255 return {
256 label: words.filter((word) => !TAG.test(word)).join(' '),
257 tags: [...new Set(tags)],
258 }
259}
260
261/** A label with its tags after it: `write the tests #auth`. */
262export const intentText = ({ label, tags }: Intent): string =>
263 [label, ...tags.map((tag) => `#${tag}`)].filter((word) => word !== '').join(' ')
264
265/** A span in hours and minutes: `1h 5m`, `25m`. */
266export const spanText = (ms: number): string => {
267 const minutes = Math.round(ms / MINUTE_MS)
268 const hours = Math.floor(minutes / 60)
269
270 return hours > 0 ? `${hours}h ${minutes % 60}m` : `${minutes}m`
271}
272
273/** A count with its word: `1 round`, `3 rounds`. */
274export const plural = (count: number, word: string): string =>
275 `${count} ${count === 1 ? word : `${word}s`}`
276
277/** A moment's hour and minute where the person is: `09:05`. */
278const timeOf = (at: number): string => {
279 const moment = new Date(at)
280
281 return `${String(moment.getHours()).padStart(2, '0')}:${String(moment.getMinutes()).padStart(2, '0')}`
282}
283
284/** The names of `rounds` with the most focus first, each with its focus. */
285const sharesOf = (
286 rounds: readonly Round[],
287 namesOf: (round: Round) => readonly string[],
288): [string, number][] => {
289 const focus = new Map<string, number>()
290
291 rounds.forEach((round) =>
292 namesOf(round).forEach((name) =>
293 focus.set(name, (focus.get(name) ?? 0) + round.focusMs),
294 ),
295 )
296
297 return [...focus.entries()].sort((a, b) => b[1] - a[1])
298}
299
300/** The last seven days in a line: what was done, Claude's part, and where. */
301const weekText = (history: History, now: number): string => {
302 const first = dayOf(now, 6)
303 const rounds = history.rounds.filter((round) => dayOf(round.end) >= first)
304 const focusMs = rounds.reduce((sum, round) => sum + round.focusMs, 0)
305
306 if (rounds.length === 0 || focusMs === 0) {
307 return ''
308 }
309
310 const done = rounds.filter((round) => round.status === 'done').length
311 const claudeMs = rounds.reduce((sum, round) => sum + round.claudeMs, 0)
312 const share = Math.round((claudeMs / focusMs) * 100)
313 const top = (shares: [string, number][], prefix: string): string =>
314 shares
315 .slice(0, 3)
316 .map(([name, ms]) => `${prefix}${name} ${spanText(ms)}`)
317 .join(', ')
318 const parts = [
319 `Last 7 days: ${plural(done, 'round')} · ${spanText(focusMs)} of focus`,
320 share > 0 ? `Claude worked ${share}% of it` : '',
321 top(sharesOf(rounds, (round) => round.projects), ''),
322 top(sharesOf(rounds, (round) => round.tags), '#'),
323 ]
324
325 return parts.filter((part) => part !== '').join(' · ')
326}
327
328/** The rounds so far, as `/pomodoro stats` answers. */
329export const statsText = (history: History, now: number, goal: number): string => {
330 const days = dayTotals(history)
331 const total = totalOf(days)
332
333 if (total.rounds === 0 && total.focusMs === 0) {
334 return 'Pomodoro: no focus rounds yet. /pomodoro start begins one.'
335 }
336
337 const today = days[dayOf(now)] ?? NO_DAY
338 const streak = streakOf(days, now)
339 const best = bestStreakOf(days)
340 const rows =
341 streak > 0
342 ? `${streak} ${streak === 1 ? 'day' : 'days'} in a row${best > streak ? `, best ${best}` : ''}`
343 : best > 0
344 ? `best ${plural(best, 'day')} in a row`
345 : ''
346 const line = [
347 `Pomodoro: today ${today.rounds}${goal > 0 ? `/${goal}` : ''}`,
348 `${spanText(today.focusMs)} of focus`,
349 rows,
350 `${plural(total.rounds, 'round')} and ${spanText(total.focusMs)} in all`,
351 ]
352 .filter((part) => part !== '')
353 .join(' · ')
354 const week = weekText(history, now)
355
356 return week === '' ? line : `${line}\n${week}`
357}
358
359const roundLine = (round: Round): string => {
360 const parts = [
361 `${timeOf(round.start)}–${timeOf(round.end)}`,
362 spanText(round.focusMs),
363 round.status === 'stopped' ? 'stopped' : '',
364 intentText(round),
365 round.projects.join(', '),
366 round.claudeMs > 0 ? `Claude ${spanText(round.claudeMs)}` : '',
367 round.prompts > 0 ? plural(round.prompts, 'prompt') : '',
368 ]
369
370 return ` ${parts.filter((part) => part !== '').join(' · ')}`
371}
372
373/** A day's rounds one to a line, as `/pomodoro log` answers. */
374export const logText = (history: History, day: string): string => {
375 const rounds = roundsOn(history, day)
376 const total = dayTotals(history)[day] ?? NO_DAY
377 const head = `Pomodoro on ${day}: ${plural(total.rounds, 'round')} · ${spanText(total.focusMs)} of focus`
378
379 if (rounds.length === 0) {
380 return total.focusMs > 0
381 ? `${head}, kept as a total with no rounds to list.`
382 : `Pomodoro: no focus rounds on ${day}.`
383 }
384
385 return [head, ...rounds.map(roundLine)].join('\n')
386}
387hooks/openpomodoro.ts 119 lines1import type { Round } from './history'
2
3export type OpenPomodoro = {
4 start: number
5 minutes: number
6 description: string
7 tags: string[]
8}
9
10const pad = (value: number): string => String(value).padStart(2, '0')
11
12/** A moment in RFC 3339, with the offset where the person is. */
13export const timestampOf = (at: number): string => {
14 const date = new Date(at)
15 const offset = -date.getTimezoneOffset()
16 const day = `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`
17 const time = `${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`
18 const fraction = date.getMilliseconds() === 0
19 ? ''
20 : `.${String(date.getMilliseconds()).padStart(3, '0')}`
21
22 return `${day}T${time}${fraction}${offset < 0 ? '-' : '+'}${pad(Math.floor(Math.abs(offset) / 60))}:${pad(Math.abs(offset) % 60)}`
23}
24
25const quoted = (text: string): string =>
26 /[\s="\\\u0000-\u001f]/.test(text) || text === 'null' ? JSON.stringify(text) : text
27
28/** One Open Pomodoro line, with empty description and tags left out. */
29export const formatEntry = (entry: OpenPomodoro): string => [
30 timestampOf(entry.start),
31 ...(entry.description === '' ? [] : [`description=${quoted(entry.description)}`]),
32 `duration=${entry.minutes}`,
33 ...(entry.tags.length === 0 ? [] : [`tags=${quoted(entry.tags.join(','))}`]),
34].join(' ')
35
36const durationOf = (text: string): number | undefined => {
37 if (/^\d+(?:\.\d+)?$/.test(text)) {
38 const minutes = Number(text)
39
40 return Number.isFinite(minutes) ? minutes : undefined
41 }
42
43 const parts = [...text.matchAll(/(\d+(?:\.\d+)?)(h|m|s)/g)]
44
45 if (parts.length === 0 || parts.map((part) => part[0]).join('') !== text) {
46 return undefined
47 }
48
49 const minutes = parts.reduce((sum, part) =>
50 sum + Number(part[1]) * (part[2] === 'h' ? 60 : part[2] === 's' ? 1 / 60 : 1), 0)
51
52 return Number.isFinite(minutes) ? minutes : undefined
53}
54
55/** A readable timestamp and logfmt fields; other keys carry no meaning here. */
56export const parseEntry = (line: string): OpenPomodoro | undefined => {
57 const first = line.trim().match(/^(\S+)(?:\s+(.*))?$/)
58 const time = first?.[1] ?? ''
59
60 if (!/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})$/i.test(time)) {
61 return undefined
62 }
63
64 const start = Date.parse(time)
65
66 if (!Number.isFinite(start)) {
67 return undefined
68 }
69
70 const entry: OpenPomodoro = { start, minutes: 25, description: '', tags: [] }
71 let rest = first?.[2] ?? ''
72
73 while (rest.trim() !== '') {
74 const field = rest.trimStart().match(/^([^\s="]+)=("(?:[^"\\]|\\.)*"|[^\s"]*)(?:\s+|$)/)
75
76 if (!field) {
77 return undefined
78 }
79
80 const key = field[1]
81 const raw = field[2] ?? ''
82 let value = raw
83
84 if (raw.startsWith('"')) {
85 try {
86 value = JSON.parse(raw)
87 } catch {
88 return undefined
89 }
90 }
91
92 if (key === 'description') {
93 entry.description = value
94 } else if (key === 'tags') {
95 entry.tags = value === '' ? [] : value.split(',')
96 } else if (key === 'duration') {
97 const minutes = durationOf(value)
98
99 if (minutes === undefined) {
100 return undefined
101 }
102
103 entry.minutes = minutes
104 }
105
106 rest = rest.trimStart().slice(field[0].length)
107 }
108
109 return entry
110}
111
112/** A round's actual focus, in whole minutes with at least one recorded. */
113export const entryOfRound = (round: Round): OpenPomodoro => ({
114 start: round.start,
115 minutes: Math.max(1, Math.round(round.focusMs / 60_000)),
116 description: round.label,
117 tags: [...round.tags],
118})
119hooks/report.tsx 305 lines1// The report pane `/pomodoro report` opens: today against the goal, the days
2// in a row, the last week and twelve weeks of focus, the hours it happens in
3// and where it went. It draws the history `load` hands it and writes nothing.
4
5import { atom, read } from 'claude-code'
6import type {
7 ElementConstructor,
8 On,
9 PaneOpenArgs,
10 RasterProps,
11 RenderElement,
12} from 'claude-code'
13
14import { plural, spanText } from './history'
15import type { History } from './history'
16import { insightsOf } from './insights'
17import type { DayBar, Insights, Share } from './insights'
18
19/** Reads the value the plugin's store keeps under `key`. */
20export type StoreGet = (key: string) => Promise<unknown>
21export type ReportLoad = (get: StoreGet) => Promise<{ history: History; goal: number }>
22// A line of text and how strongly it is drawn.
23type Line = { text: string; isStrong: boolean; isDim: boolean }
24
25export const REPORT_PANE = 'pomodoro-report'
26
27/**
28 * The pane as `/pomodoro report` opens it: with the keys, Escape closing it.
29 * The host follows `$` into no function of another file, so the command
30 * calls `$.ui.open(REPORT_OPEN)` itself.
31 */
32export const REPORT_OPEN: PaneOpenArgs = {
33 id: REPORT_PANE,
34 title: 'Pomodoro',
35 focus: true,
36 closeOnEscape: true,
37}
38
39/**
40 * Bumped each time a round is recorded, so an open pane draws again. The
41 * host lists only the state a file names itself, so the module that bumps it
42 * makes its own atom of the same name.
43 */
44const historyVersion = atom(
45 { plugin: 'pomodoro', key: 'historyVersion' } as const,
46 0,
47)
48
49const WEEKDAYS = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']
50// A day's name, its bar and its span, a cell between each.
51const NAME_WIDTH = 4
52const MAX_BAR = 40
53// No focus, then each quarter of the most focus a day had.
54const HEAT = ['·', '░', '▒', '▓', '█']
55const SPARK = ['▁', '▂', '▃', '▄', '▅', '▆', '▇', '█']
56const AXIS = ['0', '6', '12', '18'].map((hour) => hour.padEnd(6)).join('')
57// A Raster cell is three little-endian u32: its glyph and two colors.
58const CELL_BYTES = 12
59const DEFAULT_COLOR = 0x01000000
60const EMPTY_TEXT = 'No focus rounds yet. /pomodoro start begins one.'
61
62const widthOf = (text: string): number => [...text].length
63
64/** `text` cut to `width` cells, an ellipsis where it was cut. */
65const cut = (text: string, width: number): string =>
66 widthOf(text) > width
67 ? `${[...text].slice(0, Math.max(0, width - 1)).join('')}…`
68 : text
69
70/** Parts joined with ` · ` into lines no wider than `width`. */
71const packed = (parts: readonly string[], width: number): string[] =>
72 parts.reduce<string[]>((lines, part) => {
73 const last = lines.at(-1)
74
75 return last !== undefined && widthOf(`${last} · ${part}`) <= width
76 ? [...lines.slice(0, -1), `${last} · ${part}`]
77 : [...lines, part]
78 }, [])
79
80const headerOf = ({ today, goal, streak, best, total }: Insights): string[] => [
81 `Today ${goal > 0 ? `${today.rounds}/${goal} rounds` : plural(today.rounds, 'round')}, ${spanText(today.focusMs)}`,
82 `${plural(streak, 'day')} in a row (best ${best})`,
83 `${plural(total.rounds, 'round')}, ${spanText(total.focusMs)} in all`,
84]
85
86/** The name of a `2026-10-02` day: `Fri`. */
87const nameOf = (day: string): string => {
88 const [year = 0, month = 1, date = 1] = day.split('-').map(Number)
89
90 return WEEKDAYS[(new Date(year, month - 1, date).getDay() + 6) % 7] ?? ''
91}
92
93/** Each day as its name, a bar as long as its share of the most focus, and its span. */
94const weekLines = (
95 week: readonly DayBar[],
96 today: string,
97 width: number,
98): Line[] => {
99 const spanWidth = Math.max(...week.map((bar) => spanText(bar.focusMs).length))
100 const room = Math.max(0, Math.min(MAX_BAR, width - NAME_WIDTH - spanWidth - 1))
101 const most = Math.max(...week.map((bar) => bar.focusMs))
102
103 return week.map((bar) => {
104 const length =
105 bar.focusMs > 0
106 ? Math.min(room, Math.max(1, Math.round((bar.focusMs / most) * room)))
107 : 0
108
109 return {
110 text: `${nameOf(bar.day)} ${'█'.repeat(length).padEnd(room)} ${spanText(bar.focusMs).padStart(spanWidth)}`,
111 isStrong: bar.day === today,
112 isDim: bar.focusMs === 0 && bar.day !== today,
113 }
114 })
115}
116
117/**
118 * The weeks as seven rows of glyphs, one per weekday from Monday and a
119 * column per week, the denser the more focus. As many of the latest weeks
120 * as `width` holds; a cell between weeks when there is room.
121 */
122const heatRows = (weeks: readonly DayBar[][], width: number): string[] => {
123 // Too narrow for the weekday names, the weeks alone, as many as fit.
124 const hasNames = width > NAME_WIDTH
125 const room = Math.max(1, hasNames ? width - NAME_WIDTH : width)
126 const cell = room >= weeks.length * 2 ? 2 : 1
127 const shown = weeks.slice(-Math.max(1, Math.floor(room / cell)))
128 const most = Math.max(0, ...weeks.flat().map((bar) => bar.focusMs))
129 const glyphOf = (bar: DayBar | undefined): string => {
130 if (bar === undefined) {
131 return ' '
132 }
133
134 const level =
135 bar.focusMs > 0 ? Math.ceil((bar.focusMs / most) * (HEAT.length - 1)) : 0
136
137 return HEAT[level] ?? ' '
138 }
139
140 return WEEKDAYS.map(
141 (name, weekday) =>
142 `${hasNames ? `${name} ` : ''}${shown.map((week) => glyphOf(week[weekday]).padEnd(cell)).join('')}`,
143 )
144}
145
146/** Rows of glyphs as a Raster's `cells`, each in the terminal's own colors. */
147const cellsOf = (rows: readonly string[]): string => {
148 const glyphs = rows.flatMap((row) => [...row])
149 const view = new DataView(new ArrayBuffer(glyphs.length * CELL_BYTES))
150
151 glyphs.forEach((glyph, index) => {
152 const at = index * CELL_BYTES
153 view.setUint32(at, glyph.codePointAt(0) ?? 0x20, true)
154 view.setUint32(at + 4, DEFAULT_COLOR, true)
155 view.setUint32(at + 8, DEFAULT_COLOR, true)
156 })
157
158 return btoa(String.fromCharCode(...new Uint8Array(view.buffer)))
159}
160
161/** The heat rows as one Raster: the terminal's own grid of cells. */
162const heatRaster = (
163 Raster: ElementConstructor<RasterProps>,
164 rows: readonly string[],
165): RenderElement => (
166 <Raster
167 key="heat"
168 columns={widthOf(rows[0] ?? ' ')}
169 rows={rows.length}
170 cells={cellsOf(rows)}
171 />
172)
173
174/** The hours as one glyph each, taller for more rounds: blank for none. */
175const sparkOf = (hours: readonly number[]): string => {
176 const most = Math.max(...hours)
177
178 return hours
179 .map((count) =>
180 count > 0 ? (SPARK[Math.ceil((count / most) * SPARK.length) - 1] ?? '█') : ' ',
181 )
182 .join('')
183}
184
185/** Each share as its name, its focus and its rounds, in columns. */
186const shareLines = (
187 shares: readonly Share[],
188 prefix: string,
189 width: number,
190): string[] => {
191 const rows = shares.map((share) => ({
192 name: `${prefix}${share.name}`,
193 span: spanText(share.focusMs),
194 count: plural(share.rounds, 'round'),
195 }))
196 const spanWidth = Math.max(...rows.map((row) => row.span.length))
197 const countWidth = Math.max(...rows.map((row) => row.count.length))
198 const nameWidth = Math.max(
199 1,
200 Math.min(
201 Math.max(...rows.map((row) => widthOf(row.name))),
202 width - spanWidth - countWidth - 4,
203 ),
204 )
205
206 return rows.map(
207 ({ name, span, count }) =>
208 `${cut(name, nameWidth).padEnd(nameWidth + name.length - widthOf(name))} ${span.padStart(spanWidth)} ${count.padStart(countWidth)}`,
209 )
210}
211
212/**
213 * Draws the report pane on every surface from what `load` reads through the
214 * store's `get`: never `$` itself, which the host follows into no function
215 * handed in. The pane draws again whenever `historyVersion` is bumped.
216 */
217export const registerReport = (on: On, load: ReportLoad): void => {
218 on('ui.render', { component: 'Pane', requestId: REPORT_PANE }, async ($, e) => {
219 await read($, historyVersion)
220 const { history, goal } = await load((key) => $.store.get(key))
221 const report = insightsOf(history, await $.clock.now(), goal)
222 const width = Math.max(1, e.props.bodyColumns)
223 const { Box, Button, Text } = $.ui.resolve(e)
224 const close = (): void => {
225 void $.ui.close({ id: REPORT_PANE }).catch(() => undefined)
226 }
227 const closer = (
228 <Box>
229 <Button key="close" role="dismiss" onPress={close}>
230 Close
231 </Button>
232 </Box>
233 )
234
235 if (report.total.rounds === 0 && report.total.focusMs === 0) {
236 return (
237 <Box flexDirection="column" gap={1}>
238 <Text>{EMPTY_TEXT}</Text>
239 {closer}
240 </Box>
241 )
242 }
243
244 const rows = heatRows(report.weeks, width)
245 const heat =
246 e.surface === 'terminal'
247 ? heatRaster($.ui.resolve(e).Raster, rows)
248 : rows.map((row) => <Text wrap="truncate">{row}</Text>)
249 const weekFocusMs = report.week.reduce((sum, bar) => sum + bar.focusMs, 0)
250 const hasHours = report.hours.some((count) => count > 0)
251
252 return (
253 <Box flexDirection="column" gap={1}>
254 <Box flexDirection="column">
255 {packed(headerOf(report), width).map((line) => (
256 <Text bold>{line}</Text>
257 ))}
258 </Box>
259 <Box flexDirection="column">
260 <Text dimColor>Last 7 days</Text>
261 {weekLines(report.week, report.today.day, width).map((line) => (
262 <Text bold={line.isStrong} dimColor={line.isDim} wrap="truncate">
263 {line.text}
264 </Text>
265 ))}
266 </Box>
267 <Box flexDirection="column">
268 <Text dimColor>Last 12 weeks</Text>
269 {heat}
270 <Text dimColor>{`less ${HEAT.join(' ')} more`}</Text>
271 </Box>
272 {hasHours && (
273 <Box flexDirection="column">
274 <Text dimColor>Rounds by hour, last 30 days</Text>
275 <Text wrap="truncate">{sparkOf(report.hours)}</Text>
276 <Text dimColor wrap="truncate">
277 {AXIS}
278 </Text>
279 </Box>
280 )}
281 {report.projects.length > 0 && (
282 <Box flexDirection="column">
283 <Text dimColor>Projects, last 30 days</Text>
284 {shareLines(report.projects, '', width).map((line) => (
285 <Text wrap="truncate">{line}</Text>
286 ))}
287 </Box>
288 )}
289 {report.tags.length > 0 && (
290 <Box flexDirection="column">
291 <Text dimColor>Tags, last 30 days</Text>
292 {shareLines(report.tags, '#', width).map((line) => (
293 <Text wrap="truncate">{line}</Text>
294 ))}
295 </Box>
296 )}
297 {weekFocusMs > 0 && (
298 <Text>{`Claude worked ${Math.round(report.claudeShare * 100)}% of your focus time in the last 7 days.`}</Text>
299 )}
300 {closer}
301 </Box>
302 )
303 })
304}
305hooks/timer.ts 364 lines1// The pomodoro itself, with nothing of Claude Code in it: what the timer is at
2// a given moment and what it becomes. Every open session reads the same timer
3// from the plugin's store, so all of it is told from the clock alone.
4
5import { intentText } from './history'
6import type { Intent } from './history'
7import { field, isRecord, toCount, toText, toWords } from './values'
8
9export type FocusStart = 'prompt' | 'auto'
10export type BreakStart = 'claude' | 'auto'
11export type HintStyle = 'full' | 'dots' | 'minimal'
12export type Plan = {
13 focusMs: number
14 breakMs: number
15 longBreakMs: number
16 // Focus rounds in a set: the long break comes after the last.
17 rounds: number
18 // Whether a break that ran out waits for the person's next prompt.
19 focusStart: FocusStart
20 // Whether a focus round that ran out waits for Claude to start working.
21 breakStart: BreakStart
22 // Rounds a day the person aims for: 0 for none.
23 dailyGoal: number
24 hint: HintStyle
25}
26export type Timer = {
27 phase: 'idle' | 'focus' | 'break'
28 // When the phase began, moved later by every pause it sat through.
29 startedAt: number
30 // When the phase began, unmoved: a focus round's name in the history.
31 beganAt: number
32 lengthMs: number
33 // When it was paused: 0 while it runs.
34 pausedAt: number
35 // Focus rounds finished in this set.
36 round: number
37 // What the person said the rounds are for, kept from round to round.
38 label: string
39 tags: string[]
40 // A focus round the clock began, with nobody there to see it begin.
41 isUnattended: boolean
42 // A break that ran out and waits for the person to come back.
43 isDue: boolean
44}
45// What a session knows of the moment it looks at the timer.
46export type Moment = {
47 now: number
48 // Whether Claude is working in the session that looks.
49 isWorking: boolean
50 // When the person last did something, in any session.
51 activeAt: number
52}
53// What a moment makes of the timer: a break that begins, a break that ran
54// out, a focus round that begins, a pomodoro nobody was there for, or nothing.
55export type Step = {
56 timer: Timer
57 event: 'none' | 'break' | 'due' | 'focus' | 'stale'
58 // How long the round that ended ran, for a break that begins.
59 focusedMs: number
60}
61
62const SECOND_MS = 1000
63const MINUTE_MS = 60 * SECOND_MS
64const MAX_MINUTES = 600
65const MAX_ROUNDS = 12
66const FOCUS_STARTS: readonly FocusStart[] = ['prompt', 'auto']
67const BREAK_STARTS: readonly BreakStart[] = ['claude', 'auto']
68const HINT_STYLES: readonly HintStyle[] = ['full', 'dots', 'minimal']
69// A focus round that ran out waits this long for Claude to start working, so
70// the break lands on a wait; after it the break begins anyway.
71export const GRACE_MS = 5 * MINUTE_MS
72// A phase this far past its end ran out with nobody there: the pomodoro is
73// over.
74export const STALE_MS = 30 * MINUTE_MS
75
76export const IDLE: Timer = {
77 phase: 'idle',
78 startedAt: 0,
79 beganAt: 0,
80 lengthMs: 0,
81 pausedAt: 0,
82 round: 0,
83 label: '',
84 tags: [],
85 isUnattended: false,
86 isDue: false,
87}
88
89const toMs = (value: unknown, fallback: number): number =>
90 typeof value === 'number' && Number.isFinite(value) && value > 0
91 ? Math.round(Math.min(value, MAX_MINUTES) * MINUTE_MS)
92 : fallback * MINUTE_MS
93
94const toChoice = <T extends string>(
95 value: unknown,
96 choices: readonly T[],
97 fallback: T,
98): T => choices.find((choice) => choice === value) ?? fallback
99
100/** The lengths and ways the person set in the config menu, each a default when unset. */
101export const planOf = (options: Readonly<Record<string, unknown>>): Plan => ({
102 focusMs: toMs(options.focusMinutes, 25),
103 breakMs: toMs(options.breakMinutes, 5),
104 longBreakMs: toMs(options.longBreakMinutes, 15),
105 rounds: Math.min(toCount(options.rounds) || 4, MAX_ROUNDS),
106 focusStart: toChoice(options.focusStart, FOCUS_STARTS, 'prompt'),
107 breakStart: toChoice(options.breakStart, BREAK_STARTS, 'claude'),
108 dailyGoal: Math.min(toCount(options.dailyGoal), 99),
109 hint: toChoice(options.hintStyle, HINT_STYLES, 'full'),
110})
111
112/** The timer as the store keeps it: idle when nothing there reads as one. */
113export const toTimer = (value: unknown): Timer => {
114 if (!isRecord(value)) {
115 return IDLE
116 }
117
118 const phase = field(value, 'phase')
119
120 if (phase !== 'focus' && phase !== 'break') {
121 return IDLE
122 }
123
124 const startedAt = toCount(field(value, 'startedAt'))
125
126 return {
127 phase,
128 startedAt,
129 // A timer an earlier version kept has no unmoved start.
130 beganAt: toCount(field(value, 'beganAt')) || startedAt,
131 lengthMs: toCount(field(value, 'lengthMs')),
132 pausedAt: toCount(field(value, 'pausedAt')),
133 round: toCount(field(value, 'round')),
134 label: toText(field(value, 'label')),
135 tags: toWords(field(value, 'tags')),
136 isUnattended: field(value, 'isUnattended') === true,
137 isDue: field(value, 'isDue') === true,
138 }
139}
140
141export const isPaused = (timer: Timer): boolean => timer.pausedAt > 0
142
143const elapsedOf = (timer: Timer, now: number): number =>
144 (isPaused(timer) ? timer.pausedAt : now) - timer.startedAt
145
146/** What the phase has left: negative once it ran out. */
147export const leftOf = (timer: Timer, now: number): number =>
148 timer.lengthMs - elapsedOf(timer, now)
149
150/** Whether the running phase ran out by `now`: a break waiting, a round due. */
151export const isOver = (timer: Timer, now: number): boolean =>
152 timer.phase !== 'idle' && !isPaused(timer) && leftOf(timer, now) <= 0
153
154/** The focus a round has had by now: its run, and no more than `GRACE_MS` past its end. */
155export const focusOf = (timer: Timer, now: number): number =>
156 Math.max(0, Math.min(elapsedOf(timer, now), timer.lengthMs + GRACE_MS))
157
158/** A focus round that begins now, after the rounds and for the intent given. */
159export const focused = (
160 plan: Plan,
161 now: number,
162 { round, label, tags }: Pick<Timer, 'round' | 'label' | 'tags'>,
163 isUnattended = false,
164): Timer => ({
165 phase: 'focus',
166 startedAt: now,
167 beganAt: now,
168 lengthMs: plan.focusMs,
169 pausedAt: 0,
170 round,
171 label,
172 tags,
173 isUnattended,
174 isDue: false,
175})
176
177/** The break after the focus round `timer` is in: long after a set's last. */
178export const rested = (timer: Timer, plan: Plan, now: number): Timer => {
179 const round = timer.round + 1
180 const isLong = round >= plan.rounds
181
182 return {
183 ...timer,
184 phase: 'break',
185 startedAt: now,
186 beganAt: now,
187 lengthMs: isLong ? plan.longBreakMs : plan.breakMs,
188 pausedAt: 0,
189 round: isLong ? 0 : round,
190 isUnattended: false,
191 isDue: false,
192 }
193}
194
195export const paused = (timer: Timer, now: number): Timer => ({
196 ...timer,
197 pausedAt: now,
198})
199
200export const resumed = (timer: Timer, now: number): Timer => ({
201 ...timer,
202 startedAt: timer.startedAt + (now - timer.pausedAt),
203 pausedAt: 0,
204})
205
206/** The phase with `ms` more to run, counted from now once it ran out. */
207export const extended = (timer: Timer, now: number, ms: number): Timer => ({
208 ...timer,
209 lengthMs: Math.max(timer.lengthMs, elapsedOf(timer, now)) + ms,
210 isDue: false,
211})
212
213export const intended = (timer: Timer, intent: Intent): Timer => ({
214 ...timer,
215 ...intent,
216})
217
218/** The phase after this one, begun now whatever the phase has left. */
219export const skipped = (timer: Timer, plan: Plan, now: number): Timer => {
220 if (timer.phase === 'focus') {
221 return rested(timer, plan, now)
222 }
223
224 return timer.phase === 'break' ? focused(plan, now, timer) : timer
225}
226
227/** A break that ran out, turned to focus now that the person is back. */
228export const returned = (
229 timer: Timer,
230 plan: Plan,
231 now: number,
232): Timer | undefined =>
233 timer.phase === 'break' && isOver(timer, now)
234 ? focused(plan, now, timer)
235 : undefined
236
237/**
238 * What the moment makes of the timer. A break that ran out turns to focus at
239 * once, or waits for the person when `focusStart` is `prompt`. A focus round
240 * that ran out turns to a break once Claude is working, or `GRACE_MS` later
241 * when it is not. A round the clock began that nobody came to ends with no
242 * round counted.
243 */
244export const stepped = (timer: Timer, moment: Moment, plan: Plan): Step => {
245 const { now, isWorking, activeAt } = moment
246 const over = -leftOf(timer, now)
247 const kept: Step = { timer, event: 'none', focusedMs: 0 }
248
249 if (timer.phase === 'idle' || isPaused(timer) || over < 0) {
250 return kept
251 }
252
253 if (over > STALE_MS) {
254 return { timer: IDLE, event: 'stale', focusedMs: 0 }
255 }
256
257 if (timer.phase === 'break') {
258 if (plan.focusStart === 'auto') {
259 return {
260 timer: focused(plan, now, timer, true),
261 event: 'focus',
262 focusedMs: 0,
263 }
264 }
265
266 return timer.isDue
267 ? kept
268 : { timer: { ...timer, isDue: true }, event: 'due', focusedMs: 0 }
269 }
270
271 if (timer.isUnattended && activeAt < timer.beganAt && !isWorking) {
272 return { timer: IDLE, event: 'stale', focusedMs: 0 }
273 }
274
275 if (plan.breakStart === 'claude' && !isWorking && over < GRACE_MS) {
276 return kept
277 }
278
279 return {
280 timer: rested(timer, plan, now),
281 event: 'break',
282 focusedMs: focusOf(timer, now),
283 }
284}
285
286/** What is left, to the second above: `18:42`, and `0:00` once run out. */
287export const clockText = (ms: number): string => {
288 const seconds = Math.ceil(Math.max(ms, 0) / SECOND_MS)
289
290 return `${Math.floor(seconds / 60)}:${String(seconds % 60).padStart(2, '0')}`
291}
292
293/** A length in whole minutes, a fraction kept for one under ten: `5`, `1.5`. */
294export const minutesText = (ms: number): string => {
295 const minutes = ms / MINUTE_MS
296
297 return String(minutes < 10 ? Math.round(minutes * 10) / 10 : Math.round(minutes))
298}
299
300/** The round a focus phase is, of its set: `2/4`. */
301export const roundText = (timer: Timer, plan: Plan): string =>
302 `${timer.round + 1}/${plan.rounds}`
303
304/** The set's rounds as the `dots` hint draws them: `●●○○`, two done. */
305const dotsText = (timer: Timer, plan: Plan): string =>
306 '●'.repeat(Math.min(timer.round, plan.rounds)) +
307 '○'.repeat(Math.max(plan.rounds - timer.round, 0))
308
309const setText = (timer: Timer, plan: Plan): string => {
310 if (plan.hint === 'minimal') {
311 return ''
312 }
313
314 return plan.hint === 'dots'
315 ? ` ${dotsText(timer, plan)}`
316 : ` · ${roundText(timer, plan)}`
317}
318
319/** The timer as the hint line shows it: empty while no pomodoro is on. */
320export const labelOf = (timer: Timer, now: number, plan: Plan): string => {
321 if (timer.phase === 'idle') {
322 return ''
323 }
324
325 const left = leftOf(timer, now)
326 const clock = `${isPaused(timer) ? 'paused ' : ''}${clockText(left)}`
327 const isRunning = left > 0 || isPaused(timer)
328
329 if (timer.phase === 'break') {
330 return isRunning ? `☕ ${clock}` : '☕ break over'
331 }
332
333 return `🍅 ${isRunning ? clock : 'break due'}${setText(timer, plan)}`
334}
335
336/** The timer in a sentence, as `/pomodoro` answers. */
337export const statusText = (timer: Timer, now: number, plan: Plan): string => {
338 if (timer.phase === 'idle') {
339 return 'No pomodoro is on. /pomodoro start begins one.'
340 }
341
342 const left = leftOf(timer, now)
343 const intent = intentText(timer)
344 const phase = `${
345 timer.phase === 'break' ? 'break' : `focus ${roundText(timer, plan)}`
346 }${intent === '' ? '' : ` (${intent})`}`
347
348 if (isPaused(timer)) {
349 return `Pomodoro: ${phase}, paused with ${clockText(left)} left. /pomodoro resume runs it on.`
350 }
351
352 if (left > 0) {
353 return `Pomodoro: ${phase} · ${clockText(left)} left`
354 }
355
356 return timer.phase === 'break'
357 ? `Pomodoro: the break is over · focus ${roundText(timer, plan)} begins with your next prompt`
358 : `Pomodoro: ${phase} is done · the break begins when Claude starts working`
359}
360
361/** How long the phase has sat paused so far, the pause it is in included. */
362export const pausedOf = (timer: Timer, now: number): number =>
363 timer.startedAt - timer.beganAt + (isPaused(timer) ? now - timer.pausedAt : 0)
364hooks/tools.ts 42 lines1// Two tools the model can call, for a person who turned them on: one reads
2// where the pomodoro stands and what was done, the other acts on the timer
3// with the words `/pomodoro` takes. Here only as the model reads them: the
4// hooks module serves them.
5
6import type { ToolSpec } from 'claude-code'
7
8export const ACTIONS = ['start', 'pause', 'resume', 'skip', 'finish', 'stop', 'extend', 'note']
9
10export const TOOLS: readonly ToolSpec[] = [
11 {
12 name: 'stats',
13 description:
14 "Reads the person's pomodoro timer: where it stands now, their focus stats (today, days in a row, the last 7 days by project and tag) and one day's focus rounds with what each was for. Read-only.",
15 inputSchema: {
16 type: 'object',
17 properties: {
18 day: {
19 type: 'string',
20 description: 'The day to list: today (the default), yesterday or YYYY-MM-DD',
21 },
22 },
23 },
24 },
25 {
26 name: 'timer',
27 description:
28 "Acts on the person's pomodoro timer as `/pomodoro <action> [text]` does. Use it only when the person asks for it. start and note take what the rounds are for, with #tags; extend takes minutes.",
29 inputSchema: {
30 type: 'object',
31 properties: {
32 action: { type: 'string', enum: ACTIONS },
33 text: {
34 type: 'string',
35 description: 'What the rounds are for, or the minutes to extend by',
36 },
37 },
38 required: ['action'],
39 },
40 },
41]
42hooks/values.ts 24 lines1// Readers for what the store hands back: JSON a session of any version of
2// the mod wrote, so each value is taken only when it reads as its kind.
3
4export const field = (value: object, key: string): unknown =>
5 Object.entries(value).find(([name]) => name === key)?.[1]
6
7export const isRecord = (value: unknown): value is object =>
8 typeof value === 'object' && value !== null && !Array.isArray(value)
9
10/** A whole count of at least zero: 0 for anything else. */
11export const toCount = (value: unknown): number =>
12 typeof value === 'number' && Number.isFinite(value) && value > 0
13 ? Math.floor(value)
14 : 0
15
16export const toText = (value: unknown): string =>
17 typeof value === 'string' ? value : ''
18
19/** The strings of a list, each once and in their first order. */
20export const toWords = (value: unknown): string[] =>
21 Array.isArray(value)
22 ? [...new Set(value.filter((word): word is string => typeof word === 'string' && word !== ''))]
23 : []
24hooks/insights.ts 159 lines1// What the report pane shows, told from the history alone: the days, weeks
2// and hours of focus, and where it went. Nothing of Claude Code is in it.
3
4import { bestStreakOf, dayOf, dayTotals, streakOf, totalOf } from './history'
5import type { Day, History, Round } from './history'
6import { toCount } from './values'
7
8export type DayBar = { day: string; rounds: number; focusMs: number }
9export type Share = { name: string; rounds: number; focusMs: number }
10export type Insights = {
11 today: DayBar
12 /** The daily goal in rounds: 0 for none. */
13 goal: number
14 /** Days in a row with a focus round, up to today. */
15 streak: number
16 /** The most days in a row there ever was one. */
17 best: number
18 total: Day
19 /** The last 7 days, oldest first, today last. */
20 week: DayBar[]
21 /**
22 * The last 12 weeks, oldest first, each a column of its days from Monday:
23 * the current week's stops at today.
24 */
25 weeks: DayBar[][]
26 /** Done rounds by the hour they began, over the last 30 days. */
27 hours: number[]
28 /** The 5 projects with the most focus over the last 30 days. */
29 projects: Share[]
30 /** The 5 tags with the most focus over the last 30 days. */
31 tags: Share[]
32 /** How much of the last 7 days' focus Claude worked through: 0 to 1. */
33 claudeShare: number
34}
35
36const WEEK_DAYS = 7
37const WEEKS = 12
38const RECENT_DAYS = 30
39const HOURS = 24
40const TOP = 5
41const NO_DAY: Day = { rounds: 0, focusMs: 0 }
42
43const barOf = (days: Readonly<Record<string, Day>>, day: string): DayBar => ({
44 day,
45 ...(days[day] ?? NO_DAY),
46})
47
48/** The rounds that ended on one of the last `count` days, today the last. */
49const endedWithin = (
50 history: History,
51 now: number,
52 count: number,
53): Round[] => {
54 const first = dayOf(now, count - 1)
55 const today = dayOf(now)
56
57 return history.rounds.filter((round) => {
58 const day = dayOf(round.end)
59
60 return day >= first && day <= today
61 })
62}
63
64/** Each of the 12 weeks up to today, its days from Monday on. */
65const weeksOf = (
66 days: Readonly<Record<string, Day>>,
67 now: number,
68): DayBar[][] => {
69 const sinceMonday = (new Date(now).getDay() + 6) % WEEK_DAYS
70
71 return Array.from({ length: WEEKS }, (_, week) => {
72 const monday = sinceMonday + (WEEKS - 1 - week) * WEEK_DAYS
73
74 return Array.from({ length: WEEK_DAYS }, (_, weekday) => monday - weekday)
75 .filter((daysBack) => daysBack >= 0)
76 .map((daysBack) => barOf(days, dayOf(now, daysBack)))
77 })
78}
79
80const hoursOf = (rounds: readonly Round[]): number[] => {
81 const hours = Array.from({ length: HOURS }, () => 0)
82
83 rounds
84 .filter((round) => round.status === 'done')
85 .forEach((round) => {
86 const hour = new Date(round.start).getHours()
87 hours[hour] = (hours[hour] ?? 0) + 1
88 })
89
90 return hours
91}
92
93/** The names with the most focus, each with its focus and its done rounds. */
94const topOf = (
95 rounds: readonly Round[],
96 namesOf: (round: Round) => readonly string[],
97): Share[] => {
98 const shares = new Map<string, Share>()
99
100 rounds.forEach((round) =>
101 namesOf(round).forEach((name) => {
102 const kept = shares.get(name) ?? { name, rounds: 0, focusMs: 0 }
103
104 shares.set(name, {
105 name,
106 rounds: kept.rounds + (round.status === 'done' ? 1 : 0),
107 focusMs: kept.focusMs + round.focusMs,
108 })
109 }),
110 )
111
112 return [...shares.values()]
113 .filter((share) => share.rounds > 0 || share.focusMs > 0)
114 .sort(
115 (a, b) =>
116 b.focusMs - a.focusMs ||
117 b.rounds - a.rounds ||
118 a.name.localeCompare(b.name),
119 )
120 .slice(0, TOP)
121}
122
123const claudeShareOf = (rounds: readonly Round[]): number => {
124 const focusMs = rounds.reduce((sum, round) => sum + round.focusMs, 0)
125 const claudeMs = rounds.reduce((sum, round) => sum + round.claudeMs, 0)
126
127 return focusMs > 0 ? Math.min(claudeMs / focusMs, 1) : 0
128}
129
130/**
131 * The report as of `now`. Days count a round on the day it ended, as the
132 * stats do; projects, tags, hours and Claude's share come from the rounds
133 * alone, the days kept as totals having none of that.
134 */
135export const insightsOf = (
136 history: History,
137 now: number,
138 goal: number,
139): Insights => {
140 const days = dayTotals(history)
141 const recent = endedWithin(history, now, RECENT_DAYS)
142
143 return {
144 today: barOf(days, dayOf(now)),
145 goal: toCount(goal),
146 streak: streakOf(days, now),
147 best: bestStreakOf(days),
148 total: totalOf(days),
149 week: Array.from({ length: WEEK_DAYS }, (_, index) =>
150 barOf(days, dayOf(now, WEEK_DAYS - 1 - index)),
151 ),
152 weeks: weeksOf(days, now),
153 hours: hoursOf(recent),
154 projects: topOf(recent, (round) => round.projects),
155 tags: topOf(recent, (round) => round.tags),
156 claudeShare: claudeShareOf(endedWithin(history, now, WEEK_DAYS)),
157 }
158}
159types/index.d.ts 23 lines1declare module 'claude-code' {
2 interface PluginState {
3 pomodoro: {
4 /**
5 * The timer as the prompt's hint line shows it (`🍅 18:42 · 2/4`):
6 * empty while no pomodoro is on.
7 */
8 label: string
9 /**
10 * Bumped each time a round is recorded, so an open report pane, which
11 * reads it, draws again.
12 */
13 historyVersion: number
14 /**
15 * The switches every session shares, as this one last read them from
16 * the store: the sound off, the row of buttons open, and the pomodoro
17 * closed out of sight by `/pomodoro close`.
18 */
19 switches: { isMuted: boolean; areControlsOpen: boolean; isClosed: boolean }
20 }
21 }
22}
23