fix(charts): "7-Tage-Trend" label + numeric trend indicator on every chart incl. mood

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) <noreply@anthropic.com>
This commit is contained in:
Marc-André BombeckandClaude Opus 4.7 committed 2026-05-09 23:30:42 +02:00
1 parent 577d8dd29f
commit 4df6dac036
5 files changed
+68 -20

No files matched your search

+5 -4
View File
@@ -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",
+5 -4
View File
@@ -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",
@@ -50,7 +50,7 @@ describe("<TrendCard> 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("<TrendCard> 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(<TrendCard {...baseProps} />);
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", () => {
@@ -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);
});
});
+28 -8
View File
@@ -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;
}