feat(mobile): Gewichtsverlauf als Diagramm

found.md: Gewichtsdaten wurden nur als Liste angezeigt. Neues WeightChart
(react-native-svg, Port des Web-Diagramms) ab 2 Eintraegen ueber der Liste:
x-Achse zeitproportional (unregelmaessige Wiegetermine), runde y-Ticks
(1/2/5 x 10^n), Datumslabels nach Pixelabstand, Antippen waehlt die
naechste Messung (Wert + Datum im Kopf), Veraenderung seit erster Messung.
Linienfarbe als neues Token chartLine (#ea580c), mit dem Dataviz-Validator
gegen helle und dunkle Flaechen geprueft. Geometrie rein in
src/lib/weight-chart.ts mit Tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 5.5 committed 2026-10-04 14:52:38 +02:00
1 parent 81e617ece5
commit e60abbabc6
7 files changed
+507 -13

No files matched your search

@@ -0,0 +1,202 @@
import { useMemo, useState } from "react";
import { View, Text, Pressable, StyleSheet, type LayoutChangeEvent } from "react-native";
import Svg, { Path, Line, Circle, Text as SvgText, Defs, LinearGradient, Stop } from "react-native-svg";
import { useTranslation } from "react-i18next";
import { useTheme } from "../../context/ThemeContext";
import { Card } from "../Card";
import { getTypography, fontFamily, type ColorTokens, type Typography } from "../../theme/tokens";
import { buildWeightChart, nearestPointIndex, type WeightEntry } from "../../lib/weight-chart";
const CHART_HEIGHT = 170;
const BOX = { left: 40, right: 12, top: 12, bottom: 26 };
const AXIS_FONT_SIZE = 10;
const MAX_LABELED_Y_TICKS = 5;
const MARKER_RADIUS = 4;
const SELECTED_RADIUS = 6;
const LONG_SPAN_MS = 300 * 24 * 60 * 60 * 1000;
/**
* Weight history (found.md: Gewichtsdaten als Diagramm) — port of web's
* src/components/health/WeightChart.tsx. Tap anywhere on the plot to select
* the nearest weigh-in; its value and date show in the header (no floating
* tooltip to collide with the edges on a phone). The newest entry is
* selected by default. The entry list below the chart is the table view.
*/
export function WeightChart({ entries }: { entries: WeightEntry[] }) {
const { t, i18n } = useTranslation();
const { colors } = useTheme();
const styles = useMemo(() => makeStyles(colors), [colors]);
const [width, setWidth] = useState(0);
const [selectedIndex, setSelectedIndex] = useState<number | null>(null);
const chart = useMemo(
() => (width > 0 ? buildWeightChart(entries, { width, height: CHART_HEIGHT, ...BOX }) : null),
[entries, width]
);
const formatKg = (kg: number, maximumFractionDigits = 2) =>
`${kg.toLocaleString(i18n.language, { maximumFractionDigits })} kg`;
const formatDate = (time: number, options: Intl.DateTimeFormatOptions) =>
new Date(time).toLocaleDateString(i18n.language, { timeZone: "UTC", ...options });
const onLayout = (e: LayoutChangeEvent) => setWidth(Math.round(e.nativeEvent.layout.width));
if (entries.length < 2) return null;
const points = chart?.points ?? [];
const activeIndex = selectedIndex !== null && selectedIndex < points.length ? selectedIndex : points.length - 1;
const active = points[activeIndex];
const delta = chart?.deltaKg ?? 0;
const deltaLabel = `${delta > 0 ? "+" : delta < 0 ? "−" : "±"}${formatKg(Math.abs(delta))}`;
const spansLong = points.length > 1 && points[points.length - 1].time - points[0].time > LONG_SPAN_MS;
const axisDateOptions: Intl.DateTimeFormatOptions = spansLong
? { month: "short", year: "2-digit" }
: { day: "2-digit", month: "2-digit" };
const summary =
points.length > 1
? t("health.weightChartA11y", {
from: formatKg(points[0].kg),
fromDate: formatDate(points[0].time, { dateStyle: "medium" }),
to: formatKg(points[points.length - 1].kg),
toDate: formatDate(points[points.length - 1].time, { dateStyle: "medium" }),
})
: "";
return (
<Card style={styles.card}>
<View style={styles.header}>
<View>
<Text style={styles.eyebrow}>{t("health.weightChartTitle")}</Text>
{active && (
<Text style={styles.readout}>
{formatKg(active.kg)}
<Text style={styles.readoutDate}>{" "}{formatDate(active.time, { dateStyle: "medium" })}</Text>
</Text>
)}
</View>
<View style={styles.deltaChip}>
<Text style={styles.deltaText}>{deltaLabel}</Text>
<Text style={styles.deltaCaption}>{t("health.weightChartSinceFirst")}</Text>
</View>
</View>
<Pressable
onLayout={onLayout}
onPress={(e) => chart && setSelectedIndex(nearestPointIndex(chart.points, e.nativeEvent.locationX))}
accessibilityRole="image"
accessibilityLabel={summary}
style={styles.plot}
>
{chart && (
<Svg width={width} height={CHART_HEIGHT}>
<Defs>
<LinearGradient id="weightArea" x1="0" y1="0" x2="0" y2="1">
<Stop offset="0" stopColor={colors.chartLine} stopOpacity={0.2} />
<Stop offset="1" stopColor={colors.chartLine} stopOpacity={0} />
</LinearGradient>
</Defs>
{chart.yTicks.map((tick, i) => (
<Line
key={`grid-${i}`}
x1={BOX.left}
x2={width - BOX.right}
y1={tick.y}
y2={tick.y}
stroke={colors.hairlineGray}
strokeWidth={1}
/>
))}
{chart.yTicks.map((tick, i) =>
chart.yTicks.length <= MAX_LABELED_Y_TICKS || i % 2 === 0 ? (
<SvgText
key={`ylabel-${i}`}
x={BOX.left - 6}
y={tick.y + AXIS_FONT_SIZE / 3}
fontSize={AXIS_FONT_SIZE}
fontFamily={fontFamily.regular}
fill={colors.quietGray}
textAnchor="end"
>
{tick.kg.toLocaleString(i18n.language, {
minimumFractionDigits: chart.yTickDecimals,
maximumFractionDigits: chart.yTickDecimals,
})}
</SvgText>
) : null
)}
<Path d={chart.areaPath} fill="url(#weightArea)" />
<Path
d={chart.linePath}
fill="none"
stroke={colors.chartLine}
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
/>
{active && (
<Line
x1={active.x}
x2={active.x}
y1={BOX.top}
y2={chart.baselineY}
stroke={colors.quietGray}
strokeWidth={1}
strokeDasharray="3 3"
/>
)}
{chart.points.map((p, i) => (
<Circle
key={`pt-${i}`}
cx={p.x}
cy={p.y}
r={i === activeIndex ? SELECTED_RADIUS : MARKER_RADIUS}
fill={i === activeIndex ? colors.chartLine : colors.pureWhite}
stroke={i === activeIndex ? colors.pureWhite : colors.chartLine}
strokeWidth={2}
/>
))}
{chart.xLabelIndices.map((i) => {
const p = chart.points[i];
const isFirst = i === chart.xLabelIndices[0] && p.x - BOX.left < 20;
const isLast = i === chart.points.length - 1;
return (
<SvgText
key={`xlabel-${i}`}
x={p.x}
y={CHART_HEIGHT - 6}
fontSize={AXIS_FONT_SIZE}
fontFamily={fontFamily.regular}
fill={colors.quietGray}
textAnchor={isLast ? "end" : isFirst ? "start" : "middle"}
>
{formatDate(p.time, axisDateOptions)}
</SvgText>
);
})}
</Svg>
)}
</Pressable>
</Card>
);
}
function makeStyles(colors: ColorTokens) {
const typography: Typography = getTypography(colors);
return StyleSheet.create({
card: { gap: 8 },
header: { flexDirection: "row", justifyContent: "space-between", alignItems: "flex-start", gap: 12 },
eyebrow: { ...typography.label },
readout: { ...typography.title, fontSize: 20, marginTop: 2 },
readoutDate: { ...typography.label, textTransform: "none", letterSpacing: 0, fontSize: 12 },
deltaChip: { alignItems: "flex-end" },
deltaText: { ...typography.body, fontFamily: typography.title.fontFamily },
deltaCaption: { ...typography.label, textTransform: "none", letterSpacing: 0, fontSize: 11 },
plot: { height: CHART_HEIGHT },
});
}
@@ -9,6 +9,7 @@ import { TextField } from "../TextField";
import { DateField } from "../DateField";
import { Button } from "../Button";
import { Card } from "../Card";
import { WeightChart } from "./WeightChart";
import { getTypography, type ColorTokens, type Typography } from "../../theme/tokens";
import { displayDateToIso, todayAsDisplayDate } from "../../lib/date-format";
@@ -82,20 +83,23 @@ export function WeightLogSection({ petId }: { petId: string }) {
) : logs.length === 0 ? (
<Text style={styles.empty}>{t("health.weightEmpty")}</Text>
) : (
logs.map((log) => (
<View key={log.id} style={styles.row}>
<View style={styles.rowText}>
<Text style={styles.rowPrimary}>{(log.weightGrams / 1000).toLocaleString(i18n.language)} kg</Text>
<Text style={styles.rowSecondary}>
{new Date(log.date).toLocaleDateString(i18n.language)}
{log.notes ? ` · ${log.notes}` : ""}
</Text>
<>
<WeightChart entries={logs} />
{logs.map((log) => (
<View key={log.id} style={styles.row}>
<View style={styles.rowText}>
<Text style={styles.rowPrimary}>{(log.weightGrams / 1000).toLocaleString(i18n.language)} kg</Text>
<Text style={styles.rowSecondary}>
{new Date(log.date).toLocaleDateString(i18n.language)}
{log.notes ? ` · ${log.notes}` : ""}
</Text>
</View>
<Pressable onPress={() => deleteMutation.mutate({ id: log.id, petId })} hitSlop={8}>
<Trash2 color={colors.quietGray} size={16} />
</Pressable>
</View>
<Pressable onPress={() => deleteMutation.mutate({ id: log.id, petId })} hitSlop={8}>
<Trash2 color={colors.quietGray} size={16} />
</Pressable>
</View>
))
))}
</>
)}
</View>
);
+3
View File
@@ -144,6 +144,9 @@
"weightKgPlaceholder": "Gewicht (kg), z.B. 4,2",
"notesPlaceholder": "Notizen (optional)",
"weightEmpty": "Noch keine Gewichtseinträge",
"weightChartTitle": "Verlauf",
"weightChartSinceFirst": "seit erster Messung",
"weightChartA11y": "Gewichtsverlauf von {{from}} am {{fromDate}} auf {{to}} am {{toDate}}. Tippen, um eine Messung auszuwählen.",
"invalidDate": "Bitte ein gültiges Datum im Format TT.MM.JJJJ eingeben.",
"vetTitle": "Tierarzt",
"vetReasonPlaceholder": "Grund, z.B. Jahresuntersuchung",
+3
View File
@@ -144,6 +144,9 @@
"weightKgPlaceholder": "Weight (kg), e.g. 4.2",
"notesPlaceholder": "Notes (optional)",
"weightEmpty": "No weight entries yet",
"weightChartTitle": "History",
"weightChartSinceFirst": "since first entry",
"weightChartA11y": "Weight history from {{from}} on {{fromDate}} to {{to}} on {{toDate}}. Tap to select an entry.",
"invalidDate": "Please enter a valid date in DD.MM.YYYY format.",
"vetTitle": "Vet",
"vetReasonPlaceholder": "Reason, e.g. annual checkup",
+145
View File
@@ -0,0 +1,145 @@
import { describe, it, expect } from "vitest";
import { buildWeightChart, nearestPointIndex } from "./weight-chart";
const box = { width: 300, height: 180, left: 40, right: 10, top: 10, bottom: 30 };
describe("buildWeightChart", () => {
it("needs at least two entries", () => {
expect(buildWeightChart([], box)).toBeNull();
expect(buildWeightChart([{ date: "2026-01-01", weightGrams: 4000 }], box)).toBeNull();
});
it("sorts by date and spaces x by time, not by index", () => {
const chart = buildWeightChart(
[
{ date: "2026-01-31", weightGrams: 4200 },
{ date: "2026-01-01", weightGrams: 4000 },
{ date: "2026-01-04", weightGrams: 4100 },
],
box
)!;
expect(chart.points.map((p) => p.kg)).toEqual([4, 4.1, 4.2]);
expect(chart.points[0].x).toBe(40);
expect(chart.points[2].x).toBe(290);
// 3 of 30 days → 10 % of the 250px plot width, not the middle.
expect(chart.points[1].x).toBeCloseTo(65, 5);
});
it("puts heavier weights higher and reports the change since the first entry", () => {
const chart = buildWeightChart(
[
{ date: "2026-01-01", weightGrams: 4000 },
{ date: "2026-02-01", weightGrams: 4350 },
],
box
)!;
expect(chart.points[1].y).toBeLessThan(chart.points[0].y);
expect(chart.deltaKg).toBeCloseTo(0.35, 5);
});
it("keeps a flat series inside the plot and never goes below 0 kg", () => {
const chart = buildWeightChart(
[
{ date: "2026-01-01", weightGrams: 300 },
{ date: "2026-01-02", weightGrams: 300 },
],
box
)!;
for (const p of chart.points) {
expect(p.y).toBeGreaterThan(box.top);
expect(p.y).toBeLessThan(chart.baselineY);
}
expect(chart.yTicks[0].kg).toBe(0);
});
it("falls back to index spacing when all entries share a date", () => {
const chart = buildWeightChart(
[
{ date: "2026-01-01", weightGrams: 4000 },
{ date: "2026-01-01", weightGrams: 4100 },
],
box
)!;
expect(chart.points.map((p) => p.x)).toEqual([40, 290]);
});
it("labels at most a handful of dates and always the newest", () => {
const entries = Array.from({ length: 12 }, (_, i) => ({ date: new Date(Date.UTC(2026, 0, 1 + i)), weightGrams: 4000 + i }));
const chart = buildWeightChart(entries, box)!;
expect(chart.xLabelIndices.length).toBeLessThanOrEqual(4);
expect(chart.xLabelIndices).toContain(11);
});
it("never places two date labels closer than a quarter of the plot", () => {
// Two weigh-ins a day apart, then one a year later: the first two must not both get a label.
const chart = buildWeightChart(
[
{ date: "2025-01-01", weightGrams: 4000 },
{ date: "2025-01-02", weightGrams: 4010 },
{ date: "2026-01-01", weightGrams: 4500 },
],
box
)!;
const xs = chart.xLabelIndices.map((i) => chart.points[i].x);
for (let i = 1; i < xs.length; i++) expect(xs[i] - xs[i - 1]).toBeGreaterThanOrEqual(250 / 4);
expect(chart.xLabelIndices.at(-1)).toBe(2);
});
it("builds closed area and open line paths", () => {
const chart = buildWeightChart(
[
{ date: "2026-01-01", weightGrams: 4000 },
{ date: "2026-01-11", weightGrams: 4100 },
],
box
)!;
expect(chart.linePath.startsWith("M 40 ")).toBe(true);
expect(chart.areaPath.endsWith("Z")).toBe(true);
expect(chart.areaPath).toContain(` ${chart.baselineY} `);
});
});
describe("y axis ticks", () => {
it("uses round, distinct tick values even for a tiny range", () => {
const chart = buildWeightChart(
[
{ date: "2026-09-01", weightGrams: 4200 },
{ date: "2026-10-01", weightGrams: 4150 },
],
box
)!;
const labels = chart.yTicks.map((t) => t.kg.toFixed(chart.yTickDecimals));
expect(new Set(labels).size).toBe(labels.length);
expect(chart.yTicks[0].kg).toBeLessThanOrEqual(4.15);
expect(chart.yTicks.at(-1)!.kg).toBeGreaterThanOrEqual(4.2);
expect(chart.yTickDecimals).toBe(2);
});
it("uses whole kilos for a big dog over a wide range", () => {
const chart = buildWeightChart(
[
{ date: "2025-01-01", weightGrams: 25000 },
{ date: "2026-01-01", weightGrams: 33000 },
],
box
)!;
expect(chart.yTickDecimals).toBe(0);
for (const t of chart.yTicks) expect(Number.isInteger(t.kg)).toBe(true);
});
});
describe("nearestPointIndex", () => {
it("picks the point closest to the tap", () => {
const chart = buildWeightChart(
[
{ date: "2026-01-01", weightGrams: 4000 },
{ date: "2026-01-11", weightGrams: 4100 },
{ date: "2026-01-21", weightGrams: 4200 },
],
box
)!;
expect(nearestPointIndex(chart.points, 0)).toBe(0);
expect(nearestPointIndex(chart.points, 170)).toBe(1);
expect(nearestPointIndex(chart.points, 999)).toBe(2);
});
});
+129
View File
@@ -0,0 +1,129 @@
/**
* Pure geometry for the mobile weight-history chart (port of web's
* src/components/health/WeightChart.tsx). Kept free of React/SVG so it can
* be unit-tested.
*
* One deliberate difference from web: x is proportional to the date, not to
* the entry's index — weigh-ins are irregular, and index spacing would make a
* jump over three months look as steep as one over three days.
*/
export type WeightEntry = { date: Date | string; weightGrams: number };
export type ChartBox = { width: number; height: number; left: number; right: number; top: number; bottom: number };
export type ChartPoint = { x: number; y: number; kg: number; time: number; entry: WeightEntry };
export type WeightChartGeometry = {
points: ChartPoint[];
linePath: string;
areaPath: string;
yTicks: { y: number; kg: number }[];
/** Decimal places that make every y tick label distinct (derived from the tick step). */
yTickDecimals: number;
xLabelIndices: number[];
baselineY: number;
deltaKg: number;
};
const TARGET_Y_TICKS = 4;
const MAX_X_LABELS = 4;
const RANGE_PADDING = 0.18;
const FLAT_RANGE_PADDING_KG = 0.5;
/** Rounds a raw step up to 1, 2 or 5 × 10^n, so tick labels are round numbers (4,0 / 4,2 / 4,4 — not 4,13 / 4,27). */
function niceStep(rawStep: number): number {
const magnitude = 10 ** Math.floor(Math.log10(rawStep));
const normalized = rawStep / magnitude;
const nice = normalized <= 1 ? 1 : normalized <= 2 ? 2 : normalized <= 5 ? 5 : 10;
return nice * magnitude;
}
function smoothPath(points: ChartPoint[]): string {
return points
.map((p, i) => {
if (i === 0) return `M ${p.x} ${p.y}`;
const prev = points[i - 1];
const cpX = (prev.x + p.x) / 2;
return `C ${cpX} ${prev.y} ${cpX} ${p.y} ${p.x} ${p.y}`;
})
.join(" ");
}
/**
* Date labels chosen by on-screen distance, not by index (x is time-based, so
* index spacing could stack labels on top of each other): walk back from the
* newest point and keep one whenever it is far enough from the last kept one.
*/
function pickLabelIndices(points: { x: number }[], plotWidth: number): number[] {
const minGap = plotWidth / MAX_X_LABELS;
const kept = [points.length - 1];
for (let i = points.length - 2; i >= 0 && kept.length < MAX_X_LABELS; i--) {
if (points[kept[kept.length - 1]].x - points[i].x >= minGap) kept.push(i);
}
return kept.reverse();
}
/** Returns null for fewer than two entries — a single weigh-in is not a trend. */
export function buildWeightChart(entries: WeightEntry[], box: ChartBox): WeightChartGeometry | null {
if (entries.length < 2) return null;
const sorted = [...entries]
.map((entry) => ({ entry, time: new Date(entry.date).getTime(), kg: entry.weightGrams / 1000 }))
.sort((a, b) => a.time - b.time);
const kgs = sorted.map((e) => e.kg);
const minKg = Math.min(...kgs);
const maxKg = Math.max(...kgs);
const rawRange = maxKg - minKg;
const pad = rawRange === 0 ? FLAT_RANGE_PADDING_KG : rawRange * RANGE_PADDING;
const step = niceStep((maxKg + pad - Math.max(0, minKg - pad)) / TARGET_Y_TICKS);
// Snap the padded range outward to whole steps so every gridline sits on a round value.
const yMin = Math.max(0, Math.floor((minKg - pad) / step) * step);
const yMax = Math.ceil((maxKg + pad) / step) * step;
const tickCount = Math.round((yMax - yMin) / step);
const plotW = box.width - box.left - box.right;
const plotH = box.height - box.top - box.bottom;
const firstTime = sorted[0].time;
const timeSpan = sorted[sorted.length - 1].time - firstTime;
const xOf = (time: number, index: number) =>
box.left +
(timeSpan > 0 ? (time - firstTime) / timeSpan : index / (sorted.length - 1)) * plotW;
const yOf = (kg: number) => box.top + plotH - ((kg - yMin) / (yMax - yMin)) * plotH;
const points = sorted.map((e, i) => ({ x: xOf(e.time, i), y: yOf(e.kg), kg: e.kg, time: e.time, entry: e.entry }));
const baselineY = box.top + plotH;
const linePath = smoothPath(points);
const areaPath =
`M ${points[0].x} ${baselineY} L ${points[0].x} ${points[0].y} ` +
linePath.replace(/^M [^ ]+ [^ ]+ ?/, "") +
` L ${points[points.length - 1].x} ${baselineY} Z`;
const yTicks = Array.from({ length: tickCount + 1 }, (_, i) => {
// Rounded to kill float noise (0.1 + 0.2), the step decides the precision.
const kg = Number((yMin + step * i).toFixed(6));
return { kg, y: yOf(kg) };
});
return {
points,
linePath,
areaPath,
yTicks,
yTickDecimals: Math.max(0, -Math.floor(Math.log10(step) + 1e-9)),
xLabelIndices: pickLabelIndices(points, plotW),
baselineY,
deltaKg: kgs[kgs.length - 1] - kgs[0],
};
}
/** Index of the point closest to a tapped x position. */
export function nearestPointIndex(points: ChartPoint[], x: number): number {
let best = 0;
for (let i = 1; i < points.length; i++) {
if (Math.abs(points[i].x - x) < Math.abs(points[best].x - x)) best = i;
}
return best;
}
+8
View File
@@ -26,6 +26,12 @@ export interface ColorTokens {
quietGray: string;
alertRed: string;
alertRedTint: string;
/**
* Data line in charts (weight history). Checked with the dataviz palette
* validator: >= 3:1 against both surfaces and inside the lightness band in
* both modes — the brighter marigoldOrange fails one of the two each way.
*/
chartLine: string;
}
export const colors: ColorTokens = {
@@ -41,6 +47,7 @@ export const colors: ColorTokens = {
quietGray: "#737373",
alertRed: "#dc2626",
alertRedTint: "#fee2e2",
chartLine: "#ea580c",
};
/**
@@ -61,6 +68,7 @@ export const darkColors: ColorTokens = {
quietGray: "#a3a3a3",
alertRed: "#f87171",
alertRedTint: "#450a0a",
chartLine: "#ea580c",
};
export const radii = {