Merge branch 'fix/v1475-rest' into release/v1.47.5

This commit is contained in:
Marc-André Bombeck committed 2026-11-12 13:21:59 +01:00
commit c35d34768e
70 files changed
+1251 -1151

No files matched your search

-18
View File
@@ -541,24 +541,6 @@ API_TOKEN_HMAC_KEY="<openssl rand -hex 32>"
# FHIR_MAX_MEDICATION_ADMINISTRATIONS="5000"
# -----------------------------------------------------------------------------
# Dashboard snapshot -- BUILD-TIME toggle (default ON)
# -----------------------------------------------------------------------------
# The dashboard hydrates every above-the-fold tile from the single
# `GET /api/dashboard/snapshot` cell so the whole strip shares one
# completion moment. This is the default and the desired behaviour.
#
# `NEXT_PUBLIC_*` vars are inlined into the client bundle at BUILD time,
# not read at runtime -- so this has NO effect on the prebuilt GHCR
# image and is intentionally absent from the `docker-compose.yml`
# runtime `environment:` whitelist. To fall back to the legacy
# four-cell path you must rebuild the image with the var set to "false":
#
# $ NEXT_PUBLIC_DASHBOARD_SNAPSHOT=false pnpm build # or a Dockerfile ARG
#
# NEXT_PUBLIC_DASHBOARD_SNAPSHOT="false"
# -----------------------------------------------------------------------------
# Client-IP resolution -- optional (required behind Cloudflare)
# -----------------------------------------------------------------------------
+79
View File
@@ -13115,6 +13115,27 @@ paths:
"403": *a20
"422": *a4
"429": *a5
/api/insights/presence:
get:
tags:
- Insights
summary: Which Insights pages have data
description: "What the Insights tab strip gates its pills on, for the record on screen: the reading count per
measurement type (after the record's module switches) and whether the record has mood entries in the last 90
days, a tracked scheduled medication, workouts and ECG recordings. A flag whose module is off is false. Counts
and flags only; each page reads its own data. No provider is called and no AI switch refuses it. Delegable at
MANAGE level over the whole record. Cookie or Bearer auth."
responses:
"200":
description: The presence answer.
content:
application/json:
schema:
$ref: "#/components/schemas/InsightsPresenceEnvelope"
"401": *a1
"403": *a20
"422": *a4
"429": *a5
/api/insights/cards:
get:
tags:
@@ -34933,6 +34954,7 @@ components:
- BLOOD_PRESSURE_SYS
- PULSE
- BLOOD_GLUCOSE
- BLOOD_KETONES
- OXYGEN_SATURATION
- BODY_TEMPERATURE
- BODY_FAT
@@ -68518,6 +68540,63 @@ components:
additionalProperties: false
description: A significant Pearson correlation (n >= 20, p < 0.05). The field holding it is null when the pairing is not
significant.
InsightsPresenceEnvelope:
type: object
properties:
data:
$ref: "#/components/schemas/InsightsPresence"
error:
type: "null"
meta:
type: object
properties:
requestId:
type: string
additionalProperties: false
required:
- data
- error
additionalProperties: false
InsightsPresence:
type: object
properties:
metrics:
type: object
propertyNames:
type: string
additionalProperties:
type: object
properties:
count:
type: integer
minimum: -9007199254740991
maximum: 9007199254740991
required:
- count
additionalProperties: false
description: "Keyed by `MeasurementType`: the reading count of every type with at least one reading, after the record's
module switches. The same counts `GET /api/analytics?slice=summaries` reports."
hasMood:
type: boolean
description: A mood entry in the last 90 days. False while the mood module is off.
hasMedication:
type: boolean
description: An active medication with a schedule whose intake is tracked. False while the medications module is off.
hasWorkouts:
type: boolean
description: At least one workout. False while the workouts module is off.
hasEcgRecordings:
type: boolean
description: At least one ECG recording.
required:
- metrics
- hasMood
- hasMedication
- hasWorkouts
- hasEcgRecordings
additionalProperties: false
description: "Which Insights pages have something to show for the record: what the tab strip gates its pills on. Counts
and flags only, no values."
InsightsCardsEnvelope:
type: object
properties:
-20
View File
@@ -195,26 +195,6 @@ bare Nginx — including the `X-Forwarded-For` chain shape that the
rate-limiter trusts and the `TRUST_PROXY_HOPS` env var that pins how
many hops to read back.
## Build-time feature toggles
A small number of toggles are `NEXT_PUBLIC_*` variables: the framework
inlines them into the client bundle at **build time**, so the pre-built
GHCR image cannot read them at runtime. Setting them in `.env` or the
compose `environment:` block has no effect — they only take hold when
you build your own image. They are intentionally absent from the
compose runtime whitelist for that reason.
| Variable | Default | Effect when set |
| -------------------------------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `NEXT_PUBLIC_DASHBOARD_SNAPSHOT` | on | The dashboard hydrates every above-the-fold tile from the single `GET /api/dashboard/snapshot` cell so the whole strip shares one completion moment. Build with `NEXT_PUBLIC_DASHBOARD_SNAPSHOT=false` to fall back to the legacy four-cell path. |
To flip one, rebuild the image with the variable exported:
```bash
NEXT_PUBLIC_DASHBOARD_SNAPSHOT=false pnpm build
# or pass it as a Dockerfile build ARG / Docker build --build-arg
```
## 7. Where to go next
| Next step | File |
+4 -6
View File
@@ -21,12 +21,10 @@ test.describe("authenticated dashboard render", () => {
test.use({ storageState: STORAGE_STATE_PATH });
test.beforeEach(async ({ page }) => {
// v1.7.2 — the dashboard snapshot flag is default-ON, so the page
// reads every tile from `/api/dashboard/snapshot` and ignores the
// legacy cells below. Mock the snapshot with the same populated
// four-metric set so the tile strip + charts paint. The legacy
// `/api/analytics` + `/api/mood/analytics` mocks stay for the
// reversible `NEXT_PUBLIC_DASHBOARD_SNAPSHOT=false` path.
// The page reads every tile from `/api/dashboard/snapshot`; mock it
// with the same populated four-metric set so the tile strip + charts
// paint. The dashboard ignores the `/api/analytics` and
// `/api/mood/analytics` mocks below.
await mockDashboardSnapshot(page);
// Mock analytics with a populated weight summary so the weight tile
+7 -11
View File
@@ -88,20 +88,16 @@ function mockTargetBands(): DashboardTargetBands {
/**
* v1.7.2 — shared mock for `GET /api/dashboard/snapshot`.
*
* The dashboard snapshot flag flipped default-ON in v1.7.2, so
* `src/app/page.tsx` reads every above-the-fold tile from the single
* `/api/dashboard/snapshot` cell unless `NEXT_PUBLIC_DASHBOARD_SNAPSHOT`
* is `"false"`. The e2e build sets no such var, so the snapshot path is
* live in CI. Specs that previously mocked only the legacy cells
* (`/api/analytics` + `/api/mood/analytics` + `/api/dashboard/widgets`)
* now hit the REAL snapshot route, which returns empty `summaries` for
* the seed user (zero measurements) and blanks the tile strip.
* `/api/dashboard/snapshot` cell. Without a mock a spec hits the REAL
* snapshot route, which returns empty `summaries` for the seed user (zero
* measurements) and blanks the tile strip.
*
* `mockDashboardSnapshot` registers a `page.route` for the snapshot
* endpoint returning a correctly-typed `DashboardSnapshot` so the
* default-ON path paints deterministic tiles. Callers keep their legacy
* mocks alongside it — harmless, and they protect the reversible
* `NEXT_PUBLIC_DASHBOARD_SNAPSHOT=false` path.
* endpoint returning a correctly-typed `DashboardSnapshot` so the tiles
* paint deterministically. Mocks of the older cells (`/api/analytics`,
* `/api/mood/analytics`, `/api/dashboard/widgets`) beside it are unused by
* the dashboard.
*/
/** Fill a `DataSummary` from a sparse partial, defaulting the rest. */
@@ -1,11 +1,11 @@
{
"$comment": "Production files over 800 lines and their length. Maintained by src/__tests__/source-size-ratchet.test.ts.",
"count": 84,
"total": 93802,
"total": 93809,
"entries": {
"app/medications/page-client.tsx": 839,
"components/charts/mood-chart.tsx": 1166,
"components/cycle/log-day-sheet.tsx": 1639,
"components/cycle/log-day-sheet.tsx": 1646,
"components/insights/coach-panel/chat-bubble.tsx": 821,
"components/insights/coach-panel/coach-conversation.tsx": 1278,
"components/insights/coach-panel/use-coach.ts": 1486,
@@ -1,7 +1,7 @@
{
"$comment": "Production functions over 200 lines, keyed \"file :: name\", and their length. Maintained by src/__tests__/source-size-ratchet.test.ts.",
"count": 336,
"total": 109333,
"total": 109383,
"entries": {
"app/achievements/page.tsx :: AchievementsPage": 206,
"app/api/admin/backups/upload/route.ts :: apiHandler(arg)": 275,
@@ -38,7 +38,7 @@
"app/api/record-settings/[family]/route.ts :: apiHandler(arg) #2": 262,
"app/api/user/ai-provider/route.ts :: apiHandler(arg) #2": 250,
"app/api/withings/callback/route.ts :: apiHandler(arg)": 247,
"app/auth/login/page.tsx :: LoginPage": 569,
"app/auth/login/page.tsx :: LoginPage": 567,
"app/auth/register/page.tsx :: RegisterPage": 285,
"app/coach/conversations/page.tsx :: CoachConversationsBody": 249,
"app/coach/plans/page.tsx :: CoachPlansBody": 216,
@@ -74,14 +74,14 @@
"components/clinician/clinician-view.tsx :: ClinicianView": 223,
"components/command-palette/command-palette.tsx :: PaletteBody": 333,
"components/custom-metrics/custom-metric-chart.tsx :: CustomMetricChart": 228,
"components/custom-metrics/custom-metric-detail.tsx :: CustomMetricDetail": 277,
"components/custom-metrics/custom-metric-form.tsx :: CustomMetricForm": 210,
"components/custom-metrics/custom-metric-detail.tsx :: CustomMetricDetail": 280,
"components/custom-metrics/custom-metric-form.tsx :: CustomMetricForm": 212,
"components/custom-metrics/custom-metric-history-list.tsx :: CustomMetricHistoryList": 298,
"components/cycle/bbt-chart.tsx :: BbtChart": 244,
"components/cycle/cycle-calendar.tsx :: CycleCalendar": 292,
"components/cycle/cycle-settings.tsx :: CycleSettings": 228,
"components/cycle/cycle-view.tsx :: CycleView": 403,
"components/cycle/log-day-sheet.tsx :: LogDaySheet": 753,
"components/cycle/log-day-sheet.tsx :: LogDaySheet": 760,
"components/daily/today-hero.tsx :: TodayHero": 488,
"components/dashboard/dashboard-header.tsx :: DashboardHeader": 215,
"components/dashboard/medication-intake-quick-add.tsx :: MedicationIntakeQuickAdd": 383,
@@ -98,7 +98,7 @@
"components/documents/documents-view.tsx :: DocumentsView": 303,
"components/documents/sources/document-source-picker.tsx :: DocumentSourcePicker": 563,
"components/illness/log-day-sheet.tsx :: LogDaySheet": 265,
"components/illness/new-episode-sheet.tsx :: NewEpisodeSheet": 264,
"components/illness/new-episode-sheet.tsx :: NewEpisodeSheet": 278,
"components/insights/coach-panel/chat-bubble.tsx :: ChatBubbleImpl": 417,
"components/insights/coach-panel/coach-conversation.tsx :: CoachConversation": 1002,
"components/insights/coach-panel/coach-input.tsx :: CoachInput": 259,
@@ -221,8 +221,8 @@
"components/symptoms/symptom-entry-form.tsx :: SymptomEntryForm": 317,
"components/symptoms/symptoms-section.tsx :: SymptomsSection": 205,
"components/targets/range-bar.tsx :: RangeBar": 215,
"components/targets/target-edit-sheet.tsx :: TargetEditSheetBody": 473,
"components/timeline/life-event-form.tsx :: LifeEventForm": 281,
"components/targets/target-edit-sheet.tsx :: TargetEditSheetBody": 486,
"components/timeline/life-event-form.tsx :: LifeEventForm": 287,
"components/timeline/timeline-chart.tsx :: ChartBody": 236,
"components/timeline/timeline-chronicle.tsx :: TimelineChronicle": 246,
"components/timeline/timeline-view.tsx :: TimelineView": 478,
@@ -231,7 +231,7 @@
"components/vaccinations/custom-vaccine-sheet.tsx :: CustomVaccineSheet": 246,
"components/vaccinations/vaccination-form.tsx :: VaccinationForm": 218,
"components/workouts/manual-workout-form.tsx :: ManualWorkoutForm": 321,
"hooks/use-sheet-drag.ts :: createSheetDrag": 312,
"hooks/use-sheet-drag.ts :: createSheetDrag": 319,
"lib/activity/calendar-math.ts :: buildActivityCalendar": 204,
"lib/ai/anthropic-client.ts :: generateCompletion": 294,
"lib/ai/coach/cycle-snapshot.ts :: buildCycleSnapshotBlock": 202,
@@ -1,7 +1,7 @@
{
"$comment": "Comment lines that open with a release version, per file under src/. Maintained by src/__tests__/version-stamp-ratchet.test.ts.",
"count": 2876,
"total": 9172,
"count": 2872,
"total": 9153,
"entries": {
"__tests__/account-payload-consumer-guard.test.ts": 2,
"__tests__/acting-account-boundary-guard.test.ts": 7,
@@ -70,7 +70,6 @@
"__tests__/unit-preference-display-guard.test.ts": 2,
"__tests__/v1391-additive-contract.test.ts": 1,
"__tests__/write-rate-limit-guard.test.ts": 1,
"app/__tests__/dashboard-analytics-split.test.ts": 2,
"app/__tests__/dashboard-chart-reveal.test.tsx": 2,
"app/__tests__/dashboard-suspense-boundaries.test.ts": 2,
"app/__tests__/dashboard-tile-strip-skeleton.test.ts": 4,
@@ -925,7 +924,7 @@
"components/insights/coach-panel/guided-questions-card.tsx": 1,
"components/insights/coach-panel/guided-questions-machine.ts": 2,
"components/insights/coach-panel/memory-note.tsx": 1,
"components/insights/coach-panel/message-thread.tsx": 31,
"components/insights/coach-panel/message-thread.tsx": 28,
"components/insights/coach-panel/method-line.tsx": 1,
"components/insights/coach-panel/mobile-rail-tray.tsx": 2,
"components/insights/coach-panel/read-aloud.tsx": 8,
@@ -982,7 +981,7 @@
"components/insights/hero-strip.tsx": 3,
"components/insights/insight-status-card.tsx": 12,
"components/insights/insights-edit-mode.tsx": 5,
"components/insights/insights-layout-shell.tsx": 11,
"components/insights/insights-layout-shell.tsx": 3,
"components/insights/insights-tab-strip.tsx": 35,
"components/insights/intraday-pulse-chart.tsx": 1,
"components/insights/labs-changes-card.tsx": 1,
@@ -1791,7 +1790,6 @@
"lib/analytics/__tests__/effective-range.test.ts": 1,
"lib/analytics/__tests__/glucose-panel-coherence.test.ts": 1,
"lib/analytics/__tests__/glucose-panel-window.test.ts": 2,
"lib/analytics/__tests__/merge-slim-thick.test.ts": 3,
"lib/analytics/__tests__/mood-series.test.ts": 1,
"lib/analytics/__tests__/range-delta.test.ts": 1,
"lib/analytics/__tests__/resting-pulse.test.ts": 1,
@@ -1824,7 +1822,6 @@
"lib/analytics/correlations.ts": 1,
"lib/analytics/intraday-pulse-io.ts": 2,
"lib/analytics/intraday-pulse.ts": 3,
"lib/analytics/merge-slim-thick.ts": 2,
"lib/analytics/mood-series.ts": 2,
"lib/analytics/range-delta.ts": 1,
"lib/analytics/range-shared.ts": 1,
@@ -1929,7 +1926,6 @@
"lib/dashboard/__tests__/meds-today.test.ts": 1,
"lib/dashboard/__tests__/score-narrative.test.ts": 1,
"lib/dashboard/__tests__/score-rings.test.ts": 1,
"lib/dashboard/__tests__/snapshot-flag.test.ts": 1,
"lib/dashboard/__tests__/snapshot.test.ts": 15,
"lib/dashboard/__tests__/target-bands.test.ts": 2,
"lib/dashboard/bands.ts": 1,
@@ -136,11 +136,6 @@ const INVENTORY: Readonly<Record<string, Poll>> = {
cadence:
"25 s, nextAdvisorPollInterval, attempts from the response timeout so the warm can land (advisor); 10 while settling",
},
"components/insights/insights-layout-shell.tsx": {
sites: 1,
shape: "bounded",
cadence: "25 s, nextAdvisorPollInterval, 10 attempts (briefing body)",
},
};
const SITE = /\brefetchInterval\s*:/g;
@@ -136,10 +136,6 @@ const NOT_OPERATOR_CONFIGURABLE: Readonly<Record<string, string>> = {
"CI-only knob for the same nightly eval workflow",
HEALTHLOG_MCP_TOKEN:
"read by the local stdio MCP bridge the operator runs on their own machine; the production image strips tsx so it cannot run there",
// --- A whitelist entry would be obeyed by half the readers. ---
NEXT_PUBLIC_DASHBOARD_SNAPSHOT:
"NEXT_PUBLIC_* is inlined into the client bundle at build time and three of its four readers are client components, so a runtime value would steer the server half only — a split-brain worse than no knob. .env.production.example already documents it as a build-time toggle and says it is deliberately absent from the whitelist; adding it would make that note false.",
};
function sourceFiles(): string[] {
@@ -339,7 +339,6 @@ const UNPUBLISHED: Readonly<Record<string, Exemption>> = {
"/api/internal/web-vitals": { kind: "internalOps", methods: ["POST"] },
"/api/monitoring/csp-report": { kind: "internalOps", methods: ["POST"] },
"/api/monitoring/glitchtip": { kind: "internalOps", methods: ["POST"] },
"/api/monitoring/settings": { kind: "internalOps", methods: ["GET"] },
"/api/monitoring/umami-script": { kind: "internalOps", methods: ["GET"] },
"/api/monitoring/umami/{path}": { kind: "internalOps", methods: ["POST"] },
"/api/mcp/oauth/authorize": {
+1 -22
View File
@@ -304,20 +304,6 @@ function smallFields(
}
describe("no field call site goes under 16 px on a touch screen", () => {
/**
* Call sites still under 16 px, fixed by a change of their own. An entry
* that stops matching fails, so the list only shrinks.
*/
const OPEN: Readonly<Record<string, string>> = {
"components/settings/coach-memory-section.tsx":
"/settings/coach memory editor",
"components/settings/access/managed-profile-handover.tsx":
"read-only handover link field",
"components/cycle/cycle-settings.tsx": "raw cycle-length number field",
"components/command-palette/command-palette.tsx":
"search field shrinks at `md:` without `pointer-fine:`",
};
const scan = productionSources()
.filter((rel) => rel.endsWith(".tsx"))
.map((rel) => ({ rel, ...smallFields(rel, readSrc(rel)) }));
@@ -349,13 +335,6 @@ describe("no field call site goes under 16 px on a touch screen", () => {
});
it("no typed-into field sets a size under 16 px outside a fine pointer", () => {
const offenders = scan
.filter((file) => !(file.rel in OPEN))
.flatMap((file) => file.small);
expect(offenders).toEqual([]);
});
it.each(Object.keys(OPEN))("%s is still open", (rel) => {
expect(scan.find((file) => file.rel === rel)?.small ?? []).not.toEqual([]);
expect(scan.flatMap((file) => file.small)).toEqual([]);
});
});
+13 -1
View File
@@ -1511,6 +1511,10 @@ const DELEGABLE_ROUTES: Record<string, DelegableEntry> = {
domain: "record",
why: "The heaviest SQL aggregation in the product, cached per record; no provider on the path. Reached only through this module's MANAGE arm; the argument for admitting it is the manage literal's reason line, and the entry here is what leg (a) freezes and leg (f) checks the section against.",
},
"app/api/insights/presence/route.ts": {
domain: "record",
why: "Which Insights pages have data: reading counts per type and four presence flags, the gate of the tab strip over the overview. Reached only through this module's MANAGE arm; the argument for admitting it is the manage literal's reason line, and the entry here is what leg (a) freezes and leg (f) checks the section against.",
},
"app/api/insights/correlations/route.ts": {
domain: "record",
why: "Correlations over the record's own series. Reached only through this module's MANAGE arm; the argument for admitting it is the manage literal's reason line, and the entry here is what leg (a) freezes and leg (f) checks the section against.",
@@ -2049,6 +2053,11 @@ const DELEGABLE_MANAGE_ROUTES: Record<string, ManageEntry> = {
conditions: [],
why: "The heaviest SQL aggregation in the product, cached per record; no provider on the path.",
},
"app/api/insights/presence/route.ts": {
domain: "record",
conditions: [],
why: "Counts and flags only, no values, from the cells the overview and the summaries slice already serve; no provider on the path.",
},
"app/api/insights/correlations/route.ts": {
domain: "record",
conditions: [],
@@ -2304,8 +2313,11 @@ const ACTOR_ROUTES: Record<string, string> = {
* list: a standing credential to a third-party calendar is the owner's to
* make, or a managed profile's guardian's, never an adult record's delegate's.
* 276 -> 278.
*
* The Insights presence read joins the record list and the manage literal:
* the tab strip's gate over the overview, counts and flags only. 278 -> 280.
*/
const FROZEN_ENTRY_COUNT = 278;
const FROZEN_ENTRY_COUNT = 280;
/**
* The two surfaces that authenticate a Bearer token outside `requireAuth` —
@@ -1,52 +0,0 @@
import { describe, it, expect } from "vitest";
import { dashboardSource } from "@/__tests__/helpers/dashboard-source";
/**
* v1.4.39.2 — dashboard analytics slim/thick split.
*
* Pre-fix the dashboard fired a single `useAnalyticsQuery()` against the
* thick `/api/analytics` envelope; every per-type tile waited on the
* heavy fan-out before paint. Mood and medication tiles arrived first
* (separate routes) and then every other tile arrived as one burst,
* which the maintainer reported as "etwas nervig" in the v1.4.39.1 post-deploy
* trace.
*
* Post-fix the dashboard mounts TWO `useAnalyticsQuery` calls in
* parallel: one for `?slice=summaries` (per-type tile strip) and one
* for the thick envelope (BD-Zielbereich + glucose tiles). The tile
* strip paints from the slim slice as soon as it lands; thick fields
* stream in afterwards. Without this dual-mount the cold-mount UX
* waterfall regresses to the v1.4.39.1 behaviour, so the test pins the
* two call shapes.
*
* v1.7.0 — both calls now carry an `enabled` gate so the unified
* snapshot rollout flag can disable them in favour of a single
* `/api/dashboard/snapshot` cell. With the flag OFF (default) the
* slim/thick split still drives the page, so the protective intent
* holds; the matchers only relaxed to tolerate the `enabled` arg.
*/
describe("v1.4.39.2 — dashboard analytics slim/thick split", () => {
it("mounts both the slim slice and the thick slice in parallel", () => {
const src = dashboardSource();
// Slim slice — `?slice=summaries` paints the per-type tile strip.
expect(src).toMatch(/useAnalyticsQuery\(\{\s*\n?\s*slice:\s*"summaries"/);
// Thick slice — the no-slice `useAnalyticsQuery` feeds the
// BD-Zielbereich + glucose tiles. Both must coexist. The thick
// call now opens with `{ enabled:` (no `slice` key).
expect(src).toMatch(/useAnalyticsQuery\(\{\s*\n?\s*enabled:/);
});
it("merges slim and thick results so call-sites stay shape-stable", () => {
const src = dashboardSource();
// The merge contract: slim wins on overlapping fields, thick fills
// in `bpInTargetPct*` and `glucoseByContext`. The text anchors
// protect against a future refactor accidentally dropping one of
// the queries — the `data` object that downstream tile rendering
// consumes must stay shape-stable across both query states.
expect(src).toMatch(/analyticsSlimQuery/);
expect(src).toMatch(/analyticsThickQuery/);
expect(src).toMatch(/bpInTargetPct/);
expect(src).toMatch(/glucoseByContext/);
});
});
@@ -284,12 +284,9 @@ describe("the tile the user actually sees", () => {
describe("the gate reaches every feed, not just the default one", () => {
it("strips the recovery-owned series from a summaries slice", () => {
// `gateSummariesByModules` moved out of the snapshot builder, where it
// was private, so `/api/analytics` can apply the SAME filter. With
// `NEXT_PUBLIC_DASHBOARD_SNAPSHOT=false` that route feeds the tiles, and
// it filtered nothing at all — so recovery-off plus RMSSD-only still
// painted the tile, on the exact path the helper's docblock promised it
// could not. A gate that only holds on one feed is only as strong as a
// rollout flag.
// was private, so `/api/analytics` can apply the SAME filter: that route
// filtered nothing at all, so recovery-off plus RMSSD-only still reached
// every reader of the slice.
const summaries = {
HEART_RATE_VARIABILITY: summary(9, 55),
HRV_RMSSD: summary(30, 42),
@@ -1,6 +1,5 @@
import { describe, it, expect } from "vitest";
import { dashboardSource } from "@/__tests__/helpers/dashboard-source";
import { readSource } from "@/__tests__/helpers/source-files";
/**
* v1.4.40 — dashboard Suspense boundaries.
@@ -57,19 +56,6 @@ describe("v1.4.40 — dashboard per-cell Suspense boundaries", () => {
);
});
it("hoists DASHBOARD_QUERY_OPTS to module scope (audit-M2)", () => {
const src = readSource(
"src/components/dashboard/tiles/use-dashboard-data.ts",
"export function useDashboardData",
);
// Module-scope declaration — search outside the hook body. Declared
// inside it, every render would create a fresh `{}` reference.
const moduleSliceEnd = src.indexOf("export function useDashboardData");
expect(moduleSliceEnd).toBeGreaterThan(0);
const moduleSlice = src.slice(0, moduleSliceEnd);
expect(moduleSlice).toMatch(/const\s+DASHBOARD_QUERY_OPTS\s*=\s*\{/);
});
it("keeps the per-render Intl hour walk off the page (audit-H4 successor)", () => {
const src = dashboardSource();
// The audit-H4 `useMemo(getHourForTimeZone, [userTimezone])` pin
@@ -24,11 +24,9 @@ import {
* strip's footprint immediately and swaps to the real tiles the moment
* the query resolves.
*
* v1.7.0 — the gate now keys off `primaryLoading`, which resolves to
* the snapshot cell under `NEXT_PUBLIC_DASHBOARD_SNAPSHOT=true` and the
* slim analytics cell otherwise. A disabled TanStack query reports
* `isLoading: false`, so keying off the slim query in snapshot mode
* (where it is `enabled: false`) flashed the empty state for the whole
* v1.7.0 — the gate keys off `primaryLoading`, which resolves to the
* snapshot cell. A disabled TanStack query reports `isLoading: false`, so
* keying off any other query flashed the empty state for the whole
* snapshot fetch. The pure `resolveDashboardFirstPaintGate` carries the
* behaviour; textual pins keep the wiring honest.
*/
@@ -198,13 +196,13 @@ describe("v1.4.43 W11 — dashboard tile-strip skeleton (wiring)", () => {
expect(skeletonBlock?.[0]).toContain("<ChartSkeleton");
});
it("derives `primaryLoading` from the snapshot vs slim query", () => {
it("derives `primaryLoading` from the snapshot query", () => {
const src = dashboardSource();
// v1.16.4 — `!mounted` pins SSR + the hydration render to the
// skeleton branch so a late-hydrating boundary can't disagree with
// the server HTML (React #418); see `useMounted`.
expect(src).toMatch(
/const\s+primaryLoading\s*=\s*!mounted\s*\|\|\s*\(snapshotEnabled\s*\?\s*snapshotQuery\.isLoading\s*:\s*analyticsSlimQuery\.isLoading\);/,
/const\s+primaryLoading\s*=\s*!mounted\s*\|\|\s*snapshotQuery\.isLoading;/,
);
});
@@ -64,9 +64,6 @@ vi.mock("@/lib/modules/gate", () => ({
vi.mock("@/lib/dashboard/snapshot-read", () => ({
readDashboardSnapshotCached: (u: unknown) => readDashboardSnapshotCached(u),
}));
vi.mock("@/lib/dashboard/snapshot-flag", () => ({
isDashboardSnapshotEnabled: () => true,
}));
vi.mock("@/lib/dashboard/batch-chart-types", () => ({
computeBatchWindow: () => ({
from: "2026-07-01T00:00:00.000Z",
@@ -240,6 +240,8 @@ export const DATA_ROUTES: Readonly<Record<string, string>> = {
"Lab deltas, owned by the labs module.",
"src/app/api/insights/health-status/route.ts":
"Baseline drift, computed over core vitals.",
"src/app/api/insights/presence/route.ts":
"Which Insights pages have data: counts and flags, no model text.",
"src/app/api/insights/pulse/intraday/route.ts":
"The day's pulse shape, a core vital.",
"src/app/api/insights/patterns/route.ts": "Stored correlation statistics.",
@@ -350,6 +350,9 @@ const EXEMPT_ROUTES: ReadonlyArray<string> = [
"src/app/api/insights/correlations/route.ts",
"src/app/api/insights/coach-read/route.ts",
"src/app/api/insights/health-status/route.ts",
// The tab strip's gate spans every module: each flag whose page a
// switched-off module hides answers false and its rows are not read.
"src/app/api/insights/presence/route.ts",
"src/app/api/insights/pulse/intraday/route.ts",
"src/app/api/insights/patterns/route.ts",
"src/app/api/insights/patterns/[id]/route.ts",
+2 -5
View File
@@ -80,11 +80,8 @@ export const GET = apiHandler(async (request?: Request) => {
const slim = await readSummariesSlice(user.id, { serveStale: true });
// Drop the types whose module the user turned off, from the SAME map
// the snapshot builder gates on (`SUMMARY_TYPE_MODULE`, via
// `gateSummariesByModules`). With `NEXT_PUBLIC_DASHBOARD_SNAPSHOT=false`
// this slice — not the snapshot — feeds the dashboard tile strip, and
// it used to hand back every type regardless: recovery off plus an
// RMSSD-only account still painted the HRV tile, because nothing on
// this path had ever filtered.
// `gateSummariesByModules`), so a switched-off module's series never
// reaches a page that reads this slice.
//
// Gated AFTER the cache read on purpose. The cached cell stays
// module-independent, so toggling a module takes effect on the next
@@ -1,113 +0,0 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
/**
* The health status card reads two engines that walk every vital over up to
* 180 days. The answer is kept in the record's analytics cell: a second open
* of Insights serves it, and a write that sweeps the record's cells, or a
* module switch, computes it again.
*/
const computeCoincidentDeviation = vi.fn();
const buildExtendedChangepointSignals = vi.fn();
const resolveModuleMap = vi.fn();
vi.mock("@/lib/insights/derived/coincident-deviation", () => ({
computeCoincidentDeviation: (...args: unknown[]) =>
computeCoincidentDeviation(...args),
}));
vi.mock("@/lib/insights/derived/changepoint", () => ({
buildExtendedChangepointSignals: (...args: unknown[]) =>
buildExtendedChangepointSignals(...args),
}));
vi.mock("@/lib/insights/derived", () => ({
loadBaselineProfile: vi.fn().mockResolvedValue({ ageYears: 40 }),
}));
vi.mock("@/lib/db", () => ({
prisma: {
auditLog: { create: vi.fn() },
appSettings: { findUnique: vi.fn().mockResolvedValue(null) },
},
}));
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
vi.mock("@/lib/auth/audit", () => ({
auditLog: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/modules/gate", async (importOriginal) => ({
...(await importOriginal<typeof import("@/lib/modules/gate")>()),
resolveModuleMap: (userId: string) => resolveModuleMap(userId),
}));
vi.mock("@/lib/rate-limit", () => ({
checkAnalyticsReadRateLimit: vi.fn(),
}));
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
vi.mock("next/headers", () => ({
headers: vi.fn(async () => ({ get: () => null })),
cookies: vi.fn(async () => ({
get: () => undefined,
set: () => {},
delete: () => {},
})),
}));
import { GET } from "../route";
import { getSession } from "@/lib/auth/session";
import { checkAnalyticsReadRateLimit } from "@/lib/rate-limit";
import { __resetAllCachesForTests } from "@/lib/cache/server-cache";
import { invalidateUserMeasurements } from "@/lib/cache/invalidate";
const call = () => GET();
beforeEach(() => {
__resetAllCachesForTests();
vi.mocked(getSession).mockResolvedValue({
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
user: {
id: "user-1",
username: "testuser",
role: "USER",
timezone: "Europe/Berlin",
},
} as unknown as Awaited<ReturnType<typeof getSession>>);
vi.mocked(checkAnalyticsReadRateLimit).mockResolvedValue({
allowed: true,
} as unknown as Awaited<ReturnType<typeof checkAnalyticsReadRateLimit>>);
computeCoincidentDeviation
.mockReset()
.mockResolvedValue({ status: "insufficient" });
buildExtendedChangepointSignals.mockReset().mockResolvedValue([]);
resolveModuleMap.mockReset().mockResolvedValue({});
});
describe("GET /api/insights/health-status — the cached answer", () => {
it("computes once for two opens", async () => {
const first = await call();
const second = await call();
expect(first.status).toBe(200);
expect(second.status).toBe(200);
expect(computeCoincidentDeviation).toHaveBeenCalledTimes(1);
expect(buildExtendedChangepointSignals).toHaveBeenCalledTimes(1);
expect((await second.json()).data).toEqual((await first.json()).data);
});
it("computes again after a measurement write", async () => {
await call();
invalidateUserMeasurements("user-1", { evict: true });
await call();
expect(computeCoincidentDeviation).toHaveBeenCalledTimes(2);
});
it("computes again when a module is switched off", async () => {
await call();
resolveModuleMap.mockResolvedValue({ sleep: false });
await call();
expect(buildExtendedChangepointSignals).toHaveBeenCalledTimes(2);
expect(buildExtendedChangepointSignals).toHaveBeenLastCalledWith(
"user-1",
expect.any(Date),
{ sleep: false },
);
});
});
+97
View File
@@ -0,0 +1,97 @@
/**
* GET /api/insights/presence — which Insights pages have something to show.
*
* The tab strip on every Insights page gates its pills on this answer alone:
* the reading count per measurement type, and whether the record has mood
* entries, a scheduled medication, workouts and ECG recordings. The counts are
* the summaries slice's cell, after the record's module switches; mood and
* medication mean what the overview means by them. A flag whose page a
* switched-off module hides (`src/lib/modules/surface.ts`) is false, and its
* rows are not read.
*
* No AI gate: every field is computed from the record. MANAGE-level read over
* the whole record, like the overview the strip sits on.
*/
import { apiError, apiSuccess } from "@/lib/api-response";
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { checkAnalyticsReadRateLimit } from "@/lib/rate-limit";
import { prisma } from "@/lib/db";
import { readSummariesSlice } from "@/lib/analytics/summaries-slice";
import { gateSummariesByModules } from "@/lib/dashboard/widget-modules";
import { resolveModuleMap } from "@/lib/modules/gate";
import { isSurfaceVisible } from "@/lib/modules/surface";
import { TRACKED_INTAKE_WHERE } from "@/lib/medications/intake-tracking";
export const dynamic = "force-dynamic";
/** The overview's mood window: a mood pill needs an entry inside it. */
const MOOD_WINDOW_MS = 90 * 24 * 60 * 60 * 1000;
export const GET = apiHandler(async () => {
const { user } = await requireRecordAuth("manage", "record");
const rl = await checkAnalyticsReadRateLimit(user.id);
if (!rl.allowed) {
return apiError("Too many analytics requests. Please retry later.", 429);
}
const modules = await resolveModuleMap(user.id);
const shows = (slug: string) =>
isSurfaceVisible(`insights-page:${slug}`, modules);
const userId = user.id;
const [slice, mood, medication, workout, ecg] = await Promise.all([
readSummariesSlice(userId, { serveStale: true }),
shows("mood")
? prisma.moodEntry.findFirst({
where: {
userId,
deletedAt: null,
moodLoggedAt: { gte: new Date(Date.now() - MOOD_WINDOW_MS) },
},
select: { id: true },
})
: null,
// A medication with no schedule, an as-needed one or one whose intake is
// not tracked expects no dose, so the overview lists no rate for it.
shows("medications")
? prisma.medication.findFirst({
where: {
userId,
active: true,
asNeeded: false,
...TRACKED_INTAKE_WHERE,
schedules: { some: {} },
},
select: { id: true },
})
: null,
shows("workouts")
? prisma.workout.findFirst({ where: { userId }, select: { id: true } })
: null,
prisma.ecgRecording.findFirst({ where: { userId }, select: { id: true } }),
]);
const { summaries } = gateSummariesByModules(
slice.summaries,
slice.lastSeenByType,
modules,
);
const metrics: Record<string, { count: number }> = {};
for (const [type, summary] of Object.entries(summaries)) {
if (summary.count > 0) metrics[type] = { count: summary.count };
}
annotate({
action: { name: "insights.presence.read" },
meta: { metrics: Object.keys(metrics).length },
});
return apiSuccess({
metrics,
hasMood: mood !== null,
hasMedication: medication !== null,
hasWorkouts: workout !== null,
hasEcgRecordings: ecg !== null,
});
});
@@ -567,74 +567,3 @@ describe("GET /api/medications/[id]/dose-history — slots before creation (#102
});
}
});
/**
* The history reads the window's rows, not every intake the medication ever
* had: for a medication taken for years each open of the tab read thousands
* of rows to show at most a year. Of the rows before the window only the
* latest take is read, because a rolling grid re-starts from it.
*/
describe("GET /api/medications/[id]/dose-history — the read is bounded to the window", () => {
const FROM = new Date("2026-06-01T00:00:00Z");
const TO = new Date("2026-06-15T00:00:00Z");
const windowQuery = `?from=${encodeURIComponent(FROM.toISOString())}&to=${encodeURIComponent(TO.toISOString())}`;
it("asks only for rows anchored or taken in the window", async () => {
await GET(getReq(windowQuery), ROUTE_PARAMS);
const where = vi.mocked(prisma.medicationIntakeEvent.findMany).mock
.calls[0][0]?.where;
expect(where).toMatchObject({
OR: [{ scheduledFor: { gte: FROM } }, { takenAt: { gte: FROM } }],
});
const before = vi.mocked(prisma.medicationIntakeEvent.findFirst).mock
.calls[0][0];
expect(before?.where).toMatchObject({
skipped: false,
scheduledFor: { lt: FROM },
takenAt: { lt: FROM },
});
});
it("re-starts a rolling grid from the latest take before the window", async () => {
vi.mocked(prisma.medication.findUnique).mockResolvedValue({
id: "med-1",
startsOn: null,
endsOn: null,
oneShot: false,
createdAt: new Date("2026-01-01T00:00:00Z"),
schedules: [
{
id: "sched-1",
windowStart: "08:00",
windowEnd: "08:00",
daysOfWeek: null,
timesOfDay: ["08:00"],
reminderGraceMinutes: null,
rrule: null,
rollingIntervalDays: 7,
scheduleType: "SCHEDULED",
cyclicOnWeeks: null,
cyclicOffWeeks: null,
},
],
} as never);
// Taken four days before the window opens, nothing since: the next dose
// was due inside the window and reads as missed there.
vi.mocked(prisma.medicationIntakeEvent.findFirst).mockResolvedValue({
takenAt: new Date("2026-05-28T06:00:00Z"),
} as never);
const res = await GET(getReq(windowQuery), ROUTE_PARAMS);
expect(res.status).toBe(200);
const rows = (await res.json()).data.rows as Array<{
at: string;
kind: string;
status: string;
}>;
expect(rows.map((r) => [r.at.slice(0, 10), r.kind, r.status])).toEqual([
["2026-06-04", "slot", "missed"],
]);
});
});
-17
View File
@@ -1,17 +0,0 @@
import { apiHandler } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { apiSuccess } from "@/lib/api-response";
import { getPublicMonitoringSettings } from "@/lib/monitoring-settings";
export const dynamic = "force-dynamic";
export const GET = apiHandler(async () => {
annotate({ action: { name: "monitoring.settings.get" } });
const settings = await getPublicMonitoringSettings();
return apiSuccess({
umamiEnabled: settings.umamiEnabled,
umamiWebsiteId: settings.umamiWebsiteId,
glitchtipEnabled: settings.glitchtipEnabled,
});
});
+1 -4
View File
@@ -21,7 +21,6 @@ import {
} from "@/lib/api/api-fetch";
import { MfaLoginStep, type MfaMethod } from "@/components/auth/mfa-login-step";
import { OIDC_MFA_COOKIE, OIDC_MFA_COOKIE_PATH } from "@/lib/auth/oidc-cookie";
import { isDashboardSnapshotEnabled } from "@/lib/dashboard/snapshot-flag";
import { prefetchDashboardSnapshot } from "@/lib/queries/use-dashboard-snapshot";
import { clearOfflineCachesForSessionEnd } from "@/lib/pwa/query-persister";
import { sanitizeSameOriginPath } from "@/lib/url-safety";
@@ -237,9 +236,7 @@ export default function LoginPage() {
// ample headroom to land before the new snapshot is written.
void clearOfflineCachesForSessionEnd();
const target = getRedirectTarget();
if (target === "/" && isDashboardSnapshotEnabled()) {
prefetchDashboardSnapshot(queryClient);
}
if (target === "/") prefetchDashboardSnapshot(queryClient);
router.push(target);
}
-1
View File
@@ -84,7 +84,6 @@ export default function DashboardPageClient({
const dashboard = useDashboardData({
isAuthenticated,
mounted,
user,
batchWindowProp: batchWindow,
});
const { digestQuery, layout, primaryLoading } = dashboard;
+11 -23
View File
@@ -6,7 +6,6 @@ import {
import { getUnswitchedSession } from "@/lib/auth/acting-carrier";
import { readDashboardSnapshotCached } from "@/lib/dashboard/snapshot-read";
import { isDashboardSnapshotEnabled } from "@/lib/dashboard/snapshot-flag";
import {
computeBatchWindow,
deriveBatchChartTypes,
@@ -116,29 +115,18 @@ export default async function DashboardPage() {
});
}
// Above-the-fold prefetch runs ONLY in snapshot mode: legacy mode derives
// its chart-type list from analytics queries the RSC did not run, so there
// is no byte-identical key to dehydrate the series under. In legacy mode
// `batchWindow` stays undefined and the client falls back to its own
// browser-local window exactly as before — no behaviour change off-flag.
const snapshotMode = isDashboardSnapshotEnabled();
// The profile-tz window is computed ONCE and threaded to the client so
// the batched-series key matches byte-for-byte.
batchWindow = computeBatchWindow(new Date(), user.timezone);
// The profile-tz window is computed ONCE (snapshot mode only) and threaded
// to the client so the batched-series key matches byte-for-byte.
if (snapshotMode) {
batchWindow = computeBatchWindow(new Date(), user.timezone);
}
const seriesTypes = snapshotMode
? deriveBatchChartTypes(
resolveDashboardLayout(wireBody.layout),
(
wireBody.tiles as {
summaries?: Record<string, { count?: number }>;
}
)?.summaries,
)
: [];
const seriesTypes = deriveBatchChartTypes(
resolveDashboardLayout(wireBody.layout),
(
wireBody.tiles as {
summaries?: Record<string, { count?: number }>;
}
)?.summaries,
);
// Digest carries a soft budget (cold-day extras rebuild); series is a
// warm rollup read (< 100 ms) with none. Both fail soft to `undefined`.
@@ -0,0 +1,156 @@
import type { ReactNode } from "react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
/**
* Edit sheets open filled, so "the body has input" says nothing about them:
* each compares its fields with what it opened with and hands that to the
* sheet's dismissal guard. Opened and untouched, every one of them says
* "nothing to lose", so a tap outside closes it without asking; a sheet the
* guard was never wired to passes nothing at all.
*/
const sheets: unknown[] = [];
vi.mock("@/components/ui/responsive-sheet", async (importOriginal) => ({
...(await importOriginal<
typeof import("@/components/ui/responsive-sheet")
>()),
ResponsiveSheet: (props: { dirty?: unknown; children?: ReactNode }) => {
sheets.push(props.dirty);
return <>{props.children}</>;
},
}));
vi.mock("@/hooks/use-auth", () => ({
useAuth: () => ({ isAuthenticated: true, user: { modules: {} } }),
}));
vi.mock("next/navigation", () => ({
useRouter: () => ({ push: vi.fn(), replace: vi.fn(), refresh: vi.fn() }),
usePathname: () => "/",
useSearchParams: () => new URLSearchParams(),
}));
import { I18nProvider } from "@/lib/i18n/context";
import { NewEpisodeSheet } from "@/components/illness/new-episode-sheet";
import { TagEditorSheet } from "@/components/mood/manage/tag-editor-sheet";
import {
EditSymptomDefinitionSheet,
EditSymptomEventSheet,
} from "@/components/symptoms/symptom-edit-sheets";
import { TargetEditSheet } from "@/components/targets/target-edit-sheet";
import { LifeEventSheet } from "@/components/timeline/life-event-sheet";
function dirtyOf(node: ReactNode): unknown {
sheets.length = 0;
renderToStaticMarkup(
<QueryClientProvider client={new QueryClient()}>
<I18nProvider initialLocale="en">{node}</I18nProvider>
</QueryClientProvider>,
);
return sheets[0];
}
const noop = () => {};
beforeEach(() => {
sheets.length = 0;
});
describe("seeded edit sheets and the dismissal guard", () => {
it.each([
[
"an illness episode",
<NewEpisodeSheet
key="episode"
open
onOpenChange={noop}
today="2026-11-10"
editEpisode={{
id: "ep-1",
label: "Cold",
type: "INFECTION",
lifecycle: "ACUTE",
onsetAt: "2026-11-01T12:00:00.000Z",
resolvedAt: null,
parentConditionId: null,
note: "Started on the train",
bodySite: null,
laterality: null,
createdAt: "2026-11-01T12:00:00.000Z",
updatedAt: "2026-11-01T12:00:00.000Z",
}}
/>,
],
[
"a new illness episode",
<NewEpisodeSheet
key="new-episode"
open
onOpenChange={noop}
today="2026-11-10"
/>,
],
[
"a mood tag",
<TagEditorSheet
key="tag"
open
onOpenChange={noop}
groups={[{ key: "custom", name: "Custom" }]}
tag={{ key: "c-1", label: "Garden", icon: "Leaf", groupKey: "custom" }}
/>,
],
[
"a symptom occurrence",
<EditSymptomEventSheet
key="symptom-event"
label="Headache"
onClose={noop}
event={{
id: "se-1",
definitionId: "sd-1",
occurredAt: "2026-11-09T08:30:00.000Z",
intensity: 6,
note: "After screen time",
episodeId: null,
createdAt: "2026-11-09T08:30:00.000Z",
updatedAt: "2026-11-09T08:30:00.000Z",
}}
/>,
],
[
"a symptom",
<EditSymptomDefinitionSheet
key="symptom"
onClose={noop}
definition={{
id: "sd-1",
label: "Headache",
icon: "Brain",
sortOrder: 0,
isActive: true,
recent: { count30d: 1, maxIntensity30d: 6, lastOccurredAt: null },
}}
/>,
],
[
"a target",
<TargetEditSheet
key="target"
open
onOpenChange={noop}
targetType="WEIGHT"
targetLabel="Weight"
unit="kg"
initialRange={{ min: 70, max: 80 }}
/>,
],
[
"a life event",
<LifeEventSheet key="life-event" open onOpenChange={noop} event={null} />,
],
])("%s opens with nothing to lose", (_name, node) => {
expect(dirtyOf(node)).toBe(false);
});
});
@@ -329,7 +329,7 @@ export function PaletteBody({ isMobile }: { isMobile: boolean }) {
autoCorrect="off"
spellCheck={false}
enterKeyHint="go"
className="placeholder:text-muted-foreground h-14 min-w-0 flex-1 bg-transparent text-base outline-none md:text-sm"
className="placeholder:text-muted-foreground h-14 min-w-0 flex-1 bg-transparent text-base outline-none md:pointer-fine:text-sm"
/>
{isMobile ? (
<Button
@@ -83,6 +83,7 @@ export function CustomMetricDetail({
const [addFooterEl, setAddFooterEl] = useState<HTMLDivElement | null>(null);
const [editOpen, setEditOpen] = useState(false);
const [editFooterEl, setEditFooterEl] = useState<HTMLDivElement | null>(null);
const [editDirty, setEditDirty] = useState(false);
const {
data: metric,
@@ -326,6 +327,7 @@ export function CustomMetricDetail({
<ResponsiveSheet
open={editOpen}
onOpenChange={setEditOpen}
dirty={editDirty}
title={t("customMetrics.editTitle")}
description={t("customMetrics.defineDescription")}
footer={
@@ -341,6 +343,7 @@ export function CustomMetricDetail({
footerSlot={editFooterEl}
onSuccess={afterEditMetric}
onCancel={() => setEditOpen(false)}
onDirtyChange={setEditDirty}
/>
) : null}
</ResponsiveSheet>
@@ -1,6 +1,6 @@
"use client";
import { useId, useState } from "react";
import { useEffect, useId, useState } from "react";
import { createPortal } from "react-dom";
import { Loader2 } from "lucide-react";
import { toast } from "sonner";
@@ -58,6 +58,22 @@ interface CustomMetricFormProps {
* bottom-sheet branch can sticky-pin it.
*/
footerSlot?: HTMLElement | null;
/** Whether the fields differ from what the form opened with. */
onDirtyChange?: (dirty: boolean) => void;
}
/** What the form opens with: the edited metric, or an empty definition. */
function metricFields(existing: CustomMetricDto | undefined) {
const text = (n: number | null | undefined) => (n != null ? String(n) : "");
return {
name: existing?.name ?? "",
unit: existing?.unit ?? "",
low: text(existing?.targetLow),
high: text(existing?.targetHigh),
decimals: text(existing?.decimals),
description: existing?.description ?? "",
correlationEnabled: existing?.correlationEnabled ?? false,
};
}
/**
@@ -72,25 +88,27 @@ export function CustomMetricForm({
onSuccess,
onCancel,
footerSlot,
onDirtyChange,
}: CustomMetricFormProps) {
const { t } = useTranslations();
const formId = useId();
const [name, setName] = useState(existing?.name ?? "");
const [unit, setUnit] = useState(existing?.unit ?? "");
const [low, setLow] = useState(
existing?.targetLow != null ? String(existing.targetLow) : "",
);
const [high, setHigh] = useState(
existing?.targetHigh != null ? String(existing.targetHigh) : "",
);
const [decimals, setDecimals] = useState(
existing?.decimals != null ? String(existing.decimals) : "",
);
const [description, setDescription] = useState(existing?.description ?? "");
const [seed] = useState(() => metricFields(existing));
const [name, setName] = useState(seed.name);
const [unit, setUnit] = useState(seed.unit);
const [low, setLow] = useState(seed.low);
const [high, setHigh] = useState(seed.high);
const [decimals, setDecimals] = useState(seed.decimals);
const [description, setDescription] = useState(seed.description);
const [correlationEnabled, setCorrelationEnabled] = useState(
existing?.correlationEnabled ?? false,
seed.correlationEnabled,
);
const fields = { name, unit, low, high, decimals, description };
const dirty =
JSON.stringify({ ...fields, correlationEnabled }) !== JSON.stringify(seed);
useEffect(() => {
onDirtyChange?.(dirty);
}, [dirty, onDirtyChange]);
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const save = useSaveCustomMetric(existing);
@@ -46,6 +46,7 @@ export function CustomMetricList() {
const router = useRouter();
const [addOpen, setAddOpen] = useState(false);
const [addFooterEl, setAddFooterEl] = useState<HTMLDivElement | null>(null);
const [addDirty, setAddDirty] = useState(false);
const { data, isLoading, isError, error, refetch } = useQuery({
queryKey: queryKeys.customMetrics(),
@@ -154,6 +155,7 @@ export function CustomMetricList() {
<ResponsiveSheet
open={addOpen}
onOpenChange={setAddOpen}
dirty={addDirty}
title={t("customMetrics.defineTitle")}
description={t("customMetrics.defineDescription")}
footer={
@@ -164,6 +166,7 @@ export function CustomMetricList() {
footerSlot={addFooterEl}
onSuccess={afterAdd}
onCancel={() => setAddOpen(false)}
onDirtyChange={setAddDirty}
/>
</ResponsiveSheet>
</section>
+1 -1
View File
@@ -371,7 +371,7 @@ function PriorField({
max={max}
value={value}
onChange={(e) => onChange(e.target.value)}
className="border-input bg-background focus-visible:border-input-focus w-full rounded-md border px-3 py-2 text-sm focus-visible:outline-none"
className="border-input bg-background focus-visible:border-input-focus w-full rounded-md border px-3 py-2 text-base focus-visible:outline-none md:pointer-fine:text-sm"
/>
</div>
);
+7
View File
@@ -701,8 +701,10 @@ export function LogDaySheet({
? `${date}:${dayLog.isSuccess ? "loaded" : "loading"}`
: null;
const [lastFormKey, setLastFormKey] = useState<string | null>(null);
const [seed, setSeed] = useState<string | null>(null);
if (open && formKey !== lastFormKey && !dayLog.isLoading) {
setLastFormKey(formKey);
setSeed(null);
setRowId(dto?.id ?? null);
setFlow((dto?.flow ?? null) as FlowLevel | null);
setIntermenstrual(dto?.intermenstrualBleeding ?? false);
@@ -788,6 +790,10 @@ export function LogDaySheet({
note,
symptoms,
};
// The form as hydrated, for the dismissal guard (a Map reads as `{}`).
const snapshot = JSON.stringify([formState, [...symptoms].sort()]);
if (hydrated && seed === null) setSeed(snapshot);
const dirty = hydrated && seed !== null && seed !== snapshot;
const saveState = dayLogSaveState({ hydrated, rowId, form: formState });
const currentRowId = saveState.rowId;
@@ -860,6 +866,7 @@ export function LogDaySheet({
<ResponsiveSheet
open={open}
onOpenChange={onOpenChange}
dirty={dirty}
// The target date IS the title. Every button below writes to it and
// nothing else on the surface says which day it is.
title={
+5 -6
View File
@@ -19,12 +19,11 @@ import type {
/**
* v1.7.0 — first-paint gate for the dashboard tile strip.
*
* `primaryLoading` must come from whichever query actually drives the
* tiles: the snapshot cell by default, the slim analytics cell when
* `NEXT_PUBLIC_DASHBOARD_SNAPSHOT=false`. A disabled TanStack query reports
* `isLoading: false` (idle fetch status), so keying off the wrong
* source flashes the empty state for the whole fetch. Pure + exported
* so the gate has direct unit coverage without mounting the page.
* `primaryLoading` comes from the snapshot cell that drives the tiles. A
* disabled TanStack query reports `isLoading: false` (idle fetch status),
* so keying off another source flashes the empty state for the whole
* fetch. Pure + exported so the gate has direct unit coverage without
* mounting the page.
*/
export function resolveDashboardFirstPaintGate(input: {
trendCardCount: number;
+11 -15
View File
@@ -127,8 +127,7 @@ export function useDashboardTileContext({
): number | null =>
value == null ? null : unitDisplay.toDisplayDelta(type, value);
const { data, layout, layoutData, moodData, snapshot, snapshotEnabled } =
dashboard;
const { data, layout, layoutData, moodData, snapshot } = dashboard;
const summaries = data?.summaries;
const compareBaseline = layout.comparisonBaseline ?? "none";
@@ -183,15 +182,15 @@ export function useDashboardTileContext({
const layoutResolved = layoutData != null;
// Band and target numbers come resolved from the snapshot (`targetBands`).
// The client copy below is computed from the profile through the SAME
// The client copy below is computed from the profile (the age defaults; the
// person's own targets arrive with the snapshot) through the SAME
// `buildDashboardBands` the server uses, so a still-loading snapshot frame
// keeps real bands rather than blank charts and the two cannot disagree.
// keeps real bands rather than blank charts.
// The profile it reads is the caller's, and under a switch the caller is
// the delegate: `viewerBandProfile` then draws no client band at all and
// waits for the snapshot's, which resolve against the record. Keyed on the
// `user` identity so the React Compiler can preserve the memo.
const inSharedRecord = user?.accountAccess?.active != null;
const thresholdsData = dashboard.thresholdsData;
const clientBandProfile = useMemo(
() =>
viewerBandProfile(
@@ -199,35 +198,32 @@ export function useDashboardTileContext({
dateOfBirth: user?.dateOfBirth ? new Date(user.dateOfBirth) : null,
gender: toProfileSex(user?.gender),
heightCm: user?.heightCm ?? null,
thresholdsJson: thresholdsData?.overrides ?? null,
thresholdsJson: null,
},
inSharedRecord,
),
[user, thresholdsData, inSharedRecord],
[user, inSharedRecord],
);
const clientBands = useMemo(
() => buildDashboardBands(clientBandProfile),
[clientBandProfile],
);
const serverBands = snapshotEnabled ? snapshot?.targetBands : undefined;
const bands = serverBands ?? clientBands;
const bands = snapshot?.targetBands ?? clientBands;
// Which way a weight change counts as progress against the stored target.
// The snapshot carries the server's answer; the fallback asks the same
// resolver from the same profile the band fallback reads.
const weight = summaries?.WEIGHT;
const weightTrend =
(snapshotEnabled ? snapshot?.tiles.weightTrend : undefined) ??
snapshot?.tiles.weightTrend ??
resolveWeightTrend(
resolveWeightTargetOverride(clientBandProfile.thresholdsJson),
weightTrendReferenceKg(weight),
);
// Every other tile's direction comes resolved from the server against the
// record's own targets. The snapshot-disabled path has no resolved answer
// and renders neutral rather than guessing a polarity.
const tileDirections = snapshotEnabled
? snapshot?.tiles.directions
: undefined;
// record's own targets; until the snapshot lands it renders neutral rather
// than guessing a polarity.
const tileDirections = snapshot?.tiles.directions;
const directionOf = (key: string): TrendDirectionSentiment =>
tileDirections?.[key]?.direction ?? "neutral";
@@ -3,42 +3,19 @@
import { useMemo, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import type { AuthUser } from "@/hooks/use-auth";
import { useSurfaceVisible } from "@/hooks/use-surface-visible";
import { apiGet } from "@/lib/api/api-fetch";
import { mergeSlimAndThickAnalytics } from "@/lib/analytics/merge-slim-thick";
import type { DataSummary } from "@/lib/analytics/trends";
import {
computeLocalBatchWindow,
deriveBatchChartTypes,
type BatchWindow,
} from "@/lib/dashboard/batch-chart-types";
import { isDashboardSnapshotEnabled } from "@/lib/dashboard/snapshot-flag";
import { hideModuleWidgets } from "@/lib/dashboard/widget-modules";
import {
resolveDashboardLayout,
type DashboardLayout,
} from "@/lib/dashboard-layout";
import { useAnalyticsQuery } from "@/lib/queries/use-analytics-query";
import { resolveDashboardLayout } from "@/lib/dashboard-layout";
import { useDailyDigest } from "@/lib/queries/use-daily-digest";
import { useDashboardSnapshot } from "@/lib/queries/use-dashboard-snapshot";
import { queryKeys } from "@/lib/query-keys";
import type { DashboardAnalyticsData as AnalyticsData } from "@/types/analytics";
/**
* Module scope so the option object is a stable reference across renders.
*
* Refetch on window focus: the dashboard is the surface a person leaves open
* in a background tab while changing its tile selection elsewhere; without it
* the stale layout sat visible for up to the poll interval ("saved but nothing
* changed"). `staleTime` bounds the cost to one refetch a minute, and the
* server side is SWR-cached, so a focus flick is a cheap cache hit.
*/
const DASHBOARD_QUERY_OPTS = {
staleTime: 60_000,
refetchOnWindowFocus: true,
} as const;
/** One day of the batched series the dashboard charts read their slice from. */
export interface BatchedSeriesRow {
value: number;
@@ -54,55 +31,29 @@ export interface DashboardMoodData {
}
/**
* The tiles' `AnalyticsData` from whichever path feeds them. Snapshot path:
* the one cell, where `extras` is null on a rollup-coverage miss (two-phase
* contract), so the BP-in-target and glucose fields stay undefined and those
* tiles shimmer while the rest of the strip paints. Legacy path: the two
* analytics slices merged.
* The tiles' `AnalyticsData` from the snapshot. `extras` is null on a
* rollup-coverage miss (two-phase contract), so the BP-in-target and glucose
* fields stay undefined and those tiles shimmer while the rest of the strip
* paints.
*/
function assembleDashboardAnalytics(
snapshotEnabled: boolean,
snapshot: ReturnType<typeof useDashboardSnapshot>["data"],
slim: Parameters<typeof mergeSlimAndThickAnalytics>[0],
thick: Parameters<typeof mergeSlimAndThickAnalytics>[1],
snap: ReturnType<typeof useDashboardSnapshot>["data"],
): AnalyticsData | undefined {
if (snapshotEnabled) {
const snap = snapshot;
if (!snap) return undefined;
return {
summaries: snap.tiles.summaries,
lastSeenByType: snap.tiles.lastSeenByType,
// Optional on the snapshot (additive contract).
sleepSourceDiscrepancy: snap.tiles.sleepSourceDiscrepancy ?? null,
bpInTargetPct: snap.extras?.bpInTargetPct ?? null,
bpInTargetPct7d: snap.extras?.bpInTargetPct7d ?? null,
bpInTargetPct30d: snap.extras?.bpInTargetPct30d ?? null,
bpInTargetPctAllTime: snap.extras?.bpInTargetPctAllTime ?? null,
bpInTargetPctPriorMonth: snap.extras?.bpInTargetPctPriorMonth ?? null,
bpInTargetPctPriorYear: snap.extras?.bpInTargetPctPriorYear ?? null,
bpInTargetCount90: snap.extras?.bpInTargetCount90 ?? null,
bpInTargetSpanDays90: snap.extras?.bpInTargetSpanDays90 ?? null,
glucoseByContext: snap.extras?.glucoseByContext as
Record<string, DataSummary> | undefined,
};
}
// `mergeSlimAndThickAnalytics` falls back to the thick slice when the
// slim one resolves with no content, and otherwise lets slim paint first.
const merged = mergeSlimAndThickAnalytics(slim, thick);
if (!merged) return undefined;
if (!snap) return undefined;
return {
summaries: merged.summaries,
lastSeenByType: merged.lastSeenByType,
sleepSourceDiscrepancy: merged.sleepSourceDiscrepancy,
bpInTargetPct: merged.bpInTargetPct,
bpInTargetPct7d: merged.bpInTargetPct7d,
bpInTargetPct30d: merged.bpInTargetPct30d,
bpInTargetPctAllTime: merged.bpInTargetPctAllTime,
bpInTargetPctPriorMonth: merged.bpInTargetPctPriorMonth,
bpInTargetPctPriorYear: merged.bpInTargetPctPriorYear,
bpInTargetCount90: merged.bpInTargetCount90,
bpInTargetSpanDays90: merged.bpInTargetSpanDays90,
glucoseByContext: merged.glucoseByContext as
summaries: snap.tiles.summaries,
lastSeenByType: snap.tiles.lastSeenByType,
// Optional on the snapshot (additive contract).
sleepSourceDiscrepancy: snap.tiles.sleepSourceDiscrepancy ?? null,
bpInTargetPct: snap.extras?.bpInTargetPct ?? null,
bpInTargetPct7d: snap.extras?.bpInTargetPct7d ?? null,
bpInTargetPct30d: snap.extras?.bpInTargetPct30d ?? null,
bpInTargetPctAllTime: snap.extras?.bpInTargetPctAllTime ?? null,
bpInTargetPctPriorMonth: snap.extras?.bpInTargetPctPriorMonth ?? null,
bpInTargetPctPriorYear: snap.extras?.bpInTargetPctPriorYear ?? null,
bpInTargetCount90: snap.extras?.bpInTargetCount90 ?? null,
bpInTargetSpanDays90: snap.extras?.bpInTargetSpanDays90 ?? null,
glucoseByContext: snap.extras?.glucoseByContext as
Record<string, DataSummary> | undefined,
};
}
@@ -112,10 +63,7 @@ function assembleDashboardAnalytics(
*
* The tiles hydrate from ONE un-gated `/api/dashboard/snapshot` cell, so the
* whole strip shares one completion moment and `/api/auth/me` leaves the cold
* critical path. `NEXT_PUBLIC_DASHBOARD_SNAPSHOT=false` falls back to the
* legacy independent cells (two analytics slices, the widget layout, the mood
* analytics and the user's own thresholds); either way the same
* `AnalyticsData` shape comes out, so no tile knows which path fed it.
* critical path.
*
* The Today hero's digest is read here too, in its original place in the read
* order: it is the day's data, so no module owns it, and it is gated on auth
@@ -124,99 +72,34 @@ function assembleDashboardAnalytics(
export function useDashboardData({
isAuthenticated,
mounted,
user,
batchWindowProp,
}: {
isAuthenticated: boolean;
mounted: boolean;
user: AuthUser | null;
/**
* The batched-series window the RSC prefetch computed from the profile
* time zone. Adopted verbatim so the `chartSeriesBatch` key matches the key
* the server dehydrated under; absent on the prefetch-off and legacy paths,
* where the browser-local window stands in.
* the server dehydrated under; absent when the prefetch is off, where the
* browser-local window stands in.
*/
batchWindowProp?: BatchWindow;
}) {
const snapshotEnabled = isDashboardSnapshotEnabled();
const snapshotQuery = useDashboardSnapshot(snapshotEnabled);
const snapshotQuery = useDashboardSnapshot();
const digestQuery = useDailyDigest(isAuthenticated);
// Legacy path: the slim slice (`?slice=summaries`) paints every per-type
// tile; the thick slice streams the BP-in-target and glucose fields in
// afterwards. Both share `caches.analytics` server-side.
const analyticsSlimQuery = useAnalyticsQuery({
slice: "summaries",
enabled: !snapshotEnabled && isAuthenticated,
});
const analyticsThickQuery = useAnalyticsQuery({
enabled: !snapshotEnabled && isAuthenticated,
});
const data = useMemo(
() =>
assembleDashboardAnalytics(
snapshotEnabled,
snapshotQuery.data,
analyticsSlimQuery.data,
analyticsThickQuery.data,
),
[
snapshotEnabled,
snapshotQuery.data,
analyticsSlimQuery.data,
analyticsThickQuery.data,
],
() => assembleDashboardAnalytics(snapshotQuery.data),
[snapshotQuery.data],
);
// The user's own targets, for the legacy (snapshot-disabled) band fallback
// only. In snapshot mode the server resolves the same overrides into
// `targetBands` and this query never fires.
const { data: thresholdsData } = useQuery({
queryKey: queryKeys.userThresholds(),
queryFn: async () => {
return apiGet<{
overrides: Record<string, { min: number; max: number }>;
}>("/api/user/thresholds");
},
enabled: !snapshotEnabled && isAuthenticated,
...DASHBOARD_QUERY_OPTS,
});
const { data: layoutDataLegacy } = useQuery({
queryKey: queryKeys.dashboardWidgets(),
queryFn: async () => {
return apiGet<DashboardLayout>("/api/dashboard/widgets");
},
enabled: !snapshotEnabled && isAuthenticated,
...DASHBOARD_QUERY_OPTS,
});
// The snapshot publishes its layout with switched-off modules' widgets
// already hidden. The legacy feed is the stored layout as saved (Settings
// edits that exact value), so the same mask is applied here, from the same
// surface map, before anything paints.
const layoutData = snapshotEnabled
? snapshotQuery.data?.layout
: layoutDataLegacy && hideModuleWidgets(layoutDataLegacy, user?.modules);
const moodWidgetVisible = useSurfaceVisible("widget:mood");
const { data: moodDataLegacy } = useQuery({
queryKey: queryKeys.moodAnalytics(),
queryFn: async () => {
return apiGet<DashboardMoodData>("/api/mood/analytics");
},
// The route refuses with the mood module off; do not ask.
enabled: !snapshotEnabled && isAuthenticated && moodWidgetVisible,
...DASHBOARD_QUERY_OPTS,
});
const moodData: DashboardMoodData | undefined = snapshotEnabled
? snapshotQuery.data
? {
entries: snapshotQuery.data.tiles.mood.entries,
summary: (snapshotQuery.data.tiles.mood.summary ??
undefined) as DataSummary,
}
: undefined
: moodDataLegacy;
// already hidden.
const layoutData = snapshotQuery.data?.layout;
const moodData: DashboardMoodData | undefined = snapshotQuery.data
? {
entries: snapshotQuery.data.tiles.mood.entries,
summary: (snapshotQuery.data.tiles.mood.summary ??
undefined) as DataSummary,
}
: undefined;
// Visibility and order of every widget.
const layout = resolveDashboardLayout(layoutData);
@@ -259,24 +142,17 @@ export function useDashboardData({
},
});
// The loading flag follows whichever query actually drives the tiles: in
// snapshot mode the slim query is disabled and reports `isLoading: false`
// forever, which would select the empty state for the whole snapshot
// fetch. `!mounted` pins the SSR pass and the hydration render to the
// skeleton branch (server-side the data queries are disabled, and a late
// hydrating boundary would otherwise render the skeleton where the server
// rendered the empty state: React #418). The hero band and the strip share
// this one gate so both swap to content in the same render pass.
const primaryLoading =
!mounted ||
(snapshotEnabled ? snapshotQuery.isLoading : analyticsSlimQuery.isLoading);
// `!mounted` pins the SSR pass and the hydration render to the skeleton
// branch (server-side the data queries are disabled, and a late hydrating
// boundary would otherwise render the skeleton where the server rendered
// the empty state: React #418). The hero band and the strip share this one
// gate so both swap to content in the same render pass.
const primaryLoading = !mounted || snapshotQuery.isLoading;
return {
snapshotEnabled,
snapshot: snapshotQuery.data,
digestQuery,
data,
thresholdsData,
layoutData,
layout,
moodData,
+40 -11
View File
@@ -56,6 +56,21 @@ const PARENTABLE: IllnessLifecycle[] = ["FLARE", "RECURRING"];
const NONE = "__none__";
/** What the form opens with: the edited episode, or a new one from today. */
function episodeForm(episode: IllnessEpisodeDTO | undefined, today: string) {
return {
label: episode?.label ?? "",
type: episode?.type ?? ("INFECTION" as IllnessType),
lifecycle: episode?.lifecycle ?? ("ACUTE" as IllnessLifecycle),
onset: episode ? episode.onsetAt.slice(0, 10) : today,
resolved: episode?.resolvedAt ? episode.resolvedAt.slice(0, 10) : "",
parentId: episode?.parentConditionId ?? NONE,
note: episode?.note ?? "",
bodySite: episode?.bodySite ?? "",
laterality: (episode?.laterality ?? null) as BodySiteSideValue,
};
}
interface NewEpisodeSheetProps {
open: boolean;
onOpenChange: (open: boolean) => void;
@@ -102,22 +117,35 @@ export function NewEpisodeSheet({
// render keyed on the open transition (React's recommended alternative to a
// setState-in-effect).
const [wasOpen, setWasOpen] = useState(false);
const [seed, setSeed] = useState(() => episodeForm(editEpisode, today));
if (open && !wasOpen) {
const next = episodeForm(editEpisode, today);
setWasOpen(true);
setLabel(editEpisode?.label ?? "");
setType(editEpisode?.type ?? "INFECTION");
setLifecycle(editEpisode?.lifecycle ?? "ACUTE");
setOnset(editEpisode ? editEpisode.onsetAt.slice(0, 10) : today);
setResolved(
editEpisode?.resolvedAt ? editEpisode.resolvedAt.slice(0, 10) : "",
);
setParentId(editEpisode?.parentConditionId ?? NONE);
setNote(editEpisode?.note ?? "");
setBodySite(editEpisode?.bodySite ?? "");
setLaterality(editEpisode?.laterality ?? null);
setSeed(next);
setLabel(next.label);
setType(next.type);
setLifecycle(next.lifecycle);
setOnset(next.onset);
setResolved(next.resolved);
setParentId(next.parentId);
setNote(next.note);
setBodySite(next.bodySite);
setLaterality(next.laterality);
} else if (!open && wasOpen) {
setWasOpen(false);
}
const form = {
label,
type,
lifecycle,
onset,
resolved,
parentId,
note,
bodySite,
laterality,
};
const dirty = JSON.stringify(form) !== JSON.stringify(seed);
const showParent = PARENTABLE.includes(lifecycle);
@@ -172,6 +200,7 @@ export function NewEpisodeSheet({
<ResponsiveSheet
open={open}
onOpenChange={onOpenChange}
dirty={dirty}
title={isEdit ? t("illness.edit.title") : t("illness.new.title")}
description={
isEdit ? t("illness.edit.description") : t("illness.new.description")
@@ -6,6 +6,10 @@ import { renderToStaticMarkup } from "react-dom/server";
* on the tab strip but shows no briefing, so it must not read the briefing
* on entry: on a metric page that read was a request (and the schema chunk
* that parses its answer) for a button. The button itself stays.
*
* The strip's pills are gated on one small presence read and the saved
* layout. The overview read, a workouts page and the ECG list it used to
* send on every page for four flags are gone.
*/
const queries: Array<{ queryKey: readonly unknown[]; enabled?: boolean }> = [];
@@ -29,23 +33,12 @@ vi.mock("@/hooks/use-ai-capability", () => ({
useAiCapability: () => ({ available: true }),
useAiProviderState: () => ({ responseTimeoutMs: null }),
}));
vi.mock("@/hooks/use-surface-visible", () => ({
useSurfaceVisible: () => true,
}));
vi.mock("@/hooks/use-workouts", () => ({
useWorkouts: () => ({ data: undefined }),
}));
vi.mock("@/lib/queries/use-analytics-query", () => ({
useAnalyticsQuery: () => ({ data: undefined }),
}));
const layoutReads: unknown[] = [];
vi.mock("@/hooks/use-insights-layout", () => ({
useInsightsLayoutQuery: () => ({
layout: { tiles: [] },
isSuccess: false,
}),
}));
vi.mock("@/components/insights/use-ecg-list", () => ({
useEcgList: () => ({ data: undefined }),
useInsightsLayoutQuery: (enabled: unknown) => {
layoutReads.push(enabled);
return { layout: { tiles: [] }, isSuccess: false };
},
}));
vi.mock("@/components/insights/insights-tab-strip", () => ({
InsightsTabStrip: (props: { onRegenerate?: unknown }) => {
@@ -73,4 +66,20 @@ describe("InsightsLayoutShell — what it reads on entry", () => {
expect(advisor[0].enabled).toBe(false);
expect(typeof stripProps[0]?.onRegenerate).toBe("function");
});
it("reads the presence answer and the layout, nothing else", () => {
queries.length = 0;
layoutReads.length = 0;
renderToStaticMarkup(
<InsightsLayoutShell>
<div />
</InsightsLayoutShell>,
);
const sent = queries
.filter((q) => q.enabled !== false)
.map((q) => JSON.stringify(q.queryKey));
expect(sent).toEqual([JSON.stringify(queryKeys.insightsPresence())]);
expect(layoutReads).toEqual([true]);
});
});
@@ -252,6 +252,16 @@ describe("<MessageThread>", () => {
);
});
it("keeps the floating New conversation button's height free below the last message", () => {
const html = render(<MessageThread conversation={baseConversation} />);
const scroller =
html.match(/<div[^>]*data-slot="coach-message-thread"[^>]*>/)?.[0] ?? "";
expect(scroller).toContain("pb-20");
// Back to the plain gutter once the button sits beside the composer.
expect(scroller).toContain("@min-[57rem]/coach-thread:pb-6");
});
it("folds an older message's provenance into the thought-process row, never a raw key", () => {
const html = render(<MessageThread conversation={baseConversation} />);
expect(html).toContain('data-slot="coach-turn-steps-done"');
@@ -640,27 +640,25 @@ export function MessageThread({
one scroll container. */}
<div
data-slot="coach-message-thread-frame"
className="relative flex min-h-0 flex-1 flex-col"
className="@container/coach-thread relative flex min-h-0 flex-1 flex-col"
>
<div
ref={scrollerRef}
data-slot="coach-message-thread"
className={cn(
// v1.18.6.1 — `min-h-0 flex-1` (not `h-full`) so the scroll region
// resolves its height from the flex parent rather than a 100%-of-auto
// chain that let the thread grow instead of scroll.
// v1.18.7 — calmer vertical rhythm (gap-6) and more generous top/
// bottom breathing room so the conversation reads like a document,
// not a chat log packed against the chrome.
// `min-h-0 flex-1`, not `h-full`: the region takes its height from
// the flex parent, so the thread scrolls instead of growing.
"flex min-h-0 flex-1 flex-col gap-6 overflow-y-auto px-4 py-6 sm:px-6",
// v1.18.6 v1.18.7 — on the wide page surface an edge-to-
// edge thread sprawled the prose past a readable measure. Centre the
// content on a narrower, calmer Claude/ChatGPT-like column
// (`max-w-2xl`) via an `mx-auto` inner gutter; the scrollbar still
// rides the surface edge. The drawer surface is already narrow, so
// the cap only bites on the wide page surface.
// A readable measure on the wide page; the scrollbar stays on the
// surface edge.
"[&>*]:mx-auto [&>*]:w-full [&>*]:max-w-2xl",
"scroll-smooth",
// Every surface with a thread floats New conversation over its
// bottom edge while the composer fills the column (`NewChatFab`),
// so the last message keeps the button's height free, as the
// shell clears the Coach button. The button moves beside a 54rem
// composer, which the column is at 57rem with its padding.
"pb-20 @min-[57rem]/coach-thread:pb-6",
)}
>
{[
@@ -16,7 +16,8 @@ import { cn } from "@/lib/utils";
* column is wide enough, it takes the bottom right corner beside the
* composer, where the Coach button sits on other pages; where the composer
* fills the width (a phone, the drawer, a narrow column), it rises just
* above the composer's top edge so it never covers the field.
* above the composer's top edge so it never covers the field, and the thread
* keeps its height free below the last message.
*
* The parent is the composer's wrapper and must be `relative` and an
* `@container/composer`.
@@ -6,20 +6,12 @@ import { useQuery } from "@tanstack/react-query";
import { useAuth } from "@/hooks/use-auth";
import { useMounted } from "@/hooks/use-mounted";
import { useAiCapability } from "@/hooks/use-ai-capability";
import { useSurfaceVisible } from "@/hooks/use-surface-visible";
import { useWorkouts } from "@/hooks/use-workouts";
import { InsightsTabStrip } from "@/components/insights/insights-tab-strip";
import {
nextAdvisorPollInterval,
useInsightsAdvisorQuery,
} from "@/components/insights/use-insights-advisor";
import { pollAttemptCount } from "@/lib/queries/refetch-interval";
import { useAnalyticsQuery } from "@/lib/queries/use-analytics-query";
import { useInsightsAdvisorQuery } from "@/components/insights/use-insights-advisor";
import { useInsightsLayoutQuery } from "@/hooks/use-insights-layout";
import { queryKeys } from "@/lib/query-keys";
import type { InsightInputs } from "@/lib/insights/metric-availability";
import { apiGet } from "@/lib/api/api-fetch";
import { useEcgList } from "@/components/insights/use-ecg-list";
/**
* v1.4.25 — client shell for `src/app/insights/layout.tsx`.
@@ -31,26 +23,22 @@ import { useEcgList } from "@/components/insights/use-ecg-list";
* page used to send the briefing read (and load the schema chunk that
* parses it) for a button.
*
* v1.4.27 — also owns the analytics + comprehensive reads that
* power the tab-strip availability gate. The analytics consumer goes
* through the shared `useAnalyticsQuery({ slice: "summaries" })` hook
* (v1.4.33 IW2); the comprehensive consumer still owns its bespoke
* fetch but shares its cache key with the sub-page consumers so the
* payload lands once per route, dedup-shared across consumers.
* The tab-strip gate reads one small answer, `/api/insights/presence`
* (reading counts per type, and whether mood, medication, workout and ECG
* rows exist), plus the saved layout. Each page loads its own data; the
* strip used to read the overview, a workouts page and the ECG list on
* every page for four flags.
*
* CRITICAL — the `<CoachDrawer>` does NOT mount here. It lives only in
* `src/app/insights/page.tsx` body so navigating into a sub-page
* unmounts the drawer.
*/
interface ComprehensivePayload {
moodSummary: { count: number } | null;
medications: Array<{ id: string }>;
/**
* v1.16.7 — true when the route served a stale cache body while a
* background rebuild runs (stale-while-revalidate). The query polls
* (bounded) while set so the fresh aggregate lands in-session.
*/
revalidating?: boolean;
interface InsightsPresence {
metrics: Record<string, { count: number }>;
hasMood: boolean;
hasMedication: boolean;
hasWorkouts: boolean;
hasEcgRecordings: boolean;
}
export function InsightsLayoutShell({ children }: { children: ReactNode }) {
@@ -70,83 +58,33 @@ export function InsightsLayoutShell({ children }: { children: ReactNode }) {
const advisor = useInsightsAdvisorQuery(false);
// Shared analytics fetch — the layout shell only reads
// `summaries[METRIC].count` for the tab-strip availability gate, so
// it lands on IW1's slim `?slice=summaries` branch (2 SQL passes,
// no correlations / health-score / bp-in-target tail).
const analyticsQuery = useAnalyticsQuery({ slice: "summaries" });
// Shared comprehensive fetch — mood + medication signals for the
// event-driven gating branches. Sub-pages read the same key so the
// payload lands once per route.
const comprehensiveQuery = useQuery({
queryKey: queryKeys.insightsComprehensive(),
queryFn: async () => {
return apiGet<ComprehensivePayload>("/api/insights/comprehensive");
},
// Answered for the record on screen. A page whose module is off reads
// false here, and the strip hides its pill through the surface map anyway.
const presenceQuery = useQuery({
queryKey: queryKeys.insightsPresence(),
queryFn: () => apiGet<InsightsPresence>("/api/insights/presence"),
enabled: isAuthenticated,
// v1.16.7 — converge a stale-served (SWR) body in-session: while the
// route reports `revalidating: true`, poll on the same bounded
// cadence the advisor query uses until a fresh body lands.
refetchInterval: (query) =>
nextAdvisorPollInterval(
query.state.data?.revalidating,
pollAttemptCount(query.state),
),
});
const presence = presenceQuery.data;
// v1.4.32 — workout-presence gate. The workouts pill gates on at
// least one canonical row in the list-endpoint response. The
// single-row probe shares its cache slot with the list page +
// dashboard tile so navigation between surfaces is a free cache hit.
// Only while the Workouts module shows: a switched-off module is never
// probed (its pill is hidden by the surface map regardless).
const workoutsVisible = useSurfaceVisible("insights-page:workouts");
const workoutsProbe = useWorkouts({ limit: 1, enabled: workoutsVisible });
// Nutrients is opt-in, so its pill follows the module toggle alone: the
// in-context enable CTA lives on `/insights/nutrients` itself, and gating
// on a first row as well left that page unreachable before data existed.
const hasNutrients = user?.modules?.nutrients === true;
const hasEcgRecordings = presence?.hasEcgRecordings === true;
// v1.29 — nutrients gate. `nutrients` is opt-in (default off), unlike
// the always-on metric clusters, so the pill's floor is the module
// toggle alone — not a data-presence probe. Gating on BOTH the toggle
// AND a first row was a catch-22: the in-context enable CTA lives on
// `/insights/nutrients` itself, and that page was only reachable once
// data already existed. A user who just flipped the module on (or
// wants to) can now always reach the page; the page itself still
// degrades from "enable" → "no rows yet" → the three-card spine.
const nutrientsEnabled = user?.modules?.nutrients === true;
// v1.30 — ECG recording presence for the Heart-group ECG
// pill. ECG carries no `MeasurementType`, so the metric `availability`
// model can't gate it; it rides its own `hasRecordings` probe instead. The
// query shares `queryKeys.insightsEcgList()` with `<EcgSection>` and the
// ECG page, so the flag lands once per route (dedup-shared, no extra
// traffic). Undefined until it resolves → the pill stays off on first paint,
// exactly like the metric pills light up as their summaries land.
const ecgProbe = useEcgList();
const hasEcgRecordings = ecgProbe.data?.data.hasRecordings === true;
// v1.4.31 — memoise the `availability` prop so an unchanged
// payload doesn't recreate the object on every cache-write of
// analytics or comprehensive. The strip is wrapped in
// `React.memo` and would still rerun `buildTabs(availability)` on
// every parent render if this prop kept arriving as a fresh
// reference. Per
// `.planning/research/v15-insights-blocking-bug.md` fix 2.
const summaries = analyticsQuery.data?.summaries;
const hasMood = (comprehensiveQuery.data?.moodSummary?.count ?? 0) > 0;
const hasMedication = (comprehensiveQuery.data?.medications?.length ?? 0) > 0;
const hasWorkouts = (workoutsProbe.data?.workouts.length ?? 0) > 0;
const hasNutrients = nutrientsEnabled;
// Memoised so an unchanged answer keeps the same reference: the strip is
// wrapped in `React.memo` and reruns `buildTabs` on a new object.
const availability: InsightInputs | undefined = useMemo(() => {
if (!isAuthenticated) return undefined;
return { summaries, hasMood, hasMedication, hasWorkouts, hasNutrients };
}, [
isAuthenticated,
summaries,
hasMood,
hasMedication,
hasWorkouts,
hasNutrients,
]);
return {
summaries: presence?.metrics,
hasMood: presence?.hasMood === true,
hasMedication: presence?.hasMedication === true,
hasWorkouts: presence?.hasWorkouts === true,
hasNutrients,
};
}, [isAuthenticated, presence, hasNutrients]);
// v1.15.14 — couple the tab strip to the saved insights layout.
// A sub-page pill now shows iff its metric has data AND its slug is
@@ -3,8 +3,8 @@ import { renderToStaticMarkup } from "react-dom/server";
/**
* The monitoring switches come from the root layout's render, not from a
* request every page load sent to `/api/monitoring/settings` for three
* instance-wide values. The first render already mounts what they turn on.
* request on every page load for three instance-wide values. The first
* render already mounts what they turn on.
*/
const apiGet = vi.fn();
+2 -2
View File
@@ -5,8 +5,8 @@ import type { PublicMonitoringSettings } from "@/lib/monitoring-settings";
/**
* Mounts the operator's analytics script and error reporter. The root layout
* reads the instance-wide switches on the server and passes them in, so no
* page load asks `/api/monitoring/settings` for three values the document
* render already had. `null` (the key-mismatch page) mounts nothing.
* page load asks for three values the document render already has. `null`
* (the key-mismatch page) mounts nothing.
*/
export function MonitoringBootstrap({
settings,
@@ -97,12 +97,17 @@ export function TagEditorSheet({
// so the field state seeds fresh from props on every open — no
// re-seeding effect needed.
const isEdit = tag !== undefined;
const [label, setLabel] = useState(tag?.label ?? "");
const [icon, setIcon] = useState<string | null>(tag?.icon ?? "Tag");
const [groupKey, setGroupKey] = useState(
tag?.groupKey ?? initialGroupKey ?? "custom",
);
const [seed] = useState(() => ({
label: tag?.label ?? "",
icon: tag?.icon ?? "Tag",
groupKey: tag?.groupKey ?? initialGroupKey ?? "custom",
}));
const [label, setLabel] = useState(seed.label);
const [icon, setIcon] = useState<string | null>(seed.icon);
const [groupKey, setGroupKey] = useState(seed.groupKey);
const [saving, setSaving] = useState(false);
const dirty =
JSON.stringify({ label, icon, groupKey }) !== JSON.stringify(seed);
const trimmed = label.trim();
const valid = trimmed.length > 0 && trimmed.length <= LABEL_MAX_LENGTH;
@@ -158,6 +163,7 @@ export function TagEditorSheet({
<ResponsiveSheet
open={open}
onOpenChange={onOpenChange}
dirty={dirty}
title={isEdit ? t("common.edit") : t("mood.manage.newTag")}
footer={
<>
+1 -2
View File
@@ -25,7 +25,6 @@ import { OidcReproofReturnBridge } from "@/components/auth/oidc-reproof-return";
import { LocaleServerSyncBridge } from "@/components/layout/locale-server-sync-bridge";
import { useAuth, type AccountAccessStatus } from "@/hooks/use-auth";
import { useRecordSessionTransition } from "@/hooks/use-record-session-transition";
import { isDashboardSnapshotEnabled } from "@/lib/dashboard/snapshot-flag";
import {
discardDashboardSnapshotPreload,
prefetchDashboardSnapshot,
@@ -240,7 +239,7 @@ function DashboardSnapshotPreloader() {
useEffect(() => {
if (!ready) return;
const controller = new AbortController();
if (pathname === "/" && isDashboardSnapshotEnabled()) {
if (pathname === "/") {
prefetchDashboardSnapshot(queryClient, controller.signal);
}
// v1.16.7 — same waterfall cut for the medications page: its list
@@ -11,13 +11,12 @@ import { queryKeys } from "@/lib/query-keys";
* very next SAME-TAB navigation — not "eventually" via the 120 s poll or a
* window-focus flick.
*
* The web dashboard (`src/app/page-client.tsx`) reads tile visibility/order
* from TWO cache cells:
* Tile visibility/order lives in TWO cache cells:
* 1. the snapshot cell (`queryKeys.dashboardSnapshot(locale)` via
* `useDashboardSnapshot`) — `snapshotQuery.data.layout` is the layout
* source whenever snapshot mode is on (the default), and
* 2. the legacy `queryKeys.dashboardWidgets()` cell (snapshot mode off, plus
* the per-chart overlay-prefs hook).
* `useDashboardSnapshot`) — `snapshotQuery.data.layout` is what the web
* dashboard (`src/app/page-client.tsx`) paints from, and
* 2. the `queryKeys.dashboardWidgets()` cell (the settings editor and the
* per-chart overlay-prefs hook).
* The settings save writes (2) directly via `setQueryData` with the server
* response, so its freshness is unconditional. (1) is only INVALIDATED — and
* that is where the same-tab staleness lived:
@@ -154,12 +153,9 @@ describe("dashboard layout save — every layout source is covered (source pins)
);
});
it("the dashboard reads layout from the snapshot (or the dashboardWidgets cell) only", () => {
it("the dashboard reads layout from the snapshot only", () => {
const pageSrc = dashboardSource();
// Snapshot path: layout comes off the snapshot payload…
expect(pageSrc).toContain("snapshotQuery.data?.layout");
// …legacy path: the dashboardWidgets factory key (no bare literal).
expect(pageSrc).toContain("queryKeys.dashboardWidgets()");
// The analytics slices carry tile DATA (summaries), never layout — if
// someone threads layout through them, this pin forces a rethink of the
// save-path invalidation set.
@@ -468,7 +468,7 @@ function HandoverLink({
id="managed-profile-handover-url"
readOnly
value={link.url}
className="min-w-0 flex-1 font-mono text-xs"
className="min-w-0 flex-1 font-mono md:pointer-fine:text-xs"
onFocus={(e) => e.currentTarget.select()}
/>
<Button
@@ -146,7 +146,6 @@ function FactRow({
maxLength={REMEMBER_FACT_MAX_CHARS}
rows={2}
onChange={(e) => setDraft(e.target.value)}
className="text-sm"
/>
<div className="flex flex-wrap items-center justify-end gap-2">
<Button
@@ -78,6 +78,11 @@ export function EditSymptomEventSheet({
// a link the list cannot show (a deleted episode, the list still loading)
// is left alone rather than silently dropped.
const initialEpisodeId = effectiveEpisodeId(event.episodeId, episodeOptions);
const dirty =
intensity !== event.intensity ||
occurredAt !== toLocalValue(event.occurredAt) ||
note !== (event.note ?? "") ||
linkedEpisodeId !== initialEpisodeId;
const low = t("symptoms.entry.anchorLow");
const high = t("symptoms.entry.anchorHigh");
@@ -107,6 +112,7 @@ export function EditSymptomEventSheet({
onOpenChange={(open) => {
if (!open) onClose();
}}
dirty={dirty}
title={t("symptoms.section.editEventTitle", { symptom: label })}
footer={
<>
@@ -222,6 +228,10 @@ export function EditSymptomDefinitionSheet({
onOpenChange={(open) => {
if (!open) onClose();
}}
dirty={
label !== (definition.label ?? "") ||
icon !== (definition.icon ?? "Tag")
}
title={t("symptoms.section.editSymptomTitle")}
footer={
<>
+17 -4
View File
@@ -212,10 +212,22 @@ function TargetEditSheetBody({
// Display values: user-edited string (if any) wins, otherwise we
// surface the freshest seeded value derived from the
// `thresholdsData` query + the parent-provided `initialRange`.
const displayMin = minStr ?? String(primary.min);
const displayMax = maxStr ?? String(primary.max);
const displayDiaMin = diaMinStr ?? (secondary ? String(secondary.min) : "");
const displayDiaMax = diaMaxStr ?? (secondary ? String(secondary.max) : "");
const seeded = {
min: String(primary.min),
max: String(primary.max),
diaMin: secondary ? String(secondary.min) : "",
diaMax: secondary ? String(secondary.max) : "",
};
const displayMin = minStr ?? seeded.min;
const displayMax = maxStr ?? seeded.max;
const displayDiaMin = diaMinStr ?? seeded.diaMin;
const displayDiaMax = diaMaxStr ?? seeded.diaMax;
// A bound typed away from its seed; a dismissal then asks first.
const dirty =
displayMin !== seeded.min ||
displayMax !== seeded.max ||
displayDiaMin !== seeded.diaMin ||
displayDiaMax !== seeded.diaMax;
// Focus management — the first input gets focus when the sheet
// opens, matching the Dashboard chart cog's open-on-focus pattern.
@@ -412,6 +424,7 @@ function TargetEditSheetBody({
<ResponsiveSheet
open
onOpenChange={onOpenChange}
dirty={dirty}
title={t("targets.edit.title", { metric: targetLabel })}
description={
isDerivedMetric
+10 -2
View File
@@ -10,7 +10,7 @@
* capture picker's sheet, which hands it a footer slot so the actions sit in
* the sheet's sticky footer like every other quick entry.
*/
import { useId, useState } from "react";
import { useEffect, useId, useState } from "react";
import { createPortal } from "react-dom";
import { Check, Loader2, Lock, Plus, Trash2, X } from "lucide-react";
@@ -65,6 +65,8 @@ export interface LifeEventFormProps {
onCancel?: () => void;
/** Where the action row goes; inline when absent. */
footerSlot?: HTMLElement | null;
/** Whether the fields differ from what the form opened with. */
onDirtyChange?: (dirty: boolean) => void;
}
function CategoryChips({
@@ -208,6 +210,7 @@ export function LifeEventForm({
onSuccess,
onCancel,
footerSlot,
onDirtyChange,
}: LifeEventFormProps) {
const { t } = useTranslations();
const ids = {
@@ -219,9 +222,14 @@ export function LifeEventForm({
note: useId(),
};
const today = todayKeyIn(undefined);
const [draft, setDraft] = useState<LifeEventDraft>(() =>
const [seed] = useState<LifeEventDraft>(() =>
event ? draftFromEvent(event) : emptyDraft(defaultDate ?? today),
);
const [draft, setDraft] = useState<LifeEventDraft>(seed);
const dirty = JSON.stringify(draft) !== JSON.stringify(seed);
useEffect(() => {
onDirtyChange?.(dirty);
}, [dirty, onDirtyChange]);
const [errors, setErrors] = useState<LifeEventDraftErrors>({});
const [saveError, setSaveError] = useState<string | null>(null);
const { create, update, remove } = useLifeEventMutations();
+5 -1
View File
@@ -3,7 +3,8 @@
/**
* The timeline's own life-event sheet (v1.42, #613): add from the header
* button or a readiness link, edit from a life event in the chronicle or the
* selection bar. A bottom sheet on a phone, a dialog above.
* selection bar. A bottom sheet on a phone, a dialog above. A dismissal
* asks first once the form differs from what it opened with.
*/
import { useState } from "react";
@@ -24,11 +25,13 @@ export function LifeEventSheet({
}) {
const { t } = useTranslations();
const [footer, setFooter] = useState<HTMLDivElement | null>(null);
const [formDirty, setFormDirty] = useState(false);
const close = () => onOpenChange(false);
return (
<ResponsiveSheet
open={open}
onOpenChange={onOpenChange}
dirty={formDirty}
title={event ? t("lifeEvents.edit") : t("lifeEvents.title")}
contentWidth="lg"
footer={<div ref={setFooter} className="flex w-full" />}
@@ -41,6 +44,7 @@ export function LifeEventSheet({
onSuccess={close}
onCancel={close}
footerSlot={footer}
onDirtyChange={setFormDirty}
/>
)}
</ResponsiveSheet>
@@ -23,6 +23,9 @@ interface FakeEl {
parentElement: FakeEl | null;
overflowY: string;
own?: boolean;
/** Radix's `data-state` on the sheet content. */
state?: "open" | "closed";
getAttribute: (name: string) => string | null;
closest: (selector: string) => FakeEl | null;
getAnimations: () => Animation[];
setPointerCapture: (id: number) => void;
@@ -39,6 +42,8 @@ function fake(partial: Partial<FakeEl> = {}): FakeEl {
parentElement: null,
overflowY: "visible",
closest: () => (node.own ? node : null),
getAttribute: (name) =>
name === "data-state" ? (node.state ?? null) : null,
getAnimations: () => [],
setPointerCapture: vi.fn(),
querySelector: () => null,
@@ -173,6 +178,29 @@ describe("createSheetDrag", () => {
expect(s.controller.swallowClick()).toBe(false);
});
it("returns to its rest when a guarded sheet stays open after the swipe", () => {
const s = setup();
// The owner asks about unsaved input instead of closing.
s.content.state = "open";
s.drag(s.header, 140, { stepMs: 40, restMs: 200 });
expect(s.onClose).toHaveBeenCalledTimes(1);
expect(s.content.style.transform).toBe("translate3d(0, 140px, 0)");
s.flushFrames();
expect(s.content.style.transform).toBe("translate3d(0, 0px, 0)");
expect(s.overlay.style.opacity).toBe("1");
s.timers.forEach((run) => run());
expect(s.content.style.transform).toBe("");
});
it("leaves a closing sheet where it was let go", () => {
const s = setup();
s.content.state = "closed";
s.drag(s.header, 140, { stepMs: 40, restMs: 200 });
s.flushFrames();
expect(s.content.style.transform).toBe("translate3d(0, 140px, 0)");
});
it("lets its own close through: the swallowed click comes after it", () => {
const s = setup();
let swallowedDuringClose: boolean | null = null;
+7
View File
@@ -354,6 +354,13 @@ export function createSheetDrag(
// Before the swallow is armed: the close is itself a click.
options.onClose();
swallow = true;
// A sheet holding unsaved input asks first and stays open meanwhile;
// put it back where it rests rather than where it was let go.
frame(() => {
if (options.content()?.getAttribute("data-state") === "open") {
settleTo(g, options.snap());
}
});
return;
}
swallow = true;
@@ -1,141 +0,0 @@
import { describe, it, expect } from "vitest";
import { mergeSlimAndThickAnalytics } from "@/lib/analytics/merge-slim-thick";
import type { DataSummary } from "@/lib/analytics/trends";
/**
* v1.4.39.3 — dashboard slim/thick merge robustness.
*
* v1.4.39.2 split the dashboard's analytics consumption into a slim
* `?slice=summaries` query and the thick default query. The original
* inline merge used `slim?.summaries ?? thick?.summaries` which
* short-circuited on a truthy-but-empty `{}` from the slim slice and
* blanked the tile strip even when thick carried the full payload —
* the regression the maintainer's v1.4.39.3 e2e CI flagged across eight
* dashboard / chart specs (chart-overlay-controls, dashboard,
* measurement-flow, charts-mobile). The pure helper extracted in
* v1.4.39.3 uses object emptiness as the discriminator so a populated
* thick payload survives an empty slim resolve.
*/
const stubSummary = (): DataSummary => ({
count: 30,
latest: 78.5,
min: 76,
max: 80,
mean: 78,
median: 78,
avg7: 78.2,
avg30: 77.9,
slope7: null,
slope30: { slope: -0.05, direction: "down", confidence: 0.5 },
anomalyCount: 0,
});
describe("mergeSlimAndThickAnalytics", () => {
it("returns undefined when neither slim nor thick has resolved", () => {
expect(mergeSlimAndThickAnalytics(undefined, undefined)).toBeUndefined();
});
it("prefers slim summaries when slim carries content (progressive paint)", () => {
const slim = {
summaries: {
WEIGHT: stubSummary(),
},
};
const thick = {
summaries: {
WEIGHT: { ...stubSummary(), latest: 1 },
PULSE: stubSummary(),
},
bpInTargetPct: 78,
};
const merged = mergeSlimAndThickAnalytics(slim, thick);
// Slim wins on overlapping field — the v1.4.39.2 progressive-paint
// contract is preserved.
expect(merged?.summaries.WEIGHT?.latest).toBe(78.5);
// Slim does not carry PULSE; merge keeps slim's record as-is
// (matches the "as soon as slim lands, paint" semantic).
expect(merged?.summaries.PULSE).toBeUndefined();
// Thick still wires the BD-Zielbereich + glucose fields.
expect(merged?.bpInTargetPct).toBe(78);
});
it("falls back to thick when slim resolves with an empty summaries record", () => {
// The v1.4.39.3 regression: empty slim `{}` is truthy by JS
// semantics so the old `??` short-circuited on it, blanking the
// tile strip even when thick had data.
const slim = { summaries: {} as Record<string, DataSummary> };
const thick = {
summaries: {
WEIGHT: stubSummary(),
},
bpInTargetPct: 78,
};
const merged = mergeSlimAndThickAnalytics(slim, thick);
expect(merged?.summaries.WEIGHT?.latest).toBe(78.5);
expect(merged?.bpInTargetPct).toBe(78);
});
it("falls back to thick when slim has not resolved yet", () => {
const thick = {
summaries: {
WEIGHT: stubSummary(),
},
bpInTargetPct: 78,
};
const merged = mergeSlimAndThickAnalytics(undefined, thick);
expect(merged?.summaries.WEIGHT?.latest).toBe(78.5);
expect(merged?.bpInTargetPct).toBe(78);
});
it("returns slim summaries when only slim has resolved (thick still loading)", () => {
const slim = {
summaries: { WEIGHT: stubSummary() },
};
const merged = mergeSlimAndThickAnalytics(slim, undefined);
expect(merged?.summaries.WEIGHT?.latest).toBe(78.5);
// Thick-only fields collapse to null/undefined so consumers stay
// undefined-safe — the BD-Zielbereich tile self-gates on these.
expect(merged?.bpInTargetPct).toBeNull();
expect(merged?.glucoseByContext).toBeUndefined();
});
it("returns empty summaries when both sides resolve empty (legit zero-data tenant)", () => {
const merged = mergeSlimAndThickAnalytics(
{ summaries: {} },
{ summaries: {} },
);
expect(merged?.summaries).toEqual({});
expect(merged?.bpInTargetPct).toBeNull();
});
it("falls back to thick lastSeenByType when slim has none", () => {
const slim = { summaries: { WEIGHT: stubSummary() } };
const thick = {
summaries: { WEIGHT: stubSummary() },
lastSeenByType: {
WEIGHT: { lastSeenAt: "2026-05-01T00:00:00.000Z", daysAgo: 3 },
},
};
const merged = mergeSlimAndThickAnalytics(slim, thick);
expect(merged?.lastSeenByType?.WEIGHT?.daysAgo).toBe(3);
});
it("prefers slim lastSeenByType when slim carries one", () => {
const slim = {
summaries: { WEIGHT: stubSummary() },
lastSeenByType: {
WEIGHT: { lastSeenAt: "2026-05-10T00:00:00.000Z", daysAgo: 1 },
},
};
const thick = {
summaries: { WEIGHT: stubSummary() },
lastSeenByType: {
WEIGHT: { lastSeenAt: "2026-05-01T00:00:00.000Z", daysAgo: 10 },
},
};
const merged = mergeSlimAndThickAnalytics(slim, thick);
expect(merged?.lastSeenByType?.WEIGHT?.daysAgo).toBe(1);
});
});
-137
View File
@@ -1,137 +0,0 @@
/**
* v1.4.39.3 — pure helper that merges the dashboard's slim
* (`/api/analytics?slice=summaries`) and thick (`/api/analytics`)
* payloads into the single shape the dashboard tile-strip + chart row
* consumes.
*
* Background — v1.4.39.2 split `useAnalyticsQuery` into two parallel
* mounts so the per-type tile strip paints as soon as the slim slice
* lands and the BD-Zielbereich + glucose tiles stream in from the
* thick slice afterwards. The inline `useMemo` that performed the
* merge originally used `slim?.summaries ?? thick?.summaries` —
* structurally correct on production where both routes agree, but
* silently buggy when slim resolves first with an *empty* `{}` (e.g.
* a freshly-seeded tenant whose rollup tier hasn't folded yet, or a
* test environment that mocks thick but not slim). The empty object
* is truthy by JS semantics so `??` short-circuited and the tile
* strip painted blank even though thick carried the full payload —
* exactly the regression the v1.4.39.3 e2e CI surfaced across eight
* dashboard / chart specs.
*
* Post-fix: object emptiness is the discriminator. A non-empty slim
* payload still wins on overlapping fields (the v1.4.39.2 progressive-
* paint contract). An empty slim falls through to thick. When both
* are empty, the caller receives an empty record and the dashboard's
* data-floor gates render the appropriate empty state.
*
* Kept as a pure helper so the regression is verified with a tight
* unit test (`merge-slim-thick.test.ts`) rather than a source-text
* snapshot — string matching against `src/app/page.tsx` would not
* have caught the empty-object short-circuit bug.
*/
import type { DataSummary } from "@/lib/analytics/trends";
import type { SleepSourceDiscrepancy } from "@/lib/analytics/sleep-night";
export interface AnalyticsSlimLike {
summaries?: Record<string, DataSummary> | null;
lastSeenByType?: Record<
string,
{ lastSeenAt: string; daysAgo: number } | null
> | null;
/**
* v1.28.x — latest-night source-discrepancy annotation, carried by
* both slices (the slim post-pass computes it; the thick body passes
* it through). Additive and null-safe.
*/
sleepSourceDiscrepancy?: SleepSourceDiscrepancy | null;
}
export interface AnalyticsThickLike extends AnalyticsSlimLike {
bpInTargetPct?: number | null;
bpInTargetPct7d?: number | null;
bpInTargetPct30d?: number | null;
bpInTargetPctAllTime?: number | null;
bpInTargetPctPriorMonth?: number | null;
bpInTargetPctPriorYear?: number | null;
bpInTargetCount90?: number | null;
bpInTargetSpanDays90?: number | null;
glucoseByContext?: Record<string, unknown> | null;
}
export interface MergedDashboardAnalytics {
summaries: Record<string, DataSummary>;
lastSeenByType?:
Record<string, { lastSeenAt: string; daysAgo: number } | null> | undefined;
bpInTargetPct: number | null;
bpInTargetPct7d: number | null;
bpInTargetPct30d: number | null;
bpInTargetPctAllTime: number | null;
bpInTargetPctPriorMonth: number | null;
bpInTargetPctPriorYear: number | null;
bpInTargetCount90: number | null;
bpInTargetSpanDays90: number | null;
glucoseByContext: Record<string, unknown> | undefined;
sleepSourceDiscrepancy: SleepSourceDiscrepancy | null;
}
function hasContent(
record: Record<string, unknown> | null | undefined,
): boolean {
return record != null && Object.keys(record).length > 0;
}
/**
* Merge a slim and a thick analytics payload into the shape the
* dashboard renders against. Either input may be `undefined`
* (TanStack query unresolved) or carry an empty record (zero-data
* tenant); the helper falls back to whichever side has content
* without ever returning a payload that ignores a populated source.
*
* Returns `undefined` when neither input has resolved yet so the
* caller can keep its loading-state branch unchanged.
*/
export function mergeSlimAndThickAnalytics(
slim: AnalyticsSlimLike | undefined,
thick: AnalyticsThickLike | undefined,
): MergedDashboardAnalytics | undefined {
if (!slim && !thick) return undefined;
const slimSummariesHaveContent = hasContent(slim?.summaries ?? null);
const summaries: Record<string, DataSummary> = slimSummariesHaveContent
? (slim!.summaries as Record<string, DataSummary>)
: ((thick?.summaries as Record<string, DataSummary> | null | undefined) ??
(slim?.summaries as Record<string, DataSummary> | null | undefined) ??
{});
const slimFreshnessHasContent = hasContent(slim?.lastSeenByType ?? null);
const lastSeenByType:
Record<string, { lastSeenAt: string; daysAgo: number } | null> | undefined =
slimFreshnessHasContent
? (slim!.lastSeenByType as Record<
string,
{ lastSeenAt: string; daysAgo: number } | null
>)
: (thick?.lastSeenByType ?? undefined);
return {
summaries,
lastSeenByType,
bpInTargetPct: thick?.bpInTargetPct ?? null,
bpInTargetPct7d: thick?.bpInTargetPct7d ?? null,
bpInTargetPct30d: thick?.bpInTargetPct30d ?? null,
bpInTargetPctAllTime: thick?.bpInTargetPctAllTime ?? null,
bpInTargetPctPriorMonth: thick?.bpInTargetPctPriorMonth ?? null,
bpInTargetPctPriorYear: thick?.bpInTargetPctPriorYear ?? null,
bpInTargetCount90: thick?.bpInTargetCount90 ?? null,
bpInTargetSpanDays90: thick?.bpInTargetSpanDays90 ?? null,
glucoseByContext: thick?.glucoseByContext ?? undefined,
// Rides the same discriminator as `summaries`: whichever slice
// supplied the summaries also supplies the sleep annotation, so the
// marker can never point at a night the rendered headline doesn't
// show. `?? null` tolerates older cached payloads without the field.
sleepSourceDiscrepancy:
(slimSummariesHaveContent
? slim!.sleepSourceDiscrepancy
: thick?.sleepSourceDiscrepancy) ?? null,
};
}
@@ -1,43 +0,0 @@
/**
* v1.7.2 — unit tests for `isDashboardSnapshotEnabled`.
*
* The flag flipped default-ON in v1.7.2: the dashboard reads the unified
* `/api/dashboard/snapshot` cell UNLESS `NEXT_PUBLIC_DASHBOARD_SNAPSHOT`
* is exactly `"false"`. Pin the boundary so a future edit can't quietly
* regress to the old `=== "true"` opt-in semantics.
*/
import { describe, it, expect, afterEach } from "vitest";
import { isDashboardSnapshotEnabled } from "../snapshot-flag";
describe("isDashboardSnapshotEnabled", () => {
const original = process.env.NEXT_PUBLIC_DASHBOARD_SNAPSHOT;
afterEach(() => {
if (original === undefined) {
delete process.env.NEXT_PUBLIC_DASHBOARD_SNAPSHOT;
} else {
process.env.NEXT_PUBLIC_DASHBOARD_SNAPSHOT = original;
}
});
it("defaults ON when the var is unset", () => {
delete process.env.NEXT_PUBLIC_DASHBOARD_SNAPSHOT;
expect(isDashboardSnapshotEnabled()).toBe(true);
});
it("stays ON for an empty string", () => {
process.env.NEXT_PUBLIC_DASHBOARD_SNAPSHOT = "";
expect(isDashboardSnapshotEnabled()).toBe(true);
});
it('turns OFF only for the exact string "false"', () => {
process.env.NEXT_PUBLIC_DASHBOARD_SNAPSHOT = "false";
expect(isDashboardSnapshotEnabled()).toBe(false);
});
it('stays ON for "true"', () => {
process.env.NEXT_PUBLIC_DASHBOARD_SNAPSHOT = "true";
expect(isDashboardSnapshotEnabled()).toBe(true);
});
});
-21
View File
@@ -1,21 +0,0 @@
/**
* Reversible rollout flag for the unified dashboard snapshot
* consumption.
*
* Controls whether the dashboard page reads every above-the-fold tile
* from the single `GET /api/dashboard/snapshot` cell (default) or falls
* back to the legacy four independent cells (slim analytics + thick
* analytics + mood + widget layout). The snapshot path is the desired
* behaviour: one un-gated request hydrates the whole strip so every
* tile shares one completion moment, instead of four parallel cells
* where the mood cache resolves fastest and pops in ahead of the rest.
*
* Default ON. Set `NEXT_PUBLIC_DASHBOARD_SNAPSHOT=false` to fall back
* to the legacy multi-cell path. Defaulting on (rather than gating on
* `=== "true"`) avoids the `NEXT_PUBLIC_*` build-time-baking trap: the
* snapshot path is exercised in dev / e2e without a special build
* flag, and the production image gets it without a Dockerfile ARG.
*/
export function isDashboardSnapshotEnabled(): boolean {
return process.env.NEXT_PUBLIC_DASHBOARD_SNAPSHOT !== "false";
}
+5 -7
View File
@@ -99,13 +99,11 @@ export function disabledSummaryTypes(
/**
* Strip disabled-module types from a summaries slice. Lifted out of
* `@/lib/dashboard/snapshot`, where it was private, once a second
* dashboard feed needed it: with `NEXT_PUBLIC_DASHBOARD_SNAPSHOT=false`
* the tile strip hydrates from `/api/analytics` instead of the snapshot,
* and that route filtered nothing — so a module the user had turned off
* still reached the client, and the tile it owns still painted. The gate
* has to sit on every feed that carries summaries, not just the default
* one, or it is only as strong as a rollout flag.
* `@/lib/dashboard/snapshot`, where it was private, once `/api/analytics`
* needed it too: that route filtered nothing, so a module the user had
* turned off still reached the client. The gate has to sit on every feed
* that carries summaries (the snapshot, the analytics slice, the Insights
* presence read), not just one.
*
* Pure over its inputs and browser-safe (string keys + the map above), so
* the snapshot builder, the analytics route and any future feed share one
+8 -10
View File
@@ -19,9 +19,8 @@
* `summaries[METRIC].count` from the analytics endpoint.
* - `BMI` is derived from `WEIGHT` so it inherits the weight count.
* - Event-driven metrics (`MOOD`, `MEDICATION`) read the boolean
* `hasMood` / `hasMedication` flags the caller threads in from
* `/api/insights/comprehensive` (mood summary count > 0,
* medications array length > 0).
* `hasMood` / `hasMedication` flags the caller threads in (the tab
* strip from `/api/insights/presence`).
*
* Auto-light-up behaviour: once the iOS native client uploads its
* first Apple-Health measurement, `summaries[METRIC].count` flips
@@ -29,7 +28,6 @@
* pill, sub-page, and dashboard tile — no separate feature flag
* needed. (F18.)
*/
import type { DataSummary } from "@/lib/analytics/trends";
/**
* Metric keys the gating helper distinguishes. Mirrors the
@@ -133,14 +131,14 @@ export type InsightMetric =
| "NUTRIENTS";
/**
* Inputs the gating helper consumes. The `summaries` shape mirrors
* the `/api/analytics` response (`Record<MeasurementType, DataSummary>`)
* — undefined means the caller hasn't loaded analytics yet, which
* the helper treats as "not available" so loading shells aren't
* accidentally lit up.
* Inputs the gating helper consumes. `summaries` is keyed by
* `MeasurementType` and only its `count` is read, so an analytics
* summaries map and the presence read's `metrics` both fit. Undefined
* means the caller hasn't loaded it yet, which the helper treats as
* "not available" so loading shells aren't accidentally lit up.
*/
export interface InsightInputs {
summaries: Record<string, DataSummary> | undefined;
summaries: Readonly<Record<string, { count: number }>> | undefined;
/** Whether the user has logged at least one mood entry. */
hasMood: boolean;
/** Whether the user has at least one active medication. */
+24
View File
@@ -22,6 +22,7 @@ import {
import {
insightsCardsResponse,
insightsComprehensiveResponse,
insightsPresenceResponse,
metricStatusQuery,
metricStatusResponse,
biomarkerAssessmentQuery,
@@ -126,6 +127,29 @@ export const insightsPaths: NonNullable<ZodOpenApiObject["paths"]> = {
},
},
},
"/api/insights/presence": {
get: {
tags: ["Insights"],
summary: "Which Insights pages have data",
description:
"What the Insights tab strip gates its pills on, for the record on screen: the reading count per measurement type (after the record's module switches) and whether the record has mood entries in the last 90 days, a tracked scheduled medication, workouts and ECG recordings. A flag whose module is off is false. Counts and flags only; each page reads its own data. No provider is called and no AI switch refuses it. Delegable at MANAGE level over the whole record. Cookie or Bearer auth.",
responses: {
"200": {
description: "The presence answer.",
content: {
"application/json": {
schema: dataEnvelope(
insightsPresenceResponse,
"InsightsPresenceEnvelope",
),
},
},
},
...stdResponses,
...recordRefusal(),
},
},
},
"/api/insights/cards": {
get: {
tags: ["Insights"],
@@ -1691,6 +1691,36 @@ export const ecgListResponse = z
"Metadata-only ECG recording list. Never carries the waveform — the per-recording strip is fetched on demand from GET /api/insights/ecg/{id}. Read-only; no LLM call. Reflects only the device's own classification, never a HealthLog interpretation.",
});
export const insightsPresenceResponse = z
.object({
metrics: z
.record(z.string(), z.object({ count: z.number().int() }))
.describe(
"Keyed by `MeasurementType`: the reading count of every type with at least one reading, after the record's module switches. The same counts `GET /api/analytics?slice=summaries` reports.",
),
hasMood: z
.boolean()
.describe(
"A mood entry in the last 90 days. False while the mood module is off.",
),
hasMedication: z
.boolean()
.describe(
"An active medication with a schedule whose intake is tracked. False while the medications module is off.",
),
hasWorkouts: z
.boolean()
.describe(
"At least one workout. False while the workouts module is off.",
),
hasEcgRecordings: z.boolean().describe("At least one ECG recording."),
})
.meta({
id: "InsightsPresence",
description:
"Which Insights pages have something to show for the record: what the tab strip gates its pills on. Counts and flags only, no values.",
});
export const ecgDetailQuery = z
.object({
full: z
-1
View File
@@ -65,7 +65,6 @@ export const adminKeys = {
adminRecoveryKit: () => ["admin", "recovery-kit"] as const,
/** Every backup signal's verdict (admin overview + Backups). */
adminRecoveryReadiness: () => ["admin", "recovery-readiness"] as const,
adminCoachFeedback: () => ["admin", "coach-feedback"] as const,
adminFeedback: (status: string) => ["admin", "feedback", status] as const,
adminFeedbackRoot: () => ["admin", "feedback"] as const,
adminHostMetrics: (window: string) =>
+2
View File
@@ -6,6 +6,8 @@
export const insightsKeys = {
insightsRoot: () => ["insights"] as const,
insightsComprehensive: () => ["insights", "comprehensive"] as const,
/** Which Insights pages have data (`/api/insights/presence`), for the tab strip. */
insightsPresence: () => ["insights", "presence"] as const,
insightsTargets: () => ["insights", "targets"] as const,
/**
* Shared cache key for the rich `/api/insights/generate` advisor
@@ -0,0 +1,117 @@
/**
* `GET /api/medications/{id}/dose-history` reads the window it shows, against
* Postgres.
*
* Of the rows before the window only the latest take is read: a rolling grid
* re-starts from it, and earlier takes place no slot in the window. So a
* long history before the window answers the same ledger as a short one, and
* a rolling medication last taken before the window shows the dose that fell
* due inside it. All data invented.
*/
import { beforeEach, describe, expect, it } from "vitest";
import { GET as getDoseHistory } from "@/app/api/medications/[id]/dose-history/route";
import { callRoute, type RouteAuth } from "../kit/call-route";
import { makeMedication, makeSession, makeUser } from "../kit/factories";
import { getPrismaClient, truncateAllTables } from "./setup";
const prisma = getPrismaClient();
const TZ = "Europe/Berlin";
const DAY_MS = 86_400_000;
const FROM = new Date("2026-06-01T00:00:00Z");
const TO = new Date("2026-06-15T00:00:00Z");
interface LedgerBody {
rows: Array<{ at: string; kind: string; status: string }>;
}
async function ledger(auth: RouteAuth, id: string) {
const res = await callRoute<LedgerBody>(getDoseHistory, {
method: "GET",
route: "/api/medications/{id}/dose-history",
params: { id },
query: { from: FROM.toISOString(), to: TO.toISOString() },
auth,
});
expect(res.status).toBe(200);
return res.data.rows.map((r) => [r.at.slice(0, 10), r.kind, r.status]);
}
/** Doses taken on time at 08:00 Berlin (06:00 UTC in summer), one per day. */
async function takeOn(userId: string, medicationId: string, days: string[]) {
await prisma.medicationIntakeEvent.createMany({
data: days.map((day) => {
const at = new Date(`${day}T06:00:00Z`);
return {
userId,
medicationId,
scheduledFor: at,
takenAt: at,
skipped: false,
source: "WEB" as const,
};
}),
});
}
const isoDay = (ms: number) => new Date(ms).toISOString().slice(0, 10);
beforeEach(async () => {
await truncateAllTables(prisma);
});
describe("the dose history of a window", () => {
it("re-starts a rolling grid from the latest take before the window", async () => {
const user = await makeUser({ timezone: TZ });
const auth = await makeSession(user);
const med = await makeMedication(
user,
{ createdAt: new Date("2026-01-01T00:00:00Z") },
[
{
windowStart: "08:00",
windowEnd: "08:00",
timesOfDay: ["08:00"],
rollingIntervalDays: 7,
},
],
);
// Last taken four days before the window opens and not after: the next
// dose was due inside the window and reads as missed there.
await takeOn(user.id, med.id, ["2026-05-14", "2026-05-21", "2026-05-28"]);
expect(await ledger(auth, med.id)).toEqual([
["2026-06-04", "slot", "missed"],
]);
});
it("answers the same ledger after a year of earlier takes", async () => {
const user = await makeUser({ timezone: TZ });
const auth = await makeSession(user);
const daily = [
{
windowStart: "08:00",
windowEnd: "08:00",
timesOfDay: ["08:00"],
},
];
const created = { createdAt: new Date("2025-05-01T00:00:00Z") };
const short = await makeMedication(user, created, daily);
const long = await makeMedication(user, created, daily);
const inWindow = ["2026-06-02", "2026-06-03", "2026-06-07"];
await takeOn(user.id, short.id, ["2026-05-31", ...inWindow]);
const yearBefore: string[] = [];
for (let ms = Date.parse("2025-05-01"); ms <= Date.parse("2026-05-31");) {
yearBefore.push(isoDay(ms));
ms += DAY_MS;
}
await takeOn(user.id, long.id, [...yearBefore, ...inWindow]);
const shortLedger = await ledger(auth, short.id);
expect(shortLedger.filter((r) => r[2] === "taken_on_time")).toEqual(
inWindow.map((day) => [day, "slot", "taken_on_time"]),
);
expect(await ledger(auth, long.id)).toEqual(shortLedger);
});
});
@@ -0,0 +1,157 @@
/**
* `GET /api/insights/health-status` answers from the record's analytics cell,
* against Postgres.
*
* Both engines behind the card walk every vital over up to 180 days, so a
* second open of Insights serves the stored answer. A reading written past
* the API (no sweep) is the witness: the cell still holds the answer from
* before it. A measurement written through the API sweeps the cell, and a
* module switch changes the key, so either one shows the reading. All data
* invented.
*/
import { beforeEach, describe, expect, it } from "vitest";
import { GET as getHealthStatus } from "@/app/api/insights/health-status/route";
import { POST as createMeasurement } from "@/app/api/measurements/route";
import { PATCH as patchModules } from "@/app/api/auth/me/modules/route";
import { __resetAllCachesForTests } from "@/lib/cache/server-cache";
import { startOfLocalDayInTz } from "@/lib/tz/local-day";
import { callRoute, type RouteAuth } from "../kit/call-route";
import { makeMeasurement, makeSession, makeUser } from "../kit/factories";
import { getPrismaClient, truncateAllTables } from "./setup";
const prisma = getPrismaClient();
const TZ = "Europe/Berlin";
const DAY_MS = 86_400_000;
/** Well above the fortnight's 64 to 68 bpm, so the card reports it. */
const RAISED_PULSE = 112;
interface HealthStatusBody {
present: boolean;
deviations: Array<{ type: string; value: number }>;
shifts: unknown[];
generatedAt: string;
}
async function readHealthStatus(auth: RouteAuth) {
const res = await callRoute<HealthStatusBody>(getHealthStatus, {
method: "GET",
route: "/api/insights/health-status",
auth,
});
expect(res.status).toBe(200);
return res.data;
}
/**
* A fortnight of ordinary pulse and weight readings, one per earlier local
* day at noon: the coincident engine needs two banded vitals before it
* reports.
*/
async function seedFortnight(userId: string) {
const todayStart = startOfLocalDayInTz(new Date(), TZ).getTime();
const rows = [];
for (let d = 14; d >= 1; d -= 1) {
const measuredAt = new Date(todayStart - d * DAY_MS + DAY_MS / 2);
rows.push(
{
userId,
type: "PULSE" as const,
value: 64 + (d % 5),
unit: "bpm",
measuredAt,
source: "MANUAL" as const,
},
{
userId,
type: "WEIGHT" as const,
value: 80 + (d % 3) * 0.2,
unit: "kg",
measuredAt,
source: "MANUAL" as const,
},
);
}
await prisma.measurement.createMany({ data: rows });
}
async function signedInWithFortnight() {
const user = await makeUser({ timezone: TZ });
await seedFortnight(user.id);
return { user, auth: await makeSession(user) };
}
const pulseDeviation = (body: HealthStatusBody) =>
body.deviations.find((d) => d.type === "PULSE");
beforeEach(async () => {
await truncateAllTables(prisma);
__resetAllCachesForTests();
});
describe("GET /api/insights/health-status — the cached answer", () => {
it("serves the second open from the cell", async () => {
const { user, auth } = await signedInWithFortnight();
const first = await readHealthStatus(auth);
expect(pulseDeviation(first)).toBeUndefined();
// Written past the API: nothing sweeps the cell.
await makeMeasurement(user, {
type: "PULSE",
value: RAISED_PULSE,
unit: "bpm",
measuredAt: new Date(Date.now() - 60_000),
});
const second = await readHealthStatus(auth);
expect(second).toEqual(first);
});
it("computes again after a measurement written through the API", async () => {
const { auth } = await signedInWithFortnight();
expect(pulseDeviation(await readHealthStatus(auth))).toBeUndefined();
const write = await callRoute(createMeasurement, {
method: "POST",
route: "/api/measurements",
body: {
type: "PULSE",
value: RAISED_PULSE,
unit: "bpm",
measuredAt: new Date(Date.now() - 60_000).toISOString(),
},
auth,
});
expect(write.status).toBe(201);
expect(pulseDeviation(await readHealthStatus(auth))?.value).toBeCloseTo(
RAISED_PULSE,
6,
);
});
it("computes again when a module is switched off", async () => {
const { user, auth } = await signedInWithFortnight();
expect(pulseDeviation(await readHealthStatus(auth))).toBeUndefined();
await makeMeasurement(user, {
type: "PULSE",
value: RAISED_PULSE,
unit: "bpm",
measuredAt: new Date(Date.now() - 60_000),
});
const patch = await callRoute(patchModules, {
method: "PATCH",
route: "/api/auth/me/modules",
body: { mood: false },
auth,
});
expect(patch.status).toBe(200);
expect(pulseDeviation(await readHealthStatus(auth))?.value).toBeCloseTo(
RAISED_PULSE,
6,
);
});
});
+184
View File
@@ -0,0 +1,184 @@
/**
* `GET /api/insights/presence`, against Postgres.
*
* The Insights tab strip gates its pills on this one answer: the reading
* count per type and whether the record has mood entries, a scheduled
* medication, workouts and ECG recordings. It must answer for the record on
* screen, a delegate's switched record included, and read nothing a
* switched-off module hides. Every call goes through the contract check. All
* data invented.
*/
import { beforeEach, describe, expect, it } from "vitest";
import type { User } from "@/generated/prisma/client";
import { GET as getPresence } from "@/app/api/insights/presence/route";
import { __resetAllCachesForTests } from "@/lib/cache/server-cache";
import { callRoute, type RouteAuth } from "../kit/call-route";
import {
makeGrant,
makeMeasurement,
makeMedication,
makeSession,
makeUser,
makeWorkout,
} from "../kit/factories";
import { getPrismaClient, switchSessionTo, truncateAllTables } from "./setup";
const prisma = getPrismaClient();
const DAY_MS = 86_400_000;
interface PresenceBody {
metrics: Record<string, { count: number }>;
hasMood: boolean;
hasMedication: boolean;
hasWorkouts: boolean;
hasEcgRecordings: boolean;
}
async function presence(auth: RouteAuth) {
const res = await callRoute<PresenceBody>(getPresence, {
method: "GET",
route: "/api/insights/presence",
auth,
});
expect(res.status).toBe(200);
return res.data;
}
/** Two weight readings, a recent mood, a medication, a workout and an ECG. */
async function seedEverything(user: Pick<User, "id">) {
for (const daysAgo of [3, 2]) {
await makeMeasurement(user, {
measuredAt: new Date(Date.now() - daysAgo * DAY_MS),
});
}
const loggedAt = new Date(Date.now() - 5 * DAY_MS);
await prisma.moodEntry.create({
data: {
userId: user.id,
date: loggedAt.toISOString().slice(0, 10),
mood: "GUT",
score: 4,
moodLoggedAt: loggedAt,
},
});
await makeMedication(user);
await makeWorkout(user);
await prisma.ecgRecording.create({
data: {
userId: user.id,
source: "APPLE_HEALTH",
externalRecordingId: `ecg-${user.id}`,
recordedAt: new Date(Date.now() - DAY_MS),
waveformEncrypted: Buffer.from([0]),
samplingFrequency: 512,
sampleCount: 0,
},
});
}
beforeEach(async () => {
await truncateAllTables(prisma);
__resetAllCachesForTests();
});
describe("GET /api/insights/presence", () => {
it("answers what the record holds", async () => {
const user = await makeUser({ timezone: "Europe/Berlin" });
await seedEverything(user);
expect(await presence(await makeSession(user))).toEqual({
metrics: { WEIGHT: { count: 2 } },
hasMood: true,
hasMedication: true,
hasWorkouts: true,
hasEcgRecordings: true,
});
});
it("answers nothing for an empty record", async () => {
const user = await makeUser();
expect(await presence(await makeSession(user))).toEqual({
metrics: {},
hasMood: false,
hasMedication: false,
hasWorkouts: false,
hasEcgRecordings: false,
});
});
it("counts no mood older than the overview's 90 days and no medication that expects no dose", async () => {
const user = await makeUser();
const loggedAt = new Date(Date.now() - 120 * DAY_MS);
await prisma.moodEntry.create({
data: {
userId: user.id,
date: loggedAt.toISOString().slice(0, 10),
mood: "GUT",
score: 4,
moodLoggedAt: loggedAt,
},
});
await makeMedication(user, { asNeeded: true });
await makeMedication(user, { trackIntake: false });
await makeMedication(user, {}, []);
const body = await presence(await makeSession(user));
expect(body.hasMood).toBe(false);
expect(body.hasMedication).toBe(false);
});
it("reads nothing a switched-off module hides", async () => {
const user = await makeUser({
modulePreferencesJson: {
mood: false,
medications: false,
workouts: false,
},
});
await seedEverything(user);
expect(await presence(await makeSession(user))).toMatchObject({
metrics: { WEIGHT: { count: 2 } },
hasMood: false,
hasMedication: false,
hasWorkouts: false,
hasEcgRecordings: true,
});
});
it("answers for the record a delegate has switched to", async () => {
const owner = await makeUser();
const delegate = await makeUser();
await seedEverything(owner);
await makeGrant(owner, delegate, { access: "MANAGE" });
const auth = await makeSession(delegate);
expect((await presence(auth)).hasMood).toBe(false);
await switchSessionTo(auth.sessionId, owner.id);
expect(await presence(auth)).toEqual({
metrics: { WEIGHT: { count: 2 } },
hasMood: true,
hasMedication: true,
hasWorkouts: true,
hasEcgRecordings: true,
});
});
it("refuses a delegate whose grant only reads", async () => {
const owner = await makeUser();
const delegate = await makeUser();
await makeGrant(owner, delegate);
const auth = await makeSession(delegate);
await switchSessionTo(auth.sessionId, owner.id);
const res = await callRoute(getPresence, {
method: "GET",
route: "/api/insights/presence",
auth,
});
expect(res.status).toBe(403);
});
});