From e60abbabc603aa2197593eee0ec87eed262827e8 Mon Sep 17 00:00:00 2001 From: neodk2004 Date: Sun, 4 Oct 2026 14:52:38 +0200 Subject: [PATCH] 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 --- mobile/src/components/health/WeightChart.tsx | 202 ++++++++++++++++++ .../components/health/WeightLogSection.tsx | 30 +-- mobile/src/i18n/locales/de.json | 3 + mobile/src/i18n/locales/en.json | 3 + mobile/src/lib/weight-chart.test.ts | 145 +++++++++++++ mobile/src/lib/weight-chart.ts | 129 +++++++++++ mobile/src/theme/tokens.ts | 8 + 7 files changed, 507 insertions(+), 13 deletions(-) create mode 100644 mobile/src/components/health/WeightChart.tsx create mode 100644 mobile/src/lib/weight-chart.test.ts create mode 100644 mobile/src/lib/weight-chart.ts diff --git a/mobile/src/components/health/WeightChart.tsx b/mobile/src/components/health/WeightChart.tsx new file mode 100644 index 0000000..7446185 --- /dev/null +++ b/mobile/src/components/health/WeightChart.tsx @@ -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(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 ( + + + + {t("health.weightChartTitle")} + {active && ( + + {formatKg(active.kg)} + {" "}{formatDate(active.time, { dateStyle: "medium" })} + + )} + + + {deltaLabel} + {t("health.weightChartSinceFirst")} + + + + chart && setSelectedIndex(nearestPointIndex(chart.points, e.nativeEvent.locationX))} + accessibilityRole="image" + accessibilityLabel={summary} + style={styles.plot} + > + {chart && ( + + + + + + + + + {chart.yTicks.map((tick, i) => ( + + ))} + {chart.yTicks.map((tick, i) => + chart.yTicks.length <= MAX_LABELED_Y_TICKS || i % 2 === 0 ? ( + + {tick.kg.toLocaleString(i18n.language, { + minimumFractionDigits: chart.yTickDecimals, + maximumFractionDigits: chart.yTickDecimals, + })} + + ) : null + )} + + + + + {active && ( + + )} + + {chart.points.map((p, i) => ( + + ))} + + {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 ( + + {formatDate(p.time, axisDateOptions)} + + ); + })} + + )} + + + ); +} + +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 }, + }); +} diff --git a/mobile/src/components/health/WeightLogSection.tsx b/mobile/src/components/health/WeightLogSection.tsx index a62fbd5..7fb9389 100644 --- a/mobile/src/components/health/WeightLogSection.tsx +++ b/mobile/src/components/health/WeightLogSection.tsx @@ -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 ? ( {t("health.weightEmpty")} ) : ( - logs.map((log) => ( - - - {(log.weightGrams / 1000).toLocaleString(i18n.language)} kg - - {new Date(log.date).toLocaleDateString(i18n.language)} - {log.notes ? ` · ${log.notes}` : ""} - + <> + + {logs.map((log) => ( + + + {(log.weightGrams / 1000).toLocaleString(i18n.language)} kg + + {new Date(log.date).toLocaleDateString(i18n.language)} + {log.notes ? ` · ${log.notes}` : ""} + + + deleteMutation.mutate({ id: log.id, petId })} hitSlop={8}> + + - deleteMutation.mutate({ id: log.id, petId })} hitSlop={8}> - - - - )) + ))} + )} ); diff --git a/mobile/src/i18n/locales/de.json b/mobile/src/i18n/locales/de.json index e617736..ccde2a8 100644 --- a/mobile/src/i18n/locales/de.json +++ b/mobile/src/i18n/locales/de.json @@ -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", diff --git a/mobile/src/i18n/locales/en.json b/mobile/src/i18n/locales/en.json index fcffeeb..f557dbc 100644 --- a/mobile/src/i18n/locales/en.json +++ b/mobile/src/i18n/locales/en.json @@ -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", diff --git a/mobile/src/lib/weight-chart.test.ts b/mobile/src/lib/weight-chart.test.ts new file mode 100644 index 0000000..231f794 --- /dev/null +++ b/mobile/src/lib/weight-chart.test.ts @@ -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); + }); +}); diff --git a/mobile/src/lib/weight-chart.ts b/mobile/src/lib/weight-chart.ts new file mode 100644 index 0000000..7827530 --- /dev/null +++ b/mobile/src/lib/weight-chart.ts @@ -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; +} diff --git a/mobile/src/theme/tokens.ts b/mobile/src/theme/tokens.ts index 2b7a99c..1ccfa19 100644 --- a/mobile/src/theme/tokens.ts +++ b/mobile/src/theme/tokens.ts @@ -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 = {