Weekly chart
StableFocused time over the last seven days as square-topped bars to scale, with a real table underneath for screen readers.
The Timer’s weekly summary. It adds up focus and manual time per day (breaks are left out) for the seven days ending today, draws them from a zero baseline against a scale that tops out at the next whole hour, and labels today and the best day; the other values show on hover, or all at once while the chart has keyboard focus. A sentence underneath gives the gist.
Updated View source
A week of focus
import type { TimeEntry } from '@rchursin/product/data/sample'import { SampleBadge } from '@rchursin/product/shell/layout'import { WeeklyFocus } from '@rchursin/product/timer'
/** A fixed "now" (Tuesday, October 13, 2026, 5:30 PM), so the page prerenders the same chart every time */const now = new Date(2026, 9, 13, 17, 30)
/** One entry on October `day`, from "HH:MM" to "HH:MM" */function entry(id: string, label: string, day: number, from: string, to: string, kind: TimeEntry['kind'] = 'focus'): TimeEntry { const time = (hm: string) => new Date(2026, 9, day, Number(hm.slice(0, 2)), Number(hm.slice(3))).getTime() return { id, taskId: null, label, start: time(from), end: time(to), kind }}
const log: TimeEntry[] = [ entry('a', 'Review pr-review prompt cost', 7, '13:00', '13:45', 'manual'), entry('b', 'Review pr-review prompt cost', 7, '16:10', '16:35'), entry('c', 'Cut api-docs skill', 8, '14:00', '14:25'), entry('d', 'Write the skill naming guide', 9, '09:30', '09:55'), entry('e', 'Break', 9, '09:55', '10:00', 'break'), entry('f', 'Write the skill naming guide', 9, '10:00', '10:25'), entry('g', 'Write the skill naming guide', 9, '10:30', '10:55'), entry('h', 'Write the skill naming guide', 9, '11:00', '11:25'), entry('i', 'Release-notes agent tone', 12, '10:00', '10:50'), entry('j', 'Weekly skill review', 12, '16:00', '16:25', 'manual'), entry('k', 'Fix test-fixer', 13, '09:05', '09:30'), entry('l', 'Break', 13, '09:30', '09:35', 'break'), entry('m', 'Fix test-fixer', 13, '09:35', '10:00'),]
/** Seven days to scale from a zero baseline. Breaks are left out; today and the best day are labelled. */export default function WeeklyChart() { return ( <div className="flex w-full max-w-md flex-col gap-2"> <div className="flex justify-end"> <SampleBadge /> </div> <WeeklyFocus entries={log} now={now} /> </div> )}Usage
import { WeeklyFocus } from '@rchursin/product/timer'<WeeklyFocus entries={timeLog} />Examples
Nothing logged
Seven empty days on a one-hour scale, and a sentence that says so.
import { WeeklyFocus } from '@rchursin/product/timer'
/** With nothing logged, the scale stays at one hour, every bar sits at zero, and the sentence says so. */export default function WeeklyChartEmpty() { return <WeeklyFocus entries={[]} now={new Date(2026, 9, 13, 9, 0)} className="w-full max-w-md" />}API
WeeklyFocus props
| Prop | Type | Default | Description |
|---|---|---|---|
entriesrequired | TimeEntry[] | – | Time log entries ({ id, taskId, label, start, end, kind }, times in epoch ms). Entries of kind break are ignored; each counts on the day it started. |
now | Date | new Date() | Which day is “today”. Pass a fixed date for prerendered pages and tests. |
className | string | – | Extra classes on the <figure>. |
lastSevenDays(entries, now?)
| Prop | Type | Default | Description |
|---|---|---|---|
entriesrequired | TimeEntry[] | – | The same log the chart takes. |
now | Date | new Date() | The last day of the seven. |
returns | DayTotal[] | – | Seven { date, ms } totals, oldest first, breaks left out. The chart’s numbers, for your own summary. |
Accessibility
Keyboard
| Key | What it does |
|---|---|
| Tab | Focus the chart: every day’s value shows above its bar until focus moves on. |
- Underneath it is a table (explicit role="table" rows and cells, since the layout is flex and grid): a hidden header row (“Day”, “Focused”), one row per day with the full date as the row header (“Tue, Oct 13, today”) and the time in words (“1 hour 40 minutes”, or “none”).
- The table is named by the title (“Last 7 days”) and described by the summary sentence, so the gist comes first.
- Bars and the scale are aria-hidden; nothing is said twice.
- Values for days other than today and the best day appear on hover. The chart is one Tab stop: while it has keyboard focus (with a focus ring) every value shows, so nothing is hover-only.
- Today’s bar is solid ink and the others are 70%, so today stands out by weight, and it is labelled “Today” in words as well.
Guidelines
Do
- Pass a fixed now on prerendered pages.
- Keep the title as what is plotted; there is one series, so there is no legend.
Don’t
- Don’t use it for more than one series; it is built for one.
- Don’t feed it breaks expecting them to count; they are left out on purpose.
In use
Related
- Time logWhat was focused on, by day with daily totals and a seven-day chart, with time added by hand and Undo on delete.
- TimerThe Focus screen: a focus session, a stopwatch and a countdown one switch apart, with the time log beside them.
- Focus sessionA Pomodoro session: choose a task, say what you’ll finish, then focus and break in phases, with overtime and an inline Abandon.
- Laps tableThe Stopwatch’s table on its own: lap, lap time and total, newest first, with the fastest and slowest named.
Source
The component’s own files, exactly as they run on this site.
/** * Focused time over the last seven days, as square-topped bars drawn to scale. * * It is a real table underneath (explicit ARIA table roles, because the table is laid out as a chart with flex and * grid, which can strip native table semantics in some browsers): one row per day, the day as the row header and the * time as the cell. Sighted readers get a sentence under it with the total, the best day and today. * One series, so no legend: the title says what is plotted. Today and the best day are labelled; the others show * their value on hover, and all of them while the chart has keyboard focus (it is one Tab stop). Every value is in * the table. */import { useId } from "react"import { cx } from "../../components/cx"import type { TimeEntry } from "../data/sample"import { HOUR, addDays, dtf, formatDay, formatDuration, isSameDay, startOfDay } from "../lib/dates"
export interface DayTotal { date: Date ms: number}