From 45891da6568b7ef212421ced90700b975e3d41de Mon Sep 17 00:00:00 2001 From: minhtrannhat Date: Sun, 8 Nov 2026 15:18:52 -0500 Subject: [PATCH] Fix the time trend chart for all ranges All bars now share one baseline, because each column has a label row of the same height. Before, only columns with a day number had a label row, so their bars sat higher. Only the peak bar has a value label, so labels no longer overlap in a 31-day month. A readout line above the chart shows the date and value of the bar that the user hovers, taps or focuses. Day numbers are no longer cut, a period with no time has no bar, the axis line sits at the bar baseline, and bars are 24px wide or less. Co-Authored-By: Claude Opus 5.5 --- src/client/analytics.ts | 13 +++++---- src/client/main.ts | 23 +++++++++++----- src/client/styles.css | 23 ++++++++++------ src/client/trend-chart.ts | 28 ++++++++++++++++++++ tests/trend-chart.test.ts | 56 +++++++++++++++++++++++++++++++++++++++ 5 files changed, 124 insertions(+), 19 deletions(-) create mode 100644 src/client/trend-chart.ts create mode 100644 tests/trend-chart.test.ts diff --git a/src/client/analytics.ts b/src/client/analytics.ts index 011bece..99061dd 100644 --- a/src/client/analytics.ts +++ b/src/client/analytics.ts @@ -1,7 +1,10 @@ import { recordEnd, type TimeRecord } from "../shared/record.ts"; export type RangeKind = "day" | "week" | "month" | "year"; -export type Window = { start: number; end: number; label: string; bins: { start: number; end: number; label: string }[] }; +export type Window = { start: number; end: number; label: string; bins: { start: number; end: number; label: string; name: string }[] }; + +const dayName = (date: Date) => date.toLocaleDateString(undefined, { weekday: "short", month: "short", day: "numeric" }); +const hh = (hour: number) => `${String(hour).padStart(2, "0")}:00`; export function overlapMs(record: TimeRecord, start: number, end: number, now = Date.now()): number { if (record.deleted) return 0; @@ -24,7 +27,7 @@ export function rangeWindow(kind: RangeKind, offset = 0, base = new Date()): Win for (let hour = 0; hour < 24; hour += 2) { const a = new Date(startDate); a.setHours(hour); const b = new Date(startDate); b.setHours(hour + 2); - bins.push({ start: +a, end: +b, label: String(hour).padStart(2, "0") }); + bins.push({ start: +a, end: +b, label: String(hour).padStart(2, "0"), name: `${hh(hour)}–${hh(hour + 2)}` }); } } else if (kind === "week") { startDate = dayStart(base); @@ -33,7 +36,7 @@ export function rangeWindow(kind: RangeKind, offset = 0, base = new Date()): Win for (let day = 0; day < 7; day++) { const a = new Date(startDate); a.setDate(a.getDate() + day); const b = new Date(a); b.setDate(b.getDate() + 1); - bins.push({ start: +a, end: +b, label: a.toLocaleDateString(undefined, { weekday: "short" }).slice(0, 2) }); + bins.push({ start: +a, end: +b, label: a.toLocaleDateString(undefined, { weekday: "short" }).slice(0, 2), name: dayName(a) }); } } else if (kind === "month") { startDate = new Date(base.getFullYear(), base.getMonth() + offset, 1); @@ -41,7 +44,7 @@ export function rangeWindow(kind: RangeKind, offset = 0, base = new Date()): Win for (let day = 1; day <= new Date(startDate.getFullYear(), startDate.getMonth() + 1, 0).getDate(); day++) { const a = new Date(startDate.getFullYear(), startDate.getMonth(), day); const b = new Date(startDate.getFullYear(), startDate.getMonth(), day + 1); - bins.push({ start: +a, end: +b, label: day % 5 === 1 ? String(day) : "" }); + bins.push({ start: +a, end: +b, label: day % 5 === 1 ? String(day) : "", name: dayName(a) }); } } else { startDate = new Date(base.getFullYear() + offset, 0, 1); @@ -49,7 +52,7 @@ export function rangeWindow(kind: RangeKind, offset = 0, base = new Date()): Win for (let month = 0; month < 12; month++) { const a = new Date(startDate.getFullYear(), month, 1); const b = new Date(startDate.getFullYear(), month + 1, 1); - bins.push({ start: +a, end: +b, label: a.toLocaleDateString(undefined, { month: "short" }).slice(0, 1) }); + bins.push({ start: +a, end: +b, label: a.toLocaleDateString(undefined, { month: "short" }).slice(0, 1), name: a.toLocaleDateString(undefined, { month: "long", year: "numeric" }) }); } } const label = kind === "day" ? startDate.toLocaleDateString(undefined, { month: "short", day: "numeric", year: "numeric" }) diff --git a/src/client/main.ts b/src/client/main.ts index 83684a3..5847671 100644 --- a/src/client/main.ts +++ b/src/client/main.ts @@ -7,6 +7,7 @@ import { normalizeTag } from "../shared/tag.ts"; import { countdown, pad, pageTitle } from "./title.ts"; import { readyToReload } from "./update.ts"; import { recentTags } from "./recent-tags.ts"; +import { trendChart } from "./trend-chart.ts"; import { THEMES, THEME_COLORS, isTheme, type Theme } from "./theme.ts"; type View = "timer" | "log" | "insights" | "settings"; @@ -211,7 +212,6 @@ function logPage() { function insightsPage() { const range = rangeWindow(insightKind, insightOffset); const stats = totals(records, range); - const max = Math.max(...stats.trend, 1); const maxTag = stats.tags[0]?.[1] || 1; return `
${pageHeading("Where time goes.")}
${(["day", "week", "month", "year"] as RangeKind[]).map(kind => ``).join("")}
@@ -219,11 +219,7 @@ function insightsPage() { ${h(range.label.toUpperCase())}
// TIME TREND${h(insightKind.toUpperCase())}
${duration(stats.totalMs)}
${stats.sessionCount} ${stats.sessionCount === 1 ? "session" : "sessions"} in this period
- + ${trendChart(range.bins, stats.trend, duration)}
// BY TAG${stats.tags.length} CATEGORIES
${stats.tags.length ? stats.tags.map(([tag, value]) => `
${h(tag)}${duration(value)} · ${Math.round(value / stats.totalMs * 100)}%
`).join("") : `
No time logged in this period.
`}

Stopped-early sessions count for the time actually spent. Sessions crossing midnight are split between days.

@@ -486,6 +482,21 @@ app.addEventListener("input", event => { }); }); +// The trend readout shows the bar under the pointer or the keyboard focus. +function showTrendReadout(event: Event) { + const readout = document.getElementById("trend-readout"); + if (!readout) return; + const column = (event.target as HTMLElement).closest(".trend-col"); + readout.textContent = column?.dataset.readout ?? readout.dataset.default ?? ""; +} +app.addEventListener("pointerover", showTrendReadout); +app.addEventListener("focusin", showTrendReadout); +app.addEventListener("pointerout", event => { + const chart = (event.target as HTMLElement).closest(".trend-chart"); + const readout = document.getElementById("trend-readout"); + if (chart && readout && !chart.contains(event.relatedTarget as Node | null)) readout.textContent = readout.dataset.default ?? ""; +}); + window.addEventListener("online", () => { syncStatus = "pending"; sync(); }); window.addEventListener("offline", () => { syncStatus = "offline"; render(); }); document.addEventListener("visibilitychange", () => { diff --git a/src/client/styles.css b/src/client/styles.css index b534948..b305a9c 100644 --- a/src/client/styles.css +++ b/src/client/styles.css @@ -173,13 +173,21 @@ select.field { appearance: none; background-image: linear-gradient(45deg, transp .range-label { color: var(--muted); font-size: 11px; min-width: 115px; text-align: center; } .insight-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 20px; } .insight-big { font-size: clamp(36px, 5vw, 60px); letter-spacing: -.09em; line-height: 1; margin: 24px 0 10px; } -.trend-chart { height: 220px; display: flex; gap: 8px; align-items: stretch; margin-top: 28px; } -.trend-col { flex: 1; min-width: 0; display: flex; flex-direction: column; justify-content: end; align-items: center; gap: 7px; } -.trend-value { color: var(--muted); font-size: 9px; white-space: nowrap; } -.trend-track { height: 170px; width: 100%; display: flex; align-items: end; border-bottom: 1px solid var(--line-bright); } -.trend-fill { width: 100%; min-height: 2px; background: var(--muted); } +.trend { margin-top: 28px; } +.trend-readout { color: var(--muted); font-size: 11px; min-height: 16px; margin-bottom: 12px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.trend-chart { position: relative; display: flex; gap: 2px; } +/* The axis line sits at the bar baseline, above the label row (22px). */ +.trend-chart::after { content: ""; position: absolute; left: 0; right: 0; bottom: 22px; border-top: 1px solid var(--line-bright); pointer-events: none; } +/* Each column always has a label row of the same height, so all bars share one baseline. */ +.trend-col { flex: 1; min-width: 0; display: flex; flex-direction: column; align-items: center; cursor: default; outline: none; } +.trend-track { height: 186px; width: 100%; padding-top: 18px; display: flex; align-items: end; justify-content: center; } +.trend-fill { position: relative; width: 100%; max-width: 24px; min-height: 2px; background: var(--muted); } .trend-col.current .trend-fill { background: var(--accent); } -.trend-label { color: var(--faint); font-size: 10px; overflow: hidden; white-space: nowrap; max-width: 100%; } +.trend-col:hover .trend-fill, .trend-col:focus-visible .trend-fill { background: var(--ink); } +.trend-col:focus-visible .trend-label { color: var(--ink); } +/* Only the peak bar has a value label. It sits on the bar cap and can extend past the narrow column. */ +.trend-value { position: absolute; bottom: calc(100% + 4px); left: 50%; transform: translateX(-50%); color: var(--muted); font-size: 10px; white-space: nowrap; } +.trend-label { order: 2; height: 22px; line-height: 22px; color: var(--faint); font-size: 10px; white-space: nowrap; } .tag-row { margin-top: 19px; } .tag-row-head { display: flex; justify-content: space-between; gap: 12px; font-size: 12px; margin-bottom: 6px; } .tag-row-head span:last-child { color: var(--muted); white-space: nowrap; } @@ -244,8 +252,7 @@ select.field { appearance: none; background-image: linear-gradient(45deg, transp .settings-row { align-items: start; flex-direction: column; gap: 10px; } .theme-toggle { width: 100%; } .theme-option { flex: 1; } - .trend-chart { gap: 4px; } - .trend-value { font-size: 8px; } + .trend-chart { gap: 1px; } .trend-label { font-size: 9px; } .range-controls { display: grid; grid-template-columns: 40px minmax(0, 1fr) 40px; gap: 10px; } .range-controls .segmented { grid-column: 1 / -1; display: flex; } diff --git a/src/client/trend-chart.ts b/src/client/trend-chart.ts new file mode 100644 index 0000000..04db263 --- /dev/null +++ b/src/client/trend-chart.ts @@ -0,0 +1,28 @@ +import type { Window } from "./analytics.ts"; + +function escape(value: string): string { + return value.replace(/[&<>"']/g, char => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[char]!); +} + +/** + * The time trend bar chart. Only the peak bar has a value label, so labels never + * overlap in a 31-day month. The readout line shows the value of the bar that the + * user hovers, taps or focuses. + */ +export function trendChart(bins: Window["bins"], trend: number[], format: (ms: number) => string, now = Date.now()): string { + const max = Math.max(...trend, 0); + const peak = max > 0 ? trend.indexOf(max) : -1; + const readout = peak >= 0 ? `Peak · ${bins[peak]!.name} · ${format(max)}` : "No time logged in this period."; + const columns = bins.map((bin, index) => { + const value = trend[index] ?? 0; + const current = now >= bin.start && now < bin.end; + const text = `${bin.name} · ${format(value)}`; + const fill = value > 0 + ? `
${index === peak ? `${escape(format(value))}` : ""}
` + : ""; + return `
` + + `
${fill}
${escape(bin.label)}
`; + }).join(""); + return `
${escape(readout)}
` + + `
${columns}
`; +} diff --git a/tests/trend-chart.test.ts b/tests/trend-chart.test.ts new file mode 100644 index 0000000..f927d7a --- /dev/null +++ b/tests/trend-chart.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, test } from "bun:test"; +import { rangeWindow, type RangeKind } from "../src/client/analytics.ts"; +import { trendChart } from "../src/client/trend-chart.ts"; + +const HOUR = 3_600_000; +const format = (ms: number) => `${ms / HOUR}h`; +const count = (html: string, text: string) => html.split(text).length - 1; +const base = new Date(2026, 10, 8, 12); + +describe("trend chart", () => { + for (const kind of ["day", "week", "month", "year"] as RangeKind[]) { + test(`${kind}: every column has a label row, so all bars share one baseline`, () => { + const range = rangeWindow(kind, 0, base); + const html = trendChart(range.bins, range.bins.map(() => 0), format, +base); + expect(count(html, 'class="trend-label"')).toBe(range.bins.length); + }); + + test(`${kind}: every bin has a full name for the readout`, () => { + for (const bin of rangeWindow(kind, 0, base).bins) expect(bin.name.length).toBeGreaterThan(1); + }); + } + + test("only the peak bar has a value label", () => { + const { bins } = rangeWindow("month", 0, base); + const trend = bins.map((_, i) => (i === 3 ? 3 * HOUR : i < 8 ? HOUR : 0)); + const html = trendChart(bins, trend, format, +base); + expect(count(html, 'class="trend-value"')).toBe(1); + expect(html).toContain('3h'); + }); + + test("a bin with no time has no bar", () => { + const { bins } = rangeWindow("week", 0, base); + const trend = bins.map((_, i) => (i === 0 ? HOUR : 0)); + expect(count(trendChart(bins, trend, format, +base), 'class="trend-fill"')).toBe(1); + }); + + test("each column gives its date and value to the readout", () => { + const { bins } = rangeWindow("month", 0, base); + const html = trendChart(bins, bins.map((_, i) => (i === 5 ? 2 * HOUR : 0)), format, +base); + expect(html).toContain(`data-readout="${bins[5]!.name} · 2h"`); + expect(html).toContain(`data-readout="${bins[6]!.name} · 0h"`); + }); + + test("the readout starts on the peak, or says the period is empty", () => { + const { bins } = rangeWindow("year", 0, base); + const html = trendChart(bins, bins.map((_, i) => (i === 10 ? 5 * HOUR : 0)), format, +base); + expect(html).toContain(`>Peak · ${bins[10]!.name} · 5h<`); + expect(trendChart(bins, bins.map(() => 0), format, +base)).toContain(">No time logged in this period.<"); + }); + + test("the current bin is marked", () => { + const { bins } = rangeWindow("week", 0, base); + const html = trendChart(bins, bins.map(() => 0), format, +base); + expect(count(html, "trend-col current")).toBe(1); + }); +});