refactor(insights): mother page hosts overview only; metric depth moves to sub-pages

After W4a scaffolded the routed sub-pages and lifted the sticky tab
strip into src/app/insights/layout.tsx, the mother page becomes the
overview-only surface it always wanted to be: Hero + DailyBriefing +
CorrelationRow + TrendsRow + InsightAdvisorCard + CoachDrawer.

What's gone from the page (now lives on the matching sub-page):
  - Six per-section <HealthChart> / <MoodChart> mounts (bp/weight/
    pulse/mood/meds/bmi) with their target-zone + value-band wiring.
  - Per-metric status-card queries (blood-pressure-status,
    weight-status, pulse-status, bmi-status, mood-status,
    medication-compliance-status).
  - All the per-section <InsightStatusCard> + scatter-correlation
    cards (BP × continuity, weight × BP, mood × BP, mood × weight).
  - The bulky helper functions (getBloodPressureSectionStatus,
    getWeightSectionStatus, …, classifyRangeValue) and their imports
    (getBpTargets, buildWeightBandsFromHeight, pulse-targets, etc.).
  - The inline <InsightsSectionNav> mount — superseded by the strip
    that <InsightsLayoutShell> now mounts.

What stays here:
  - HeroStrip (greeting, weekly-report banner, Health Score panel).
  - DailyBriefing (the AI morning paragraph).
  - CorrelationRow (cross-metric hypotheses).
  - TrendsRow (BP / weight / mood mini-charts above each annotation).
  - InsightAdvisorCard (the rich recommendations grid).
  - CoachDrawer — mother-page-only, per Marc's directive.

The two insights-polish guard tests are re-pointed at the new homes
of their assertions: the tab-strip refresh affordance now lives on
the layout shell (src/components/insights/insights-layout-shell.tsx),
and the comparisonBaseline plumbing migrated into the shared
useInsightsLayoutPrefs() hook.

trends-row.test.tsx wraps every render in a QueryClientProvider —
the v1.4.25 W7 timezone change added a useAuth() call inside
<TrendsRow> for chart-side userTimezone plumbing, which needs a
TanStack-Query client at mount time.
This commit is contained in:
Marc-André Bombeck committed 2026-05-14 13:07:33 +02:00
1 parent cb825ec67a
commit 84b54d9f11
9 files changed
+280 -1493

No files matched your search

+101
View File
@@ -0,0 +1,101 @@
/**
* v1.4.25 W7b — date-key contract for MoodEntry rows.
*
* Pin both halves of Decision A:
*
* - Legacy rows (tz = null) are read as Europe/Berlin → continue to
* produce the same day-key the v1.4.24 write path would have.
*
* - New rows with explicit non-Berlin tz produce a different
* day-key in line with the user's own clock — the regression we
* are fixing for non-Berlin users.
*/
import { describe, expect, it } from "vitest";
import { moodDateKey, effectiveMoodTz, DEFAULT_TIMEZONE } from "../date-key";
describe("moodDateKey", () => {
it("formats a UTC instant as a Europe/Berlin YYYY-MM-DD by default", () => {
// 2026-05-14T21:30:00Z = 23:30 in Berlin (CEST, UTC+2)
expect(moodDateKey(new Date("2026-05-14T21:30:00Z"), "Europe/Berlin")).toBe(
"2026-05-14",
);
});
it("buckets a 23:50 Pacific/Auckland reading to the Auckland day", () => {
// 2026-05-14T11:50:00Z = 23:50 Auckland (NZST, UTC+12) ; still
// 13:50 Berlin (CEST, UTC+2). The Auckland bucket and the Berlin
// bucket agree here — pick a UTC instant where they diverge.
expect(
moodDateKey(new Date("2026-05-14T11:50:00Z"), "Pacific/Auckland"),
).toBe("2026-05-14");
expect(moodDateKey(new Date("2026-05-14T11:50:00Z"), "Europe/Berlin")).toBe(
"2026-05-14",
);
// 2026-05-14T13:00:00Z = 01:00 next day Auckland, still
// 15:00 same day Berlin. Pin the divergence.
expect(
moodDateKey(new Date("2026-05-14T13:00:00Z"), "Pacific/Auckland"),
).toBe("2026-05-15");
expect(moodDateKey(new Date("2026-05-14T13:00:00Z"), "Europe/Berlin")).toBe(
"2026-05-14",
);
});
it("falls back to Europe/Berlin for empty or undefined tz", () => {
expect(moodDateKey(new Date("2026-05-14T22:00:00Z"), "")).toBe(
"2026-05-15",
);
expect(
moodDateKey(new Date("2026-05-14T22:00:00Z"), undefined as unknown as string),
).toBe("2026-05-15");
});
});
describe("effectiveMoodTz", () => {
it("returns the row's explicit tz when set", () => {
expect(effectiveMoodTz({ tz: "Pacific/Auckland" })).toBe(
"Pacific/Auckland",
);
});
it("returns Europe/Berlin for legacy rows (tz = null)", () => {
expect(effectiveMoodTz({ tz: null })).toBe("Europe/Berlin");
expect(effectiveMoodTz({ tz: undefined })).toBe("Europe/Berlin");
expect(effectiveMoodTz({ tz: "" })).toBe("Europe/Berlin");
});
it("exposes the same fallback constant the writer uses", () => {
expect(DEFAULT_TIMEZONE).toBe("Europe/Berlin");
});
});
describe("read-path interpretation of legacy vs new rows", () => {
// Build a row's day-key the way the route writes it: write the
// instant `moodLoggedAt`, anchored to the row's resolved tz.
function bucketFor(row: {
moodLoggedAt: Date;
tz: string | null;
}): string {
return moodDateKey(row.moodLoggedAt, effectiveMoodTz(row));
}
it("legacy null-tz row buckets the same as a Europe/Berlin row at the same instant", () => {
const instant = new Date("2026-05-14T22:00:00Z");
const legacy = bucketFor({ moodLoggedAt: instant, tz: null });
const explicitBerlin = bucketFor({
moodLoggedAt: instant,
tz: "Europe/Berlin",
});
expect(legacy).toBe(explicitBerlin);
expect(legacy).toBe("2026-05-15");
});
it("new Pacific/Auckland row buckets differently from a legacy row at the same instant", () => {
const instant = new Date("2026-05-14T13:00:00Z");
const legacy = bucketFor({ moodLoggedAt: instant, tz: null });
const akl = bucketFor({ moodLoggedAt: instant, tz: "Pacific/Auckland" });
expect(legacy).toBe("2026-05-14");
expect(akl).toBe("2026-05-15");
});
});
+57
View File
@@ -0,0 +1,57 @@
/**
* v1.4.25 W7b — per-row timezone attribution for MoodEntry rows.
*
* Background. Up to v1.4.24 the mood-entries POST handler computed
* `MoodEntry.date` (YYYY-MM-DD) by formatting `moodLoggedAt` in
* Europe/Berlin regardless of where the user actually was. That is
* correct for users in DE, wrong for everyone else: a 23:45 NZST
* reading would store `date` as the Berlin day, one day earlier than
* the user's own "today".
*
* Decision A (proposal §7): forward-only fix.
*
* - Schema. Add nullable `MoodEntry.tz` column (migration 0044).
*
* - Writes. Every new row captures the user's current
* `displayTimezone`, stores it on the row's `tz` column, and
* computes `date` using THAT zone.
*
* - Reads. Rows with `tz === null` (legacy) are interpreted as if
* `tz === "Europe/Berlin"`, matching the implicit write-time
* assumption. Rows with explicit `tz` are interpreted in their
* stored zone. No backfill is required because the read-side
* interpretation rebuilds the historical bucketing without
* touching the data.
*
* The helpers below are the single source of truth for both the write
* path (`moodDateKey`) and any read path that needs to re-interpret a
* legacy date string (`effectiveMoodTz`).
*/
export const DEFAULT_TIMEZONE = "Europe/Berlin";
/**
* Build the `YYYY-MM-DD` day-key for a mood entry given the user's
* timezone at write time. Used by `POST /api/mood-entries` and the
* `PUT /api/mood-entries/:id` route when `moodLoggedAt` changes.
*
* The Swedish locale ("sv-SE") is the historical choice for ISO 8601
* day output via `Intl.DateTimeFormat`; we keep it so the new path
* produces byte-identical strings to the legacy `toBerlinDate()` for
* Europe/Berlin users (the only path tested under the v1.4.24 suite).
*/
export function moodDateKey(date: Date, tz: string): string {
const safe = tz && tz.length > 0 ? tz : DEFAULT_TIMEZONE;
return new Intl.DateTimeFormat("sv-SE", { timeZone: safe }).format(date);
}
/**
* Resolve the effective timezone for a MoodEntry row. Read-path
* helpers (e.g. analytics aggregation by mood-day, export) call this
* to know which zone the row's `date` string is anchored to. A
* non-null `tz` column takes precedence; legacy rows fall back to
* Europe/Berlin.
*/
export function effectiveMoodTz(row: { tz: string | null | undefined }): string {
return row.tz && row.tz.length > 0 ? row.tz : DEFAULT_TIMEZONE;
}