SLOPSHOPPER

pomodoro

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

newpanespinnerguardcommandtoast
v0.2.3MITupdated 2026-10-05barisdemirhan/claude-pomodoro
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · pomodoro
│ ┃ pomodoro-report ✕ › fix the failing auth test and add an audit log call │ ┃ No focus rounds yet. /pomodoro start begins │ ┃ one. ⏺ Read(src/auth.ts) │ ┃ ⎿ Read 6 lines │ ┃ [ Close ] ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /pomodoro │ ⎿ pomodoro: Pomodoro is on: 25 minutes of focus, round 1/4. The br │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ⟨Claude Code's own drawing⟩ 🍅 25:00 · 1/4 [ ❚❚ pause ] [ +5m ] [ ✓ finish ] [ ■ stop ] [ ● sound ] [ report ] [ × ]

Draws

Pane · pomodoro-report
No focus rounds yet. /pomodoro start begins one. [ Close ]
Prompt hint
⟨Claude Code's own drawing⟩ 🍅 25:00 · 1/4 [ ❚❚ pause ] [ +5m ] [ ✓ finish ] [ ■ stop ] [ ● sound ] [ report ] [ × ]
README

claude-pomodoro

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

Install

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.

If you installed from claude-pomodoro

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

Use

CommandWhat 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 pauseHolds the timer where it is
/pomodoro resumeRuns a paused timer on
/pomodoro extend [minutes]Gives the phase 5 more minutes, or as many as you say
/pomodoro finishEnds the focus round now and counts it with the time it ran, then the break begins
/pomodoro skipGoes to the next phase now. A focus round skipped before its end is not counted, though its focus time is
/pomodoro stopEnds the pomodoro
/pomodoro note [what #tag]Says what the rounds are for from now on. With nothing after it, clears it
/pomodoro undoTakes 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 statsToday'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 reportOpens 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 soundTurns the sounds off or on. /pomodoro sound on and /pomodoro sound off say which. Every open session follows within a second
/pomodoro controlsOpens 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 closeTakes 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 lineIt means
🍅 18:42 · 2/4Focus round 2 of 4, 18:42 left
🍅 break due · 2/4The round ran out and waits for Claude to start working
☕ 4:12A break, 4:12 left
☕ break overThe break ran out and the next round waits for you to come back
🍅 paused 18:42 · 2/4Paused

It keeps time with Claude

  • The break begins while Claude works. When a focus round runs out in the middle of a turn, the break starts right then: Claude is busy and you are waiting. When it runs out while you are at the prompt, the hint line says 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.
  • The next round begins when you are back. When a break runs out, a toast and a sound say so, the hint line says 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.
  • It tells you when Claude wants you back. If Claude finishes its turn or asks for a permission during your break, a toast says so and how much of the break is left. When you are back, a toast says how many turns Claude finished and how many times it asked for you while you were away.
  • It knows Claude's part. A round keeps how long Claude worked inside it and how many prompts you sent, and the stats say what share of your focus time Claude was working.
  • One timer for every session. All your open Claude Code sessions show the same pomodoro. Start it in one, and the others pick it up within five seconds; a break shows as a toast in each, and the sound plays once. Before a session turns the phase it reads the timer again, and leaves the turn to another session that changed it first. Claude Code's store has no atomic write, so two sessions turning it in the very same moment can still both do so.
  • It stops when nobody is there. A break that has waited half an hour for you ends the pomodoro, and so does a phase that ran out more than half an hour ago with the machine asleep or every session closed. Neither counts a round nobody did.

What a round keeps

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.

Settings

All of these are in Claude Code's /config menu, under the plugin's name:

SettingDefaultWhat it does
Focus minutes25
Break minutes5
Long break minutes15
Rounds4Focus rounds in a set, the long break after the last
Focus startspromptAfter 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 startsclaudeAfter a focus round: claude waits up to five minutes for Claude to start working, auto starts the break at once
Daily goal0Rounds a day you aim for, shown in the stats and told with a toast when met. 0 for none
Hint stylefullfull shows 2/4, dots shows ●●○○, minimal the clock alone
Control rowalwaysThe timer's row of buttons where there is a pointer: always, running (only while a pomodoro is on) or off
Volume50How loud the sounds play, 0 to 100
Spoken announcementsoffSays each change of phase aloud as well, with the system voice
Open Pomodoro filesoffSee below
Hook scriptsoffSee below
Tools for ClaudeoffSee below

A change of length applies from the next phase on.

Open Pomodoro and hook scripts

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:

  • Open Pomodoro files keeps ~/.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.
  • Hook scripts runs the executables you keep in ~/.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.

Tools for Claude

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.

Requirements

  • A Claude Code build with mod support (plugins that ship a hooks module). Built and tested on 2.1.288. Mods sit behind a rollout switch, so if /pomodoro does not show up after installing, the switch may still be off for you.
  • The terminal or the desktop app: the timer is drawn only there. The terminal has it on the hint line, the desktop app among the mode labels beside it.
  • Sound needs macOS, where Claude Code has a player for it. Elsewhere the timer is silent.

Privacy and data handling

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.
  • Open Pomodoro files reads HOME to find ~/.pomodoro, reads and writes current and history there, and nothing else.
  • Hook scripts runs the files in ~/.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.

Hooks

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.

Develop

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.

More mods

From the same marketplace, claude-mods:

  • ambient: a living band above the prompt, with sound, fed by Claude's work.
  • dino: a T-Rex runner in a pane, with Claude's tool calls as the obstacles.
  • tycoon: Token Tycoon, an idle game where Claude's tool calls earn the money.

License

MIT

Source 11 files
hooks/register.tsx 1563 lines
1import { 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 lines
1import 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}
79
hooks/export.ts 102 lines
1import 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}
102
hooks/history.ts 387 lines
1// 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}
387
hooks/openpomodoro.ts 119 lines
1import 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})
119
hooks/report.tsx 305 lines
1// 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}
305
hooks/timer.ts 364 lines
1// 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)
364
hooks/tools.ts 42 lines
1// 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]
42
hooks/values.ts 24 lines
1// 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    : []
24
hooks/insights.ts 159 lines
1// 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}
159
types/index.d.ts 23 lines
1declare 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