From 4df6dac036f4ff6c64d1d3b8edf1b20196fa951d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 9 May 2026 23:30:42 +0200 Subject: [PATCH] fix(charts): "7-Tage-Trend" label + numeric trend indicator on every chart incl. mood MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Marc reported v1.4.15 A4 didn't fully land: - DE label still read "7-Tage-Schnitt" / "7T-Schnitt" in places - mood tile showed no numeric trend delta even though other tiles did i18n rename: - de.json + en.json: trend7dShort, movingAverage7d, moodMA all read "7-Tage-Trend" / "7-day trend". The legacy "7T:" / "7d:" header on the avg7 line is preserved for tiles that haven't been migrated to the trend7Delta API. trend-delta fallback: - summaryToTrend7Delta() now falls back to slope30 (projected onto 7 days) when slope7 is null. Mood entries are typically logged less than daily, so slope7 needs ≥ 2 points within the trailing 7-day window — for sparser users that came back null and the tile silently dropped the delta indicator. slope30 still answers "is this metric drifting?" so we project that onto the 7-day window for label parity. Co-Authored-By: Claude Opus 4.7 (1M context) --- messages/de.json | 9 ++--- messages/en.json | 9 ++--- .../__tests__/trend-card-7d-trend.test.tsx | 6 ++-- .../analytics/__tests__/trend-delta.test.ts | 28 ++++++++++++++- src/lib/analytics/trend-delta.ts | 36 ++++++++++++++----- 5 files changed, 68 insertions(+), 20 deletions(-) diff --git a/messages/de.json b/messages/de.json index bbaad8b2f..11a7dc5ab 100644 --- a/messages/de.json +++ b/messages/de.json @@ -587,13 +587,14 @@ "bodyWater": "Körperwasser", "boneMass": "Knochenmasse", "spo2": "SpO₂", - "movingAverage7d": "7T-Schnitt", + "movingAverage7d": "7-Tage-Trend", "avg7dShort": "7T", "avg30dShort": "30T", - "trend7dShort": "7T-Trend", + "trend7dShort": "7-Tage-Trend", "trend": "Trend", "targetRanges": "Zielbereiche", "perWeek": "/Woche", + "totalDelta": "Gesamt", "selectMedication": "Medikament wählen", "noComplianceData": "Keine Compliance-Daten verfügbar", "history": "Verlauf", @@ -603,7 +604,7 @@ "colorRed": "Rot:", "mood": "Stimmung", "moodScore": "Stimmungswert", - "moodMA": "7-Tage-Schnitt", + "moodMA": "7-Tage-Trend", "moodLabel1": "Lausig", "moodLabel2": "Schlecht", "moodLabel3": "Okay", @@ -1102,7 +1103,7 @@ "rawDataWarning": "Rohdaten aktiv: Die KI erhält zusätzlich anonymisierte Messpunkte der letzten 30 Tage für eine präzisere Auswertung.", "regenerateInsights": "Reports neu generieren", "regenerateSuccess": "Reports wurden neu generiert", - "regenerateRateLimit": "Bitte warte — maximal 2 Analysen pro Stunde.", + "regenerateRateLimit": "Bitte warte — du hast das stündliche Limit für Analysen erreicht.", "lastGeneratedAt": "Zuletzt generiert", "ai": { "title": "KI-Insights", diff --git a/messages/en.json b/messages/en.json index 7dd35bb2b..d386bc974 100644 --- a/messages/en.json +++ b/messages/en.json @@ -587,13 +587,14 @@ "bodyWater": "Body water", "boneMass": "Bone mass", "spo2": "SpO₂", - "movingAverage7d": "7d avg", + "movingAverage7d": "7-day trend", "avg7dShort": "7d", "avg30dShort": "30d", - "trend7dShort": "7d trend", + "trend7dShort": "7-day trend", "trend": "Trend", "targetRanges": "Target ranges", "perWeek": "/week", + "totalDelta": "Total", "selectMedication": "Select medication", "noComplianceData": "No compliance data available", "history": "History", @@ -603,7 +604,7 @@ "colorRed": "Red:", "mood": "Mood", "moodScore": "Mood Score", - "moodMA": "7-Day Average", + "moodMA": "7-day trend", "moodLabel1": "Awful", "moodLabel2": "Bad", "moodLabel3": "Okay", @@ -1102,7 +1103,7 @@ "rawDataWarning": "Raw mode enabled: AI receives additional anonymized points from the last 30 days for higher analysis accuracy.", "regenerateInsights": "Regenerate reports", "regenerateSuccess": "Reports regenerated successfully", - "regenerateRateLimit": "Please wait — max 2 analyses per hour.", + "regenerateRateLimit": "Please wait — you've hit the hourly limit for analyses.", "lastGeneratedAt": "Last generated", "ai": { "title": "AI Insights", diff --git a/src/components/charts/__tests__/trend-card-7d-trend.test.tsx b/src/components/charts/__tests__/trend-card-7d-trend.test.tsx index a9567c540..3ec901027 100644 --- a/src/components/charts/__tests__/trend-card-7d-trend.test.tsx +++ b/src/components/charts/__tests__/trend-card-7d-trend.test.tsx @@ -50,7 +50,7 @@ describe(" 7-day trend label", () => { />, ); // New label flips on for the trend variant. - expect(html).toContain("7d trend"); + expect(html).toContain("7-day trend"); // Legacy avg7 short label must NOT appear in the trend variant — // this is the whole point of the rename. expect(html).not.toContain(">7d:<"); @@ -65,13 +65,13 @@ describe(" 7-day trend label", () => { />, "de", ); - expect(html).toContain("7T-Trend"); + expect(html).toContain("7-Tage-Trend"); }); it("keeps the legacy avg7 label when no delta is supplied (back-compat)", () => { const html = render(); expect(html).toContain("7d"); - expect(html).not.toContain("7d trend"); + expect(html).not.toContain("7-day trend"); }); it("renders a positive delta with `+` sign and one decimal", () => { diff --git a/src/lib/analytics/__tests__/trend-delta.test.ts b/src/lib/analytics/__tests__/trend-delta.test.ts index fb2320649..28a5922fe 100644 --- a/src/lib/analytics/__tests__/trend-delta.test.ts +++ b/src/lib/analytics/__tests__/trend-delta.test.ts @@ -19,8 +19,9 @@ describe("summaryToTrend7Delta()", () => { expect(summaryToTrend7Delta(undefined)).toBeNull(); }); - it("returns null when slope7 is not computable (insufficient data)", () => { + it("returns null when neither slope7 nor slope30 is computable", () => { expect(summaryToTrend7Delta({ slope7: null })).toBeNull(); + expect(summaryToTrend7Delta({ slope7: null, slope30: null })).toBeNull(); }); it("projects slope (units per day) over a 7-day window", () => { @@ -36,4 +37,29 @@ describe("summaryToTrend7Delta()", () => { it("returns 0 for a stable slope (the tile then paints ±0)", () => { expect(summaryToTrend7Delta({ slope7: STABLE })).toBe(0); }); + + it("falls back to slope30 when slope7 is null (sparse metrics like mood)", () => { + // v1.4.16 fix A4: mood-tile showed no trend number for users who + // log mood < daily because slope7 needs ≥2 points within the + // trailing 7-day window. slope30 still answers "is this metric + // drifting?" so we project that onto 7 days for label parity. + expect( + summaryToTrend7Delta({ slope7: null, slope30: RISING_HALF_PER_DAY }), + ).toBe(3.5); + expect( + summaryToTrend7Delta({ + slope7: null, + slope30: FALLING_TENTH_PER_DAY, + }), + ).toBeCloseTo(-0.7, 3); + }); + + it("prefers slope7 over slope30 when both are present", () => { + expect( + summaryToTrend7Delta({ + slope7: RISING_HALF_PER_DAY, + slope30: FALLING_TENTH_PER_DAY, + }), + ).toBe(3.5); + }); }); diff --git a/src/lib/analytics/trend-delta.ts b/src/lib/analytics/trend-delta.ts index 8d682a294..b99ce162b 100644 --- a/src/lib/analytics/trend-delta.ts +++ b/src/lib/analytics/trend-delta.ts @@ -12,9 +12,17 @@ * delta = slope.slope * 7 // units per day → unit delta over 7d * * The trend-card then paints the delta with metric-aware sentiment - * colour (up-good vs up-bad). Returning `null` when slope7 is not - * available keeps the tile rendering the legacy avg label for back- - * compat with call sites that haven't been migrated. + * colour (up-good vs up-bad). + * + * v1.4.16 Fix A4: fall back to slope30 (projected onto 7 days) when + * slope7 is unavailable. Some metrics (mood, sleep) have <2 entries + * within the trailing 7-day window for users who don't log daily, + * which used to surface as a missing delta on the dashboard tile — + * Marc's complaint: "Bei Stimmung wird zum Beispiel kein Trend in + * Zahlen dargestellt." A 30-day slope still answers the question + * "is this metric drifting up or down?" — we just project it onto + * a 7-day window for label consistency. Returning `null` is reserved + * for the truly insufficient case where neither slope is available. * * Pure & deterministic — pinned by unit test. */ @@ -22,14 +30,26 @@ import type { TrendSlope } from "@/lib/analytics/trends"; export interface TrendCapableSummary { slope7: TrendSlope | null; + slope30?: TrendSlope | null; } export function summaryToTrend7Delta( summary: TrendCapableSummary | undefined | null, ): number | null { - const slope7 = summary?.slope7; - if (!slope7) return null; - // slope.slope is units per day (see lib/analytics/trends.ts) → 7 days - // is the projected end-to-end delta of the regression line. - return slope7.slope * 7; + if (!summary) return null; + // Primary: trailing-7-day linear-regression slope projected to a + // 7-day window. slope.slope is units per day (see + // lib/analytics/trends.ts). + if (summary.slope7) { + return summary.slope7.slope * 7; + } + // Fallback: slope30 projected onto 7 days. Same units (per day) so + // we still multiply by 7. This keeps the delta visible for sparser + // metrics (mood, sleep) that don't accumulate enough points in + // every trailing 7-day window. The arrow + colour interpretation + // stays correct because slope30 is computed the same way. + if (summary.slope30) { + return summary.slope30.slope * 7; + } + return null; }