mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 22:26:13 +00:00
Merge branch 'fix/v1475-rest' into release/v1.47.5
This commit is contained in:
commit
c35d34768e
70 files changed
+1251
-1151
No files matched your search
@@ -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)
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
|
||||
@@ -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 },
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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"],
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -84,7 +84,6 @@ export default function DashboardPageClient({
|
||||
const dashboard = useDashboardData({
|
||||
isAuthenticated,
|
||||
mounted,
|
||||
user,
|
||||
batchWindowProp: batchWindow,
|
||||
});
|
||||
const { digestQuery, layout, primaryLoading } = dashboard;
|
||||
|
||||
+11
-23
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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={
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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={
|
||||
<>
|
||||
|
||||
@@ -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={
|
||||
<>
|
||||
|
||||
@@ -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,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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user