From a2cb4fb4a962f47868628e1b50fabbee5d7ec147 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 18 Jul 2026 04:42:08 +0200 Subject: [PATCH 1/8] perf(workouts): cache the canonical dedup projection per filter, not per page GET /api/workouts read and deduped the full filtered row set on EVERY page request because the cache key included offset/limit. A historical drain (the iOS app's multi-page pull, thousands of rows on a long Apple Health import) paid the full read + dedup pass once per page instead of once per filter combination. Split the route into a cached projection builder (keyed on userId|since|until|sportType, deliberately excluding offset/limit) and a cheap in-memory slice per page. Also runs the row read and the sourcePriorityJson lookup concurrently instead of sequentially. --- src/app/api/workouts/route.ts | 154 +++++++++++++++++++++++----------- 1 file changed, 103 insertions(+), 51 deletions(-) diff --git a/src/app/api/workouts/route.ts b/src/app/api/workouts/route.ts index f05224994..c58e37537 100644 --- a/src/app/api/workouts/route.ts +++ b/src/app/api/workouts/route.ts @@ -30,10 +30,18 @@ * slice the canonical projection. A naive `skip` + per-window dedup * double-counts boundary clusters across pages because a cluster * whose first member sits in page N-1 and whose later member sits in - * page N would be dropped on N-1 and surface again on N. Workout - * volume for HealthLog is bounded (single user, tens of workouts per - * week — single-digit MB at the table's busiest), so reading the - * full filtered set per request is well within budget. + * page N would be dropped on N-1 and surface again on N. + * + * The canonical projection (the deduped, sorted row set) is cached + * keyed on the FILTER (`userId|since|until|sportType`) — deliberately + * WITHOUT `offset`/`limit` — so every page of a drain shares one cache + * entry: the first page's request builds it (one `findMany` + one + * dedup pass), every later page in the same TTL window slices the + * already-built array for free. Before this, each page carried its own + * cache key, so a full historical drain (the iOS app's "drain + * historical workouts" path, potentially thousands of rows on a + * 10-year Apple Health import) re-ran the full read + dedup once per + * page — O(pages × rows) instead of O(rows). * * Response shape (iOS contract): * - `workouts: WorkoutListEntry[]` — each entry exposes @@ -68,6 +76,32 @@ interface WorkoutsParams { readonly sportType: string | null; } +type WorkoutFilterParams = Pick< + WorkoutsParams, + "since" | "until" | "sportType" +>; + +interface CanonicalWorkoutRow { + id: string; + source: string; + externalId: string | null; + sportType: string | null; + startedAt: Date | null; + endedAt: Date | null; + durationSec: number | null; + totalDistanceM: number | null; + totalEnergyKcal: number | null; + avgHeartRate: number | null; + maxHeartRate: number | null; + createdAt: Date; +} + +/** The cached, deduped, most-recent-first row set for one filter combination. */ +interface WorkoutsProjection { + canonical: CanonicalWorkoutRow[]; + rawCount: number; +} + interface WorkoutsResult { workouts: Array<{ id: string; @@ -90,11 +124,11 @@ interface WorkoutsResult { }; } -async function buildWorkoutsResponse( +async function buildWorkoutsProjection( userId: string, - params: WorkoutsParams, -): Promise { - const { limit, offset, since, until, sportType } = params; + params: WorkoutFilterParams, +): Promise { + const { since, until, sportType } = params; const where: Record = { userId }; if (since || until) { @@ -115,29 +149,34 @@ async function buildWorkoutsResponse( where.sportType = sportType; } - const rows = await prisma.workout.findMany({ - where, - orderBy: [{ startedAt: "desc" }, { id: "asc" }], - select: { - id: true, - source: true, - externalId: true, - sportType: true, - startedAt: true, - endedAt: true, - durationSec: true, - totalDistanceM: true, - totalEnergyKcal: true, - avgHeartRate: true, - maxHeartRate: true, - createdAt: true, - }, - }); + // Independent reads — the sourcePriorityJson lookup doesn't depend on the + // row read, so run them concurrently rather than paying two sequential + // round trips. + const [rows, userRow] = await Promise.all([ + prisma.workout.findMany({ + where, + orderBy: [{ startedAt: "desc" }, { id: "asc" }], + select: { + id: true, + source: true, + externalId: true, + sportType: true, + startedAt: true, + endedAt: true, + durationSec: true, + totalDistanceM: true, + totalEnergyKcal: true, + avgHeartRate: true, + maxHeartRate: true, + createdAt: true, + }, + }), + prisma.user.findUnique({ + where: { id: userId }, + select: { sourcePriorityJson: true }, + }), + ]); - const userRow = await prisma.user.findUnique({ - where: { id: userId }, - select: { sourcePriorityJson: true }, - }); const canonical = pickCanonicalWorkoutRows( rows, userRow?.sourcePriorityJson ?? null, @@ -154,27 +193,37 @@ async function buildWorkoutsResponse( return a.id.localeCompare(b.id); }); - const page = canonical.slice(offset, offset + limit).map((row) => ({ - id: row.id, - sportType: row.sportType, - startedAt: row.startedAt, - endedAt: row.endedAt, - durationSec: row.durationSec, - distanceM: row.totalDistanceM, - activeEnergyKcal: row.totalEnergyKcal, - avgHr: row.avgHeartRate, - maxHr: row.maxHeartRate, - source: row.source, - externalId: row.externalId, - })); + return { canonical, rawCount: rows.length }; +} + +function sliceWorkoutsProjection( + projection: WorkoutsProjection, + limit: number, + offset: number, +): WorkoutsResult { + const page = projection.canonical + .slice(offset, offset + limit) + .map((row) => ({ + id: row.id, + sportType: row.sportType, + startedAt: row.startedAt, + endedAt: row.endedAt, + durationSec: row.durationSec, + distanceM: row.totalDistanceM, + activeEnergyKcal: row.totalEnergyKcal, + avgHr: row.avgHeartRate, + maxHr: row.maxHeartRate, + source: row.source, + externalId: row.externalId, + })); return { workouts: page, meta: { - total: canonical.length, + total: projection.canonical.length, limit, offset, - droppedDuplicates: rows.length - canonical.length, + droppedDuplicates: projection.rawCount - projection.canonical.length, }, }; } @@ -207,15 +256,18 @@ export const GET = apiHandler(async (request: NextRequest) => { const until = searchParams.get("until"); const sportType = searchParams.get("sportType"); - const params: WorkoutsParams = { limit, offset, since, until, sportType }; - const cacheKey = `${user.id}|${limit}|${offset}|${since ?? ""}|${until ?? ""}|${sportType ?? ""}`; + // Deliberately excludes `limit`/`offset` — every page of one filter + // combination shares this cached projection (see the docblock above). + const projectionKey = `${user.id}|${since ?? ""}|${until ?? ""}|${sportType ?? ""}`; - const result = await cached( - caches.workouts as ServerCache, - cacheKey, - () => buildWorkoutsResponse(user.id, params), + const projection = await cached( + caches.workouts as ServerCache, + projectionKey, + () => buildWorkoutsProjection(user.id, { since, until, sportType }), annotate, ); + const result = sliceWorkoutsProjection(projection, limit, offset); + return apiSuccess(result); }); From 5169be5887b712d699024489fac991420600f1d6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 18 Jul 2026 04:42:18 +0200 Subject: [PATCH 2/8] fix(insights): order the correlations read desc so a hit cap keeps recent rows The FDR discovery read capped a dense account's 180-day window at orderBy asc + take, so exceeding the cap silently dropped the NEWEST rows. The early-detection pass exists specifically to see the recent window, so a capped read gave it nothing to work with. Order desc for both the measurement and mood reads, re-sort ascending in JS afterward (every downstream day-bucketing pass groups by day key, so pre-sort order doesn't matter to them), and annotate when a read actually hit its cap. --- .../correlations/__tests__/route.test.ts | 21 +++++++++++ src/app/api/insights/correlations/route.ts | 35 ++++++++++++++++--- 2 files changed, 51 insertions(+), 5 deletions(-) diff --git a/src/app/api/insights/correlations/__tests__/route.test.ts b/src/app/api/insights/correlations/__tests__/route.test.ts index a584897c9..3f4b1ed4e 100644 --- a/src/app/api/insights/correlations/__tests__/route.test.ts +++ b/src/app/api/insights/correlations/__tests__/route.test.ts @@ -196,6 +196,27 @@ describe("GET /api/insights/correlations", () => { expect(prisma.measurement.findMany).not.toHaveBeenCalled(); }); + // v1.30 (DATAINT M1) — `asc` + `take` truncated the RECENT end of the + // 180-day window on a dense account; the read must order `desc` so the + // cap keeps the newest rows (re-sorted `asc` in JS before series-building). + it("reads the measurement + mood window ordered desc so the cap keeps the newest rows", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + await callGet(makeReq()); + + expect(prisma.measurement.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + orderBy: { measuredAt: "desc" }, + take: 20000, + }), + ); + expect(prisma.moodEntry.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + orderBy: { moodLoggedAt: "desc" }, + take: 5000, + }), + ); + }); + // v1.18.0 (B2) — the route now also requires the `insights` module. it("returns 403 + module.disabled when the insights module is off", async () => { vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); diff --git a/src/app/api/insights/correlations/route.ts b/src/app/api/insights/correlations/route.ts index e741f1607..b4e1899fa 100644 --- a/src/app/api/insights/correlations/route.ts +++ b/src/app/api/insights/correlations/route.ts @@ -101,7 +101,17 @@ export const GET = apiHandler(async () => { // `buildMeasurementDailySeries`'s per-type grain resolution below (source // collapse for cumulative types, per-night reconstruction for sleep); the // rest of the channels never look at them. - const [measurements, moodEntries, priorityJson] = await Promise.all([ + // v1.30 (PERFAUDIT M1) — `orderBy asc` + `take` truncates from the START + // of the window. A dense account (unconsolidated step chunks alone can + // run ~200 rows/day → 36 000 rows over 180 days) can exceed the cap, and + // an ascending order then drops the NEWEST rows — exactly backwards for + // `discoverEmergingCorrelations`, whose entire job is the recent window. + // Order DESC so the cap keeps the most-recent rows, then re-sort ASC in + // JS before folding into per-type series (every downstream day-bucketing + // pass is order-insensitive, but callers document an ascending contract). + const MEASUREMENT_READ_CAP = 20000; + const MOOD_READ_CAP = 5000; + const [measurementsDesc, moodEntriesDesc, priorityJson] = await Promise.all([ prisma.measurement.findMany({ where: { userId: user.id, @@ -109,8 +119,8 @@ export const GET = apiHandler(async () => { measuredAt: { gte: since }, type: { in: [...behaviourTypes, ...outcomeTypes] }, }, - orderBy: { measuredAt: "asc" }, - take: 20000, + orderBy: { measuredAt: "desc" }, + take: MEASUREMENT_READ_CAP, select: { type: true, value: true, @@ -122,12 +132,20 @@ export const GET = apiHandler(async () => { }), prisma.moodEntry.findMany({ where: { userId: user.id, deletedAt: null, moodLoggedAt: { gte: since } }, - orderBy: { moodLoggedAt: "asc" }, - take: 5000, + orderBy: { moodLoggedAt: "desc" }, + take: MOOD_READ_CAP, select: { score: true, moodLoggedAt: true }, }), loadUserSourcePriority(user.id), ]); + const measurementsCapped = measurementsDesc.length >= MEASUREMENT_READ_CAP; + const moodCapped = moodEntriesDesc.length >= MOOD_READ_CAP; + const measurements = [...measurementsDesc].sort( + (a, b) => a.measuredAt.getTime() - b.measuredAt.getTime(), + ); + const moodEntries = [...moodEntriesDesc].sort( + (a, b) => a.moodLoggedAt.getTime() - b.moodLoggedAt.getTime(), + ); const measurementsByType = new Map(); for (const m of measurements) { @@ -221,6 +239,13 @@ export const GET = apiHandler(async () => { pairs_tested: result.pairsTested, discovered: result.discovered.length, fdr_q: result.fdrQ, + // PERFAUDIT M1 — surfaces when a dense account's window exceeded the + // read cap. The cap now falls on the OLDEST rows (desc + take), so a + // capped read still covers the recent window `discoverEmergingCorrelations` + // needs; this only tells a dashboard the retrospective scan's older + // half of the window may be thin. + measurements_capped: measurementsCapped, + mood_entries_capped: moodCapped, // FDREXTEND — per-channel day-counts so a dashboard can see whether the // two sparse new channels reached the n ≥ 20 floor or degraded to absent. compliance_days: complianceSeries.points.length, From 71d7ca78cb97ec5215bbec6017dc6eeb75fdb990 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 18 Jul 2026 04:42:27 +0200 Subject: [PATCH 3/8] fix(insights): scope intraday-pulse reads to the local day and fix the baseline tz MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two bugs in the S11 intraday pulse seam: - The PULSE/step reads queried a ±15h/39h padded superset around the viewed day, then capped the row count ascending. On a dense account the cap could be entirely consumed by the PREVIOUS local day's samples before reaching the viewed day at all, truncating the chart partway through the afternoon. Query the exact local-day window (DST-aware) instead — the workout-overlap read keeps the wider padding since it has no cap and a workout can legitimately span a local-day boundary. - The resting-baseline PULSE-proxy fallback bucketed every user's samples by a hardcoded Berlin day, not the caller's own timezone, shifting the personal floor the tension detector judges the day against for every non-Berlin user. Threads `timezone` into `resolveBaseline`'s `dayKeyOf`. --- .../__tests__/intraday-pulse-io.test.ts | 157 ++++++++++++++++++ src/lib/analytics/intraday-pulse-io.ts | 33 +++- 2 files changed, 186 insertions(+), 4 deletions(-) create mode 100644 src/lib/analytics/__tests__/intraday-pulse-io.test.ts diff --git a/src/lib/analytics/__tests__/intraday-pulse-io.test.ts b/src/lib/analytics/__tests__/intraday-pulse-io.test.ts new file mode 100644 index 000000000..f2d97fc0e --- /dev/null +++ b/src/lib/analytics/__tests__/intraday-pulse-io.test.ts @@ -0,0 +1,157 @@ +/** + * Unit coverage for `loadIntradayPulse` — the two DATAINT-audit fixes: + * + * - M2: the PULSE/step reads are bounded to the exact local-day window + * (`localDayWindow`), not the ±15h/39h padded superset a dense account's + * read cap could be entirely consumed by (the previous evening's + * samples starving the viewed day's afternoon). + * - M5: the resting-baseline PULSE-proxy fallback buckets by the CALLER's + * timezone, not a hardcoded Berlin default — `resolveBaseline` now + * threads `timezone` into `resolveRestingPulseSeries`'s `dayKeyOf`. + */ +import { describe, expect, it, vi, beforeEach } from "vitest"; + +const findManyMock = vi.fn(); +const workoutFindManyMock = vi.fn(); + +vi.mock("@/lib/db", () => ({ + prisma: { + measurement: { findMany: (...args: unknown[]) => findManyMock(...args) }, + workout: { findMany: (...args: unknown[]) => workoutFindManyMock(...args) }, + }, +})); + +import { loadIntradayPulse } from "@/lib/analytics/intraday-pulse-io"; +import { + resolveRestingPulseSeries, + type PulseSample, +} from "@/lib/analytics/resting-pulse"; +import { toBerlinDayKey } from "@/lib/tz/resolver"; +import { userDayKey } from "@/lib/tz/format"; +import { percentile } from "@/lib/insights/strain-score"; +import { localDayWindow } from "@/lib/measurements/consolidation-tz"; + +/** Mirrors the private `median()` helper in `intraday-pulse-io.ts`. */ +function median(values: number[]): number | null { + if (values.length === 0) return null; + return Math.round(percentile(values, 50) * 10) / 10; +} + +describe("loadIntradayPulse", () => { + beforeEach(() => { + findManyMock.mockReset(); + workoutFindManyMock.mockReset(); + workoutFindManyMock.mockResolvedValue([]); + }); + + it("M2 — queries PULSE/step rows against the exact local-day window, not the padded lead/trail superset", async () => { + const tz = "America/New_York"; + const dateKey = "2026-06-15"; + const { dayStart, dayEnd } = localDayWindow(dateKey, tz); + + findManyMock.mockResolvedValue([]); + + await loadIntradayPulse("user-1", tz, dateKey); + + const pulseCall = findManyMock.mock.calls.find( + (call) => call[0]?.where?.type === "PULSE", + ); + const stepCall = findManyMock.mock.calls.find( + (call) => call[0]?.where?.type === "ACTIVITY_STEPS", + ); + expect(pulseCall).toBeDefined(); + expect(stepCall).toBeDefined(); + expect(pulseCall![0].where.measuredAt).toEqual({ + gte: dayStart, + lt: dayEnd, + }); + expect(stepCall![0].where.measuredAt).toEqual({ + gte: dayStart, + lt: dayEnd, + }); + }); + + it("M5 — the PULSE-proxy baseline buckets by the caller's timezone, not Berlin", async () => { + const tz = "Pacific/Kiritimati"; // UTC+14, no DST — deterministic offset + const dateKey = "2026-06-15"; + + // Two UTC instants that land on DIFFERENT Kiritimati calendar days but + // the SAME Berlin calendar day (Kiritimati is ~13h ahead of Berlin in + // June) — a clean tz-bucketing fork. + const dayA = [ + new Date("2026-06-10T09:00:00.000Z"), // Kiritimati: 2026-06-10T23:00 (day A) + new Date("2026-06-10T10:00:00.000Z"), + new Date("2026-06-10T11:00:00.000Z"), + ]; + const dayB = [ + new Date("2026-06-10T12:00:00.000Z"), // Kiritimati: 2026-06-11T02:00 (day B) + new Date("2026-06-10T13:00:00.000Z"), + new Date("2026-06-10T14:00:00.000Z"), + ]; + expect(userDayKey(dayA[0], tz)).not.toBe(userDayKey(dayB[0], tz)); + // Sanity: under the OLD hardcoded-Berlin bug all six samples fall on the + // SAME Berlin day — proving the two tz groupings genuinely diverge. + const berlinDays = new Set( + [...dayA, ...dayB].map((d) => toBerlinDayKey(d)), + ); + expect(berlinDays.size).toBe(1); + + const pulseHistory: Array<{ value: number; measuredAt: Date }> = [ + ...dayA.map((measuredAt, i) => ({ value: 50 + i, measuredAt })), + ...dayB.map((measuredAt, i) => ({ value: 80 + i, measuredAt })), + ]; + + findManyMock.mockImplementation( + async (args: { where: { type: string } }) => { + if (args.where.type === "PULSE") return []; + if (args.where.type === "RESTING_HEART_RATE") return []; + return []; + }, + ); + // `resolveBaseline` fires a SECOND PULSE read (take: 365, no window) when + // resting rows are thin — swap the mock to serve it on the second PULSE + // call specifically by checking call order isn't reliable, so key off + // `select`/`take` shape instead. + findManyMock.mockImplementation( + async (args: { + where: { type: string; measuredAt?: unknown }; + take?: number; + }) => { + if (args.where.type === "RESTING_HEART_RATE") return []; + if (args.where.type === "PULSE" && args.take === 365) { + return pulseHistory; + } + // The view-day-windowed PULSE / ACTIVITY_STEPS reads — irrelevant here. + return []; + }, + ); + + const result = await loadIntradayPulse("user-1", tz, dateKey); + + expect(result.baselineSource).toBe("proxy"); + + const expected = resolveRestingPulseSeries({ + restingSamples: [], + pulseSamples: pulseHistory as PulseSample[], + dayKeyOf: (d) => userDayKey(d, tz), + }); + const expectedBaseline = median( + expected.series.slice(-30).map((p) => p.value), + ); + expect(result.baseline).toBe(expectedBaseline); + + // Regression guard: the OLD hardcoded-Berlin bucketing would have folded + // every sample into ONE day (see the `berlinDays.size === 1` sanity + // check above) and produced a materially different proxy value — assert + // the fixed result does NOT match that wrong grouping's outcome. + const buggyBerlin = resolveRestingPulseSeries({ + restingSamples: [], + pulseSamples: pulseHistory as PulseSample[], + dayKeyOf: (d) => toBerlinDayKey(d), + }); + const buggyBaseline = median( + buggyBerlin.series.slice(-30).map((p) => p.value), + ); + expect(result.baseline).not.toBe(buggyBaseline); + }); +}); diff --git a/src/lib/analytics/intraday-pulse-io.ts b/src/lib/analytics/intraday-pulse-io.ts index df5089d6b..0ec6ce966 100644 --- a/src/lib/analytics/intraday-pulse-io.ts +++ b/src/lib/analytics/intraday-pulse-io.ts @@ -18,6 +18,8 @@ import { prisma } from "@/lib/db"; import { percentile } from "@/lib/insights/strain-score"; import { resolveRestingPulseSeries } from "@/lib/analytics/resting-pulse"; +import { localDayWindow } from "@/lib/measurements/consolidation-tz"; +import { userDayKey } from "@/lib/tz/format"; import { BUCKET_MINUTES, HOURLY_BUCKET_MINUTES, @@ -73,8 +75,18 @@ function median(values: number[]): number | null { * baseline is the median of the resolved daily series — robust to the odd * outlier — and is "mature" only once at least `MIN_BASELINE_DAYS` distinct * daily points exist. + * + * `timezone` buckets the PULSE-proxy fallback in the USER's local day + * (DATAINT M5) — the caller previously omitted it, so + * `deriveRestingProxyFromPulse` defaulted to Berlin-day buckets for every + * user. A non-Berlin user's late-evening (often workout-heavy) samples then + * folded into the wrong day's bucket, shifting the daily floor the tension + * detector's `baseline + 12 bpm` threshold judges the day against. */ -async function resolveBaseline(userId: string): Promise<{ +async function resolveBaseline( + userId: string, + timezone: string, +): Promise<{ baseline: number | null; mature: boolean; source: "resting" | "proxy" | "none"; @@ -106,6 +118,7 @@ async function resolveBaseline(userId: string): Promise<{ measuredAt: r.measuredAt, value: r.value, })), + dayKeyOf: (d) => userDayKey(d, timezone), }); const recent = resolved.series.slice(-30).map((p) => p.value); @@ -148,6 +161,18 @@ export async function loadIntradayPulse( const anchorMs = new Date(`${dateKey}T00:00:00.000Z`).getTime(); const start = new Date(anchorMs - WINDOW_LEAD_MS); const end = new Date(anchorMs + WINDOW_TRAIL_MS); + // DATAINT M2 — PULSE/step reads are bounded to the EXACT local-day window + // (DST-aware; 23/24/25h), not the ±15h/39h padded superset. The prior + // padded read meant `take: MAX_DAY_*_ROWS` (an ascending cap) could be + // entirely consumed by the PREVIOUS local day's samples (e.g. a + // workout-heavy evening) before reaching the viewed day at all on a dense + // account — the cap now only ever spends against rows that are actually + // inside `dateKey`. `computeTenMinuteMeanSeries` / the step-bucket loop + // already re-filter by `dayKey` defensively, so this is a pure narrowing. + // The workout-overlap read keeps the wider padded window: it has no `take` + // cap, so there is no truncation risk, and a workout can legitimately + // start the local-day before or end the local-day after. + const { dayStart, dayEnd } = localDayWindow(dateKey, timezone); const [pulseRows, stepRows, workoutRows, baseline] = await Promise.all([ prisma.measurement.findMany({ @@ -155,7 +180,7 @@ export async function loadIntradayPulse( userId, type: "PULSE", deletedAt: null, - measuredAt: { gte: start, lte: end }, + measuredAt: { gte: dayStart, lt: dayEnd }, }, orderBy: { measuredAt: "asc" }, take: MAX_DAY_PULSE_ROWS, @@ -166,7 +191,7 @@ export async function loadIntradayPulse( userId, type: "ACTIVITY_STEPS", deletedAt: null, - measuredAt: { gte: start, lte: end }, + measuredAt: { gte: dayStart, lt: dayEnd }, }, orderBy: { measuredAt: "asc" }, take: MAX_DAY_STEP_ROWS, @@ -180,7 +205,7 @@ export async function loadIntradayPulse( }, select: { startedAt: true, endedAt: true }, }), - resolveBaseline(userId), + resolveBaseline(userId, timezone), ]); // Split live PULSE rows into raw per-sample rows and already-folded From 44ab74e1253337a439ae7bc62b0c3ffbb9d2999b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 18 Jul 2026 04:42:38 +0200 Subject: [PATCH 4/8] fix(nutrients): count each day once and anchor the overview window on user tz GET /api/nutrients had two correctness bugs: - `daysWithData` incremented on every row whose day differed from the PINNED latest-day field, so a second source's row on an already-seen older day (water logged via both Apple Health and a manual entry on the same day) opened a phantom second "day". Track a per-nutrient set of distinct day keys instead. - The window floor used a UTC day key against a local-timezone `day` column, off by up to a day at the edge for non-UTC users. Anchor on the caller's own local "today" instead, mirroring the sibling `GET /api/nutrients/daily` route. --- src/app/api/nutrients/__tests__/route.test.ts | 26 ++++++++++ src/app/api/nutrients/route.ts | 50 ++++++++++++------- 2 files changed, 59 insertions(+), 17 deletions(-) diff --git a/src/app/api/nutrients/__tests__/route.test.ts b/src/app/api/nutrients/__tests__/route.test.ts index a3ae3fac1..5e8ae5db3 100644 --- a/src/app/api/nutrients/__tests__/route.test.ts +++ b/src/app/api/nutrients/__tests__/route.test.ts @@ -132,6 +132,32 @@ describe("GET /api/nutrients", () => { ]); }); + it("counts a multi-source day once (DATAINT M3 regression)", async () => { + // v1.29 migration 0249 lets a day carry one row per source. A non-latest + // day logged via both APPLE_HEALTH and MANUAL must still count as ONE + // day, not two. + vi.mocked(prisma.nutrientIntakeDay.findMany).mockResolvedValue([ + { nutrient: "water", unit: "ml", day: "2026-07-13", amount: 900 }, + { nutrient: "water", unit: "ml", day: "2026-07-12", amount: 600 }, + { nutrient: "water", unit: "ml", day: "2026-07-12", amount: 400 }, + { nutrient: "water", unit: "ml", day: "2026-07-11", amount: 500 }, + ] as never); + + const res = await GET(getReq("?days=7")); + const body = (await res.json()) as OverviewResponse; + const water = body.data.nutrients.find((n) => n.nutrient === "water"); + expect(water).toEqual({ + nutrient: "water", + unit: "ml", + latestDay: "2026-07-13", + latestAmount: 900, + // Three distinct days (13th, 12th, 11th) — the buggy version counted + // four, since the 12th's second source row didn't match the pinned + // `latestDay` (the 13th) and opened a phantom extra day. + daysWithData: 3, + }); + }); + it("scopes the query to the authenticated user and the window floor", async () => { await GET(getReq("?days=14")); expect(prisma.nutrientIntakeDay.findMany).toHaveBeenCalledWith( diff --git a/src/app/api/nutrients/route.ts b/src/app/api/nutrients/route.ts index 29b87314b..2cd5d8824 100644 --- a/src/app/api/nutrients/route.ts +++ b/src/app/api/nutrients/route.ts @@ -17,6 +17,7 @@ import { annotate } from "@/lib/logging/context"; import { requireModuleEnabled } from "@/lib/modules/gate"; import { NUTRIENT_CODES, isNutrientCode } from "@/lib/nutrients/catalog"; import { nutrientOverviewQuerySchema } from "@/lib/validations/nutrients"; +import { DEFAULT_TIMEZONE, shiftDateKey, userDayKey } from "@/lib/tz/format"; export const dynamic = "force-dynamic"; @@ -36,12 +37,14 @@ export const GET = apiHandler(async (request: NextRequest) => { } const { days } = parsed.data; - // Window floor as a UTC day key. The stored `day` is a local-timezone - // key, so the UTC floor is off by at most one calendar day at the - // window edge — fine for a "last N days" summary card. - const since = new Date(Date.now() - (days - 1) * 24 * 60 * 60 * 1000) - .toISOString() - .slice(0, 10); + // v1.30 (DATAINT L4) — window floor anchored on the caller's own local + // "today" (`User.timezone`), mirroring the sibling `GET + // /api/nutrients/daily` route. The stored `day` is a local-timezone key, + // so a UTC floor was off by up to a calendar day at the window edge for + // any non-UTC user. + const userTz = user.timezone || DEFAULT_TIMEZONE; + const todayKey = userDayKey(new Date(), userTz); + const since = shiftDateKey(todayKey, -(days - 1)); const rows = await prisma.nutrientIntakeDay.findMany({ where: { userId: user.id, day: { gte: since } }, @@ -50,14 +53,26 @@ export const GET = apiHandler(async (request: NextRequest) => { }); // v1.29 — `source` joined the PK (migration 0249): a day can now carry - // an APPLE_HEALTH row AND a MANUAL row, so the fold sums amounts - // WITHIN (nutrient, day) before folding across days. Rows arrive - // day-DESC inside each nutrient, so the first DAY seen per code is - // the latest; a second row for that same day (the other source) adds - // to the running total instead of opening a new "day". + // an APPLE_HEALTH row AND a MANUAL row. Rows arrive day-DESC inside each + // nutrient, so the first DAY seen per code is the latest; a second row + // for that same day (the other source) adds to the running total instead + // of opening a new "day". + // + // v1.30 (DATAINT M3) — `daysWithData` used to increment on every row + // whose `day` differed from the PINNED `latestDay` field, so a second + // source's row on an already-counted OLDER day (e.g. water logged via + // both APPLE_HEALTH and MANUAL on the same non-latest day) opened a + // second "day" for that same calendar date. `daysSeen` is a per-nutrient + // set of distinct day keys — every row adds to it, so a repeated day + // (any number of source rows) is counted exactly once. const byCode = new Map< string, - { unit: string; latestDay: string; latestAmount: number; days: number } + { + unit: string; + latestDay: string; + latestAmount: number; + daysSeen: Set; + } >(); for (const row of rows) { const existing = byCode.get(row.nutrient); @@ -66,12 +81,13 @@ export const GET = apiHandler(async (request: NextRequest) => { unit: row.unit, latestDay: row.day, latestAmount: row.amount, - days: 1, + daysSeen: new Set([row.day]), }); - } else if (row.day === existing.latestDay) { + continue; + } + existing.daysSeen.add(row.day); + if (row.day === existing.latestDay) { existing.latestAmount += row.amount; - } else { - existing.days += 1; } } @@ -86,7 +102,7 @@ export const GET = apiHandler(async (request: NextRequest) => { unit: summary.unit, latestDay: summary.latestDay, latestAmount: summary.latestAmount, - daysWithData: summary.days, + daysWithData: summary.daysSeen.size, }; }); From e9e186dca5ad11be181e23ef209cfb1d20a09059 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 18 Jul 2026 04:42:49 +0200 Subject: [PATCH 5/8] fix(gamification): bucket achievement streaks by the badge owner's own tz MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every day-key derivation feeding the achievement streak/count badges (login, entry, weekend, miss-free, sleep-log, on-time, compliance-80) plus the hidden night-owl/early-bird triggers hardcoded Europe/Berlin, including the SQL vitals-bucket query's `AT TIME ZONE` literal. A non-CET user's evening entries could fold into the wrong calendar day, or two genuinely distinct local days could merge into one, silently breaking streak badges for every non-CET account. Threads the account's own `User.timezone` through `buildAchievementsResult` into the SQL query (bound as a parameter, not spliced — Postgres accepts a text value on the right side of `AT TIME ZONE`, verified against a live instance) and every JS-side day-key helper. The pure helpers in `expansion-metrics.ts` and `care-metrics.ts` keep the Berlin default when `tz` is omitted, so every existing caller/fixture is unaffected. --- .../__tests__/care-metrics.test.ts | 16 +++ .../__tests__/expansion-metrics.test.ts | 55 +++++++++ src/lib/gamification/achievements-result.ts | 111 +++++++++++------- src/lib/gamification/care-metrics.ts | 14 ++- src/lib/gamification/expansion-metrics.ts | 97 +++++++-------- 5 files changed, 200 insertions(+), 93 deletions(-) diff --git a/src/lib/gamification/__tests__/care-metrics.test.ts b/src/lib/gamification/__tests__/care-metrics.test.ts index c4cc41904..00a97b356 100644 --- a/src/lib/gamification/__tests__/care-metrics.test.ts +++ b/src/lib/gamification/__tests__/care-metrics.test.ts @@ -73,6 +73,22 @@ describe("getMissFreeDayKeys", () => { ]); expect(keys).toEqual(["2026-05-01"]); }); + + it("buckets by the CALLER's timezone when supplied (DATAINT M4)", () => { + // 2026-05-01T23:30:00Z: Berlin (CEST, UTC+2) reads 2026-05-02T01:30 — + // the NEXT day. A resolved slot at that instant must key to 05-01 for + // an America/New_York (UTC-4 in May) caller, not 05-02. + const lateEvent: CareIntakeEventRecord = { + scheduledFor: new Date("2026-05-01T23:30:00Z"), + takenAt: new Date("2026-05-01T23:35:00Z"), + skipped: false, + autoMissed: false, + }; + const berlinDefault = getMissFreeDayKeys([lateEvent]); + const newYork = getMissFreeDayKeys([lateEvent], "America/New_York"); + expect(berlinDefault).toEqual(["2026-05-02"]); + expect(newYork).toEqual(["2026-05-01"]); + }); }); describe("getWeeklyConsistency", () => { diff --git a/src/lib/gamification/__tests__/expansion-metrics.test.ts b/src/lib/gamification/__tests__/expansion-metrics.test.ts index 4a5671a16..6dde24ab6 100644 --- a/src/lib/gamification/__tests__/expansion-metrics.test.ts +++ b/src/lib/gamification/__tests__/expansion-metrics.test.ts @@ -268,6 +268,61 @@ describe("getHiddenMetrics", () => { }); }); +describe("getEngagementMetrics — tz threading (DATAINT M4)", () => { + it("buckets entry-day-streak by the CALLER's timezone, not a hardcoded Berlin default", () => { + // Two instants 2.5h apart in UTC. Under Berlin (CEST, UTC+2 in June) + // they straddle a MIDNIGHT boundary and land on two DIFFERENT + // consecutive local days (streak 2). Under Kiritimati (UTC+14) both + // fall on the SAME local day (streak 1). If `tz` were silently ignored + // (still hardcoded Berlin), both calls would report streak 2. + const measurements = [ + { type: "WEIGHT", measuredAt: new Date("2026-06-10T21:00:00.000Z") }, + { type: "PULSE", measuredAt: new Date("2026-06-10T23:30:00.000Z") }, + ]; + const berlinDefault = getEngagementMetrics({ + measurements, + moodEntries: [], + intakeEvents: [], + }); + const kiritimati = getEngagementMetrics({ + measurements, + moodEntries: [], + intakeEvents: [], + tz: "Pacific/Kiritimati", + }); + expect(berlinDefault.entryDayStreak).toBe(2); + expect(kiritimati.entryDayStreak).toBe(1); + }); +}); + +describe("getHiddenMetrics — tz threading (DATAINT M4)", () => { + it("classifies night-owl/early-bird hours against the caller's own timezone", () => { + // 2026-04-15T00:30:00Z: Berlin (CEST, UTC+2) reads 02:30 → night-owl. + // The same instant in America/Los_Angeles (PDT, UTC-7) reads 17:30 the + // PREVIOUS day → neither night-owl nor early-bird. If `tz` were still + // hardcoded Berlin, both calls would report the same night-owl count. + const measurements = [ + { type: "WEIGHT", measuredAt: new Date("2026-04-15T00:30:00Z") }, + ]; + const berlin = getHiddenMetrics({ + measurements, + moodEntries: [], + intakeEvents: [], + auditEvents: [], + }); + const losAngeles = getHiddenMetrics({ + measurements, + moodEntries: [], + intakeEvents: [], + auditEvents: [], + tz: "America/Los_Angeles", + }); + expect(berlin.nightOwlCount).toBe(1); + expect(losAngeles.nightOwlCount).toBe(0); + expect(losAngeles.earlyBirdCount).toBe(0); + }); +}); + describe("getEarnabilityFlags", () => { it("flags categories the user has data for", () => { expect( diff --git a/src/lib/gamification/achievements-result.ts b/src/lib/gamification/achievements-result.ts index e6da30bc1..ed8115368 100644 --- a/src/lib/gamification/achievements-result.ts +++ b/src/lib/gamification/achievements-result.ts @@ -22,7 +22,6 @@ import { calculateLongestStreak, evaluateAchievementsWithCompletionDates, moduleForMetric, - toBerlinDayKey, type AchievementMetrics, type AchievementProgress, } from "@/lib/gamification/achievements"; @@ -41,6 +40,7 @@ import { } from "@/lib/analytics/classifications"; import { classifyIntakeTiming } from "@/lib/analytics/compliance"; import { wallClockInTz } from "@/lib/tz/wall-clock"; +import { DEFAULT_TIMEZONE, userDayKey } from "@/lib/tz/format"; export type AuthedUser = Awaited>["user"]; @@ -74,22 +74,23 @@ type MedicationScheduleRecord = { /** * v1.28.25 — one SQL-aggregated vitals bucket: every non-deleted, non-import - * row of `type` inside the Berlin-local hour `hour` of day `dayKey`, folded - * to a row count + value sum by the database. The vitals window is - * rollout-date → now and grows forever, and PULSE can be per-sample heart - * rate (six figures on a long-tenured wearable account) — reading raw rows - * here was the scale hazard. Every consumer's semantics survive the fold: - * green-day classification needs the per-day per-type MEAN (= Σsum / Σcount - * across the day's hour buckets — identical to the raw mean up to - * floating-point summation order), the count badges need row counts, the - * engagement / consistency series need day presence, and the hidden + * row of `type` inside the badge-owner's LOCAL hour `hour` of day `dayKey` + * (DATAINT M4 — was hardcoded Berlin; the query now binds the caller's own + * `User.timezone`), folded to a row count + value sum by the database. The + * vitals window is rollout-date → now and grows forever, and PULSE can be + * per-sample heart rate (six figures on a long-tenured wearable account) — + * reading raw rows here was the scale hazard. Every consumer's semantics + * survive the fold: green-day classification needs the per-day per-type MEAN + * (= Σsum / Σcount across the day's hour buckets — identical to the raw mean + * up to floating-point summation order), the count badges need row counts, + * the engagement / consistency series need day presence, and the hidden * night-owl / early-bird / leap-day tallies need per-hour row counts. */ type VitalsHourBucket = { type: "WEIGHT" | "BLOOD_PRESSURE_SYS" | "BLOOD_PRESSURE_DIA" | "PULSE"; - /** Berlin-local YYYY-MM-DD. */ + /** User-local YYYY-MM-DD. */ dayKey: string; - /** Berlin-local hour 0–23. */ + /** User-local hour 0–23. */ hour: number; /** Number of raw rows folded into this bucket. */ rowCount: number; @@ -102,21 +103,22 @@ function maxDate(a: Date, b: Date): Date { } /** - * v1.18.1 P4 — every Berlin-local day key covered by an illness episode - * (onset day through the resolved day, or through `now` while ongoing), - * inclusive on both ends. These are the days a streak is allowed to lapse - * across without breaking (the Rest Mode freeze). Uses the same - * `toBerlinDayKey` day anchoring every other streak series uses, so the + * v1.18.1 P4 — every local day key covered by an illness episode (onset day + * through the resolved day, or through `now` while ongoing), inclusive on + * both ends. These are the days a streak is allowed to lapse across without + * breaking (the Rest Mode freeze). Uses the same `tz` day anchoring every + * other streak series uses (DATAINT M4 — was hardcoded Berlin), so the * frozen days line up with the qualifying days exactly. */ function collectIllnessDayKeys( episodes: Array<{ onsetAt: Date; resolvedAt: Date | null }>, now: Date, + tz: string, ): Set { const frozen = new Set(); for (const ep of episodes) { - const startSerial = dayKeyToSerial(toBerlinDayKey(ep.onsetAt)); - const endSerial = dayKeyToSerial(toBerlinDayKey(ep.resolvedAt ?? now)); + const startSerial = dayKeyToSerial(userDayKey(ep.onsetAt, tz)); + const endSerial = dayKeyToSerial(userDayKey(ep.resolvedAt ?? now, tz)); for (let serial = startSerial; serial <= endSerial; serial++) { frozen.add(serialToDayKey(serial)); } @@ -188,14 +190,17 @@ function toDaySeries(dayKeys: string[]): { }; } -function getEventDaySeries(dates: Date[]): { +function getEventDaySeries( + dates: Date[], + tz: string, +): { dayKeys: string[]; firstDateByDay: Map; } { const firstDateByDay = new Map(); for (const date of dates) { - const dayKey = toBerlinDayKey(date); + const dayKey = userDayKey(date, tz); const existing = firstDateByDay.get(dayKey); if (!existing || date < existing) { @@ -288,7 +293,10 @@ function getOnTimePerfectDaySeries( const eventsByDay = new Map(); for (const event of intakeEvents) { - const dayKey = toBerlinDayKey(event.scheduledFor); + // DATAINT M4 — was `toBerlinDayKey`; this function already threads `tz` + // for the schedule-window classification below, but the day-BUCKETING + // key itself was still hardcoded Berlin. + const dayKey = userDayKey(event.scheduledFor, tz); const list = eventsByDay.get(dayKey) ?? []; list.push(event); eventsByDay.set(dayKey, list); @@ -392,6 +400,7 @@ function getExpectedIntakesForDay( function getIntakeIssueMetrics( intakeEvents: IntakeEventRecord[], schedulesByMedicationId: Map, + tz: string, ) { const skippedIntakeDates = intakeEvents .filter((event) => event.skipped) @@ -408,7 +417,7 @@ function getIntakeIssueMetrics( continue; } - const dayKey = toBerlinDayKey(event.scheduledFor); + const dayKey = userDayKey(event.scheduledFor, tz); const byDay = eventsByMedicationDay.get(event.medicationId) ?? new Map(); const list = byDay.get(dayKey) ?? []; list.push(event); @@ -470,11 +479,12 @@ function getCompliance80DaySeries( intakeEvents: IntakeEventRecord[], startDate: Date, endDate: Date, + tz: string, ) { const perDayCounts = new Map(); for (const event of intakeEvents) { - const serial = dayKeyToSerial(toBerlinDayKey(event.scheduledFor)); + const serial = dayKeyToSerial(userDayKey(event.scheduledFor, tz)); const current = perDayCounts.get(serial) ?? { logged: 0, taken: 0 }; const logged = event.skipped || event.takenAt !== null; @@ -486,8 +496,8 @@ function getCompliance80DaySeries( perDayCounts.set(serial, current); } - const startSerial = dayKeyToSerial(toBerlinDayKey(startDate)); - const endSerial = dayKeyToSerial(toBerlinDayKey(endDate)); + const startSerial = dayKeyToSerial(userDayKey(startDate, tz)); + const endSerial = dayKeyToSerial(userDayKey(endDate, tz)); let rollingLogged = 0; let rollingTaken = 0; @@ -538,6 +548,14 @@ export async function buildAchievementsResult( const now = new Date(); const userId = user.id; const startDate = maxDate(user.createdAt, GAMIFICATION_ROLLOUT_AT); + // v1.30 (DATAINT M4) — every day-key derivation below anchors on the + // BADGE OWNER's own timezone, not a hardcoded Berlin default. Before this, + // a non-CET user's evening entries could fold into the wrong calendar day + // (or merge two real local days into one), silently breaking every + // day-streak badge (login, entry, weekend, miss-free, sleep-log, on-time, + // compliance-80) plus the hidden night-owl / early-bird / leap-day + // triggers for every non-CET account. + const tz = user.timezone || DEFAULT_TIMEZONE; const [ vitalsBuckets, @@ -550,7 +568,7 @@ export async function buildAchievementsResult( healthProfile, illnessEpisodes, ] = await Promise.all([ - // v1.28.25 — vitals folded to (type, Berlin day, Berlin hour) buckets in + // v1.28.25 — vitals folded to (type, local day, local hour) buckets in // SQL instead of one row per sample. The window (rollout → now) grows // forever and PULSE can be per-sample heart rate, so the raw read ran to // six figures on long-tenured wearable accounts; the bucket set is @@ -561,18 +579,21 @@ export async function buildAchievementsResult( // (never user input), source ≠ IMPORT, and soft-deleted rows stay // excluded (v1.4.41 W-DELETED-2) so deleting a row immediately rolls // back any streak / count badge it earned. `AT TIME ZONE 'UTC' AT TIME - // ZONE 'Europe/Berlin'` re-tags the naive UTC `measured_at` and converts - // it to the same Berlin wall clock `toBerlinDayKey` / `berlinHour` - // derive via Intl. Ordering pins day → hour → type for determinism. + // ZONE ${tz}` re-tags the naive UTC `measured_at` and converts it to the + // BADGE OWNER's own wall clock (DATAINT M4 — was hardcoded + // 'Europe/Berlin'; `tz` is bound as a query parameter, never spliced, and + // is only ever a value `PUT /api/auth/me/timezone` already validated + // against `Intl`'s IANA zone list before it reached the column). + // Ordering pins day → hour → type for determinism. prisma.$queryRaw` SELECT m."type"::text AS "type", to_char( - (m."measured_at" AT TIME ZONE 'UTC') AT TIME ZONE 'Europe/Berlin', + (m."measured_at" AT TIME ZONE 'UTC') AT TIME ZONE ${tz}, 'YYYY-MM-DD' ) AS "dayKey", EXTRACT( - HOUR FROM (m."measured_at" AT TIME ZONE 'UTC') AT TIME ZONE 'Europe/Berlin' + HOUR FROM (m."measured_at" AT TIME ZONE 'UTC') AT TIME ZONE ${tz} )::int AS "hour", COUNT(*)::int AS "rowCount", SUM(m."value")::double precision AS "valueSum" @@ -708,12 +729,12 @@ export async function buildAchievementsResult( : Promise.resolve([]), ]); - // v1.18.1 P4 — Rest Mode streak-freeze. Build the set of Berlin-local day - // keys covered by an illness episode (onset → resolved, or → now while + // v1.18.1 P4 — Rest Mode streak-freeze. Build the set of local-day keys + // covered by an illness episode (onset → resolved, or → now while // ongoing). A day-streak that lapses only across these days is bridged // rather than broken (see `bridgeFrozenStreakGaps`): being unwell pauses a // streak, it does not fail it. - const frozenIllnessDayKeys = collectIllnessDayKeys(illnessEpisodes, now); + const frozenIllnessDayKeys = collectIllnessDayKeys(illnessEpisodes, now, tz); const freeze = (dayKeys: string[]): string[] => bridgeFrozenStreakGaps(dayKeys, frozenIllnessDayKeys); @@ -723,6 +744,7 @@ export async function buildAchievementsResult( const intakeIssueMetrics = getIntakeIssueMetrics( intakeEvents, schedulesByMedicationId, + tz, ); const takenIntakeDates = intakeEvents @@ -735,12 +757,12 @@ export async function buildAchievementsResult( const passwordLoginDates = auditEvents .filter((event) => event.action === "auth.login.password") .map((event) => event.createdAt); - const loginDaySeries = getEventDaySeries([ - ...passkeyLoginDates, - ...passwordLoginDates, - ]); + const loginDaySeries = getEventDaySeries( + [...passkeyLoginDates, ...passwordLoginDates], + tz, + ); - // v1.28.25 — the buckets already carry the Berlin day-key + hour straight + // v1.28.25 — the buckets already carry the local day-key + hour straight // from SQL (superseding the v1.18.11 Intl-once optimisation: no Intl pass // over the vitals remains at all). The parallel arrays thread into every // consumer exactly as the per-row keys did; day-presence consumers dedup @@ -762,21 +784,23 @@ export async function buildAchievementsResult( const onTimeSeries = getOnTimePerfectDaySeries( intakeEvents, schedulesByMedicationId, - user.timezone, + tz, ); const complianceSeries = getCompliance80DaySeries( intakeEvents, startDate, now, + tz, ); // v1.16.1 — care-routine series. Miss-free days and sleep-logging // days reuse the standard day-series plumbing; the weekly measurement // consistency folds distinct active vitals days into Monday-anchored // weeks (≥5 active days each, 4 consecutive weeks for the badge). - const missFreeSeries = toDaySeries(getMissFreeDayKeys(intakeEvents)); + const missFreeSeries = toDaySeries(getMissFreeDayKeys(intakeEvents, tz)); const sleepSeries = getEventDaySeries( sleepMeasurements.map((m) => m.measuredAt), + tz, ); const weeklyConsistency = getWeeklyConsistency( // Bucket day-keys — `getWeeklyConsistency` dedups to distinct days, so @@ -799,11 +823,12 @@ export async function buildAchievementsResult( moodEntries, intakeEvents, auditEvents, - // The SQL-derived Berlin day-keys + hours ride in parallel to the + // The SQL-derived local day-keys + hours ride in parallel to the // weighted bucket records — the expansion passes never touch a bucket's // representative `measuredAt`. measurementDayKeys: vitalsDayKeys, measurementHours: vitalsHours, + tz, }); const earnability = getEarnabilityFlags({ hasMedication: medications.length > 0, diff --git a/src/lib/gamification/care-metrics.ts b/src/lib/gamification/care-metrics.ts index 5ed7f8cc7..0ae370622 100644 --- a/src/lib/gamification/care-metrics.ts +++ b/src/lib/gamification/care-metrics.ts @@ -24,7 +24,7 @@ * boring, clinically useful habit of measuring most days of the * week, week after week, without demanding a perfect daily streak. */ -import { toBerlinDayKey } from "./achievements"; +import { DEFAULT_TIMEZONE, userDayKey } from "@/lib/tz/format"; export interface CareIntakeEventRecord { scheduledFor: Date; @@ -37,11 +37,19 @@ export interface CareIntakeEventRecord { * Qualifying day keys for the miss-free streak, sorted ascending. * Feed the result to `calculateLongestStreak` / * `findStreakCompletionDate` like every other day series. + * + * `tz` defaults to `DEFAULT_TIMEZONE` ("Europe/Berlin") so every existing + * caller/fixture that never set one keeps its prior behaviour byte-for- + * byte (DATAINT M4) — the achievements builder now threads the badge + * owner's real `User.timezone` in. */ -export function getMissFreeDayKeys(events: CareIntakeEventRecord[]): string[] { +export function getMissFreeDayKeys( + events: CareIntakeEventRecord[], + tz: string = DEFAULT_TIMEZONE, +): string[] { const byDay = new Map(); for (const event of events) { - const dayKey = toBerlinDayKey(event.scheduledFor); + const dayKey = userDayKey(event.scheduledFor, tz); const bucket = byDay.get(dayKey) ?? { resolved: 0, missed: 0 }; if (event.autoMissed) { bucket.missed += 1; diff --git a/src/lib/gamification/expansion-metrics.ts b/src/lib/gamification/expansion-metrics.ts index 34285f2b9..f85e28742 100644 --- a/src/lib/gamification/expansion-metrics.ts +++ b/src/lib/gamification/expansion-metrics.ts @@ -7,35 +7,27 @@ * `evaluateAchievementsWithCompletionDates` evaluates the new * achievements with no further plumbing. */ -import { - calculateLongestStreak, - toBerlinDayKey, - type EarnabilityFlags, -} from "./achievements"; +import { calculateLongestStreak, type EarnabilityFlags } from "./achievements"; +import { DEFAULT_TIMEZONE, userDayKey } from "@/lib/tz/format"; +import { wallClockInTz } from "@/lib/tz/wall-clock"; -const BERLIN_TIMEZONE = "Europe/Berlin"; - -const BERLIN_HOUR_FORMATTER = new Intl.DateTimeFormat("en-US", { - timeZone: BERLIN_TIMEZONE, - hour: "2-digit", - hour12: false, -}); - -function berlinHour(date: Date): number { - const value = BERLIN_HOUR_FORMATTER.format(date); - // formatToParts is more reliable across runtimes — split + parseInt - // works because the formatter uses 24-hour with no extra tokens. - const parsed = Number.parseInt(value, 10); - return Number.isNaN(parsed) ? 0 : parsed; +/** + * v1.30 (DATAINT M4) — day-key / hour derivation now takes the CALLER's + * timezone (`User.timezone`), defaulting to `DEFAULT_TIMEZONE` ("Europe/ + * Berlin") when omitted so every existing fixture/caller that never set a + * `tz` keeps its prior (Berlin-anchored) behaviour byte-for-byte. Before + * this, every derivation hardcoded Berlin regardless of the achievement + * owner's real timezone — a non-CET user's evening entries could fold + * into the wrong calendar day (or merge two real days into one), silently + * breaking day-streak badges (`entryDayStreak`, `weekendStreakCount`) and + * the hidden night-owl / early-bird triggers for every non-CET account. + */ +function hourInTz(date: Date, tz: string): number { + return wallClockInTz(date, tz).hour; } -const BERLIN_WEEKDAY_FORMATTER = new Intl.DateTimeFormat("en-US", { - timeZone: BERLIN_TIMEZONE, - weekday: "short", -}); - -function berlinWeekday(date: Date): string { - return BERLIN_WEEKDAY_FORMATTER.format(date); +function isSaturdayInTz(date: Date, tz: string): boolean { + return wallClockInTz(date, tz).weekday === 6; } export interface MoodEntryRecord { @@ -165,30 +157,33 @@ export function getEngagementMetrics(input: { moodEntries: MoodEntryRecord[]; intakeEvents: IntakeEventRecord[]; /** - * v1.18.11 (W5 perf) — Berlin day-keys for `measurements`, precomputed - * once by the caller and passed in parallel to the array. When present the - * measurement rows reuse these instead of re-deriving a `toBerlinDayKey` - * per row; mood / intake timestamps (far fewer) still derive inline. Falls - * back to per-row derivation when absent — byte-identical result either way. + * v1.18.11 (W5 perf) — day-keys for `measurements`, precomputed once by + * the caller and passed in parallel to the array. When present the + * measurement rows reuse these instead of re-deriving a day key per row; + * mood / intake timestamps (far fewer) still derive inline. Falls back to + * per-row derivation when absent — byte-identical result either way. */ measurementDayKeys?: string[]; + /** v1.30 (DATAINT M4) — day-key timezone; defaults to Berlin (see file docblock). */ + tz?: string; }): { consistentMonthCount: number; entryDayStreak: number; weekendStreakCount: number; } { + const tz = input.tz ?? DEFAULT_TIMEZONE; const dayKeyAccum: string[] = []; for (let i = 0; i < input.measurements.length; i++) { dayKeyAccum.push( input.measurementDayKeys?.[i] ?? - toBerlinDayKey(input.measurements[i].measuredAt), + userDayKey(input.measurements[i].measuredAt, tz), ); } for (const e of input.moodEntries) - dayKeyAccum.push(toBerlinDayKey(e.moodLoggedAt)); + dayKeyAccum.push(userDayKey(e.moodLoggedAt, tz)); for (const i of input.intakeEvents) { - if (i.takenAt) dayKeyAccum.push(toBerlinDayKey(i.takenAt)); - else if (i.skipped) dayKeyAccum.push(toBerlinDayKey(i.scheduledFor)); + if (i.takenAt) dayKeyAccum.push(userDayKey(i.takenAt, tz)); + else if (i.skipped) dayKeyAccum.push(userDayKey(i.scheduledFor, tz)); } if (dayKeyAccum.length === 0) { @@ -238,20 +233,20 @@ export function getEngagementMetrics(input: { let weekendStreakCount = 0; let weekendRun = 0; // Walk one Saturday at a time. Use a simple `Date` cursor; the - // Berlin TZ formatter resolves the weekday so DST shifts are safe. + // caller's tz formatter resolves the weekday so DST shifts are safe. const cursor = parseDayKey(firstDay); const end = parseDayKey(lastDay); // Snap cursor to the next Saturday at 12:00 UTC (avoids DST edges). cursor.setUTCHours(12, 0, 0, 0); - while (berlinWeekday(cursor) !== "Sat") { + while (!isSaturdayInTz(cursor, tz)) { cursor.setUTCDate(cursor.getUTCDate() + 1); if (cursor > end) break; } while (cursor <= end) { - const sat = toBerlinDayKey(cursor); + const sat = userDayKey(cursor, tz); const sunDate = new Date(cursor); sunDate.setUTCDate(sunDate.getUTCDate() + 1); - const sun = toBerlinDayKey(sunDate); + const sun = userDayKey(sunDate, tz); if (daySet.has(sat) && daySet.has(sun)) { weekendRun += 1; weekendStreakCount = Math.max(weekendStreakCount, weekendRun); @@ -290,6 +285,8 @@ export function getHiddenMetrics(input: { */ measurementDayKeys?: string[]; measurementHours?: number[]; + /** v1.30 (DATAINT M4) — day-key timezone; defaults to Berlin (see file docblock). */ + tz?: string; }): { nightOwlCount: number; earlyBirdCount: number; @@ -297,6 +294,7 @@ export function getHiddenMetrics(input: { doctorPdfCount: number; localeFlipCount: number; } { + const tz = input.tz ?? DEFAULT_TIMEZONE; let nightOwl = 0; let earlyBird = 0; let leapDay = 0; @@ -314,16 +312,16 @@ export function getHiddenMetrics(input: { for (let i = 0; i < input.measurements.length; i++) { const m = input.measurements[i]; tally( - input.measurementHours?.[i] ?? berlinHour(m.measuredAt), - input.measurementDayKeys?.[i] ?? toBerlinDayKey(m.measuredAt), + input.measurementHours?.[i] ?? hourInTz(m.measuredAt, tz), + input.measurementDayKeys?.[i] ?? userDayKey(m.measuredAt, tz), m.count ?? 1, ); } for (const e of input.moodEntries) { - tally(berlinHour(e.moodLoggedAt), toBerlinDayKey(e.moodLoggedAt)); + tally(hourInTz(e.moodLoggedAt, tz), userDayKey(e.moodLoggedAt, tz)); } for (const i of input.intakeEvents) { - if (i.takenAt) tally(berlinHour(i.takenAt), toBerlinDayKey(i.takenAt)); + if (i.takenAt) tally(hourInTz(i.takenAt, tz), userDayKey(i.takenAt, tz)); } let doctorPdfCount = 0; @@ -412,22 +410,26 @@ export function buildExpansionMetricValues(input: { * hours derive from `measuredAt` exactly as before. */ measurementHours?: number[]; + /** v1.30 (DATAINT M4) — day-key timezone; defaults to Berlin (see file docblock). */ + tz?: string; }): ExpansionMetrics { + const tz = input.tz ?? DEFAULT_TIMEZONE; const counts = countMeasurementsByType(input.measurements); const mood = getMoodMetrics(input.moodEntries); - // The hidden pass also needs the per-row Berlin hour; derive it once here - // (only when the caller supplied day-keys, i.e. on the hot achievements - // path) so a single hour pass replaces the per-pass re-derivation. + // The hidden pass also needs the per-row hour; derive it once here (only + // when the caller supplied day-keys, i.e. on the hot achievements path) so + // a single hour pass replaces the per-pass re-derivation. const measurementHours = input.measurementHours ?? (input.measurementDayKeys - ? input.measurements.map((m) => berlinHour(m.measuredAt)) + ? input.measurements.map((m) => hourInTz(m.measuredAt, tz)) : undefined); const engagement = getEngagementMetrics({ measurements: input.measurements, moodEntries: input.moodEntries, intakeEvents: input.intakeEvents, measurementDayKeys: input.measurementDayKeys, + tz, }); const hidden = getHiddenMetrics({ measurements: input.measurements, @@ -436,6 +438,7 @@ export function buildExpansionMetricValues(input: { auditEvents: input.auditEvents, measurementDayKeys: input.measurementDayKeys, measurementHours, + tz, }); return { From 8dd04e0170ed763d01f14a977f3fa8060d504e5d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 18 Jul 2026 05:05:22 +0200 Subject: [PATCH 6/8] fix(ux): close medium UX-audit findings across flows, IA, and onboarding MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Coach deep-links now carry launch context in the URL instead of dropping it at the route boundary: /coach reads ?scope= (validated against the closed CoachScopeSource enum) and ?ask= to seed the drawer's own prefill/scope props, the Today check-in's "Adjust" action seeds ?ask= with the plan's own words instead of opening a blank chat, and the workout detail page scopes its coach button to workouts. IA: give the document vault a "View N lab values" link on LAB_RESULT-shaped documents (the auto-stage/OCR-commit linkage existed server-side with no UI surface); disambiguate the two "Recovery" pages (RECOVERY_SCORE anatomy vs the composite wearable page) with a distinct label plus a two-way cross-link; move the opt-in nutrients sync card from Source priority to Integrations, where every other sync surface lives; rename the user-menu theme switcher off "Appearance" (collides with the Settings hub) and the Environment settings section to "Location & weather"; cross-link mood, mental-wellbeing check-ins, and mood insights, which were three unconnected islands. Onboarding: the getting-started checklist's data-source/notifications rows now gate their fetch on the same checklistRelevant condition the other rows use, instead of only-while-onboarding — they used to be stuck undone forever for anyone who finished the wizard with under 5 measurements; the tour wrap-up no longer promises a replay affordance that was removed; the wizard's Withings card shows a connected badge once the OAuth round-trip lands instead of leaving the button looking untouched in the tab it was opened from; the done screen and Modules hub get a quiet cross-link; the sleep empty-state CTA reads "Connect a source" instead of being pinned to one vendor. All copy changes land across every locale; de.json reviewed for register. --- messages/de.json | 26 +++++++---- messages/en.json | 26 +++++++---- messages/es.json | 26 +++++++---- messages/fr.json | 26 +++++++---- messages/it.json | 26 +++++++---- messages/pl.json | 26 +++++++---- src/app/coach/page.tsx | 44 ++++++++++++++++++- src/app/insights/mood/page.tsx | 30 ++++++++++++- src/app/insights/recovery/page.tsx | 17 ++++++- src/app/insights/scores/[metric]/page.tsx | 18 ++++++++ src/app/insights/workouts/[id]/page.tsx | 6 ++- src/app/mood/page.tsx | 18 +++++++- .../documents/document-detail-sheet.tsx | 30 +++++++++++++ .../insights/coach-launch-button.tsx | 13 +++++- .../mental-health/mental-wellbeing.tsx | 15 +++++++ .../__tests__/source-card-grid.test.tsx | 25 ++++++++++- src/components/onboarding/done-screen.tsx | 39 ++++++++++++---- .../onboarding/getting-started-checklist.tsx | 18 +++++--- .../onboarding/source-card-grid.tsx | 32 +++++++++++++- .../settings/integrations-section.tsx | 17 +++++++ src/components/settings/sources-section.tsx | 9 ++-- src/lib/daily/__tests__/digest.test.ts | 9 +++- src/lib/daily/digest.ts | 14 +++++- 23 files changed, 431 insertions(+), 79 deletions(-) diff --git a/messages/de.json b/messages/de.json index 13fe46be9..c4e0a4ea5 100644 --- a/messages/de.json +++ b/messages/de.json @@ -228,7 +228,7 @@ "logout": "Abmelden", "login": "Anmelden", "notifications": "Benachrichtigungen", - "theme": "Darstellung", + "theme": "Design", "themeSystem": "System", "themeDark": "Dunkel", "themeLight": "Hell", @@ -605,6 +605,7 @@ "mood": { "title": "Stimmung", "subtitle": "Stimmungsverlauf und Einträge", + "mentalWellbeingLink": "Check-in zum seelischen Wohlbefinden machen", "addEntry": "Hinzufügen", "editEntry": "Eintrag bearbeiten", "moodLevel": "Stimmung", @@ -2894,7 +2895,7 @@ "sleep": { "title": "Noch keine Schlafwerte", "description": "Sobald Schlafdaten vorliegen (manuell oder via Withings / Apple Health), erscheint hier der nächtliche Stadien-Verlauf.", - "cta": "Withings verbinden" + "cta": "Quelle verbinden" } }, "navWorkouts": "Workouts", @@ -3262,6 +3263,8 @@ }, "navWalkingSpeed": "Gehgeschwindigkeit", "mood": { + "logLink": "Stimmung eintragen", + "mentalWellbeingLink": "Check-in zum seelischen Wohlbefinden machen", "heatmapTitle": "Stimmungskalender", "heatmapNoEntry": "Kein Eintrag", "legendGreat": "Super", @@ -3550,7 +3553,8 @@ "sectionSubtitle": "Zusammengesetzte Werte für Erholung, Stress und Belastung aus deinen verbundenen Geräten", "loadError": "Deine Wellness-Werte konnten gerade nicht geladen werden.", "loadingLabel": "Deine Gesundheitswerte werden geladen", - "recovery": "Erholung", + "recovery": "Erholungs-Score", + "recoveryCrossLink": "Erholungs- und Belastungsdaten deines Wearables ansehen", "stress": "Stress", "strain": "Belastung" }, @@ -3721,6 +3725,7 @@ "recovery": { "title": "Erholung", "description": "Wie gut sich dein Körper erholt hat – die Erholungs-, Belastungs- und Load-Signale, die dein Wearable über Nacht aufzeichnet.", + "scoreCrossLink": "Den zusammengesetzten Erholungs-Score ansehen", "emptyTitle": "Noch keine Erholungsdaten", "empty": "Noch keine Erholungsdaten. Verbinde ein WHOOP-, Polar- oder Oura-Konto, dann füllen sich diese Werte, sobald dein Gerät synchronisiert.", "block": { @@ -4009,7 +4014,7 @@ }, "wrapUp": { "title": "Das war die Tour", - "body": "Du kannst jedes Modul jederzeit einzeln erneut ansehen („Diese Tour zeigen“ auf jeder Seite) oder die komplette Tour unter Einstellungen → Erweitert neu starten." + "body": "Diese Führung läuft einmalig – sie öffnet sich nicht von selbst erneut. Alles Gezeigte bleibt erhalten, und du kannst Funktionen jederzeit unter Einstellungen → Module ein- oder ausschalten." } } }, @@ -4084,7 +4089,8 @@ "learning": "Manche Einblicke – Glukose, Schlafrhythmus, Blutdrucktrends, Zyklus – schärfen sich in den ersten ein bis zwei Wochen, sobald sich deine Daten ansammeln. Leere Kacheln heißen jetzt nur, dass wir deine Basislinie noch lernen.", "connectCta": "Gerät verbinden", "logCta": "Ersten Wert eintragen", - "importCta": "Bestehende Daten importieren" + "importCta": "Bestehende Daten importieren", + "modulesCta": "Module auswählen" }, "welcomeBack": { "title": "Willkommen zurück", @@ -4549,7 +4555,7 @@ "description": "Strukturierte Datensätze zu Allergien und Familienanamnese." }, "environment": { - "title": "Umwelt", + "title": "Standort & Wetter", "subtitle": "Wetter, Tageslicht und Standort-Kontext.", "disabledHint": "Aktiviere das Umwelt-Modul, um einen Heimatort festzulegen und tägliches Wetter und Tageslicht abzurufen.", "searchPlaceholder": "Stadt suchen", @@ -7959,6 +7965,7 @@ "mentalHealth": { "pageTitle": "Seelisches Wohlbefinden", "pageDescription": "Vier validierte, freiwillige Selbsttests — PHQ-9 (Stimmung), GAD-7 (Sorgen), WHO-5 (Wohlbefinden) und SCI (Schlaf) — ergänzend zu deinem Stimmungstagebuch. Aussagekräftig ist der Verlauf über die Zeit, nicht ein einzelner Wert.", + "moodLink": "Deinen Stimmungsverlauf ansehen", "attributionLabel": "Quelle", "attributionShow": "Quelle und Lizenz für {instrument}", "choosePrompt": "Check-in auswählen", @@ -8404,7 +8411,8 @@ "noConditions": "Mit keiner Erkrankung verknüpft", "previewUnavailable": "Keine Inline-Vorschau für diesen Dateityp", "uploadedMeta": "Hinzugefügt am {date} · {size}", - "share": "Teilen" + "share": "Teilen", + "labValuesLink": "{count} Laborwerte ansehen" }, "dropOverlay": { "title": "Zum Hochladen ablegen", @@ -8666,7 +8674,9 @@ "coachCheckin": { "title": "Zeit, einen Plan anzuschauen", "body": "Es ist etwa eine Woche her, seit du diesen Plan gesetzt hast — behalte ihn, passe ihn an oder lass ihn los. Ganz ohne Druck.", - "bodyPlan": "Vor etwa einer Woche hast du dir vorgenommen: „{plan}“. Behalte es, passe es an oder lass es los — ganz ohne Druck." + "bodyPlan": "Vor etwa einer Woche hast du dir vorgenommen: „{plan}“. Behalte es, passe es an oder lass es los — ganz ohne Druck.", + "adjustPrompt": "Ich möchte meinen Check-in-Plan anpassen.", + "adjustPromptPlan": "Ich möchte diesen Plan anpassen: „{plan}“" }, "tensionWindow": { "title": "Vielleicht eine angespannte Phase", diff --git a/messages/en.json b/messages/en.json index 82501a4bb..f6bc18f83 100644 --- a/messages/en.json +++ b/messages/en.json @@ -228,7 +228,7 @@ "logout": "Sign out", "login": "Sign in", "notifications": "Notifications", - "theme": "Appearance", + "theme": "Theme", "themeSystem": "System", "themeDark": "Dark", "themeLight": "Light", @@ -605,6 +605,7 @@ "mood": { "title": "Mood", "subtitle": "Mood tracker and entries", + "mentalWellbeingLink": "Take a mental-wellbeing check-in", "addEntry": "Add", "editEntry": "Edit entry", "moodLevel": "Mood", @@ -2894,7 +2895,7 @@ "sleep": { "title": "No sleep entries yet", "description": "Once sleep data lands (manual or via Withings / Apple Health), the per-night stage breakdown will appear here.", - "cta": "Connect Withings" + "cta": "Connect a source" } }, "navWorkouts": "Workouts", @@ -3262,6 +3263,8 @@ }, "navWalkingSpeed": "Walking Speed", "mood": { + "logLink": "Log a mood entry", + "mentalWellbeingLink": "Take a mental-wellbeing check-in", "heatmapTitle": "Mood calendar", "heatmapNoEntry": "No entry", "legendGreat": "Great", @@ -3550,7 +3553,8 @@ "sectionSubtitle": "Composite recovery, stress and strain read from your connected devices", "loadError": "Could not load your wellness scores right now.", "loadingLabel": "Loading your health scores", - "recovery": "Recovery", + "recovery": "Recovery score", + "recoveryCrossLink": "See your wearable's recovery & strain data", "stress": "Stress", "strain": "Strain" }, @@ -3721,6 +3725,7 @@ "recovery": { "title": "Recovery", "description": "How well your body has bounced back — the recovery, strain and load signals your wearable records overnight.", + "scoreCrossLink": "See the composite recovery score", "emptyTitle": "No recovery data yet", "empty": "No recovery data yet. Connect a WHOOP, Polar or Oura account and these scores fill in once your device syncs.", "block": { @@ -4009,7 +4014,7 @@ }, "wrapUp": { "title": "That’s the tour", - "body": "You can revisit any module on its own at any time (“Show this tour” on every page) or restart the whole tour under Settings → Advanced." + "body": "This walkthrough runs once — it won't reopen on its own. Everything it covered stays put, and you can turn features on or off any time under Settings → Modules." } } }, @@ -4084,7 +4089,8 @@ "learning": "Some insights — glucose, sleep rhythm, blood-pressure trends, cycle — sharpen over the first week or two as your data builds up. Empty tiles now just mean we're still learning your baseline.", "connectCta": "Connect a device", "logCta": "Log your first measurement", - "importCta": "Import existing history" + "importCta": "Import existing history", + "modulesCta": "Choose your modules" }, "welcomeBack": { "title": "Welcome back", @@ -4549,7 +4555,7 @@ "description": "Structured allergies and family history records." }, "environment": { - "title": "Environment", + "title": "Location & weather", "subtitle": "Weather, daylight, and location context.", "disabledHint": "Turn on the Environment module to set a home location and pull in daily weather and daylight.", "searchPlaceholder": "Search for a city", @@ -7959,6 +7965,7 @@ "mentalHealth": { "pageTitle": "Mental wellbeing", "pageDescription": "Validated, opt-in self-checks — PHQ-9 (mood), GAD-7 (worry), WHO-5 (well-being) and SCI (sleep) — that sit beside your mood tracking. The value is the trend over time, not any single number.", + "moodLink": "View your mood history", "attributionLabel": "Attribution", "attributionShow": "Source and licence for {instrument}", "choosePrompt": "Choose a check-in", @@ -8404,7 +8411,8 @@ "noConditions": "Not linked to a condition", "previewUnavailable": "No inline preview for this file type", "uploadedMeta": "Added {date} · {size}", - "share": "Share" + "share": "Share", + "labValuesLink": "View {count} lab values" }, "dropOverlay": { "title": "Drop files to upload", @@ -8666,7 +8674,9 @@ "coachCheckin": { "title": "Time to check in on a plan", "body": "It's been about a week since you set this plan — keep it, adjust it, or let it go. No pressure either way.", - "bodyPlan": "About a week ago you set: “{plan}”. Keep it, adjust it, or let it go — no pressure either way." + "bodyPlan": "About a week ago you set: “{plan}”. Keep it, adjust it, or let it go — no pressure either way.", + "adjustPrompt": "I'd like to adjust my check-in plan.", + "adjustPromptPlan": "I'd like to adjust this plan: “{plan}”" }, "tensionWindow": { "title": "Possible tension today", diff --git a/messages/es.json b/messages/es.json index fc6cbd9c5..8d741a5b9 100644 --- a/messages/es.json +++ b/messages/es.json @@ -228,7 +228,7 @@ "logout": "Cerrar sesión", "login": "Iniciar sesión", "notifications": "Notificaciones", - "theme": "Apariencia", + "theme": "Tema", "themeSystem": "Sistema", "themeDark": "Oscuro", "themeLight": "Claro", @@ -605,6 +605,7 @@ "mood": { "title": "Estado de ánimo", "subtitle": "Tendencia y registros de estado de ánimo", + "mentalWellbeingLink": "Hacer un chequeo de bienestar mental", "addEntry": "Añadir", "editEntry": "Editar entrada", "moodLevel": "Estado de ánimo", @@ -2894,7 +2895,7 @@ "sleep": { "title": "Aún no hay registros de sueño", "description": "Una vez disponibles los datos de sueño (manual o vía Withings / Apple Health), aquí aparecerá el desglose por fases.", - "cta": "Conectar Withings" + "cta": "Conectar una fuente" } }, "navWorkouts": "Entrenamientos", @@ -3262,6 +3263,8 @@ }, "navWalkingSpeed": "Velocidad al andar", "mood": { + "logLink": "Registrar un estado de ánimo", + "mentalWellbeingLink": "Hacer un chequeo de bienestar mental", "heatmapTitle": "Calendario de ánimo", "heatmapNoEntry": "Sin registro", "legendGreat": "Genial", @@ -3550,7 +3553,8 @@ "sectionSubtitle": "Valores combinados de recuperación, estrés y esfuerzo leídos de tus dispositivos conectados", "loadError": "No se pudieron cargar tus puntuaciones de bienestar en este momento.", "loadingLabel": "Cargando tus indicadores de salud", - "recovery": "Recuperación", + "recovery": "Puntuación de recuperación", + "recoveryCrossLink": "Ver los datos de recuperación y esfuerzo de tu wearable", "stress": "Estrés", "strain": "Esfuerzo" }, @@ -3721,6 +3725,7 @@ "recovery": { "title": "Recuperación", "description": "Cómo se ha recuperado tu cuerpo: las señales de recuperación, esfuerzo y carga que tu wearable registra durante la noche.", + "scoreCrossLink": "Ver la puntuación de recuperación combinada", "emptyTitle": "Aún no hay datos de recuperación", "empty": "Aún no hay datos de recuperación. Conecta una cuenta de WHOOP, Polar u Oura y estas puntuaciones se completarán cuando tu dispositivo sincronice.", "block": { @@ -4009,7 +4014,7 @@ }, "wrapUp": { "title": "Esto es la visita", - "body": "Puedes volver a ver cualquier módulo por separado cuando quieras («Mostrar esta visita» en cada página) o reiniciar la visita completa en Ajustes → Avanzado." + "body": "Esta visita guiada se muestra una sola vez, no volverá a abrirse por sí sola. Todo lo que has visto sigue disponible, y puedes activar o desactivar funciones cuando quieras en Ajustes → Módulos." } } }, @@ -4084,7 +4089,8 @@ "learning": "Algunas conclusiones —glucosa, ritmo de sueño, tendencias de presión arterial, ciclo— se afinan durante la primera semana o dos a medida que se acumulan tus datos. Las casillas vacías ahora solo significan que aún estamos aprendiendo tu línea base.", "connectCta": "Conectar un dispositivo", "logCta": "Registra tu primera medición", - "importCta": "Importar historial existente" + "importCta": "Importar historial existente", + "modulesCta": "Elige tus módulos" }, "welcomeBack": { "title": "Bienvenido de nuevo", @@ -4549,7 +4555,7 @@ "description": "Registros estructurados de alergias y antecedentes familiares." }, "environment": { - "title": "Entorno", + "title": "Ubicación y clima", "subtitle": "Contexto de tiempo, luz diurna y ubicación.", "disabledHint": "Activa el módulo Entorno para definir una ubicación principal e incorporar el tiempo y las horas de luz diarias.", "searchPlaceholder": "Buscar una ciudad", @@ -7959,6 +7965,7 @@ "mentalHealth": { "pageTitle": "Bienestar mental", "pageDescription": "Cuatro autoevaluaciones validadas y opcionales — PHQ-9 (estado de ánimo), GAD-7 (preocupación), WHO-5 (bienestar) y SCI (sueño) — junto a tu registro de ánimo. Lo valioso es la tendencia en el tiempo, no un único número.", + "moodLink": "Ver tu historial de estado de ánimo", "attributionLabel": "Fuente", "attributionShow": "Fuente y licencia de {instrument}", "choosePrompt": "Elige una revisión", @@ -8404,7 +8411,8 @@ "noConditions": "Sin vincular a una afección", "previewUnavailable": "Sin vista previa integrada para este tipo de archivo", "uploadedMeta": "Añadido el {date} · {size}", - "share": "Compartir" + "share": "Compartir", + "labValuesLink": "Ver {count} valores de laboratorio" }, "dropOverlay": { "title": "Suelta los archivos para subirlos", @@ -8666,7 +8674,9 @@ "coachCheckin": { "title": "Es buen momento para revisar un plan", "body": "Ha pasado alrededor de una semana desde que definiste este plan: consérvalo, ajústalo o déjalo ir. Sin presión.", - "bodyPlan": "Hace aproximadamente una semana te propusiste: «{plan}». Consérvalo, ajústalo o déjalo ir, sin presión." + "bodyPlan": "Hace aproximadamente una semana te propusiste: «{plan}». Consérvalo, ajústalo o déjalo ir, sin presión.", + "adjustPrompt": "Me gustaría ajustar mi plan de seguimiento.", + "adjustPromptPlan": "Me gustaría ajustar este plan: «{plan}»" }, "tensionWindow": { "title": "Quizá algo de tensión hoy", diff --git a/messages/fr.json b/messages/fr.json index e5839646e..1de8e7e72 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -228,7 +228,7 @@ "logout": "Se déconnecter", "login": "Se connecter", "notifications": "Notifications", - "theme": "Apparence", + "theme": "Thème", "themeSystem": "Système", "themeDark": "Sombre", "themeLight": "Clair", @@ -605,6 +605,7 @@ "mood": { "title": "Humeur", "subtitle": "Tendance et entrées d’humeur", + "mentalWellbeingLink": "Faire un bilan de bien-être mental", "addEntry": "Ajouter", "editEntry": "Modifier l’entrée", "moodLevel": "Humeur", @@ -2894,7 +2895,7 @@ "sleep": { "title": "Aucune donnée de sommeil", "description": "Dès que des données de sommeil sont disponibles (manuel ou via Withings / Apple Health), la répartition par stade apparaîtra ici.", - "cta": "Connecter Withings" + "cta": "Connecter une source" } }, "navWorkouts": "Entraînements", @@ -3262,6 +3263,8 @@ }, "navWalkingSpeed": "Vitesse de marche", "mood": { + "logLink": "Enregistrer une humeur", + "mentalWellbeingLink": "Faire un bilan de bien-être mental", "heatmapTitle": "Calendrier d'humeur", "heatmapNoEntry": "Aucune entrée", "legendGreat": "Excellent", @@ -3550,7 +3553,8 @@ "sectionSubtitle": "Valeurs combinées de récupération, de stress et d'effort issues de tes appareils connectés", "loadError": "Impossible de charger vos scores de bien-être pour le moment.", "loadingLabel": "Chargement de tes indicateurs de santé", - "recovery": "Récupération", + "recovery": "Score de récupération", + "recoveryCrossLink": "Voir les données de récupération et d'effort de votre wearable", "stress": "Stress", "strain": "Effort" }, @@ -3721,6 +3725,7 @@ "recovery": { "title": "Récupération", "description": "À quel point votre corps a récupéré : les signaux de récupération, de charge et d'effort que votre wearable enregistre la nuit.", + "scoreCrossLink": "Voir le score de récupération composite", "emptyTitle": "Pas encore de données de récupération", "empty": "Pas encore de données de récupération. Connectez un compte WHOOP, Polar ou Oura et ces scores se rempliront dès que votre appareil se synchronise.", "block": { @@ -4009,7 +4014,7 @@ }, "wrapUp": { "title": "C’est la fin de la visite", - "body": "Vous pouvez revoir chaque module séparément à tout moment (« Afficher cette visite » sur chaque page) ou relancer la visite complète dans Réglages → Avancé." + "body": "Cette visite guidée ne s'affiche qu'une fois — elle ne se rouvrira pas d'elle-même. Tout ce qu'elle a montré reste en place, et vous pouvez activer ou désactiver des fonctionnalités à tout moment dans Réglages → Modules." } } }, @@ -4084,7 +4089,8 @@ "learning": "Certaines analyses — glycémie, rythme de sommeil, tendances de tension, cycle — s'affinent au cours de la première semaine ou deux à mesure que vos données s'accumulent. Des tuiles vides signifient simplement que nous apprenons encore votre référence.", "connectCta": "Connecter un appareil", "logCta": "Enregistrez votre première mesure", - "importCta": "Importer un historique existant" + "importCta": "Importer un historique existant", + "modulesCta": "Choisir vos modules" }, "welcomeBack": { "title": "Bon retour", @@ -4549,7 +4555,7 @@ "description": "Dossiers structurés des allergies et des antécédents familiaux." }, "environment": { - "title": "Environnement", + "title": "Localisation et météo", "subtitle": "Contexte météo, lumière du jour et lieu.", "disabledHint": "Activez le module Environnement pour définir un lieu de référence et intégrer la météo et la durée du jour quotidiennes.", "searchPlaceholder": "Rechercher une ville", @@ -7959,6 +7965,7 @@ "mentalHealth": { "pageTitle": "Bien-être mental", "pageDescription": "Quatre auto-évaluations validées et facultatives — PHQ-9 (humeur), GAD-7 (inquiétude), WHO-5 (bien-être) et SCI (sommeil) — en complément de ton suivi de l'humeur. Ce qui compte, c'est la tendance dans le temps, pas un chiffre isolé.", + "moodLink": "Voir ton historique d'humeur", "attributionLabel": "Source", "attributionShow": "Source et licence pour {instrument}", "choosePrompt": "Choisis un bilan", @@ -8404,7 +8411,8 @@ "noConditions": "Lié à aucune affection", "previewUnavailable": "Pas d'aperçu intégré pour ce type de fichier", "uploadedMeta": "Ajouté le {date} · {size}", - "share": "Partager" + "share": "Partager", + "labValuesLink": "Voir {count} valeurs de laboratoire" }, "dropOverlay": { "title": "Déposez les fichiers pour les téléverser", @@ -8666,7 +8674,9 @@ "coachCheckin": { "title": "Il est temps de revoir un plan", "body": "Cela fait environ une semaine que vous avez défini ce plan : gardez-le, ajustez-le ou laissez-le. Sans pression.", - "bodyPlan": "Il y a environ une semaine, vous vous étiez fixé : « {plan} ». Gardez-le, ajustez-le ou laissez-le, sans pression." + "bodyPlan": "Il y a environ une semaine, vous vous étiez fixé : « {plan} ». Gardez-le, ajustez-le ou laissez-le, sans pression.", + "adjustPrompt": "J'aimerais ajuster mon plan de suivi.", + "adjustPromptPlan": "J'aimerais ajuster ce plan : « {plan} »" }, "tensionWindow": { "title": "Peut-être un peu de tension", diff --git a/messages/it.json b/messages/it.json index 744b0e761..870fc1256 100644 --- a/messages/it.json +++ b/messages/it.json @@ -228,7 +228,7 @@ "logout": "Esci", "login": "Accedi", "notifications": "Notifiche", - "theme": "Aspetto", + "theme": "Tema", "themeSystem": "Sistema", "themeDark": "Scuro", "themeLight": "Chiaro", @@ -605,6 +605,7 @@ "mood": { "title": "Umore", "subtitle": "Andamento e voci dell’umore", + "mentalWellbeingLink": "Fai un check-in sul benessere mentale", "addEntry": "Aggiungi", "editEntry": "Modifica voce", "moodLevel": "Umore", @@ -2894,7 +2895,7 @@ "sleep": { "title": "Nessuna voce di sonno", "description": "Quando saranno disponibili dati sul sonno (manuale o tramite Withings / Apple Health), qui apparirà la suddivisione per fase.", - "cta": "Collega Withings" + "cta": "Collega una fonte" } }, "navWorkouts": "Allenamenti", @@ -3262,6 +3263,8 @@ }, "navWalkingSpeed": "Velocità di cammino", "mood": { + "logLink": "Registra un umore", + "mentalWellbeingLink": "Fai un check-in sul benessere mentale", "heatmapTitle": "Calendario dell'umore", "heatmapNoEntry": "Nessun dato", "legendGreat": "Ottimo", @@ -3550,7 +3553,8 @@ "sectionSubtitle": "Valori combinati di recupero, stress e sforzo letti dai tuoi dispositivi connessi", "loadError": "Al momento non è stato possibile caricare i tuoi punteggi di benessere.", "loadingLabel": "Caricamento dei tuoi indicatori di salute", - "recovery": "Recupero", + "recovery": "Punteggio di recupero", + "recoveryCrossLink": "Vedi i dati di recupero e sforzo del tuo wearable", "stress": "Stress", "strain": "Sforzo" }, @@ -3721,6 +3725,7 @@ "recovery": { "title": "Recupero", "description": "Quanto bene il tuo corpo si è ripreso: i segnali di recupero, sforzo e carico che il tuo wearable registra durante la notte.", + "scoreCrossLink": "Vedi il punteggio di recupero composito", "emptyTitle": "Ancora nessun dato di recupero", "empty": "Ancora nessun dato di recupero. Collega un account WHOOP, Polar o Oura e questi punteggi si popoleranno appena il dispositivo si sincronizza.", "block": { @@ -4009,7 +4014,7 @@ }, "wrapUp": { "title": "Questa è la visita guidata", - "body": "Puoi rivedere ogni modulo singolarmente in qualsiasi momento («Mostra questa visita» su ogni pagina) o riavviare l’intera visita da Impostazioni → Avanzate." + "body": "Questa visita guidata viene mostrata una sola volta: non si riaprirà da sola. Tutto ciò che hai visto resta al suo posto e puoi attivare o disattivare le funzioni quando vuoi da Impostazioni → Moduli." } } }, @@ -4084,7 +4089,8 @@ "learning": "Alcune analisi — glicemia, ritmo del sonno, tendenze della pressione, ciclo — si affinano nella prima settimana o due man mano che i tuoi dati si accumulano. Le caselle vuote ora indicano solo che stiamo ancora imparando il tuo riferimento.", "connectCta": "Collega un dispositivo", "logCta": "Registra la tua prima misurazione", - "importCta": "Importa la cronologia esistente" + "importCta": "Importa la cronologia esistente", + "modulesCta": "Scegli i tuoi moduli" }, "welcomeBack": { "title": "Bentornato", @@ -4549,7 +4555,7 @@ "description": "Record strutturati di allergie e anamnesi familiare." }, "environment": { - "title": "Ambiente", + "title": "Posizione e meteo", "subtitle": "Contesto meteo, luce del giorno e posizione.", "disabledHint": "Attiva il modulo Ambiente per impostare una posizione principale e integrare il meteo e le ore di luce giornaliere.", "searchPlaceholder": "Cerca una città", @@ -7959,6 +7965,7 @@ "mentalHealth": { "pageTitle": "Benessere mentale", "pageDescription": "Quattro autovalutazioni validate e facoltative — PHQ-9 (umore), GAD-7 (preoccupazione), WHO-5 (benessere) e SCI (sonno) — accanto al tuo diario dell'umore. Ciò che conta è l'andamento nel tempo, non un singolo numero.", + "moodLink": "Vedi il tuo andamento dell'umore", "attributionLabel": "Fonte", "attributionShow": "Fonte e licenza di {instrument}", "choosePrompt": "Scegli un check-in", @@ -8404,7 +8411,8 @@ "noConditions": "Non collegato a una patologia", "previewUnavailable": "Nessuna anteprima integrata per questo tipo di file", "uploadedMeta": "Aggiunto il {date} · {size}", - "share": "Condividi" + "share": "Condividi", + "labValuesLink": "Vedi {count} valori di laboratorio" }, "dropOverlay": { "title": "Rilascia i file per caricarli", @@ -8666,7 +8674,9 @@ "coachCheckin": { "title": "È il momento di rivedere un piano", "body": "È passata circa una settimana da quando hai definito questo piano: mantienilo, modificalo o lascialo andare. Senza pressioni.", - "bodyPlan": "Circa una settimana fa ti eri proposto: «{plan}». Mantienilo, modificalo o lascialo andare, senza pressioni." + "bodyPlan": "Circa una settimana fa ti eri proposto: «{plan}». Mantienilo, modificalo o lascialo andare, senza pressioni.", + "adjustPrompt": "Vorrei modificare il mio piano di controllo.", + "adjustPromptPlan": "Vorrei modificare questo piano: «{plan}»" }, "tensionWindow": { "title": "Forse un po' di tensione oggi", diff --git a/messages/pl.json b/messages/pl.json index 5e5e0bb6c..a7899e3b1 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -228,7 +228,7 @@ "logout": "Wyloguj", "login": "Zaloguj", "notifications": "Powiadomienia", - "theme": "Wygląd", + "theme": "Motyw", "themeSystem": "Systemowy", "themeDark": "Ciemny", "themeLight": "Jasny", @@ -605,6 +605,7 @@ "mood": { "title": "Nastrój", "subtitle": "Trend i wpisy nastroju", + "mentalWellbeingLink": "Wykonaj sprawdzian dobrostanu psychicznego", "addEntry": "Dodaj", "editEntry": "Edytuj wpis", "moodLevel": "Nastrój", @@ -2894,7 +2895,7 @@ "sleep": { "title": "Brak danych o śnie", "description": "Gdy pojawią się dane o śnie (ręcznie lub przez Withings / Apple Health), wyświetli się tu rozkład faz na noc.", - "cta": "Połącz Withings" + "cta": "Połącz źródło" } }, "navWorkouts": "Treningi", @@ -3262,6 +3263,8 @@ }, "navWalkingSpeed": "Prędkość chodu", "mood": { + "logLink": "Zapisz nastrój", + "mentalWellbeingLink": "Wykonaj sprawdzian dobrostanu psychicznego", "heatmapTitle": "Kalendarz nastroju", "heatmapNoEntry": "Brak wpisu", "legendGreat": "Świetnie", @@ -3550,7 +3553,8 @@ "sectionSubtitle": "Złożone wartości regeneracji, stresu i obciążenia odczytane z Twoich połączonych urządzeń", "loadError": "Nie udało się teraz wczytać Twoich wyników dobrostanu.", "loadingLabel": "Wczytywanie Twoich wskaźników zdrowia", - "recovery": "Regeneracja", + "recovery": "Wynik regeneracji", + "recoveryCrossLink": "Zobacz dane regeneracji i obciążenia z Twojego urządzenia", "stress": "Stres", "strain": "Obciążenie" }, @@ -3721,6 +3725,7 @@ "recovery": { "title": "Regeneracja", "description": "Jak dobrze Twój organizm się zregenerował – sygnały regeneracji, obciążenia i load, które Twoje urządzenie rejestruje w nocy.", + "scoreCrossLink": "Zobacz łączny wynik regeneracji", "emptyTitle": "Brak danych o regeneracji", "empty": "Brak danych o regeneracji. Połącz konto WHOOP, Polar lub Oura, a te wyniki uzupełnią się po synchronizacji urządzenia.", "block": { @@ -4009,7 +4014,7 @@ }, "wrapUp": { "title": "To koniec przewodnika", - "body": "Każdy moduł możesz obejrzeć osobno w dowolnej chwili („Pokaż ten przewodnik” na każdej stronie) lub uruchomić cały przewodnik ponownie w Ustawienia → Zaawansowane." + "body": "Ten przewodnik pokazuje się tylko raz — nie otworzy się ponownie sam. Wszystko, co pokazał, zostaje na miejscu, a funkcje możesz włączać i wyłączać w dowolnym momencie w Ustawienia → Moduły." } } }, @@ -4084,7 +4089,8 @@ "learning": "Niektóre wnioski — glukoza, rytm snu, trendy ciśnienia, cykl — wyostrzają się w ciągu pierwszego tygodnia lub dwóch, w miarę gromadzenia danych. Puste kafelki oznaczają teraz tylko, że wciąż poznajemy Twój punkt odniesienia.", "connectCta": "Połącz urządzenie", "logCta": "Zapisz pierwszy pomiar", - "importCta": "Importuj istniejącą historię" + "importCta": "Importuj istniejącą historię", + "modulesCta": "Wybierz swoje moduły" }, "welcomeBack": { "title": "Witaj ponownie", @@ -4549,7 +4555,7 @@ "description": "Ustrukturyzowane rekordy alergii i wywiadu rodzinnego." }, "environment": { - "title": "Środowisko", + "title": "Lokalizacja i pogoda", "subtitle": "Kontekst pogody, światła dziennego i lokalizacji.", "disabledHint": "Włącz moduł Środowisko, aby ustawić lokalizację domową i pobierać dzienną pogodę oraz długość dnia.", "searchPlaceholder": "Szukaj miasta", @@ -7959,6 +7965,7 @@ "mentalHealth": { "pageTitle": "Dobrostan psychiczny", "pageDescription": "Cztery zwalidowane, dobrowolne testy — PHQ-9 (nastrój), GAD-7 (lęk), WHO-5 (samopoczucie) i SCI (sen) — obok Twojego dziennika nastroju. Liczy się trend w czasie, a nie pojedynczy wynik.", + "moodLink": "Zobacz historię swojego nastroju", "attributionLabel": "Źródło", "attributionShow": "Źródło i licencja: {instrument}", "choosePrompt": "Wybierz sprawdzenie", @@ -8404,7 +8411,8 @@ "noConditions": "Niepowiązany ze schorzeniem", "previewUnavailable": "Brak podglądu dla tego typu pliku", "uploadedMeta": "Dodano {date} · {size}", - "share": "Udostępnij" + "share": "Udostępnij", + "labValuesLink": "Zobacz {count} wyników laboratoryjnych" }, "dropOverlay": { "title": "Upuść pliki, aby je przesłać", @@ -8666,7 +8674,9 @@ "coachCheckin": { "title": "Czas przyjrzeć się planowi", "body": "Minął mniej więcej tydzień, odkąd ustaliłeś ten plan — zachowaj go, dostosuj lub odpuść. Bez presji.", - "bodyPlan": "Około tygodnia temu postanowiłeś: „{plan}”. Zachowaj to, dostosuj lub odpuść — bez presji." + "bodyPlan": "Około tygodnia temu postanowiłeś: „{plan}”. Zachowaj to, dostosuj lub odpuść — bez presji.", + "adjustPrompt": "Chciałbym dostosować mój plan kontrolny.", + "adjustPromptPlan": "Chciałbym dostosować ten plan: „{plan}”" }, "tensionWindow": { "title": "Może chwila napięcia dzisiaj", diff --git a/src/app/coach/page.tsx b/src/app/coach/page.tsx index 96c077437..4e6b1c169 100644 --- a/src/app/coach/page.tsx +++ b/src/app/coach/page.tsx @@ -6,11 +6,13 @@ import { useQuery } from "@tanstack/react-query"; import { CoachConversation } from "@/components/insights/coach-panel/coach-conversation"; import type { CoachNudgeStatus } from "@/components/insights/layout-coach-fab"; +import type { CoachLaunchScope } from "@/lib/insights/coach-launch-context"; import { useCoachLaunch } from "@/lib/insights/coach-launch-context"; import { useFeatureFlags } from "@/hooks/use-feature-flags"; import { useDisableCoach } from "@/hooks/use-disable-coach"; import { queryKeys } from "@/lib/query-keys"; import { apiGet } from "@/lib/api/api-fetch"; +import { coachScopeSourceSchema } from "@/lib/ai/coach/types"; /** * v1.12.0 (Coach v2 #6) — full-page Coach conversation. @@ -57,6 +59,23 @@ import { apiGet } from "@/lib/api/api-fetch"; * most-recent thread so the user lands on what the Coach said. * The search-param read sits in a Suspense child so the client-bailout * never de-opts the route. + * + * 2026-07-17 UX-flows audit — the route understood only `?c=` / `?doc=`, so + * every other cross-surface hand-off (a metric card's "ask about this", the + * Today check-in's "Adjust", a workout detail's coach button) dropped its + * context at the URL boundary and landed on a blank composer (F1-2 / F4-1 / + * F6-1). Two additive params close that gap, both seeding the SAME props + * `` already exposes for the drawer's suggested-prompt + * chips — no new plumbing inside the conversation surface itself: + * - `?scope=` — narrows the snapshot the FIRST turn of a + * fresh conversation reads (validated against the closed enum; an + * unrecognised value is silently dropped rather than reaching the chat + * route with a free-form string). + * - `?ask=` — seeds the composer prefill (mirrors `prefill` on the + * in-app `askCoach()` launch call). The user still reviews/sends it — + * this is a prefill, not an auto-send. + * Both are ignored once `?c=` or `?doc=` pin an existing/scoped thread — scope + * only ever applies to a fresh conversation's first turn. */ function CoachPageBody() { const searchParams = useSearchParams(); @@ -71,13 +90,30 @@ function CoachPageBody() { const rawDoc = searchParams.get("doc"); const seedDocumentId = deepLinkedId === null && rawDoc ? rawDoc : null; + // A fresh conversation only — an existing thread (`?c=`) or a doc-scoped + // chat (`?doc=`) keeps its own established scope. + const freshChat = deepLinkedId === null && seedDocumentId === null; + const rawScope = freshChat ? searchParams.get("scope") : null; + const scopeResult = rawScope + ? coachScopeSourceSchema.safeParse(rawScope) + : null; + const launchScope: CoachLaunchScope | null = scopeResult?.success + ? { metric: scopeResult.data } + : null; + const seedPrefill = freshChat ? searchParams.get("ask") : null; + // C1 exception — an unread coach-initiated message opens the most-recent // conversation (which holds that proactive turn). Only consulted when the // entry did not pin a specific thread or ask for a fresh chat. // A `?doc=` open is an explicit fresh doc-scoped chat — never override it by - // resuming the most-recent thread on an unread nudge. + // resuming the most-recent thread on an unread nudge. A `?scope=`/`?ask=` + // hand-off is likewise an explicit fresh-chat request. const exceptionEligible = - deepLinkedId === null && rawC !== "new" && seedDocumentId === null; + deepLinkedId === null && + rawC !== "new" && + seedDocumentId === null && + launchScope === null && + !seedPrefill; const { data: nudge } = useQuery({ queryKey: queryKeys.coachNudgeStatus(), queryFn: async (): Promise => @@ -95,6 +131,10 @@ function CoachPageBody() { // v1.28.51 — seed the document scope for a `?doc=` open so the first // turn is created + sent through the hardened fenced document endpoint. initialDocumentId={seedDocumentId} + // 2026-07-17 UX-flows audit F1-2/F4-1/F6-1 — seed the scope/prefill a + // cross-surface hand-off carried in the URL. + launchScope={launchScope} + prefill={seedPrefill} // Default is the new-chat hero; only resume most-recent for the // unread coach-initiated exception. autoOpenMostRecent={hasUnreadCoachMessage} diff --git a/src/app/insights/mood/page.tsx b/src/app/insights/mood/page.tsx index 2f9223886..6573fff92 100644 --- a/src/app/insights/mood/page.tsx +++ b/src/app/insights/mood/page.tsx @@ -3,7 +3,7 @@ import { useQuery } from "@tanstack/react-query"; import dynamic from "next/dynamic"; import Link from "next/link"; -import { Smile } from "lucide-react"; +import { ArrowRight, Smile } from "lucide-react"; import { useAuth } from "@/hooks/use-auth"; import { queryKeys } from "@/lib/query-keys"; @@ -41,7 +41,7 @@ interface ComprehensiveMoodData { } export default function InsightsStimmungPage() { - const { isAuthenticated } = useAuth(); + const { user, isAuthenticated } = useAuth(); const { t } = useTranslations(); const { compareBaseline } = useInsightsLayoutPrefs(isAuthenticated); @@ -101,6 +101,32 @@ export default function InsightsStimmungPage() { The heatmap and the assessment are lifted out of `MoodInsightsSections` into their own regions so they land in this exact order; all three regions share one `moodInsights` query (TanStack dedups the fetch). */} + + {/* 2026-07-17 UX/IA audit M9 — mood, the mental-wellbeing screeners, and + this insights page form one mental-health domain; this cross-links + into the other two so the trend a user reads here can lead + somewhere. */} +
+ + {t("insights.mood.logLink")} +
+ {/* No `` here: mood is event-driven, not a diff --git a/src/app/insights/recovery/page.tsx b/src/app/insights/recovery/page.tsx index 7db6abea0..9cd494b8e 100644 --- a/src/app/insights/recovery/page.tsx +++ b/src/app/insights/recovery/page.tsx @@ -1,6 +1,7 @@ "use client"; -import { Loader2 } from "lucide-react"; +import Link from "next/link"; +import { ArrowRight, Loader2 } from "lucide-react"; import { useTranslations } from "@/lib/i18n/context"; import { useModulePageGuard } from "@/hooks/use-module-page-guard"; @@ -46,6 +47,20 @@ export default function InsightsRecoveryPage() { explainerMetric="recoveryPage" coachLaunch > + {/* 2026-07-17 UX/IA audit M4 — reverse direction of the cross-link added + to the score-anatomy page: this composite wearable page shares the + "Recovery" nav label with the RECOVERY_SCORE anatomy view at + `/insights/scores/recovery`, so a user who arrived here looking for + the score ring / contributor breakdown gets a pointer instead of a + dead end. */} + + {t("insights.recovery.scoreCrossLink")} +