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
|
// @vitest-environment jsdom
|
||||||
import { describe, it, expect, afterEach } from "vitest";
|
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";
|
import { WeeklyDistanceChart } from "./WeeklyDistanceChart.tsx";
|
||||||
|
|
||||||
afterEach(cleanup);
|
afterEach(cleanup);
|
||||||
|
|
@ -14,17 +14,28 @@ describe("WeeklyDistanceChart", () => {
|
||||||
expect(container.firstChild).toBeNull();
|
expect(container.firstChild).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("renders one bar per week, including zero weeks (contiguous axis)", () => {
|
it("renders the chart with a bar only for non-zero weeks", () => {
|
||||||
const { container } = render(<WeeklyDistanceChart weeks={weeks([1000, 0, 2000, 0])} />);
|
const { container } = render(<WeeklyDistanceChart weeks={weeks([5000, 0, 2500, 0])} />);
|
||||||
const bars = container.querySelectorAll("div[style]");
|
expect(container.querySelector("svg")).not.toBeNull();
|
||||||
expect(bars).toHaveLength(4);
|
// 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", () => {
|
it("tops the y-scale at the busiest week", () => {
|
||||||
const { container } = render(<WeeklyDistanceChart weeks={weeks([5000, 10000, 2500])} />);
|
const { getByText } = render(<WeeklyDistanceChart weeks={weeks([5000, 10000, 2500])} />);
|
||||||
const heights = [...container.querySelectorAll("div[style]")].map(
|
// peak gridline label = 10 km (>= 10 → integer)
|
||||||
(b) => (b as HTMLElement).style.height,
|
expect(getByText("10 km")).toBeTruthy();
|
||||||
);
|
// half gridline
|
||||||
expect(heights).toEqual(["50%", "100%", "25%"]);
|
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 { useTranslation } from "react-i18next";
|
||||||
import { formatDistanceKm } from "~/lib/stats";
|
import { formatDistanceKm } from "~/lib/stats";
|
||||||
|
|
||||||
|
|
@ -6,11 +7,25 @@ export interface WeeklyDistanceBucket {
|
||||||
distance: number;
|
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 →
|
* Weekly-distance bar chart for the profile (last N weeks, oldest → newest).
|
||||||
* newest). Bars are normalized to the busiest week; empty weeks keep their slot
|
* Gridlines + a y-scale topped at the busiest week, faint per-week tracks so
|
||||||
* as a zero-height bar so the axis stays contiguous. Renders nothing when there
|
* the 12-week axis is always visible (empty weeks read as gaps, not nothing),
|
||||||
* is no distance in the window.
|
* and a hover readout naming the week + its distance. Hidden when there is no
|
||||||
|
* distance in the window.
|
||||||
*/
|
*/
|
||||||
export function WeeklyDistanceChart({
|
export function WeeklyDistanceChart({
|
||||||
weeks,
|
weeks,
|
||||||
|
|
@ -19,22 +34,102 @@ export function WeeklyDistanceChart({
|
||||||
weeks: WeeklyDistanceBucket[];
|
weeks: WeeklyDistanceBucket[];
|
||||||
className?: string;
|
className?: string;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation("journal");
|
const { t, i18n } = useTranslation("journal");
|
||||||
const max = weeks.reduce((m, w) => Math.max(m, w.distance), 0);
|
const [hover, setHover] = useState<number | null>(null);
|
||||||
if (max <= 0) return 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 (
|
return (
|
||||||
<div className={className}>
|
<div className={className}>
|
||||||
<p className="mb-1 text-xs text-gray-500">{t("profileStats.weeklyDistance")}</p>
|
<div className="mb-1 flex items-baseline justify-between text-xs text-gray-500">
|
||||||
<div className="flex h-16 items-end gap-1" role="img" aria-label={t("profileStats.weeklyDistance")}>
|
<span>{t("profileStats.weeklyDistance")}</span>
|
||||||
{weeks.map((w) => (
|
{active && (
|
||||||
<div
|
<span className="tabular-nums text-gray-700">
|
||||||
key={w.weekStart}
|
{t("profileStats.weekOf", { date: fmtWeek(active.weekStart) })} ·{" "}
|
||||||
className="flex-1 rounded-t bg-blue-500/70"
|
<span className="font-semibold">{formatDistanceKm(active.distance)}</span>
|
||||||
style={{ height: `${(w.distance / max) * 100}%` }}
|
</span>
|
||||||
title={formatDistanceKm(w.distance)}
|
)}
|
||||||
/>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -416,6 +416,7 @@ export default {
|
||||||
time: "Zeit",
|
time: "Zeit",
|
||||||
last4Weeks: "{{count}} in den letzten 4 Wochen",
|
last4Weeks: "{{count}} in den letzten 4 Wochen",
|
||||||
weeklyDistance: "Wochendistanz (letzte 12 Wochen)",
|
weeklyDistance: "Wochendistanz (letzte 12 Wochen)",
|
||||||
|
weekOf: "Woche vom {{date}}",
|
||||||
},
|
},
|
||||||
settings: {
|
settings: {
|
||||||
title: "Einstellungen",
|
title: "Einstellungen",
|
||||||
|
|
|
||||||
|
|
@ -416,6 +416,7 @@ export default {
|
||||||
time: "Time",
|
time: "Time",
|
||||||
last4Weeks: "{{count}} in the last 4 weeks",
|
last4Weeks: "{{count}} in the last 4 weeks",
|
||||||
weeklyDistance: "Weekly distance (last 12 weeks)",
|
weeklyDistance: "Weekly distance (last 12 weeks)",
|
||||||
|
weekOf: "Week of {{date}}",
|
||||||
},
|
},
|
||||||
settings: {
|
settings: {
|
||||||
title: "Settings",
|
title: "Settings",
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue