Timer
StableThe Focus screen: a focus session, a stopwatch and a countdown one switch apart, with the time log beside them.
TimerApp is the whole tool inside the RChursin_ app window. It needs a FocusProvider above it: the provider holds the one focus session, its settings and the log, and does all the timekeeping (deadlines, the 5 and 1 minute announcements, the chime, logging). Every clock counts from saved timestamps, so a reload, a sleeping laptop or a background tab never loses time. Its one prop, hrefFor, says where the window’s links to the other tools go. Everything saves to this browser until you connect a backend.
The Focus screen
import { useRouter } from '@rchursin/product/lib/router'import type { KitSlug } from '@rchursin/product/shell/pages'import { FocusProvider, TimerApp } from '@rchursin/product/timer'
/** The window's tool links go to each tool's page in this Library (Today has none, so it shows as text) */const toolPages: Partial<Record<KitSlug, string>> = { tasks: '/library/tools/tasks-app', calendar: '/library/tools/calendar-app', timer: '/library/tools/timer-app', notes: '/library/tools/notes-app',}
/** * The whole Focus screen on a page of its own, on sample data. TimerApp reads the session from the * FocusProvider above it. Click inside, then 1, 2 and 3 switch between Focus, Stopwatch and Countdown, * Space starts and pauses the one you are in, and F finishes a focus session. * Everything saves to this browser; "Reset sample data" puts it back. */export default function TimerAppPage() { const { href } = useRouter() const hrefFor = (slug: KitSlug) => { const path = toolPages[slug] return path && href(path) } return ( <main className="min-h-dvh bg-sunken/40 px-4 pt-6 pb-24 sm:px-8 sm:pt-10"> <div className="mx-auto flex max-w-6xl flex-col gap-5"> <header className="flex flex-col gap-1"> <h1 className="text-xl font-semibold tracking-[-0.02em] text-balance">Timer</h1> <p className="text-[13px] text-pretty text-fg-3">A sample workspace. Everything you do here stays in this browser.</p> </header> {/* Keeps the outline in order: the app's own headings (the time log) are h3 */} <h2 className="sr-only">Focus screen</h2> <FocusProvider> <TimerApp hrefFor={hrefFor} /> </FocusProvider> </div> </main> )}Usage
import { FocusProvider, TimerApp, useFocus } from '@rchursin/product/timer'<FocusProvider> <TimerApp /></FocusProvider>Examples
Your own view with useFocus
Anything under the provider can read the session and press its buttons. This one has its own session, separate from the screen above.
import { useId } from 'react'import { formatDuration } from '@rchursin/product/lib/dates'import { SampleBadge } from '@rchursin/product/shell/layout'import { FocusProvider, PhaseChip, TimeDisplay, useFocus, type TimeState } from '@rchursin/product/timer'import { Button } from '@rchursin/product/ui'
/** * A view of your own: anything under a FocusProvider can read the session and act on it. * The provider does the timekeeping (deadline, announcements, logging), so this stays small. */function FocusStrip() { const f = useFocus() const labelId = useId() const { status, phase } = f.session const running = status === 'running' const state: TimeState = status === 'idle' ? 'idle' : status === 'paused' ? 'paused' : f.overtime ? 'overflow' : 'running'
return ( <div className="lux-card flex w-full max-w-md flex-col gap-4 rounded-2xl bg-raised p-5"> <div className="flex items-center justify-between gap-3">API
TimerApp props
| Prop | Type | Default | Description |
|---|---|---|---|
hrefFor | (slug: KitSlug) => string | undefined | – | Where the window’s tool links (Today, Tasks, Calendar, Notes) go: your own pages. The default is the product kit’s own #tasks, #calendar … anchors; return undefined to show a tool as plain text. |
FocusProvider props
| Prop | Type | Default | Description |
|---|---|---|---|
storagePrefix | string | 'timer' | Where the session, settings and log are saved in this browser: “<prefix>:focus”, “<prefix>:settings” and “<prefix>:log”. Give each provider on a page its own prefix. |
tasks | Task[] | the sample tasks | Your tasks. The open ones (not done or canceled) are offered in the task picker, and any of them names the log entries that point at it. |
childrenrequired | ReactNode | – | Everything that shows the session: FocusSession, FocusWidget, TimeLog, TimerApp or your own views. |
useFocus() returns
| Prop | Type | Default | Description |
|---|---|---|---|
session | FocusState | – | status (idle, running, paused), phase (focus, short, long), durationMs, endAt, done (focus sessions finished in this set), taskId, intention and reachedZero. |
settings | FocusSettings | – | focusMin, shortMin, longMin, longEvery, autoStart and chime. The defaults are 25 / 5 / 15 minutes, a long break after 4, both switches off. |
updateSettings | (patch: Partial<FocusSettings>) => void | – | Change settings. A running phase keeps its length; the change applies from the next one. |
durationMs | number | – | Length of the current phase. It follows the settings until the phase starts, then stays fixed. |
read | (now: number) => number | – | Time left at now; negative means overtime. Pass it to TimeDisplay as read. |
readElapsed | (now: number) => number | – | Time spent at now, for a progress square. |
overtime | boolean | – | The phase ran past zero and is still counting. |
toggle | () => void | – | Start, pause or resume the current phase, and say so to screen readers (“Focus started, 25 minutes”). |
finish | () => void | – | End the phase now and log it. Shows a toast with Undo, and the next phase waits for Start (autoStart only applies when a phase runs out on its own). |
abandon | () => void | – | Stop a focus session without logging it. |
skipBreak | () => void | – | Leave a break and get ready to focus, logging nothing. FocusSession offers it before the break starts. |
tasks | Task[] | – | Open tasks to focus on: the provider’s tasks prop (the sample tasks by default), without the done and canceled ones. |
task | Task | null | – | The chosen task. |
taskById | (id: string | null) => Task | null | – | Look up any of the provider’s tasks, open or not. |
setTask | (id: string | null) => void | – | Choose the task (null for none). |
setIntention | (text: string) => void | – | What this session should get done. |
log | TimeEntry[] | – | Every logged entry: focus, break or added by hand. |
addEntry | (entry: Omit<TimeEntry, 'id' | 'label'> & { label?: string }) => TimeEntry | – | Log time by hand. The label defaults to the task’s title. |
deleteEntry | (id: string) => TimeEntry | null | – | Remove an entry and get it back, for Undo. |
restoreEntry | (entry: TimeEntry) => void | – | Put a deleted entry back. |
todayMs | number | – | Focus and hand-added time logged today; breaks are left out. |
reset | () => void | – | Back to an idle session, the default settings and the sample log. |
Accessibility
Keyboard
| Key | What it does |
|---|---|
| 123 | Show Focus, Stopwatch or Countdown, and move focus into it so its own keys work at once. |
| Space | Start or pause the timer you are in. On a button, Space presses that button instead. |
| F | Finish the focus session (or end the break) and log it. |
| L | Stopwatch: lap. |
| R | Stopwatch or countdown: reset, with Undo. |
| M | Countdown: add a minute while it runs or is paused. |
| N | Time log: add time by hand. |
| Left arrowRight arrow | In the Focus / Stopwatch / Countdown switch: choose the next or previous one. |
| Esc | Close the Abandon question (focus goes back to Abandon), a popover or the Add time dialog. |
- The window is a region named “RChursin_ app: Focus (sample workspace)”. Each timer is a section named Focus session, Stopwatch or Countdown; the hidden ones stay mounted, so they keep counting and announcing.
- Shortcuts are scoped: 1, 2 and 3 work while focus is in the timer area (not the window’s toolbar), F, L, R, M and N inside their own timer or log, never while you type, and never inside a popover or dialog opened from there.
- The switch is a radio group. A running timer is marked with a small solid square, and “running” is added to its name for screen readers.
- The clocks are role="timer", which screen readers don’t read on their own; they read the time in words when asked (“24 minutes 59 seconds left”). Starting, pausing, the 5 and 1 minute marks and the end are announced once each, the end assertively.
- Results such as “Focus logged” and “Sample data reset” arrive as toasts; logging offers Undo.
- Nothing starts by itself: the next phase waits unless “Start the next phase by itself” is turned on, and anything running can be paused (WCAG 2.2.2). The chime is off by default and can only play after a click.
Guidelines
Do
- Put one FocusProvider high enough to cover every view of the session (the Focus screen, a dashboard widget, the log).
- Give it room: its columns follow its own width (a container query), so the log sits beside the timer from 752 pixels of app width and under it below that, in a side column or on a phone alike.
- When you connect a backend, keep the provider’s timestamps (anchor, banked time, deadline) and save those, not a running total.
Don’t
- Don’t put two TimerApps on a page: the view, stopwatch and countdown are saved under fixed keys (“timer:view”, “timer:stopwatch”, “timer:countdown”), so they would share one run.
- Don’t give two providers on one page the same storagePrefix: both would log the same phase and announce it twice.
- Don’t ship the sample badge and “Reset sample data” to real users; they are for demos.
In use
Related
- Focus sessionA Pomodoro session: choose a task, say what you’ll finish, then focus and break in phases, with overtime and an inline Abandon.
- Time logWhat was focused on, by day with daily totals and a seven-day chart, with time added by hand and Undo on delete.
- Focus widgetThe focus session for a dashboard: choose a task, start or pause, see the time left in the square and today’s total.
- StopwatchA stopwatch with laps: a square that fills once a minute, hundredths drawn per frame, and Undo after a reset.
- CountdownA countdown with presets or a typed length, +1 min while it runs, a closing square and an honest finished banner.
- Timer engineClocks that keep honest time: stopwatch and countdown hooks built on timestamps, one shared frame loop, and duration parsing.
- App windowThe RChursin_ app frame: a sidebar with the tools, a title row with a toolbar, and your tool in the body.
Source
The component’s own files, exactly as they run on this site.
/** * The Focus screen of the RChursin_ app: the focus session as the hero, the stopwatch and countdown one switch away, * and the time log beside it (below it on a phone). * The columns follow the room the app has (a container query), not the screen: the log moves beside the timer once * the app is 752px wide (47rem), which on a full page is the lg screen width (1024px) it always switched at. * * All three timers stay mounted while you switch (the hidden ones keep their deadlines, chimes and announcements); * a small square in the switch marks the ones that are running. * Keys (inside the app): 1 2 3 switch between Focus, Stopwatch and Countdown. */import { useCallback, useRef, useState, type ReactNode } from "react"import { Icon } from "../icons/Icon"import { useHotkeys } from "../lib/hotkeys"import { clearStored } from "../lib/storage"import { AppWindow } from "../shell/AppWindow"import { SampleBadge } from "../shell/layout"import type { KitSlug } from "../shell/pages"import { Button } from "../ui/button"import { Segmented } from "../ui/fields"import { useToast } from "../ui/toast"