profile-weekly-distance: make the chart legible (axis, grid, tracks, hover)
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 <noreply@anthropic.com>
This commit is contained in:
parent
ceda9f1877
commit
4754c229b9
4 changed files with 136 additions and 28 deletions
|
|
@ -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(<WeeklyDistanceChart weeks={weeks([1000, 0, 2000, 0])} />);
|
||||
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(<WeeklyDistanceChart weeks={weeks([5000, 0, 2500, 0])} />);
|
||||
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(<WeeklyDistanceChart weeks={weeks([5000, 10000, 2500])} />);
|
||||
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(<WeeklyDistanceChart weeks={weeks([5000, 10000, 2500])} />);
|
||||
// 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(<WeeklyDistanceChart weeks={weeks([3000, 8000])} />);
|
||||
expect(queryByText("3.0 km")).toBeNull();
|
||||
const hit = container.querySelectorAll("rect[fill='transparent']")[0]!;
|
||||
fireEvent.mouseEnter(hit);
|
||||
expect(queryByText("3.0 km")).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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<number | null>(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 (
|
||||
<div className={className}>
|
||||
<p className="mb-1 text-xs text-gray-500">{t("profileStats.weeklyDistance")}</p>
|
||||
<div className="flex h-16 items-end gap-1" role="img" aria-label={t("profileStats.weeklyDistance")}>
|
||||
{weeks.map((w) => (
|
||||
<div
|
||||
key={w.weekStart}
|
||||
className="flex-1 rounded-t bg-blue-500/70"
|
||||
style={{ height: `${(w.distance / max) * 100}%` }}
|
||||
title={formatDistanceKm(w.distance)}
|
||||
<div className="mb-1 flex items-baseline justify-between text-xs text-gray-500">
|
||||
<span>{t("profileStats.weeklyDistance")}</span>
|
||||
{active && (
|
||||
<span className="tabular-nums text-gray-700">
|
||||
{t("profileStats.weekOf", { date: fmtWeek(active.weekStart) })} ·{" "}
|
||||
<span className="font-semibold">{formatDistanceKm(active.distance)}</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<svg
|
||||
viewBox={`0 0 ${W} ${H}`}
|
||||
className="h-28 w-full"
|
||||
role="img"
|
||||
aria-label={t("profileStats.weeklyDistance")}
|
||||
onMouseLeave={() => setHover(null)}
|
||||
>
|
||||
{/* gridlines + y-axis labels (0 · half · peak) */}
|
||||
{gridFracs.map((f) => {
|
||||
const yy = BASE_Y - f * PLOT_H;
|
||||
return (
|
||||
<g key={f}>
|
||||
<line x1={PAD.left} y1={yy} x2={W - PAD.right} y2={yy} stroke="#e5e7eb" strokeWidth="1" />
|
||||
<text x={PAD.left - 5} y={yy + 3} textAnchor="end" fontSize="9" fill="#9ca3af">
|
||||
{axisLabel(maxKm * f)}
|
||||
</text>
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
{weeks.map((w, i) => {
|
||||
const isActive = hover === i;
|
||||
return (
|
||||
<g key={w.weekStart}>
|
||||
{/* faint week-slot track so empty weeks stay visible */}
|
||||
<rect
|
||||
x={x(i)}
|
||||
y={PAD.top}
|
||||
width={barW}
|
||||
height={PLOT_H}
|
||||
rx="1.5"
|
||||
fill={isActive ? "#dbeafe" : "#f3f4f6"}
|
||||
/>
|
||||
))}
|
||||
{/* distance bar */}
|
||||
{w.distance > 0 && (
|
||||
<rect
|
||||
data-week-bar
|
||||
x={x(i)}
|
||||
y={yFor(w.distance)}
|
||||
width={barW}
|
||||
height={BASE_Y - yFor(w.distance)}
|
||||
rx="1.5"
|
||||
fill={isActive ? "#1d4ed8" : "#3b82f6"}
|
||||
/>
|
||||
)}
|
||||
{/* full-height hover hit area */}
|
||||
<rect
|
||||
x={PAD.left + i * colW}
|
||||
y={PAD.top}
|
||||
width={colW}
|
||||
height={PLOT_H}
|
||||
fill="transparent"
|
||||
onMouseEnter={() => setHover(i)}
|
||||
>
|
||||
<title>{`${fmtWeek(w.weekStart)} · ${formatDistanceKm(w.distance)}`}</title>
|
||||
</rect>
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
</svg>
|
||||
<div className="flex justify-between px-px text-[10px] text-gray-400">
|
||||
<span>{firstLabel}</span>
|
||||
<span>{lastLabel}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue