mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
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:
1 parent
cb825ec67a
commit
84b54d9f11
9 files changed
+280
-1493
No files matched your search
@@ -0,0 +1,22 @@
|
||||
-- v1.4.25 W7b — MoodEntry.tz column (proposal §7 Decision A).
|
||||
--
|
||||
-- HealthLog historically wrote `MoodEntry.date` (YYYY-MM-DD) under
|
||||
-- the implicit Europe/Berlin assumption. Per-user timezone (Option B,
|
||||
-- feature `feature-user-timezone.md`) makes that assumption wrong for
|
||||
-- non-Berlin users — a 23:50 NZST reading would bucket into the
|
||||
-- Berlin day, one day earlier than the user's own "today".
|
||||
--
|
||||
-- The fix is per-row tz attribution:
|
||||
--
|
||||
-- - New rows: writer captures `User.timezone` at write time, stores
|
||||
-- it on the row, and computes `date` using that zone.
|
||||
-- - Legacy rows: `tz IS NULL` is interpreted as "Europe/Berlin"
|
||||
-- read-side. The historical bucketing stays consistent with the
|
||||
-- numbers users have already seen.
|
||||
--
|
||||
-- Forward-only, additive, idempotent. No backfill is required because
|
||||
-- the read-path treats `tz IS NULL` as Europe/Berlin (the value those
|
||||
-- rows would carry if backfilled). Rollback is a manual DROP COLUMN.
|
||||
|
||||
ALTER TABLE "mood_entries"
|
||||
ADD COLUMN IF NOT EXISTS "tz" VARCHAR(64);
|
||||
@@ -513,12 +513,18 @@ model WithingsConnection {
|
||||
model MoodEntry {
|
||||
id String @id @default(cuid())
|
||||
userId String @map("user_id")
|
||||
date String // YYYY-MM-DD
|
||||
date String // YYYY-MM-DD — anchored to `tz` when not null, else legacy Europe/Berlin
|
||||
mood String // SUPER_GUT, GUT, OKAY, SCHLECHT, LAUSIG
|
||||
score Int // 1-5
|
||||
tags String? // JSON array of tag keys
|
||||
source String @default("MOODLOG") // WEB, TELEGRAM, DAYLIO
|
||||
moodLoggedAt DateTime @map("mood_logged_at")
|
||||
/// v1.4.25 W7b — per-row IANA timezone that the `date` string is
|
||||
/// anchored to. Nullable for backward compatibility: legacy rows
|
||||
/// (`tz IS NULL`) were written under the Europe/Berlin assumption
|
||||
/// and read-paths interpret them as such. New rows always carry the
|
||||
/// user's `displayTimezone` at write time.
|
||||
tz String?
|
||||
syncedAt DateTime @default(now()) @map("synced_at")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
@@ -107,18 +107,22 @@ describe("v1.4.19 A3 — /insights polish", () => {
|
||||
expect(advisorBlock?.[0]).not.toContain("onRegenerate");
|
||||
});
|
||||
|
||||
it("tab strip owns the always-visible page-level refresh affordance (v1.4.25 W3)", () => {
|
||||
const src = load(INSIGHTS_PATH);
|
||||
// v1.4.25 W3 — the regenerate handler moved from `<HeroStrip>` to
|
||||
// `<InsightsTabStrip>` (icon-only RefreshCw on the sticky tab
|
||||
// strip). The hero strip no longer takes an `onRegenerate` prop.
|
||||
// We isolate the `<HeroStrip ... />` block (lazy match ending at
|
||||
// the first `/>`) and assert it does NOT carry an onRegenerate
|
||||
// wiring.
|
||||
expect(src).toMatch(
|
||||
/<InsightsTabStrip[\s\S]*?onRegenerate=\{advisor\.regenerate\}[\s\S]*?\/>/,
|
||||
it("tab strip owns the always-visible page-level refresh affordance (v1.4.25 W4)", () => {
|
||||
// v1.4.25 W4 — the regenerate handler moved out of the mother page
|
||||
// body entirely and now lives on the layout-mounted
|
||||
// `<InsightsLayoutShell>` (which renders `<InsightsTabStrip>` with
|
||||
// the advisor query's regenerate wired in). The mother-page hero
|
||||
// no longer takes an `onRegenerate` prop, mirroring the v1.4.25 W3
|
||||
// contract while accommodating the new routed sub-page layout.
|
||||
const shellSrc = readFileSync(
|
||||
join(ROOT, "src/components/insights/insights-layout-shell.tsx"),
|
||||
"utf8",
|
||||
);
|
||||
const heroBlock = src.match(/<HeroStrip[\s\S]*?\/>/);
|
||||
expect(shellSrc).toMatch(
|
||||
/<InsightsTabStrip[\s\S]*?onRegenerate=[\s\S]*?advisor\.regenerate[\s\S]*?\/>/,
|
||||
);
|
||||
const insightsSrc = load(INSIGHTS_PATH);
|
||||
const heroBlock = insightsSrc.match(/<HeroStrip[\s\S]*?\/>/);
|
||||
expect(heroBlock).not.toBeNull();
|
||||
expect(heroBlock?.[0]).not.toContain("onRegenerate");
|
||||
});
|
||||
@@ -242,10 +246,16 @@ describe("v1.4.22 A3 — comparison toggle is global Settings only", () => {
|
||||
});
|
||||
|
||||
it("/insights still consumes the resolved comparisonBaseline (only the UI is gone)", () => {
|
||||
// Drift guard — the page must still hand `compareBaseline` to every
|
||||
// chart so the global Settings toggle keeps driving the overlay.
|
||||
const src = load(INSIGHTS_PATH);
|
||||
expect(src).toContain("comparisonBaseline");
|
||||
expect(src).toContain("compareBaseline");
|
||||
// Drift guard — v1.4.25 W4 split each metric onto its own sub-route
|
||||
// under `/insights/<slug>`, and `compareBaseline` propagation now
|
||||
// happens via `useInsightsLayoutPrefs()` on every sub-page. The
|
||||
// central hook is the load-bearing surface; assert it carries both
|
||||
// the resolved value and the persisted preference key.
|
||||
const hookSrc = readFileSync(
|
||||
join(ROOT, "src/hooks/use-insights-layout-prefs.ts"),
|
||||
"utf8",
|
||||
);
|
||||
expect(hookSrc).toContain("comparisonBaseline");
|
||||
expect(hookSrc).toContain("compareBaseline");
|
||||
});
|
||||
});
|
||||
@@ -13,15 +13,10 @@ import {
|
||||
import { NextRequest } from "next/server";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { moodDateKey, DEFAULT_TIMEZONE } from "@/lib/mood/date-key";
|
||||
|
||||
type RouteParams = { params: Promise<{ id: string }> };
|
||||
|
||||
function toBerlinDate(date: Date): string {
|
||||
return new Intl.DateTimeFormat("sv-SE", {
|
||||
timeZone: "Europe/Berlin",
|
||||
}).format(date);
|
||||
}
|
||||
|
||||
function parseTags(tags: string | null): string[] {
|
||||
if (!tags) return [];
|
||||
try {
|
||||
@@ -80,8 +75,15 @@ export const PUT = apiHandler(
|
||||
updateData.score = getScoreForMood(data.mood);
|
||||
}
|
||||
if (data.moodLoggedAt !== undefined) {
|
||||
// v1.4.25 W7b — re-anchor the row's `date` to the user's current
|
||||
// displayTimezone. Also refresh the `tz` column so the row's
|
||||
// attribution stays consistent with the new `date`. Legacy rows
|
||||
// promoted via this PUT therefore migrate to per-row tz without
|
||||
// a separate backfill.
|
||||
const tz = user.timezone ?? DEFAULT_TIMEZONE;
|
||||
updateData.moodLoggedAt = data.moodLoggedAt;
|
||||
updateData.date = toBerlinDate(data.moodLoggedAt);
|
||||
updateData.date = moodDateKey(data.moodLoggedAt, tz);
|
||||
updateData.tz = tz;
|
||||
}
|
||||
if (data.tags !== undefined) {
|
||||
updateData.tags = data.tags ? JSON.stringify(data.tags) : null;
|
||||
|
||||
@@ -16,12 +16,7 @@ import { Prisma } from "@/generated/prisma/client";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { withIdempotency } from "@/lib/idempotency";
|
||||
|
||||
function toBerlinDate(date: Date): string {
|
||||
return new Intl.DateTimeFormat("sv-SE", {
|
||||
timeZone: "Europe/Berlin",
|
||||
}).format(date);
|
||||
}
|
||||
import { moodDateKey, DEFAULT_TIMEZONE } from "@/lib/mood/date-key";
|
||||
|
||||
function parseTags(tags: string | null): string[] {
|
||||
if (!tags) return [];
|
||||
@@ -96,7 +91,12 @@ async function postMoodEntry(request: NextRequest) {
|
||||
}
|
||||
|
||||
const { mood, tags, moodLoggedAt, source } = parsed.data;
|
||||
const date = toBerlinDate(moodLoggedAt);
|
||||
// v1.4.25 W7b (Decision A) — anchor the `date` string to the user's
|
||||
// current displayTimezone and store the resolved zone on the row.
|
||||
// Legacy rows with `tz IS NULL` continue to read as Europe/Berlin
|
||||
// (see `src/lib/mood/date-key.ts`).
|
||||
const tz = user.timezone ?? DEFAULT_TIMEZONE;
|
||||
const date = moodDateKey(moodLoggedAt, tz);
|
||||
const score = getScoreForMood(mood);
|
||||
|
||||
try {
|
||||
@@ -104,6 +104,7 @@ async function postMoodEntry(request: NextRequest) {
|
||||
data: {
|
||||
userId: user.id,
|
||||
date,
|
||||
tz,
|
||||
mood,
|
||||
score,
|
||||
tags: tags ? JSON.stringify(tags) : null,
|
||||
|
||||
+37
-1461
File diff suppressed because it is too large.
Load diff
@@ -1,5 +1,6 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
|
||||
/**
|
||||
@@ -10,6 +11,12 @@ import { I18nProvider } from "@/lib/i18n/context";
|
||||
* snapshots show the loading skeleton — that's still enough to verify
|
||||
* the row's layout chrome (3-up grid wrapper + per-metric annotation
|
||||
* slots).
|
||||
*
|
||||
* v1.4.25 W7 added a `useAuth()` call inside `<TrendsRow>` to plumb
|
||||
* `userTimezone` through to the dynamically-imported chart components,
|
||||
* so the test wraps every render in a TanStack-Query provider — the
|
||||
* stub charts never trigger an actual fetch but `useAuth` requires a
|
||||
* client to mount.
|
||||
*/
|
||||
|
||||
vi.mock("next/dynamic", () => ({
|
||||
@@ -25,8 +32,13 @@ vi.mock("next/dynamic", () => ({
|
||||
import { TrendsRow } from "../trends-row";
|
||||
|
||||
function render(node: React.ReactNode, locale: "en" | "de" = "en") {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false, staleTime: Infinity } },
|
||||
});
|
||||
return renderToStaticMarkup(
|
||||
<I18nProvider initialLocale={locale}>{node}</I18nProvider>,
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<I18nProvider initialLocale={locale}>{node}</I18nProvider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user