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 <[email protected]>
This commit is contained in:
1 parent
52e8948d73
commit
45891da656
5 files changed
+124
-19
No files matched your search
@@ -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" })
|
||||
|
||||
+17
-6
@@ -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 `<section class="page">${pageHeading("Where time goes.")}
|
||||
<div class="toolbar range-controls"><div class="segmented" role="group" aria-label="Insight range">${(["day", "week", "month", "year"] as RangeKind[]).map(kind => `<button class="segment ${insightKind === kind ? "active" : ""}" data-action="range" data-range="${kind}" type="button">${kind}</button>`).join("")}</div>
|
||||
@@ -219,11 +219,7 @@ function insightsPage() {
|
||||
<span class="range-label">${h(range.label.toUpperCase())}</span><button class="small-btn" data-action="next-insight" type="button" aria-label="Next period">→</button></div>
|
||||
<div class="insight-grid"><div class="panel"><div class="panel-inner"><div class="panel-label"><span>// TIME TREND</span><span class="end">${h(insightKind.toUpperCase())}</span></div>
|
||||
<div class="insight-big">${duration(stats.totalMs)}</div><div class="muted" style="font-size:11px">${stats.sessionCount} ${stats.sessionCount === 1 ? "session" : "sessions"} in this period</div>
|
||||
<div class="trend-chart" role="img" aria-label="Logged time trend for ${h(range.label)}">${range.bins.map((bin, index) => {
|
||||
const value = stats.trend[index]!;
|
||||
const isCurrent = Date.now() >= bin.start && Date.now() < bin.end;
|
||||
return `<div class="trend-col ${isCurrent ? "current" : ""}" title="${h(bin.label)}: ${duration(value)}"><span class="trend-value">${value ? duration(value) : ""}</span><div class="trend-track"><div class="trend-fill" style="height:${Math.max(2, value / max * 100)}%"></div></div><span class="trend-label">${h(bin.label)}</span></div>`;
|
||||
}).join("")}</div>
|
||||
${trendChart(range.bins, stats.trend, duration)}
|
||||
</div></div><div class="panel"><div class="panel-inner"><div class="panel-label"><span>// BY TAG</span><span class="end">${stats.tags.length} CATEGORIES</span></div>
|
||||
${stats.tags.length ? stats.tags.map(([tag, value]) => `<div class="tag-row"><div class="tag-row-head"><span>${h(tag)}</span><span>${duration(value)} · ${Math.round(value / stats.totalMs * 100)}%</span></div><div class="tag-track"><div class="tag-fill" style="width:${Math.max(1, value / maxTag * 100)}%"></div></div></div>`).join("") : `<div class="empty">No time logged in this period.</div>`}
|
||||
<p class="insight-note">Stopped-early sessions count for the time actually spent. Sessions crossing midnight are split between days.</p>
|
||||
@@ -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<HTMLElement>(".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", () => {
|
||||
|
||||
+15
-8
@@ -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; }
|
||||
|
||||
@@ -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
|
||||
? `<div class="trend-fill" style="height:${(value / max * 100).toFixed(2)}%">${index === peak ? `<span class="trend-value">${escape(format(value))}</span>` : ""}</div>`
|
||||
: "";
|
||||
return `<div class="trend-col${current ? " current" : ""}" role="listitem" tabindex="0" data-readout="${escape(text)}" aria-label="${escape(text)}">`
|
||||
+ `<div class="trend-track">${fill}</div><span class="trend-label">${escape(bin.label)}</span></div>`;
|
||||
}).join("");
|
||||
return `<div class="trend"><div class="trend-readout" id="trend-readout" data-default="${escape(readout)}" aria-live="polite">${escape(readout)}</div>`
|
||||
+ `<div class="trend-chart" role="list" aria-label="Logged time per period">${columns}</div></div>`;
|
||||
}
|
||||
Reference in new issue
Block a user