From 4754c229b918be8acf7111ba1d5a6d91fce445ce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ullrich=20Sch=C3=A4fer?= Date: Sat, 13 Jun 2026 08:50:01 +0200 Subject: [PATCH] profile-weekly-distance: make the chart legible (axis, grid, tracks, hover) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses feedback that a lone bar conveyed nothing. Rewrite WeeklyDistanceChart as a proper SVG chart: - y-scale topped at the busiest week with 0 / half / peak gridlines + km labels; - faint per-week track columns so the 12-week axis is always visible (empty weeks read as gaps, not nothing — no more floating bar); - oldest→newest date bounds on the x-axis; - a hover readout naming the week + its distance ("Week of {{date}} · X km"). i18n adds profileStats.weekOf. Component test updated for the SVG structure (bar per non-zero week, peak-topped scale, hover readout). typecheck + lint + unit (journal 322) green; verified in the browser. Co-Authored-By: Claude Opus 4.8 --- .../components/WeeklyDistanceChart.test.tsx | 33 +++-- .../app/components/WeeklyDistanceChart.tsx | 129 +++++++++++++++--- packages/i18n/src/locales/de.ts | 1 + packages/i18n/src/locales/en.ts | 1 + 4 files changed, 136 insertions(+), 28 deletions(-) diff --git a/apps/journal/app/components/WeeklyDistanceChart.test.tsx b/apps/journal/app/components/WeeklyDistanceChart.test.tsx index c0d8b43..9ae069c 100644 --- a/apps/journal/app/components/WeeklyDistanceChart.test.tsx +++ b/apps/journal/app/components/WeeklyDistanceChart.test.tsx @@ -1,6 +1,6 @@ // @vitest-environment jsdom import { describe, it, expect, afterEach } from "vitest"; -import { render, cleanup } from "@testing-library/react"; +import { render, cleanup, fireEvent } from "@testing-library/react"; import { WeeklyDistanceChart } from "./WeeklyDistanceChart.tsx"; afterEach(cleanup); @@ -14,17 +14,28 @@ describe("WeeklyDistanceChart", () => { expect(container.firstChild).toBeNull(); }); - it("renders one bar per week, including zero weeks (contiguous axis)", () => { - const { container } = render(); - const bars = container.querySelectorAll("div[style]"); - expect(bars).toHaveLength(4); + it("renders the chart with a bar only for non-zero weeks", () => { + const { container } = render(); + expect(container.querySelector("svg")).not.toBeNull(); + // bars only for the two non-zero weeks; empty weeks keep their slot via the track rect + expect(container.querySelectorAll("[data-week-bar]")).toHaveLength(2); }); - it("normalizes bar heights to the busiest week", () => { - const { container } = render(); - const heights = [...container.querySelectorAll("div[style]")].map( - (b) => (b as HTMLElement).style.height, - ); - expect(heights).toEqual(["50%", "100%", "25%"]); + it("tops the y-scale at the busiest week", () => { + const { getByText } = render(); + // peak gridline label = 10 km (>= 10 → integer) + expect(getByText("10 km")).toBeTruthy(); + // half gridline + expect(getByText("5.0 km")).toBeTruthy(); + }); + + it("shows a hover readout with the week's distance", () => { + // max 8 km → axis labels are 8.0 / 4.0 / 0 km; "3.0 km" is unique to the + // readout for week 0, so it only appears once that week is hovered. + const { container, queryByText } = render(); + expect(queryByText("3.0 km")).toBeNull(); + const hit = container.querySelectorAll("rect[fill='transparent']")[0]!; + fireEvent.mouseEnter(hit); + expect(queryByText("3.0 km")).not.toBeNull(); }); }); diff --git a/apps/journal/app/components/WeeklyDistanceChart.tsx b/apps/journal/app/components/WeeklyDistanceChart.tsx index a81385a..cfeec61 100644 --- a/apps/journal/app/components/WeeklyDistanceChart.tsx +++ b/apps/journal/app/components/WeeklyDistanceChart.tsx @@ -1,3 +1,4 @@ +import { useState } from "react"; import { useTranslation } from "react-i18next"; import { formatDistanceKm } from "~/lib/stats"; @@ -6,11 +7,25 @@ export interface WeeklyDistanceBucket { distance: number; } +// SVG layout (user units; rendered responsive via viewBox). +const W = 480; +const H = 132; +const PAD = { top: 10, right: 6, bottom: 18, left: 36 }; +const PLOT_W = W - PAD.left - PAD.right; +const PLOT_H = H - PAD.top - PAD.bottom; +const BASE_Y = PAD.top + PLOT_H; + +function axisLabel(km: number): string { + if (km <= 0) return "0"; + return km < 10 ? `${km.toFixed(1)} km` : `${Math.round(km)} km`; +} + /** - * Compact weekly-distance bar chart for the profile (last N weeks, oldest → - * newest). Bars are normalized to the busiest week; empty weeks keep their slot - * as a zero-height bar so the axis stays contiguous. Renders nothing when there - * is no distance in the window. + * Weekly-distance bar chart for the profile (last N weeks, oldest → newest). + * Gridlines + a y-scale topped at the busiest week, faint per-week tracks so + * the 12-week axis is always visible (empty weeks read as gaps, not nothing), + * and a hover readout naming the week + its distance. Hidden when there is no + * distance in the window. */ export function WeeklyDistanceChart({ weeks, @@ -19,22 +34,102 @@ export function WeeklyDistanceChart({ weeks: WeeklyDistanceBucket[]; className?: string; }) { - const { t } = useTranslation("journal"); - const max = weeks.reduce((m, w) => Math.max(m, w.distance), 0); - if (max <= 0) return null; + const { t, i18n } = useTranslation("journal"); + const [hover, setHover] = useState(null); + + const maxM = weeks.reduce((m, w) => Math.max(m, w.distance), 0); + if (maxM <= 0) return null; + + const maxKm = maxM / 1000; + const n = weeks.length; + const colW = PLOT_W / n; + const barW = colW * 0.6; + const x = (i: number) => PAD.left + i * colW + (colW - barW) / 2; + const yFor = (m: number) => BASE_Y - (m / maxM) * PLOT_H; + + const fmtWeek = (iso: string) => + new Date(`${iso}T00:00:00`).toLocaleDateString(i18n.language, { month: "short", day: "numeric" }); + + const gridFracs = [0, 0.5, 1]; + const active = hover != null ? weeks[hover] : null; + const first = weeks[0]; + const last = weeks[n - 1]; + const firstLabel = first ? fmtWeek(first.weekStart) : ""; + const lastLabel = last ? fmtWeek(last.weekStart) : ""; return (
-

{t("profileStats.weeklyDistance")}

-
- {weeks.map((w) => ( -
- ))} +
+ {t("profileStats.weeklyDistance")} + {active && ( + + {t("profileStats.weekOf", { date: fmtWeek(active.weekStart) })} ·{" "} + {formatDistanceKm(active.distance)} + + )} +
+ setHover(null)} + > + {/* gridlines + y-axis labels (0 · half · peak) */} + {gridFracs.map((f) => { + const yy = BASE_Y - f * PLOT_H; + return ( + + + + {axisLabel(maxKm * f)} + + + ); + })} + {weeks.map((w, i) => { + const isActive = hover === i; + return ( + + {/* faint week-slot track so empty weeks stay visible */} + + {/* distance bar */} + {w.distance > 0 && ( + + )} + {/* full-height hover hit area */} + setHover(i)} + > + {`${fmtWeek(w.weekStart)} · ${formatDistanceKm(w.distance)}`} + + + ); + })} + +
+ {firstLabel} + {lastLabel}
); diff --git a/packages/i18n/src/locales/de.ts b/packages/i18n/src/locales/de.ts index edc4a05..f2200fd 100644 --- a/packages/i18n/src/locales/de.ts +++ b/packages/i18n/src/locales/de.ts @@ -416,6 +416,7 @@ export default { time: "Zeit", last4Weeks: "{{count}} in den letzten 4 Wochen", weeklyDistance: "Wochendistanz (letzte 12 Wochen)", + weekOf: "Woche vom {{date}}", }, settings: { title: "Einstellungen", diff --git a/packages/i18n/src/locales/en.ts b/packages/i18n/src/locales/en.ts index aec464b..16d3eb5 100644 --- a/packages/i18n/src/locales/en.ts +++ b/packages/i18n/src/locales/en.ts @@ -416,6 +416,7 @@ export default { time: "Time", last4Weeks: "{{count}} in the last 4 weeks", weeklyDistance: "Weekly distance (last 12 weeks)", + weekOf: "Week of {{date}}", }, settings: { title: "Settings",