diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 42491d862..275c8730d 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -26754,6 +26754,95 @@ paths: $ref: "#/components/schemas/ErrorEnvelope" "422": *a4 "429": *a5 + /api/topics: + get: + tags: + - Insights + summary: Topic overview (preview) + description: One entry per topic the record holds data for, with a headline value, a rule-based sentence and a 30-day + sparkline, computed from the same daily values the metric pages draw. No model, no AI gate. Topics follow the + record's module switches. Reads across sections, so a grant scoped to some of them is refused. + responses: + "200": + description: The overview. + content: + application/json: + schema: + $ref: "#/components/schemas/TopicOverviewEnvelope" + "401": *a1 + "403": *a15 + "404": + description: "`topics.preview_off`: the person reading has not switched the topics preview on (Settings > System > + Preview features). The preview is per account and off by default." + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "422": *a4 + "429": *a5 + /api/topics/{topic}: + get: + tags: + - Insights + summary: One topic (preview) + description: "A topic's signals with their usual ranges, the side-by-side series for `range`, relationships between its + signals (nothing is said below 14 paired days), 90-day trends, the signals it holds nothing for yet, and its + blocks: training load, lab markers with their trend (three readings spanning a year), screener trends, today's + cycle day. No model, no AI gate." + parameters: + - in: path + name: topic + schema: + type: string + enum: + - heart + - body + - blood-pressure + - sleep + - activity + - metabolism + - mind + - cycle + description: The topic, as its URL slug. + required: true + description: The topic, as its URL slug. + - in: query + name: range + schema: + default: 30d + description: The window the side-by-side series cover. The usual ranges, the trends and the relationships always read at + least the trailing 90 days. + type: string + enum: + - 7d + - 30d + - 90d + - 1y + description: The window the side-by-side series cover. The usual ranges, the trends and the relationships always read at + least the trailing 90 days. + responses: + "200": + description: The topic. + content: + application/json: + schema: + $ref: "#/components/schemas/TopicDetailEnvelope" + "401": *a1 + "403": *a15 + "404": + description: >- + `topics.preview_off`: the person reading has not switched the topics preview on (Settings > System > Preview + features). The preview is per account and off by default. + + + `topics.not_found`: no topic by that name. `topics.not_available`: the record's module switches do not offer + this topic. + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "422": *a4 + "429": *a5 /api/bugreport: post: security: [] @@ -38003,10 +38092,11 @@ components: symptoms.definition.limitReached, symptoms.definition.notFound, symptoms.episode.notOpen, symptoms.event.notFound, symptoms.invalid; timeline — timeline.invalid; tokens — tokens.documents.ceiling_reached, tokens.measurements.ceiling_reached, tokens.workouts.ceiling_reached; - vaccination — vaccination.booster-invalid, vaccination.booster-no-antigen, - vaccination.booster-out-of-scope, vaccination.custom-vaccine-not-found, vaccination.custom.invalid, - vaccination.custom.name-taken, vaccination.custom.not-found, vaccination.encounter-not-found, - vaccination.identity-required, vaccination.invalid, vaccination.link.invalid, vaccination.not-found, + topics — topics.not_available, topics.not_found, topics.preview_off; vaccination — + vaccination.booster-invalid, vaccination.booster-no-antigen, vaccination.booster-out-of-scope, + vaccination.custom-vaccine-not-found, vaccination.custom.invalid, vaccination.custom.name-taken, + vaccination.custom.not-found, vaccination.encounter-not-found, vaccination.identity-required, + vaccination.invalid, vaccination.link.invalid, vaccination.not-found, vaccination.practitioner-not-found; workout — workout.batch.invalid, workout.batch.payload_too_large, workout.batch.source_not_permitted, workout.batch.too_large, workout.delete.synced_source; (unprefixed) — about_me_conflict, ai_response_truncated, coach_prefs_conflict, dashboard_layout_conflict, empty_file, @@ -72671,7 +72761,8 @@ components: properties: topics: type: boolean - description: Topic overview pages above the single-metric Insights pages. Off by default. + description: "The Topics area (`/api/topics`): overview pages that bring related signals together above the + single-metric Insights pages, which stay as they are. Off by default." required: - topics additionalProperties: false @@ -85168,6 +85259,8 @@ components: $ref: "#/components/schemas/AiAccountBlock" coachReasoning: $ref: "#/components/schemas/CoachReasoningState" + previewFeatures: + $ref: "#/components/schemas/PreviewFeatures" recordSession: anyOf: - $ref: "#/components/schemas/RecordSessionState" @@ -85198,6 +85291,7 @@ components: - moduleAccess - ai - coachReasoning + - previewFeatures - recordSession - notificationPrefs - onboarding @@ -92230,6 +92324,665 @@ components: - data - error additionalProperties: false + TopicOverviewEnvelope: + type: object + properties: + data: + $ref: "#/components/schemas/TopicOverview" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + TopicOverview: + type: object + properties: + topics: + type: array + items: + $ref: "#/components/schemas/TopicOverviewEntry" + empty: + type: array + items: + $ref: "#/components/schemas/TopicKey" + description: Offered topics with nothing recorded yet. + hiddenByModule: + type: array + items: + $ref: "#/components/schemas/TopicKey" + description: Topics whose every module is switched off for the record. + required: + - topics + - empty + - hiddenByModule + additionalProperties: false + TopicOverviewEntry: + type: object + properties: + key: + $ref: "#/components/schemas/TopicKey" + headline: + anyOf: + - $ref: "#/components/schemas/TopicHeadline" + - type: "null" + summary: + $ref: "#/components/schemas/TopicSummary" + tone: + type: string + enum: + - steady + - watch + - learning + sparkline: + type: array + items: + type: number + description: The headline signal's daily values over the last 30 days, oldest first; empty when the headline is a block. + recorded: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + total: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + required: + - key + - headline + - summary + - tone + - sparkline + - recorded + - total + additionalProperties: false + TopicKey: + type: string + enum: + - heart + - body + - blood-pressure + - sleep + - activity + - metabolism + - mind + - cycle + description: A topic, as its URL slug. + TopicHeadline: + oneOf: + - type: object + properties: + kind: + type: string + const: signal + key: + $ref: "#/components/schemas/TopicSignalKey" + value: + type: number + day: + type: string + description: A local calendar day, `YYYY-MM-DD`. + required: + - kind + - key + - value + - day + additionalProperties: false + - type: object + properties: + kind: + type: string + const: labs + inRange: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + total: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + required: + - kind + - inRange + - total + additionalProperties: false + - type: object + properties: + kind: + type: string + const: cycleDay + dayOfCycle: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + required: + - kind + - dayOfCycle + additionalProperties: false + - type: object + properties: + kind: + type: string + const: screener + instrument: + type: string + score: + type: number + required: + - kind + - instrument + - score + additionalProperties: false + type: object + TopicSignalKey: + anyOf: + - $ref: "#/components/schemas/MeasurementType" + - type: string + const: MOOD + description: "A daily series a topic reads: a `MeasurementType`, or `MOOD` for the day's mean mood score (1 to 5). + Values are in the type's stored unit (sleep in minutes asleep per night, keyed by the day the night ended); + clients convert for display as they do for the metric pages." + TopicSummary: + type: object + properties: + code: + type: string + enum: + - outside + - labs + - cycleDay + - steady + - learning + - empty + signal: + anyOf: + - $ref: "#/components/schemas/TopicSignalKey" + - type: "null" + direction: + anyOf: + - type: string + enum: + - above + - below + - type: "null" + count: + anyOf: + - type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + - type: "null" + total: + anyOf: + - type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + - type: "null" + required: + - code + - signal + - direction + - count + - total + additionalProperties: false + description: "The topic's one sentence as a code with parameters, picked by a fixed rule (never model text): `outside` + names the first signal outside its usual range and the direction; `labs` counts lab markers inside their window + (`count` of `total`); `cycleDay` gives today's cycle day in `count`; `steady` counts signals inside their range + (`count` of `total` recorded); `learning` while no range is established; `empty` without any data." + TopicDetailEnvelope: + type: object + properties: + data: + $ref: "#/components/schemas/TopicDetail" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + TopicDetail: + type: object + properties: + key: + $ref: "#/components/schemas/TopicKey" + range: + type: string + enum: + - 7d + - 30d + - 90d + - 1y + from: + type: string + description: A local calendar day, `YYYY-MM-DD`. + to: + type: string + description: A local calendar day, `YYYY-MM-DD`. + status: + type: string + enum: + - ready + - partial + - empty + summary: + $ref: "#/components/schemas/TopicSummary" + signals: + type: array + items: + $ref: "#/components/schemas/TopicSignal" + series: + type: array + items: + type: object + properties: + key: + $ref: "#/components/schemas/TopicSignalKey" + usual: + anyOf: + - $ref: "#/components/schemas/TopicUsualRange" + - type: "null" + points: + type: array + items: + type: object + properties: + day: + type: string + description: A local calendar day, `YYYY-MM-DD`. + value: + type: number + required: + - day + - value + additionalProperties: false + required: + - key + - usual + - points + additionalProperties: false + relationships: + type: array + items: + $ref: "#/components/schemas/TopicRelationship" + trends: + type: array + items: + $ref: "#/components/schemas/TopicTrend" + missing: + type: array + items: + $ref: "#/components/schemas/TopicSignalKey" + description: Signals the topic could show that hold no value yet. + labs: + anyOf: + - $ref: "#/components/schemas/TopicLabPanel" + - type: "null" + training: + anyOf: + - $ref: "#/components/schemas/WorkoutTrainingLoad" + - type: "null" + screeners: + anyOf: + - type: array + items: + $ref: "#/components/schemas/MentalHealthAssessmentTrend" + - type: "null" + cycle: + anyOf: + - $ref: "#/components/schemas/TopicCycleSummary" + - type: "null" + required: + - key + - range + - from + - to + - status + - summary + - signals + - series + - relationships + - trends + - missing + - labs + - training + - screeners + - cycle + additionalProperties: false + TopicSignal: + type: object + properties: + key: + $ref: "#/components/schemas/TopicSignalKey" + latest: + anyOf: + - type: object + properties: + day: + type: string + description: A local calendar day, `YYYY-MM-DD`. + value: + type: number + required: + - day + - value + additionalProperties: false + - type: "null" + usual: + anyOf: + - $ref: "#/components/schemas/TopicUsualRange" + - type: "null" + state: + type: string + enum: + - within + - above + - below + - learning + - none + description: "`latest` against `usual`: `learning` while the range needs more days, `none` without a value." + href: + description: The in-app single-metric page of this signal. + type: + - string + - "null" + required: + - key + - latest + - usual + - state + - href + additionalProperties: false + TopicUsualRange: + type: object + properties: + low: + type: number + high: + type: number + center: + type: number + days: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + description: Days the band was built from. + required: + - low + - high + - center + - days + additionalProperties: false + description: "The personal usual range over the trailing 90 days: median ± 3 scaled MAD of the daily values, never + narrower than the measurement's own wobble. Needs 7 days, else null." + TopicRelationship: + type: object + properties: + id: + type: string + x: + $ref: "#/components/schemas/TopicSignalKey" + y: + $ref: "#/components/schemas/TopicSignalKey" + lagDays: + anyOf: + - type: number + const: 0 + - type: number + const: 1 + description: "`x` on day d is held against `y` on day d + `lagDays`." + kind: + type: string + enum: + - correlation + - split + pairedDays: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + requiredDays: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + description: "Paired days needed before anything is said: 14." + status: + type: string + enum: + - insufficient + - no_clear_link + - linked + description: "`linked` only at p < 0.05 (Pearson, or Welch for a split); a pattern, never a cause." + r: + type: + - number + - "null" + pValue: + type: + - number + - "null" + split: + anyOf: + - type: object + properties: + threshold: + type: number + belowDays: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + otherDays: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + belowMean: + type: number + otherMean: + type: number + difference: + type: number + required: + - threshold + - belowDays + - otherDays + - belowMean + - otherMean + - difference + additionalProperties: false + - type: "null" + description: "For a split: the mean of `y` on days `x` sat below `threshold` against the other days." + points: + type: array + items: + type: object + properties: + x: + type: number + y: + type: number + required: + - x + - y + additionalProperties: false + description: Paired values for a scatter, at most 120; empty when insufficient. + required: + - id + - x + - y + - lagDays + - kind + - pairedDays + - requiredDays + - status + - r + - pValue + - split + - points + additionalProperties: false + TopicTrend: + type: object + properties: + key: + $ref: "#/components/schemas/TopicSignalKey" + status: + type: string + enum: + - ok + - insufficient + days: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + change: + description: The least-squares change across the trailing 90 days, in the stored unit. Needs 14 days. + type: + - number + - "null" + weekly: + type: array + items: + type: object + properties: + day: + type: string + description: A local calendar day, `YYYY-MM-DD`. + value: + type: number + required: + - day + - value + additionalProperties: false + description: Weekly means, each keyed by its Monday. + required: + - key + - status + - days + - change + - weekly + additionalProperties: false + TopicLabPanel: + type: object + properties: + markers: + type: array + items: + $ref: "#/components/schemas/TopicLabMarker" + description: At most 24, newest reading first. + inRange: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + withRange: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + latestOn: + anyOf: + - type: string + description: A local calendar day, `YYYY-MM-DD`. + - type: "null" + required: + - markers + - inRange + - withRange + - latestOn + additionalProperties: false + TopicLabMarker: + type: object + properties: + biomarkerId: + type: string + name: + type: string + unit: + type: string + latest: + type: number + takenOn: + type: string + description: A local calendar day, `YYYY-MM-DD`. + low: + type: + - number + - "null" + high: + type: + - number + - "null" + status: + type: string + enum: + - in-range + - below + - above + - unknown + readings: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + trend: + $ref: "#/components/schemas/BiomarkerTrend" + required: + - biomarkerId + - name + - unit + - latest + - takenOn + - low + - high + - status + - readings + - trend + additionalProperties: false + TopicCycleSummary: + type: object + properties: + today: + anyOf: + - type: object + properties: + dayOfCycle: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + phase: + type: + - string + - "null" + required: + - dayOfCycle + - phase + additionalProperties: false + - type: "null" + lengths: + type: array + items: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + averageLength: + type: + - number + - "null" + periodsLogged: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + required: + - today + - lengths + - averageLength + - periodsLogged + additionalProperties: false __schema0: anyOf: - type: string diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 970aeaa03..55844a50f 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -388,9 +388,10 @@ model User { // Shape: { "version": 1, "labs": { "view": "list", "order": [""], "sortDir": "manual" }, ... } moduleListLayoutJson Json? @map("module_list_layout_json") /// Per-user opt-in to preview features, one boolean per flag, all off by - /// default (`src/lib/settings/preview-features.ts`). Read and written - /// through `/api/settings/preview-features` and carried by the backup. - /// @pending-consumer(topics preview) the `topics` flag is stored, but no surface reads it and the Settings > System switch is unlisted until the topic overview pages ship. + /// default (`src/lib/settings/preview-features.ts`). Written by Settings > + /// System > Preview features through `/api/settings/preview-features`, + /// published on `/api/auth/me`, read by the navigation and the topic routes + /// (`topics`), and carried by the backup. previewFeaturesJson Json? @map("preview_features_json") /// Optional activity goals the person set for themselves: workouts, active /// minutes and steps per week, steps per month. Null or an absent key = no diff --git a/src/__tests__/sharing-surface-guard.test.ts b/src/__tests__/sharing-surface-guard.test.ts index b818a899f..caf7b3a4e 100644 --- a/src/__tests__/sharing-surface-guard.test.ts +++ b/src/__tests__/sharing-surface-guard.test.ts @@ -1074,6 +1074,17 @@ const DELEGABLE_ROUTES: Record = { domain: "record", why: "One search over the record: hits from every section with an in-app path each. Read-only, scoped to the resolved user, every source narrowed to the record's module switches and to the sections the grant covers, the query never logged.", }, + // The topic pages (preview) lay several sections side by side, like the + // timeline, so they stay whole-record reads. The preview switch they check + // is the ACTOR's own row and decides nothing about the record. + "app/api/topics/route.ts": { + domain: "record", + why: "The topic overview: a headline value, a rule-based sentence and a sparkline per topic, computed from the record's daily values, lab markers, screener totals and cycle starts. Read-only, no model, scoped to the resolved user, every signal and block narrowed to the record's module switches and to the sections the grant covers.", + }, + "app/api/topics/[topic]/route.ts": { + domain: "record", + why: "One topic page: signals with usual ranges, side-by-side series, relationships, trends and the topic's blocks. Read-only, no model, scoped to the resolved user, narrowed per signal and block like the overview.", + }, "app/api/workouts/route.ts": { domain: "measurements", why: "The record's workout list. Verified to carry no AI-written paragraph: the single-workout route beside it does, and stays refused for exactly that reason.", @@ -2197,8 +2208,11 @@ const ACTOR_ROUTES: Record = { * side). The activity goals route does not: setting or changing goals stays * owner-only, and a delegate sees the goals only as the calendar reports them. * 270 -> 273. + * + * The topic overview and the topic page (preview) join the record list as + * whole-record reads, like the timeline. 273 -> 275. */ -const FROZEN_ENTRY_COUNT = 273; +const FROZEN_ENTRY_COUNT = 275; /** * The two surfaces that authenticate a Bearer token outside `requireAuth` — diff --git a/src/app/api/auth/me/route.ts b/src/app/api/auth/me/route.ts index ef1167630..24d43f989 100644 --- a/src/app/api/auth/me/route.ts +++ b/src/app/api/auth/me/route.ts @@ -44,6 +44,7 @@ import { apiSuccess } from "@/lib/api-response"; import { apiHandler, requireActorAuth } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; +import { resolvePreviewFeatures } from "@/lib/settings/preview-features"; import { setOnboardingPendingCookie } from "@/lib/auth/session"; import { syncMfaEnrollCookie } from "@/lib/auth/mfa-enrollment"; import { buildAvatarUrl } from "@/lib/avatar"; @@ -93,6 +94,21 @@ const SERVER_FEATURES = { lifeCircumstances: true, } as const; +/** + * v1.7.0 — patient-identity fields for the health-record export. The KVNR is + * stored encrypted; decrypt fail-soft so a key-rotation gap on one row never + * 500s the whole profile fetch (the field just reads null and the user + * re-enters it). + */ +function decryptInsuranceNumber(encrypted: string | null): string | null { + if (!encrypted) return null; + try { + return decrypt(encrypted); + } catch { + return null; + } +} + export const GET = apiHandler(async () => { const auth = await requireActorAuth(); const { user } = auth; @@ -238,10 +254,11 @@ export const GET = apiHandler(async () => { }), // The encrypted insurance number is not on the per-request user // (`SESSION_USER_SELECT`); it is read here, for the one response that - // shows it. + // shows it. The preview opt-ins ride the same point read of the caller's + // own row. prisma.user.findUnique({ where: { id: user.id }, - select: { insuranceNumberEncrypted: true }, + select: { insuranceNumberEncrypted: true, previewFeaturesJson: true }, }), ]); @@ -289,18 +306,9 @@ export const GET = apiHandler(async () => { sectionsOpen(sections, "cycle") && isCycleEnabled(record?.gender ?? null, cycleProfile); - // v1.7.0 — patient-identity fields for the health-record export. The - // KVNR is stored encrypted; decrypt fail-soft so a key-rotation gap on - // one row never 500s the whole profile fetch (the field just reads - // null and the user re-enters it). - let insuranceNumber: string | null = null; - if (identity?.insuranceNumberEncrypted) { - try { - insuranceNumber = decrypt(identity.insuranceNumberEncrypted); - } catch { - insuranceNumber = null; - } - } + const insuranceNumber = decryptInsuranceNumber( + identity?.insuranceNumberEncrypted ?? null, + ); return apiSuccess({ id: user.id, @@ -447,6 +455,12 @@ export const GET = apiHandler(async () => { // `offIsReal` whether the lowest option is a real off or "Minimal", and // `source` which layer decided. Clients never recompute it. coachReasoning, + // The caller's own preview opt-ins (Settings > System > Preview + // features), resolved: every flag present, off unless switched on. The + // navigation offers the Topics entry from `topics`. The ACTOR's, like the + // Coach preferences above, so it does not move under a switch: a preview is + // chrome of the person reading, never a property of the record on screen. + previewFeatures: resolvePreviewFeatures(identity?.previewFeaturesJson), // v1.36.0 — account sharing, resolved. `accounts` is the switcher's menu, // `active` is the record this session is inside (null when it is in its // own), `canSwitch` and per-entry `canWrite` are the booleans the UI binds diff --git a/src/app/api/topics/[topic]/route.ts b/src/app/api/topics/[topic]/route.ts new file mode 100644 index 000000000..f539f55ba --- /dev/null +++ b/src/app/api/topics/[topic]/route.ts @@ -0,0 +1,80 @@ +/** + * `GET /api/topics/{topic}` — one topic page (preview). + * + * The topic's signals with their usual ranges, the side-by-side series for the + * chosen window, the relationships between its signals (14 paired days before + * anything is said), the 90-day trends, the signals it could show but holds + * nothing for, and its extra blocks (training load, lab markers with their + * trend, screener trends, today's place in the cycle). Every figure is + * computed on the server from existing reads; no model is involved. + * + * 404 `topics.preview_off` when the viewer has not switched the preview on, + * `topics.not_available` when the record's module switches do not offer the + * topic. Admission declares the whole record, like the overview. + */ +import { NextRequest } from "next/server"; + +import { apiHandler, requireRecordAuth } from "@/lib/api-handler"; +import { apiError, apiSuccess, returnAllZodIssues } from "@/lib/api-response"; +import { annotate } from "@/lib/logging/context"; +import { checkAnalyticsReadRateLimit } from "@/lib/rate-limit"; +import { hasTopicsPreview, loadTopicDetail } from "@/lib/topics/service"; +import { + topicDetailQuerySchema, + topicPathSchema, +} from "@/lib/validations/topics"; + +export const dynamic = "force-dynamic"; + +type RouteParams = { params: Promise<{ topic: string }> }; + +export const GET = apiHandler( + async (request: NextRequest, { params }: RouteParams) => { + const { user, actor, grantId } = await requireRecordAuth("read", "record"); + annotate({ action: { name: "topics.detail.read" } }); + + if (!(await hasTopicsPreview(actor.id))) { + return apiError("Topics are a preview that is switched off", 404, { + errorCode: "topics.preview_off", + }); + } + + const path = topicPathSchema.safeParse(await params); + if (!path.success) { + return apiError("No such topic", 404, { errorCode: "topics.not_found" }); + } + const query = topicDetailQuerySchema.safeParse({ + range: request.nextUrl.searchParams.get("range") ?? undefined, + }); + if (!query.success) return returnAllZodIssues(query.error, 422); + + const rl = await checkAnalyticsReadRateLimit(actor.id); + if (!rl.allowed) { + return apiError("Too many analytics requests. Please retry later.", 429); + } + + const detail = await loadTopicDetail({ + userId: user.id, + grantId, + topic: path.data.topic, + range: query.data.range, + now: new Date(), + }); + if (!detail) { + return apiError("This topic is not available for this record", 404, { + errorCode: "topics.not_available", + }); + } + annotate({ + meta: { + topic: detail.key, + topic_range: detail.range, + topic_status: detail.status, + topic_relationships_linked: detail.relationships.filter( + (r) => r.status === "linked", + ).length, + }, + }); + return apiSuccess(detail); + }, +); diff --git a/src/app/api/topics/route.ts b/src/app/api/topics/route.ts new file mode 100644 index 000000000..f50eb3839 --- /dev/null +++ b/src/app/api/topics/route.ts @@ -0,0 +1,54 @@ +/** + * `GET /api/topics` — the topic overview (preview). + * + * One tile per topic the record holds data for: a headline value, a rule-based + * sentence and a sparkline, computed on the server from the same daily values + * the single-metric pages draw. No model is involved and no AI gate applies. + * + * The preview is the viewer's own opt-in (Settings > System > Preview + * features): with it off the route answers 404 `topics.preview_off`, the same + * as for a path that does not exist. The topics, signals and blocks follow the + * RECORD's module switches; a topic whose every module is off is named in + * `hiddenByModule`, an offered topic with nothing recorded in `empty`. + * + * Admission declares the whole record, like the timeline: a topic lays several + * sections side by side, so a grant scoped to some of them does not open it. + */ +import { apiHandler, requireRecordAuth } from "@/lib/api-handler"; +import { apiError, apiSuccess } from "@/lib/api-response"; +import { annotate } from "@/lib/logging/context"; +import { checkAnalyticsReadRateLimit } from "@/lib/rate-limit"; +import { hasTopicsPreview, loadTopicOverview } from "@/lib/topics/service"; + +export const dynamic = "force-dynamic"; + +export const GET = apiHandler(async () => { + const { user, actor, grantId } = await requireRecordAuth("read", "record"); + annotate({ action: { name: "topics.overview.read" } }); + + if (!(await hasTopicsPreview(actor.id))) { + return apiError("Topics are a preview that is switched off", 404, { + errorCode: "topics.preview_off", + }); + } + + // The shared analytics-read budget, spent by the person reading. + const rl = await checkAnalyticsReadRateLimit(actor.id); + if (!rl.allowed) { + return apiError("Too many analytics requests. Please retry later.", 429); + } + + const overview = await loadTopicOverview({ + userId: user.id, + grantId, + now: new Date(), + }); + annotate({ + meta: { + topics_shown: overview.topics.length, + topics_empty: overview.empty.length, + topics_hidden: overview.hiddenByModule.length, + }, + }); + return apiSuccess(overview); +}); diff --git a/src/hooks/use-auth.ts b/src/hooks/use-auth.ts index d80fdcbfd..5949f1e5c 100644 --- a/src/hooks/use-auth.ts +++ b/src/hooks/use-auth.ts @@ -24,6 +24,10 @@ import { storeTimezone } from "@/lib/timezone-mirror"; import type { ModuleKey } from "@/lib/modules/registry"; import type { AiCapabilities } from "@/lib/ai/capabilities/types"; import type { CoachReasoningState } from "@/lib/ai/reasoning/resolve"; +import { + resolvePreviewFeatures, + type PreviewFeatures, +} from "@/lib/settings/preview-features"; import type { ModuleAccessState } from "@/lib/sharing/module-disclosure"; import type { OnboardingStateDto } from "@/lib/onboarding/needs"; import type { TourProgress } from "@/lib/onboarding/tour-progress"; @@ -254,6 +258,14 @@ export interface AuthUser { * server image that does not publish it. */ coachReasoning?: CoachReasoningState | null; + /** + * The person's own preview opt-ins, resolved on the server: every + * flag present, off unless switched on in Settings > System > Preview + * features. Read through `usePreviewFeature`. Optional so existing + * fixtures stay valid; `fetchMe` coerces an absent block (an older server) + * to every flag off. + */ + previewFeatures?: PreviewFeatures; /** * v1.36.0 — account sharing, resolved server-side. `accounts` is the * switcher's menu, `active` is the record this browser is inside (null when @@ -477,6 +489,8 @@ export async function fetchMe(): Promise { data.coachReasoning && typeof data.coachReasoning === "object" ? data.coachReasoning : null, + // An older server publishes no opt-ins; every preview stays off. + previewFeatures: resolvePreviewFeatures(data.previewFeatures), accountAccess, accountAccessStatus, }; diff --git a/src/lib/openapi/error-codes.ts b/src/lib/openapi/error-codes.ts index b91330fb2..d4f950a74 100644 --- a/src/lib/openapi/error-codes.ts +++ b/src/lib/openapi/error-codes.ts @@ -452,6 +452,7 @@ export const ERROR_CODE_CATALOGUE: Readonly> = "tokens.measurements.ceiling_reached", "tokens.workouts.ceiling_reached", ], + topics: ["topics.not_available", "topics.not_found", "topics.preview_off"], vaccination: [ "vaccination.booster-invalid", "vaccination.booster-no-antigen", diff --git a/src/lib/openapi/routes/account-sharing.ts b/src/lib/openapi/routes/account-sharing.ts index a42bc4a88..fa82f8963 100644 --- a/src/lib/openapi/routes/account-sharing.ts +++ b/src/lib/openapi/routes/account-sharing.ts @@ -44,6 +44,7 @@ import { } from "@/lib/validations/managed-profile-handover"; import { onboardingStateResource } from "./onboarding"; import { aiAccountBlock, moduleAccessMap } from "./profile"; +import { previewFeaturesSchema } from "./settings"; import { dataEnvelope, errorEnvelope, @@ -562,6 +563,11 @@ const accountPayload = z ai: aiAccountBlock, // v1.41 — enumerated for the reason `ai` is: the settings decide on it. coachReasoning: coachReasoningBlock, + // Enumerated because the navigation decides on it: the Topics + // entry exists only while the person reading has switched it on. The + // caller's own opt-in, never the record's, so it does not move under a + // switch. The same object `GET /api/settings/preview-features` answers. + previewFeatures: previewFeaturesSchema, recordSession: recordSessionState.nullable(), // Enumerated because a client DECIDES on it rather than displays it: the // medication surface hides the server-side reminder switch when diff --git a/src/lib/openapi/routes/biomarkers.ts b/src/lib/openapi/routes/biomarkers.ts index fc850c598..1e3e75e0b 100644 --- a/src/lib/openapi/routes/biomarkers.ts +++ b/src/lib/openapi/routes/biomarkers.ts @@ -77,7 +77,7 @@ const biomarkerRow = z "A user-scoped catalog marker. `context` is the decrypted per-marker note (or null); `hasContext` flags its presence. `analyteKey` is the analyte the marker was recognised as: the catalog slug its name belongs to (e.g. `fasting-glucose`, `ldl`, `hdl`), or a key of the unit-conversion registry for an analyte with no catalog seed (e.g. `urea`, `uric-acid`), or null when its name matches none. It is derived from `name` and `unit`, never chosen: it is set on create and re-derived (or cleared) when an update changes either. A reading in another unit is converted only for a marker that has one. `hidden` drops the marker from the active catalog list and the lab-entry pickers without deleting it (its readings and unit/range survive). A `LabResult` linking this marker resolves its unit + reference bounds from here.", }); -const labTrend = z +export const biomarkerTrendSchema = z .union([ z.object({ status: z.literal("ok"), @@ -140,7 +140,9 @@ const labTrend = z const biomarkerDetail = biomarkerRow .extend({ - trend: labTrend.optional().describe("v1.44 — absent on older servers."), + trend: biomarkerTrendSchema + .optional() + .describe("v1.44 — absent on older servers."), }) .meta({ id: "BiomarkerDetail" }); diff --git a/src/lib/openapi/routes/index.ts b/src/lib/openapi/routes/index.ts index 31da5dec6..9c77328ca 100644 --- a/src/lib/openapi/routes/index.ts +++ b/src/lib/openapi/routes/index.ts @@ -56,6 +56,7 @@ import { cyclePaths } from "./cycle"; import { dashboardWidgetPaths } from "./dashboard"; import { dayPaths } from "./day"; import { searchPaths } from "./search"; +import { topicPaths } from "./topics"; import { biomarkerPaths } from "./biomarkers"; import { inboundDocumentPaths } from "./documents"; import { documentSourcePaths } from "./document-sources"; @@ -204,6 +205,9 @@ const registeredPaths: NonNullable = { ...lifeCircumstancesPaths, ...dawarichPaths, ...relationLinkPaths, + // The topic pages, a preview each account switches on for itself + // (appended, spread order is load-bearing). + ...topicPaths, // The environmental-context overview. Its own module because nothing else // owns the surface (appended, spread order is load-bearing). // diff --git a/src/lib/openapi/routes/mental-health.ts b/src/lib/openapi/routes/mental-health.ts index 8a15fd8e7..8009b96d6 100644 --- a/src/lib/openapi/routes/mental-health.ts +++ b/src/lib/openapi/routes/mental-health.ts @@ -108,7 +108,7 @@ const changeVerdict = z .enum(["improved", "worsened", "within_threshold"]) .nullable(); -const assessmentTrend = z +export const assessmentTrendSchema = z .object({ instrument: z.enum(["PHQ9", "GAD7", "WHO5"]), n: z.number().int(), @@ -136,7 +136,7 @@ const listResponse = z .object({ assessments: z.array(assessmentRow), trends: z - .array(assessmentTrend) + .array(assessmentTrendSchema) .optional() .describe( "v1.44 — one per PHQ-9 / GAD-7 / WHO-5 with at least one administration. Absent on older servers.", diff --git a/src/lib/openapi/routes/settings.ts b/src/lib/openapi/routes/settings.ts index 5b1ac7c2b..c27ea3cd9 100644 --- a/src/lib/openapi/routes/settings.ts +++ b/src/lib/openapi/routes/settings.ts @@ -72,12 +72,12 @@ const moduleListLayoutSchema = z "Per-user presentation of the labs, illness and checkups lists. Display-only; a client that does not render a list ignores it.", }); -const previewFeaturesSchema = z +export const previewFeaturesSchema = z .object({ topics: z .boolean() .describe( - "Topic overview pages above the single-metric Insights pages. Off by default.", + "The Topics area (`/api/topics`): overview pages that bring related signals together above the single-metric Insights pages, which stay as they are. Off by default.", ), }) .meta({ diff --git a/src/lib/openapi/routes/topics.ts b/src/lib/openapi/routes/topics.ts new file mode 100644 index 000000000..1b0e87e65 --- /dev/null +++ b/src/lib/openapi/routes/topics.ts @@ -0,0 +1,324 @@ +/** + * OpenAPI route table for the topic pages (preview): `/api/topics` and + * `/api/topics/{topic}`. + * + * Part of the OpenAPI route table; aggregated in `./index.ts`. The request + * schemas are the routes' own (`@/lib/validations/topics`); the response + * shapes mirror `src/lib/topics/build.ts` and reuse the published lab-trend, + * training-load and screener-trend components rather than restating them. + */ +import type { ZodOpenApiObject } from "zod-openapi"; +import { z } from "zod/v4"; + +import { TOPIC_KEYS, TOPIC_RANGES } from "@/lib/topics/registry"; +import { + topicDetailQuerySchema, + topicPathSchema, +} from "@/lib/validations/topics"; + +import { biomarkerTrendSchema } from "./biomarkers"; +import { assessmentTrendSchema } from "./mental-health"; +import { + dataEnvelope, + errorEnvelope, + measurementTypeEnum, + recordRefusal, + stdResponses, +} from "./shared"; +import { trainingLoadSchema } from "./workouts"; + +const dayKey = z.string().describe("A local calendar day, `YYYY-MM-DD`."); + +const topicKey = z.enum(TOPIC_KEYS).meta({ + id: "TopicKey", + description: "A topic, as its URL slug.", +}); + +const signalKey = z.union([measurementTypeEnum, z.literal("MOOD")]).meta({ + id: "TopicSignalKey", + description: + "A daily series a topic reads: a `MeasurementType`, or `MOOD` for the day's mean mood score (1 to 5). Values are in the type's stored unit (sleep in minutes asleep per night, keyed by the day the night ended); clients convert for display as they do for the metric pages.", +}); + +const dayValue = z.object({ day: dayKey, value: z.number() }); + +const usualRange = z + .object({ + low: z.number(), + high: z.number(), + center: z.number(), + days: z.number().int().describe("Days the band was built from."), + }) + .meta({ + id: "TopicUsualRange", + description: + "The personal usual range over the trailing 90 days: median ± 3 scaled MAD of the daily values, never narrower than the measurement's own wobble. Needs 7 days, else null.", + }); + +const signal = z + .object({ + key: signalKey, + latest: dayValue.nullable(), + usual: usualRange.nullable(), + state: z + .enum(["within", "above", "below", "learning", "none"]) + .describe( + "`latest` against `usual`: `learning` while the range needs more days, `none` without a value.", + ), + href: z + .string() + .nullable() + .describe("The in-app single-metric page of this signal."), + }) + .meta({ id: "TopicSignal" }); + +const summary = z + .object({ + code: z.enum([ + "outside", + "labs", + "cycleDay", + "steady", + "learning", + "empty", + ]), + signal: signalKey.nullable(), + direction: z.enum(["above", "below"]).nullable(), + count: z.number().int().nullable(), + total: z.number().int().nullable(), + }) + .meta({ + id: "TopicSummary", + description: + "The topic's one sentence as a code with parameters, picked by a fixed rule (never model text): `outside` names the first signal outside its usual range and the direction; `labs` counts lab markers inside their window (`count` of `total`); `cycleDay` gives today's cycle day in `count`; `steady` counts signals inside their range (`count` of `total` recorded); `learning` while no range is established; `empty` without any data.", + }); + +const headline = z + .discriminatedUnion("kind", [ + z.object({ + kind: z.literal("signal"), + key: signalKey, + value: z.number(), + day: dayKey, + }), + z.object({ + kind: z.literal("labs"), + inRange: z.number().int(), + total: z.number().int(), + }), + z.object({ kind: z.literal("cycleDay"), dayOfCycle: z.number().int() }), + z.object({ + kind: z.literal("screener"), + instrument: z.string(), + score: z.number(), + }), + ]) + .meta({ id: "TopicHeadline" }); + +const overviewEntry = z + .object({ + key: topicKey, + headline: headline.nullable(), + summary, + tone: z.enum(["steady", "watch", "learning"]), + sparkline: z + .array(z.number()) + .describe( + "The headline signal's daily values over the last 30 days, oldest first; empty when the headline is a block.", + ), + recorded: z.number().int(), + total: z.number().int(), + }) + .meta({ id: "TopicOverviewEntry" }); + +const overviewSchema = z + .object({ + topics: z.array(overviewEntry), + empty: z + .array(topicKey) + .describe("Offered topics with nothing recorded yet."), + hiddenByModule: z + .array(topicKey) + .describe("Topics whose every module is switched off for the record."), + }) + .meta({ id: "TopicOverview" }); + +const relationship = z + .object({ + id: z.string(), + x: signalKey, + y: signalKey, + lagDays: z + .union([z.literal(0), z.literal(1)]) + .describe("`x` on day d is held against `y` on day d + `lagDays`."), + kind: z.enum(["correlation", "split"]), + pairedDays: z.number().int(), + requiredDays: z + .number() + .int() + .describe("Paired days needed before anything is said: 14."), + status: z + .enum(["insufficient", "no_clear_link", "linked"]) + .describe( + "`linked` only at p < 0.05 (Pearson, or Welch for a split); a pattern, never a cause.", + ), + r: z.number().nullable(), + pValue: z.number().nullable(), + split: z + .object({ + threshold: z.number(), + belowDays: z.number().int(), + otherDays: z.number().int(), + belowMean: z.number(), + otherMean: z.number(), + difference: z.number(), + }) + .nullable() + .describe( + "For a split: the mean of `y` on days `x` sat below `threshold` against the other days.", + ), + points: z + .array(z.object({ x: z.number(), y: z.number() })) + .describe( + "Paired values for a scatter, at most 120; empty when insufficient.", + ), + }) + .meta({ id: "TopicRelationship" }); + +const trend = z + .object({ + key: signalKey, + status: z.enum(["ok", "insufficient"]), + days: z.number().int(), + change: z + .number() + .nullable() + .describe( + "The least-squares change across the trailing 90 days, in the stored unit. Needs 14 days.", + ), + weekly: z + .array(dayValue) + .describe("Weekly means, each keyed by its Monday."), + }) + .meta({ id: "TopicTrend" }); + +const labMarker = z + .object({ + biomarkerId: z.string(), + name: z.string(), + unit: z.string(), + latest: z.number(), + takenOn: dayKey, + low: z.number().nullable(), + high: z.number().nullable(), + status: z.enum(["in-range", "below", "above", "unknown"]), + readings: z.number().int(), + trend: biomarkerTrendSchema, + }) + .meta({ id: "TopicLabMarker" }); + +const labPanel = z + .object({ + markers: z.array(labMarker).describe("At most 24, newest reading first."), + inRange: z.number().int(), + withRange: z.number().int(), + latestOn: dayKey.nullable(), + }) + .meta({ id: "TopicLabPanel" }); + +const cycleSummary = z + .object({ + today: z + .object({ + dayOfCycle: z.number().int(), + phase: z.string().nullable(), + }) + .nullable(), + lengths: z.array(z.number().int()), + averageLength: z.number().nullable(), + periodsLogged: z.number().int(), + }) + .meta({ id: "TopicCycleSummary" }); + +const detailSchema = z + .object({ + key: topicKey, + range: z.enum(TOPIC_RANGES), + from: dayKey, + to: dayKey, + status: z.enum(["ready", "partial", "empty"]), + summary, + signals: z.array(signal), + series: z.array( + z.object({ + key: signalKey, + usual: usualRange.nullable(), + points: z.array(dayValue), + }), + ), + relationships: z.array(relationship), + trends: z.array(trend), + missing: z + .array(signalKey) + .describe("Signals the topic could show that hold no value yet."), + labs: labPanel.nullable(), + training: trainingLoadSchema.nullable(), + screeners: z.array(assessmentTrendSchema).nullable(), + cycle: cycleSummary.nullable(), + }) + .meta({ id: "TopicDetail" }); + +const PREVIEW_OFF = + "`topics.preview_off`: the person reading has not switched the topics preview on (Settings > System > Preview features). The preview is per account and off by default."; + +export const topicPaths: NonNullable = { + "/api/topics": { + get: { + tags: ["Insights"], + summary: "Topic overview (preview)", + description: + "One entry per topic the record holds data for, with a headline value, a rule-based sentence and a 30-day sparkline, computed from the same daily values the metric pages draw. No model, no AI gate. Topics follow the record's module switches. Reads across sections, so a grant scoped to some of them is refused.", + responses: { + "200": { + description: "The overview.", + content: { + "application/json": { + schema: dataEnvelope(overviewSchema, "TopicOverviewEnvelope"), + }, + }, + }, + "404": { + description: PREVIEW_OFF, + content: { "application/json": { schema: errorEnvelope } }, + }, + ...stdResponses, + ...recordRefusal(), + }, + }, + }, + "/api/topics/{topic}": { + get: { + tags: ["Insights"], + summary: "One topic (preview)", + description: + "A topic's signals with their usual ranges, the side-by-side series for `range`, relationships between its signals (nothing is said below 14 paired days), 90-day trends, the signals it holds nothing for yet, and its blocks: training load, lab markers with their trend (three readings spanning a year), screener trends, today's cycle day. No model, no AI gate.", + requestParams: { path: topicPathSchema, query: topicDetailQuerySchema }, + responses: { + "200": { + description: "The topic.", + content: { + "application/json": { + schema: dataEnvelope(detailSchema, "TopicDetailEnvelope"), + }, + }, + }, + "404": { + description: `${PREVIEW_OFF}\n\n\`topics.not_found\`: no topic by that name. \`topics.not_available\`: the record's module switches do not offer this topic.`, + content: { "application/json": { schema: errorEnvelope } }, + }, + ...stdResponses, + ...recordRefusal(), + }, + }, + }, +}; diff --git a/src/lib/openapi/routes/workouts.ts b/src/lib/openapi/routes/workouts.ts index 1fec9ab70..aa5d5a3dd 100644 --- a/src/lib/openapi/routes/workouts.ts +++ b/src/lib/openapi/routes/workouts.ts @@ -122,7 +122,7 @@ const trainingLoadPoint = z.object({ ), }); -const trainingLoad = z +export const trainingLoadSchema = z .union([ z.object({ status: z.literal("ok"), @@ -174,7 +174,7 @@ const workoutListResponse = z offset: z.number().int().nonnegative(), droppedDuplicates: z.number().int().nonnegative(), }), - trainingLoad: trainingLoad + trainingLoad: trainingLoadSchema .optional() .describe( "v1.44 — on the first page (`offset=0`) only, account-wide whatever the filter. Absent on later pages and older servers.", diff --git a/src/lib/settings/preview-features.ts b/src/lib/settings/preview-features.ts index 514b55fd1..2d47e0944 100644 --- a/src/lib/settings/preview-features.ts +++ b/src/lib/settings/preview-features.ts @@ -6,10 +6,11 @@ * adds it beside what is there and never replaces an existing page. * * Flags: - * - `topics`: topic overview pages above the single-metric Insights pages. - * Stored, served by `/api/settings/preview-features` and carried by the - * backup. The Settings > System switch is not listed until the topic pages - * ship, because nothing reads the flag yet. + * - `topics`: the Topics area (`/topics`, `/api/topics`), overview pages + * that bring related signals together above the single-metric Insights + * pages, which stay exactly as they are. Switched in Settings > System > + * Preview features, published on `/api/auth/me` as `previewFeatures`, read + * by the navigation and refused by the topic routes while off. * * Unknown keys in a stored blob are dropped on read, so a flag retired later * disappears cleanly and a malformed blob reads as everything off. diff --git a/src/lib/topics/__tests__/build.test.ts b/src/lib/topics/__tests__/build.test.ts new file mode 100644 index 000000000..125529ff2 --- /dev/null +++ b/src/lib/topics/__tests__/build.test.ts @@ -0,0 +1,161 @@ +import { describe, expect, it } from "vitest"; + +import { shiftDateKey } from "@/lib/tz/format"; + +import { buildDetail, buildOverview, summarise } from "../build"; +import type { TopicInputs } from "../read"; +import { TOPICS, type TopicSignalKey } from "../registry"; +import type { TopicVisibility } from "../visibility"; + +const TODAY = "2026-10-23"; + +function series(n: number, f: (i: number) => number) { + const out = new Map(); + for (let i = 0; i < n; i++) out.set(shiftDateKey(TODAY, -(n - 1 - i)), f(i)); + return out; +} + +function inputs( + entries: Array<[TopicSignalKey, Map]>, + extra: Partial = {}, +): TopicInputs { + return { + series: new Map(entries), + labs: null, + training: null, + screeners: null, + cycle: null, + ...extra, + }; +} + +const everything: TopicVisibility = { modules: {}, sees: () => true }; + +describe("summarise", () => { + it("names the first signal outside its usual range", () => { + const data = inputs([ + ["RESTING_HEART_RATE", series(30, (i) => (i === 29 ? 75 : 58 + (i % 2)))], + ]); + const detail = buildDetail(TOPICS.heart, "30d", data, everything, TODAY); + expect(detail.summary).toMatchObject({ + code: "outside", + signal: "RESTING_HEART_RATE", + direction: "above", + }); + }); + + it("is still learning below a week, and empty without anything", () => { + expect( + summarise( + [ + { + key: "WEIGHT", + latest: { day: TODAY, value: 80 }, + usual: null, + state: "learning", + href: null, + }, + ], + inputs([]), + ).code, + ).toBe("learning"); + expect(summarise([], inputs([])).code).toBe("empty"); + }); +}); + +describe("buildOverview", () => { + it("gives a tile to a topic with data and names the empty and the hidden ones", () => { + const data = inputs([["WEIGHT", series(20, () => 80)]]); + const overview = buildOverview( + ["heart", "body"], + ["cycle"], + data, + everything, + TODAY, + ); + expect(overview.topics.map((t) => t.key)).toEqual(["body"]); + expect(overview.empty).toEqual(["heart"]); + expect(overview.hiddenByModule).toEqual(["cycle"]); + const body = overview.topics[0]; + expect(body.headline).toMatchObject({ kind: "signal", key: "WEIGHT" }); + expect(body.sparkline).toHaveLength(20); + expect(body.recorded).toBe(1); + expect(body.total).toBe(TOPICS.body.signals.length); + expect(body.tone).toBe("steady"); + }); +}); + +describe("buildDetail", () => { + it("lists what it could show but holds nothing for, and says it is partial", () => { + const data = inputs([["RESTING_HEART_RATE", series(20, () => 58)]]); + const detail = buildDetail(TOPICS.heart, "30d", data, everything, TODAY); + expect(detail.status).toBe("partial"); + expect(detail.missing).toContain("HEART_RATE_VARIABILITY"); + expect(detail.series.map((s) => s.key)).toEqual(["RESTING_HEART_RATE"]); + expect(detail.from).toBe(shiftDateKey(TODAY, -29)); + expect(detail.series[0].points).toHaveLength(20); + }); + + it("never names a pair or a chart row whose module is off", () => { + const sleepOff: TopicVisibility = { + modules: { sleep: false }, + sees: () => true, + }; + const data = inputs([ + ["RESTING_HEART_RATE", series(30, () => 58)], + ["SLEEP_DURATION", series(30, () => 420)], + ]); + const detail = buildDetail(TOPICS.heart, "30d", data, sleepOff, TODAY); + expect(detail.series.map((s) => s.key)).not.toContain("SLEEP_DURATION"); + expect( + detail.relationships.some( + (r) => r.x === "SLEEP_DURATION" || r.y === "SLEEP_DURATION", + ), + ).toBe(false); + }); + + it("states a below-threshold pair with its count instead of a figure", () => { + const data = inputs([ + ["SLEEP_DURATION", series(10, () => 400)], + ["RESTING_HEART_RATE", series(10, () => 60)], + ]); + const detail = buildDetail(TOPICS.heart, "30d", data, everything, TODAY); + const pair = detail.relationships.find( + (r) => r.id === "short-nights-resting-hr", + ); + expect(pair).toMatchObject({ status: "insufficient", pairedDays: 10 }); + }); + + it("does not name a pair one side of which holds nothing", () => { + const data = inputs([["ACTIVITY_STEPS", series(30, () => 8000)]]); + const detail = buildDetail( + TOPICS.metabolism, + "30d", + data, + everything, + TODAY, + ); + expect(detail.relationships).toEqual([]); + }); + + it("drops a block the grant does not open", () => { + const noLabs: TopicVisibility = { + modules: {}, + sees: (scope) => scope !== "labs", + }; + const labs = { + markers: [], + inRange: 0, + withRange: 0, + latestOn: null, + }; + const detail = buildDetail( + TOPICS.metabolism, + "30d", + inputs([], { labs }), + noLabs, + TODAY, + ); + expect(detail.labs).toBeNull(); + }); +}); diff --git a/src/lib/topics/__tests__/labs.test.ts b/src/lib/topics/__tests__/labs.test.ts new file mode 100644 index 000000000..89adb8c99 --- /dev/null +++ b/src/lib/topics/__tests__/labs.test.ts @@ -0,0 +1,83 @@ +import { describe, expect, it } from "vitest"; + +import { completedCycleLengths } from "../read"; +import { shapeLabPanel } from "../labs"; + +const NOW = new Date("2026-10-23T12:00:00Z"); + +function reading(biomarkerId: string, value: number, takenAt: string) { + return { + biomarkerId, + value, + takenAt: new Date(takenAt), + sourceReferenceLow: null, + sourceReferenceHigh: null, + sourceReferenceText: null, + sourceUnit: null, + }; +} + +describe("shapeLabPanel", () => { + const markers = [ + { id: "ldl", name: "LDL", unit: "mg/dL", lowerBound: 0, upperBound: 130 }, + { + id: "vitd", + name: "Vitamin D", + unit: "ng/mL", + lowerBound: 30, + upperBound: 100, + }, + { + id: "none", + name: "Unused", + unit: "x", + lowerBound: null, + upperBound: null, + }, + ]; + + it("judges the newest reading and carries the marker's trend", () => { + const panel = shapeLabPanel( + markers, + [ + reading("ldl", 98, "2026-10-01T08:00:00Z"), + reading("ldl", 120, "2025-09-01T08:00:00Z"), + reading("ldl", 140, "2024-08-01T08:00:00Z"), + reading("vitd", 24, "2026-09-10T08:00:00Z"), + ], + NOW, + ); + expect(panel).not.toBeNull(); + expect(panel!.markers.map((m) => m.biomarkerId)).toEqual(["ldl", "vitd"]); + const ldl = panel!.markers[0]; + expect(ldl).toMatchObject({ latest: 98, status: "in-range", readings: 3 }); + expect(ldl.trend.status).toBe("ok"); + const vitd = panel!.markers[1]; + expect(vitd.status).toBe("below"); + // One reading is not a trend; the panel says so rather than drawing one. + expect(vitd.trend).toMatchObject({ status: "insufficient", n: 1 }); + expect(panel).toMatchObject({ + inRange: 1, + withRange: 2, + latestOn: "2026-10-01", + }); + }); + + it("is absent without a single numeric reading", () => { + expect(shapeLabPanel(markers, [], NOW)).toBeNull(); + }); +}); + +describe("completedCycleLengths", () => { + it("measures between logged starts and skips gaps in logging", () => { + expect( + completedCycleLengths([ + "2026-06-01", + "2026-06-29", + "2026-07-28", + "2026-10-20", + ]), + ).toEqual([28, 29]); + expect(completedCycleLengths(["2026-06-01"])).toEqual([]); + }); +}); diff --git a/src/lib/topics/__tests__/stats.test.ts b/src/lib/topics/__tests__/stats.test.ts new file mode 100644 index 000000000..b62c678a8 --- /dev/null +++ b/src/lib/topics/__tests__/stats.test.ts @@ -0,0 +1,222 @@ +import { describe, expect, it } from "vitest"; + +import { shiftDateKey } from "@/lib/tz/format"; + +import { + RELATIONSHIP_MIN_PAIRED_DAYS, + type TopicRelationshipDef, +} from "../registry"; +import { + computeRelationship, + computeTrend, + latestValue, + pairDays, + stateAgainstRange, + usualRange, + welchPValue, + windowValues, +} from "../stats"; + +const TODAY = "2026-10-23"; + +/** `n` consecutive days ending today, valued by `f(i)` (i = 0 oldest). */ +function series(n: number, f: (i: number) => number, end = TODAY) { + const out = new Map(); + for (let i = 0; i < n; i++) { + out.set(shiftDateKey(end, -(n - 1 - i)), f(i)); + } + return out; +} + +describe("windowValues / latestValue", () => { + it("keeps the window's days in order and ignores a day after today", () => { + const s = new Map([ + ["2026-10-20", 3], + ["2026-10-18", 1], + ["2026-10-24", 9], + ]); + expect(windowValues(s, "2026-10-18", TODAY).map((p) => p.day)).toEqual([ + "2026-10-18", + "2026-10-20", + ]); + expect(latestValue(s, TODAY)).toEqual({ day: "2026-10-20", value: 3 }); + expect(latestValue(undefined, TODAY)).toBeNull(); + }); +}); + +describe("usualRange", () => { + it("says nothing below a week of days", () => { + expect( + usualRange( + "PULSE", + series(6, () => 60), + TODAY, + ), + ).toBeNull(); + }); + + it("builds the personal band from a week or more", () => { + const band = usualRange( + "RESTING_HEART_RATE", + series(30, (i) => 58 + (i % 3)), + TODAY, + ); + expect(band).not.toBeNull(); + expect(band!.days).toBe(30); + expect(band!.low).toBeLessThanOrEqual(58); + expect(band!.high).toBeGreaterThanOrEqual(60); + }); + + it("reads only the trailing 90 days", () => { + const old = series(30, () => 100, shiftDateKey(TODAY, -200)); + expect(usualRange("WEIGHT", old, TODAY)).toBeNull(); + }); +}); + +describe("stateAgainstRange", () => { + const band = { low: 55, high: 62, center: 58, days: 30 }; + it("places a value against the band, and says when it cannot", () => { + expect(stateAgainstRange(null, band)).toBe("none"); + expect(stateAgainstRange({ day: TODAY, value: 60 }, null)).toBe("learning"); + expect(stateAgainstRange({ day: TODAY, value: 60 }, band)).toBe("within"); + expect(stateAgainstRange({ day: TODAY, value: 70 }, band)).toBe("above"); + expect(stateAgainstRange({ day: TODAY, value: 50 }, band)).toBe("below"); + }); +}); + +describe("computeTrend", () => { + it("draws no slope through fewer than 14 days", () => { + const trend = computeTrend( + "WEIGHT", + series(10, (i) => 80 - i), + TODAY, + ); + expect(trend.status).toBe("insufficient"); + expect(trend.change).toBeNull(); + expect(trend.days).toBe(10); + }); + + it("reports the fitted change across the window", () => { + // One kilogram down every ten days, for ninety days. + const trend = computeTrend( + "WEIGHT", + series(90, (i) => 85 - i / 10), + TODAY, + ); + expect(trend.status).toBe("ok"); + expect(trend.change).toBeCloseTo(-8.9, 1); + expect(trend.weekly.length).toBeGreaterThanOrEqual(13); + expect(trend.weekly[0].value).toBeGreaterThan(trend.weekly.at(-1)!.value); + }); +}); + +describe("pairDays", () => { + it("holds x on day d against y on day d + lag, inside the window", () => { + const x = series(5, (i) => i); + const y = series(5, (i) => 10 + i); + const sameDay = pairDays(x, y, 0, shiftDateKey(TODAY, -4), TODAY); + expect(sameDay).toHaveLength(5); + const nextDay = pairDays(x, y, 1, shiftDateKey(TODAY, -4), TODAY); + expect(nextDay).toHaveLength(4); + expect(nextDay[0]).toMatchObject({ x: 0, y: 11 }); + }); +}); + +describe("computeRelationship", () => { + const corr: TopicRelationshipDef = { + id: "a-b", + x: "HEART_RATE_VARIABILITY", + y: "RESTING_HEART_RATE", + lagDays: 0, + kind: "correlation", + }; + const from = shiftDateKey(TODAY, -89); + + it("says nothing but the count below 14 paired days", () => { + const n = RELATIONSHIP_MIN_PAIRED_DAYS - 1; + const rel = computeRelationship( + corr, + series(n, (i) => 50 + i), + series(n, (i) => 70 - i), + from, + TODAY, + ); + expect(rel.status).toBe("insufficient"); + expect(rel.pairedDays).toBe(n); + expect(rel.requiredDays).toBe(14); + expect(rel.r).toBeNull(); + expect(rel.points).toEqual([]); + }); + + it("reports a clear negative link with its r and its days", () => { + const rel = computeRelationship( + corr, + series(30, (i) => 50 + i), + series(30, (i) => 70 - i + (i % 2) * 0.5), + from, + TODAY, + ); + expect(rel.status).toBe("linked"); + expect(rel.r).toBeLessThan(-0.9); + expect(rel.pairedDays).toBe(30); + expect(rel.points).toHaveLength(30); + }); + + it("calls an unrelated pair what it is", () => { + const noise = [3, 9, 1, 7, 5, 2, 8, 4, 6, 0, 9, 2, 7, 1, 5, 8, 3, 6, 0, 4]; + const rel = computeRelationship( + corr, + series(20, (i) => i), + series(20, (i) => noise[i]), + from, + TODAY, + ); + expect(rel.status).toBe("no_clear_link"); + expect(rel.pairedDays).toBe(20); + }); + + it("compares the days below a threshold with the others", () => { + const split: TopicRelationshipDef = { + id: "short-nights", + x: "SLEEP_DURATION", + y: "RESTING_HEART_RATE", + lagDays: 0, + kind: "split", + threshold: 420, + }; + const sleep = series(28, (i) => (i % 4 === 0 ? 360 : 480)); + const rhr = series(28, (i) => (i % 4 === 0 ? 63 : 58) + (i % 3) * 0.2); + const rel = computeRelationship(split, sleep, rhr, from, TODAY); + expect(rel.status).toBe("linked"); + expect(rel.split).toMatchObject({ belowDays: 7, otherDays: 21 }); + expect(rel.split!.difference).toBeGreaterThan(4); + }); + + it("will not compare a split whose one side is too thin", () => { + const split: TopicRelationshipDef = { + id: "short-nights", + x: "SLEEP_DURATION", + y: "MOOD", + lagDays: 0, + kind: "split", + threshold: 420, + }; + const sleep = series(20, (i) => (i < 2 ? 360 : 480)); + const rel = computeRelationship( + split, + sleep, + series(20, () => 3), + from, + TODAY, + ); + expect(rel.status).toBe("insufficient"); + expect(rel.pairedDays).toBe(20); + }); +}); + +describe("welchPValue", () => { + it("is 1 for identical groups and small for well separated ones", () => { + expect(welchPValue([1, 1, 1], [1, 1, 1])).toBe(1); + expect(welchPValue([1, 2, 1, 2, 1], [9, 8, 9, 8, 9])).toBeLessThan(0.001); + }); +}); diff --git a/src/lib/topics/__tests__/visibility.test.ts b/src/lib/topics/__tests__/visibility.test.ts new file mode 100644 index 000000000..f406b4368 --- /dev/null +++ b/src/lib/topics/__tests__/visibility.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it } from "vitest"; + +import { TOPICS } from "../registry"; +import { + isSignalVisible, + offeredTopics, + signalsToRead, + visibleExtras, +} from "../visibility"; + +const sees = () => true; + +describe("topic visibility", () => { + it("offers every topic when nothing is switched off", () => { + expect(offeredTopics({ modules: {}, sees })).toEqual([ + "heart", + "body", + "blood-pressure", + "sleep", + "activity", + "metabolism", + "mind", + "cycle", + ]); + }); + + it("follows the modules: cycle only with the cycle module, mind with either of its two", () => { + const off = offeredTopics({ + modules: { cycle: false, mood: false, mentalHealth: false, sleep: false }, + sees, + }); + expect(off).not.toContain("cycle"); + expect(off).not.toContain("mind"); + expect(off).not.toContain("sleep"); + expect(off).toContain("heart"); + expect(offeredTopics({ modules: { mood: false }, sees })).toContain("mind"); + }); + + it("never reads a signal or a block whose module is off or whose section is closed", () => { + const ctx = { + modules: { sleep: false, workouts: false }, + sees: (scope: string) => scope !== "mind", + }; + expect(isSignalVisible("SLEEP_DURATION", ctx)).toBe(false); + expect(isSignalVisible("MOOD", ctx)).toBe(false); + expect(isSignalVisible("PULSE", ctx)).toBe(true); + expect([...signalsToRead(TOPICS.heart, ctx)]).not.toContain( + "SLEEP_DURATION", + ); + expect([...visibleExtras(TOPICS.heart, ctx)]).toEqual([]); + }); +}); diff --git a/src/lib/topics/build.ts b/src/lib/topics/build.ts new file mode 100644 index 000000000..930231595 --- /dev/null +++ b/src/lib/topics/build.ts @@ -0,0 +1,386 @@ +/** + * Assemble the topic overview and a topic page from what `read.ts` returned. + * Pure: every input arrives as an argument, so a fixture covers every state. + * + * The summary sentence is a rule, never model text: the first signal outside + * its usual range names itself; otherwise the lab panel or the cycle day + * speaks for its topic; otherwise the count of signals inside their range; + * otherwise "still learning". The client words the code; the server only + * decides which one applies. + */ +import { insightsHrefForMetric } from "@/lib/insights/metric-deep-link"; +import type { AssessmentTrend } from "@/lib/mental-health/assessment-trend"; +import type { TrainingLoad } from "@/lib/insights/training-load"; +import { shiftDateKey } from "@/lib/tz/format"; + +import type { TopicLabPanel } from "./labs"; +import type { TopicCycleSummary, TopicInputs } from "./read"; +import { + MOOD_SIGNAL, + SPARKLINE_POINTS, + TOPIC_BASE_WINDOW_DAYS, + TOPIC_RANGE_DAYS, + TOPICS, + type TopicDefinition, + type TopicKey, + type TopicRange, + type TopicSignalKey, +} from "./registry"; +import { + computeRelationship, + computeTrend, + latestValue, + stateAgainstRange, + usualRange, + windowValues, + type DayValue, + type SignalState, + type TopicRelationship, + type TopicTrend, + type UsualRange, +} from "./stats"; +import { + isExtraVisible, + isSignalVisible, + visibleSignals, + type TopicVisibility, +} from "./visibility"; + +export interface TopicSignal { + key: TopicSignalKey; + latest: DayValue | null; + usual: UsualRange | null; + state: SignalState; + /** The single-metric page this signal opens, when it has one. */ + href: string | null; +} + +export type TopicSummaryCode = + "outside" | "labs" | "cycleDay" | "steady" | "learning" | "empty"; + +export interface TopicSummary { + code: TopicSummaryCode; + signal: TopicSignalKey | null; + direction: "above" | "below" | null; + count: number | null; + total: number | null; +} + +export type TopicHeadline = + | { kind: "signal"; key: TopicSignalKey; value: number; day: string } + | { kind: "labs"; inRange: number; total: number } + | { kind: "cycleDay"; dayOfCycle: number } + | { kind: "screener"; instrument: string; score: number }; + +export type TopicTone = "steady" | "watch" | "learning"; + +export interface TopicOverviewEntry { + key: TopicKey; + headline: TopicHeadline | null; + summary: TopicSummary; + tone: TopicTone; + /** The headline signal's last values, oldest first (empty for a block headline). */ + sparkline: number[]; + /** Signals of the topic's list that hold a value. */ + recorded: number; + /** Signals of the topic's list the record could hold. */ + total: number; +} + +export interface TopicOverview { + topics: TopicOverviewEntry[]; + /** Offered topics with nothing recorded yet. */ + empty: TopicKey[]; + /** Topics whose every module is switched off. */ + hiddenByModule: TopicKey[]; +} + +export interface TopicSeries { + key: TopicSignalKey; + usual: UsualRange | null; + points: DayValue[]; +} + +export interface TopicDetail { + key: TopicKey; + range: TopicRange; + from: string; + to: string; + status: "ready" | "partial" | "empty"; + summary: TopicSummary; + signals: TopicSignal[]; + series: TopicSeries[]; + relationships: TopicRelationship[]; + trends: TopicTrend[]; + /** Signals the topic could show that hold no value yet. */ + missing: TopicSignalKey[]; + labs: TopicLabPanel | null; + training: TrainingLoad | null; + screeners: AssessmentTrend[] | null; + cycle: TopicCycleSummary | null; +} + +function signalHref(key: TopicSignalKey): string | null { + if (key === MOOD_SIGNAL) return "/insights/mood"; + return insightsHrefForMetric(key); +} + +export function buildSignals( + keys: readonly TopicSignalKey[], + inputs: TopicInputs, + todayKey: string, +): TopicSignal[] { + return keys.map((key) => { + const series = inputs.series.get(key); + const latest = latestValue(series, todayKey); + const usual = usualRange(key, series, todayKey); + return { + key, + latest, + usual, + state: stateAgainstRange(latest, usual), + href: signalHref(key), + }; + }); +} + +function hasBlockData(inputs: TopicInputs): boolean { + return ( + inputs.labs !== null || + (inputs.screeners !== null && inputs.screeners.length > 0) || + inputs.cycle !== null || + (inputs.training !== null && inputs.training.status === "ok") + ); +} + +/** The rule that picks the topic's one sentence. */ +export function summarise( + signals: readonly TopicSignal[], + inputs: TopicInputs, +): TopicSummary { + const none: TopicSummary = { + code: "empty", + signal: null, + direction: null, + count: null, + total: null, + }; + const recorded = signals.filter((s) => s.latest !== null); + if (recorded.length === 0 && !hasBlockData(inputs)) return none; + + const outside = recorded.find( + (s) => s.state === "above" || s.state === "below", + ); + if (outside) { + return { + ...none, + code: "outside", + signal: outside.key, + direction: outside.state === "above" ? "above" : "below", + }; + } + if (inputs.labs && inputs.labs.withRange > 0) { + return { + ...none, + code: "labs", + count: inputs.labs.inRange, + total: inputs.labs.withRange, + }; + } + if (inputs.cycle?.today) { + return { ...none, code: "cycleDay", count: inputs.cycle.today.dayOfCycle }; + } + const within = recorded.filter((s) => s.state === "within"); + if (within.length > 0) { + return { + ...none, + code: "steady", + count: within.length, + total: recorded.length, + }; + } + return { ...none, code: "learning" }; +} + +function toneOf(summary: TopicSummary, labs: TopicLabPanel | null): TopicTone { + if (summary.code === "outside") return "watch"; + if (summary.code === "labs") { + return labs && labs.inRange < labs.withRange ? "watch" : "steady"; + } + if (summary.code === "steady" || summary.code === "cycleDay") return "steady"; + return "learning"; +} + +function headlineOf( + signals: readonly TopicSignal[], + inputs: TopicInputs, +): TopicHeadline | null { + const first = signals.find((s) => s.latest !== null); + if (first?.latest) { + return { + kind: "signal", + key: first.key, + value: first.latest.value, + day: first.latest.day, + }; + } + if (inputs.labs) { + return { + kind: "labs", + inRange: inputs.labs.inRange, + total: inputs.labs.withRange, + }; + } + if (inputs.cycle?.today) { + return { kind: "cycleDay", dayOfCycle: inputs.cycle.today.dayOfCycle }; + } + const screener = inputs.screeners?.[0]; + if (screener) { + return { + kind: "screener", + instrument: screener.instrument, + score: screener.latest.score, + }; + } + return null; +} + +/** The topic's slice of the shared read, with blocks it may not show removed. */ +export function inputsForTopic( + def: TopicDefinition, + inputs: TopicInputs, + ctx: TopicVisibility, +): TopicInputs { + const allowed = (extra: "labs" | "training" | "screeners" | "cycle") => + def.extras.includes(extra) && isExtraVisible(extra, ctx); + return { + series: inputs.series, + labs: allowed("labs") ? inputs.labs : null, + training: allowed("training") ? inputs.training : null, + screeners: allowed("screeners") ? inputs.screeners : null, + cycle: allowed("cycle") ? inputs.cycle : null, + }; +} + +export function buildOverviewEntry( + def: TopicDefinition, + inputs: TopicInputs, + ctx: TopicVisibility, + todayKey: string, +): TopicOverviewEntry | null { + const own = inputsForTopic(def, inputs, ctx); + const signals = buildSignals(visibleSignals(def, ctx), own, todayKey); + const summary = summarise(signals, own); + if (summary.code === "empty") return null; + const headline = headlineOf(signals, own); + const sparkline = + headline?.kind === "signal" + ? windowValues( + own.series.get(headline.key), + shiftDateKey(todayKey, -(SPARKLINE_POINTS - 1)), + todayKey, + ).map((p) => p.value) + : []; + return { + key: def.key, + headline, + summary, + tone: toneOf(summary, own.labs), + sparkline, + recorded: signals.filter((s) => s.latest !== null).length, + total: signals.length, + }; +} + +export function buildOverview( + offered: readonly TopicKey[], + hiddenByModule: readonly TopicKey[], + inputs: TopicInputs, + ctx: TopicVisibility, + todayKey: string, +): TopicOverview { + const topics: TopicOverviewEntry[] = []; + const empty: TopicKey[] = []; + for (const key of offered) { + const entry = buildOverviewEntry(TOPICS[key], inputs, ctx, todayKey); + if (entry) topics.push(entry); + else empty.push(key); + } + return { topics, empty, hiddenByModule: [...hiddenByModule] }; +} + +/** First day of the read a topic page needs for its range. */ +export function detailReadStart(range: TopicRange, todayKey: string): string { + const days = Math.max(TOPIC_RANGE_DAYS[range], TOPIC_BASE_WINDOW_DAYS); + return shiftDateKey(todayKey, -days); +} + +export function buildDetail( + def: TopicDefinition, + range: TopicRange, + inputs: TopicInputs, + ctx: TopicVisibility, + todayKey: string, +): TopicDetail { + const own = inputsForTopic(def, inputs, ctx); + const signalKeys = visibleSignals(def, ctx); + const signals = buildSignals(signalKeys, own, todayKey); + const summary = summarise(signals, own); + const from = shiftDateKey(todayKey, -(TOPIC_RANGE_DAYS[range] - 1)); + const pairFrom = detailReadStart(range, todayKey); + + const series: TopicSeries[] = []; + for (const key of def.chart) { + if (!isSignalVisible(key, ctx)) continue; + const values = own.series.get(key); + const points = windowValues(values, from, todayKey); + if (points.length === 0) continue; + series.push({ key, usual: usualRange(key, values, todayKey), points }); + } + + // A pair shows when both sides may be shown and both hold values: that is + // when "needs 14 paired days, you have n" tells the reader something. A + // pair with a switched-off or never-recorded side is not named. + const relationships = def.relationships + .filter((rel) => isSignalVisible(rel.x, ctx) && isSignalVisible(rel.y, ctx)) + .filter((rel) => own.series.has(rel.x) && own.series.has(rel.y)) + .map((rel) => + computeRelationship( + rel, + own.series.get(rel.x), + own.series.get(rel.y), + pairFrom, + todayKey, + ), + ); + + const trends = signals + .filter((s) => s.latest !== null) + .map((s) => computeTrend(s.key, own.series.get(s.key), todayKey)); + + const missing = signals.filter((s) => s.latest === null).map((s) => s.key); + const status = + summary.code === "empty" + ? "empty" + : missing.length > 0 + ? "partial" + : "ready"; + + return { + key: def.key, + range, + from, + to: todayKey, + status, + summary, + signals, + series, + relationships, + trends, + missing, + labs: own.labs, + training: own.training, + screeners: own.screeners, + cycle: own.cycle, + }; +} diff --git a/src/lib/topics/labs.ts b/src/lib/topics/labs.ts new file mode 100644 index 000000000..dec8ca028 --- /dev/null +++ b/src/lib/topics/labs.ts @@ -0,0 +1,164 @@ +/** + * The lab block of the metabolism topic: each marker's latest reading against + * the reference window in force for it, and the marker's trend line. + * + * The window is the one the labs list judges the reading by + * (`resolveEffectiveReferenceRange`: the range the report printed, else the + * marker's catalog band); the trend is `computeLabTrend` over every numeric + * reading of the marker against the marker's band, exactly as the marker page + * computes it. A trend needs three readings spanning a year; below that it is + * `insufficient` and the topic says what is missing. + */ +import { prisma } from "@/lib/db"; +import { computeLabTrend, type LabTrend } from "@/lib/labs/lab-trend"; +import { + classifyReferenceRange, + resolveEffectiveReferenceRange, + type ReferenceRangeStatus, +} from "@/lib/labs/reference-range"; +import { dateOnlyKey } from "@/lib/tz/date-only"; + +/** Markers the panel shows, the most recently measured first. */ +export const LAB_PANEL_MAX_MARKERS = 24; + +/** Readings the panel reads; far above any real history, the cap falls on the oldest. */ +const LAB_READ_CAP = 4000; + +export interface TopicLabMarker { + biomarkerId: string; + name: string; + unit: string; + latest: number; + /** Calendar day of the newest reading. */ + takenOn: string; + low: number | null; + high: number | null; + status: ReferenceRangeStatus; + readings: number; + trend: LabTrend; +} + +export interface TopicLabPanel { + markers: TopicLabMarker[]; + /** Markers whose latest reading sits inside its window. */ + inRange: number; + /** Markers with a window to sit inside. */ + withRange: number; + /** Calendar day of the newest reading across the panel. */ + latestOn: string | null; +} + +interface MarkerRow { + id: string; + name: string; + unit: string; + lowerBound: number | null; + upperBound: number | null; +} + +interface ReadingRow { + biomarkerId: string | null; + value: number | null; + takenAt: Date; + sourceReferenceLow: number | null; + sourceReferenceHigh: number | null; + sourceReferenceText: string | null; + sourceUnit: string | null; +} + +/** Shape the panel from the rows. Pure; `readings` arrive newest first. */ +export function shapeLabPanel( + markers: readonly MarkerRow[], + readings: readonly ReadingRow[], + now: Date, +): TopicLabPanel | null { + const byMarker = new Map(); + for (const row of readings) { + if (row.biomarkerId === null || row.value === null) continue; + const list = byMarker.get(row.biomarkerId) ?? []; + list.push(row); + byMarker.set(row.biomarkerId, list); + } + const shaped: TopicLabMarker[] = []; + for (const marker of markers) { + const rows = byMarker.get(marker.id); + if (!rows || rows.length === 0) continue; + const newest = rows.reduce((a, b) => (b.takenAt > a.takenAt ? b : a)); + const window = resolveEffectiveReferenceRange( + marker.lowerBound, + marker.upperBound, + newest, + ); + const latest = newest.value as number; + shaped.push({ + biomarkerId: marker.id, + name: marker.name, + unit: marker.unit, + latest, + takenOn: dateOnlyKey(newest.takenAt), + low: window.low, + high: window.high, + status: classifyReferenceRange(latest, window.low, window.high), + readings: rows.length, + trend: computeLabTrend( + rows.map((r) => ({ takenAt: r.takenAt, value: r.value as number })), + { low: marker.lowerBound, high: marker.upperBound }, + now, + ), + }); + } + if (shaped.length === 0) return null; + shaped.sort((a, b) => + a.takenOn === b.takenOn + ? a.name.localeCompare(b.name) + : a.takenOn < b.takenOn + ? 1 + : -1, + ); + const panel = shaped.slice(0, LAB_PANEL_MAX_MARKERS); + const withWindow = panel.filter((m) => m.status !== "unknown"); + return { + markers: panel, + inRange: withWindow.filter((m) => m.status === "in-range").length, + withRange: withWindow.length, + latestOn: panel[0]?.takenOn ?? null, + }; +} + +export async function readLabPanel( + userId: string, + now: Date, +): Promise { + const [markers, readings] = await Promise.all([ + prisma.biomarker.findMany({ + where: { userId, hidden: false }, + select: { + id: true, + name: true, + unit: true, + lowerBound: true, + upperBound: true, + }, + }), + prisma.labResult.findMany({ + where: { + userId, + deletedAt: null, + value: { not: null }, + biomarkerId: { not: null }, + }, + orderBy: { takenAt: "desc" }, + take: LAB_READ_CAP, + select: { + biomarkerId: true, + value: true, + takenAt: true, + sourceReferenceLow: true, + sourceReferenceHigh: true, + sourceReferenceText: true, + sourceUnit: true, + }, + }), + ]); + return shapeLabPanel(markers, readings, now); +} diff --git a/src/lib/topics/read.ts b/src/lib/topics/read.ts new file mode 100644 index 000000000..fe63c51bd --- /dev/null +++ b/src/lib/topics/read.ts @@ -0,0 +1,225 @@ +/** + * The reads behind the topic pages. Every one is a read the single-metric + * pages already make, so a topic never shows a value its metric page would + * not: + * + * - measurement days: `readLocalDailyStats`, the canonical-source local-day + * value the charts draw (a cumulative type's day total, a pulse-like + * type's mean of hourly means), in one query for every type a page needs; + * - sleep: the night's time asleep as the sleep page reconstructs it + * (`readNightCells`), keyed by the day the night ended; + * - mood: the day's mean of its entries' 1–5 score; + * - the extra blocks: the training load the workouts page shows, the lab + * markers with the trend the marker page shows, the screener trends of the + * check-in page, and today's place in the cycle as the cycle page decides + * it. + * + * The caller decides what may be read (module switches, the grant's + * sections); a signal or block it leaves out is never queried. + */ +import type { MeasurementType } from "@/generated/prisma/enums"; +import { readTodayCycle } from "@/lib/cycle/today-verdict"; +import { readLocalDailyStats } from "@/lib/day/daily-stats"; +import { prisma } from "@/lib/db"; +import { + readTrainingLoad, + type TrainingLoad, +} from "@/lib/insights/training-load"; +import { + computeAssessmentTrends, + TREND_INSTRUMENTS, + type AssessmentTrend, +} from "@/lib/mental-health/assessment-trend"; +import { loadUserSourcePriority } from "@/lib/rollups/measurement-read"; +import { readNightCells } from "@/lib/timeline/series"; +import { daysBetweenDateKeys, shiftDateKey } from "@/lib/tz/format"; +import { startOfLocalDayKey } from "@/lib/tz/local-day"; + +import { readLabPanel, type TopicLabPanel } from "./labs"; +import { MOOD_SIGNAL, type TopicExtra, type TopicSignalKey } from "./registry"; +import type { DaySeries } from "./stats"; + +/** Screener administrations the trends read; the cap falls on the oldest. */ +const SCREENER_READ_CAP = 1500; + +/** Cycle lengths the cycle block reports, newest kept. */ +const CYCLE_LENGTHS_SHOWN = 6; + +/** A plausible cycle length; anything outside is a gap in logging. */ +const CYCLE_LENGTH_MIN_DAYS = 15; +const CYCLE_LENGTH_MAX_DAYS = 60; + +export interface TopicCycleSummary { + /** Today's cycle day and phase, or null while the cycle is still learned. */ + today: { dayOfCycle: number; phase: string | null } | null; + /** Lengths of the completed cycles, oldest first, at most six. */ + lengths: number[]; + /** Mean of `lengths`, or null with none. */ + averageLength: number | null; + /** Logged period starts on record. */ + periodsLogged: number; +} + +export interface TopicReadRequest { + userId: string; + tz: string; + todayKey: string; + /** First local day to read (inclusive); the last is `todayKey`. */ + fromKey: string; + signals: ReadonlySet; + extras: ReadonlySet; + now: Date; +} + +export interface TopicInputs { + series: Map; + labs: TopicLabPanel | null; + training: TrainingLoad | null; + screeners: AssessmentTrend[] | null; + cycle: TopicCycleSummary | null; +} + +async function readMeasurementDays( + req: TopicReadRequest, + types: MeasurementType[], + priorityJson: unknown, +): Promise> { + const out = new Map(); + if (types.length === 0) return out; + const stats = await readLocalDailyStats({ + userId: req.userId, + types, + from: startOfLocalDayKey(req.fromKey, req.tz), + to: startOfLocalDayKey(shiftDateKey(req.todayKey, 1), req.tz), + tz: req.tz, + priorityJson, + }); + for (const [type, days] of stats) { + if (days.size > 0) out.set(type, days); + } + return out; +} + +async function readSleepNights( + req: TopicReadRequest, + priorityJson: unknown, +): Promise { + const cells = await readNightCells({ + userId: req.userId, + from: req.fromKey, + to: req.todayKey, + tz: req.tz, + priorityJson, + }); + if (cells.size === 0) return null; + const out = new Map(); + for (const [day, cell] of cells) out.set(day, cell.value); + return out; +} + +async function readMoodDays(req: TopicReadRequest): Promise { + const rows = await prisma.moodEntry.groupBy({ + by: ["date"], + where: { + userId: req.userId, + deletedAt: null, + date: { gte: req.fromKey, lte: req.todayKey }, + }, + _avg: { score: true }, + }); + const out = new Map(); + for (const row of rows) { + if (row._avg.score !== null) out.set(row.date, row._avg.score); + } + return out.size > 0 ? out : null; +} + +async function readScreenerTrends(userId: string): Promise { + const rows = await prisma.mentalHealthAssessment.findMany({ + where: { userId, deletedAt: null, instrument: { in: TREND_INSTRUMENTS } }, + orderBy: [{ takenAt: "desc" }, { id: "desc" }], + take: SCREENER_READ_CAP, + select: { id: true, instrument: true, totalScore: true, takenAt: true }, + }); + return computeAssessmentTrends(rows); +} + +/** Lengths between consecutive logged period starts, plausible ones only. */ +export function completedCycleLengths(starts: readonly string[]): number[] { + const sorted = [...starts].sort(); + const lengths: number[] = []; + for (let i = 1; i < sorted.length; i++) { + const length = daysBetweenDateKeys(sorted[i - 1], sorted[i]); + if (length >= CYCLE_LENGTH_MIN_DAYS && length <= CYCLE_LENGTH_MAX_DAYS) { + lengths.push(length); + } + } + return lengths.slice(-CYCLE_LENGTHS_SHOWN); +} + +async function readCycleSummary( + req: TopicReadRequest, +): Promise { + const [today, cycles] = await Promise.all([ + readTodayCycle(req.userId, req.tz, req.now), + prisma.menstrualCycle.findMany({ + where: { userId: req.userId, deletedAt: null, isPredicted: false }, + orderBy: { startDate: "asc" }, + select: { startDate: true }, + }), + ]); + const starts = cycles + .map((c) => c.startDate) + .filter((day) => day <= req.todayKey); + if (starts.length === 0 && today === null) return null; + const lengths = completedCycleLengths(starts); + const averageLength = + lengths.length > 0 + ? Math.round((lengths.reduce((s, v) => s + v, 0) / lengths.length) * 10) / + 10 + : null; + return { + today: today ? { dayOfCycle: today.dayOfCycle, phase: today.phase } : null, + lengths, + averageLength, + periodsLogged: starts.length, + }; +} + +/** Read everything one request needs, in parallel. */ +export async function readTopicInputs( + req: TopicReadRequest, +): Promise { + const wantsSleep = req.signals.has("SLEEP_DURATION"); + const wantsMood = req.signals.has(MOOD_SIGNAL); + const types = [...req.signals].filter( + (key): key is MeasurementType => + key !== MOOD_SIGNAL && key !== "SLEEP_DURATION", + ); + const needsPriority = types.length > 0 || wantsSleep; + const priorityJson = needsPriority + ? await loadUserSourcePriority(req.userId) + : null; + + const [measurements, sleep, mood, labs, training, screeners, cycle] = + await Promise.all([ + readMeasurementDays(req, types, priorityJson), + wantsSleep ? readSleepNights(req, priorityJson) : Promise.resolve(null), + wantsMood ? readMoodDays(req) : Promise.resolve(null), + req.extras.has("labs") + ? readLabPanel(req.userId, req.now) + : Promise.resolve(null), + req.extras.has("training") + ? readTrainingLoad(req.userId, req.now) + : Promise.resolve(null), + req.extras.has("screeners") + ? readScreenerTrends(req.userId) + : Promise.resolve(null), + req.extras.has("cycle") ? readCycleSummary(req) : Promise.resolve(null), + ]); + + const series = measurements; + if (sleep) series.set("SLEEP_DURATION", sleep); + if (mood) series.set(MOOD_SIGNAL, mood); + return { series, labs, training, screeners, cycle }; +} diff --git a/src/lib/topics/registry.ts b/src/lib/topics/registry.ts new file mode 100644 index 000000000..fca3dbdf8 --- /dev/null +++ b/src/lib/topics/registry.ts @@ -0,0 +1,321 @@ +/** + * The topic pages (preview): which signals each topic brings together, which + * pairs it checks against each other and which extra blocks it carries. + * + * A topic is an overview layer above the single-metric Insights pages. It + * never owns a number of its own: every value is the same daily value the + * metric page draws, and every topic links into those pages. This file is the + * one declaration; the server builds from it (`build.ts`) and the browser + * imports only the keys and labels, so it carries constants and types only. + * + * Module ownership is not restated here. A measurement signal follows its + * type's owner (`moduleForMeasurementType`), the mood signal follows `mood`, + * and the extra blocks name their module in {@link TOPIC_EXTRA_MODULE}. A + * topic is offered when at least one of its {@link TopicDefinition.modules} + * is on (an empty list means it rides core signals and is always offered); + * inside it, each signal and block shows only when its own owner is on. + */ +import type { MeasurementType } from "@/generated/prisma/enums"; +import type { ModuleKey } from "@/lib/modules/registry"; + +/** The topics, in the overview's reading order. Also the URL slugs. */ +export const TOPIC_KEYS = [ + "heart", + "body", + "blood-pressure", + "sleep", + "activity", + "metabolism", + "mind", + "cycle", +] as const; + +export type TopicKey = (typeof TOPIC_KEYS)[number]; + +export function isTopicKey(value: unknown): value is TopicKey { + return ( + typeof value === "string" && + (TOPIC_KEYS as readonly string[]).includes(value) + ); +} + +/** The mood score (1 to 5, the day's mean of its entries) as a signal. */ +export const MOOD_SIGNAL = "MOOD"; + +/** A daily series a topic reads: a measurement type or the mood score. */ +export type TopicSignalKey = MeasurementType | typeof MOOD_SIGNAL; + +/** The extra blocks a topic may carry beside its signals. */ +export type TopicExtra = "training" | "labs" | "screeners" | "cycle"; + +/** The module each extra block follows. */ +export const TOPIC_EXTRA_MODULE: Readonly> = { + training: "workouts", + labs: "labs", + screeners: "mentalHealth", + cycle: "cycle", +}; + +/** + * One pair a topic checks. `x` on day d is held against `y` on day + * d + `lagDays`. A sleep night is keyed by the day it ended, so "the night + * before" and "that morning" share a key and need no lag. + * + * `correlation` reports Pearson's r; `split` compares the mean of `y` on the + * days `x` sat below `threshold` (in `x`'s stored unit) with the other days. + */ +export interface TopicRelationshipDef { + id: string; + x: TopicSignalKey; + y: TopicSignalKey; + lagDays: 0 | 1; + kind: "correlation" | "split"; + threshold?: number; +} + +export interface TopicDefinition { + key: TopicKey; + /** Any one of these on offers the topic; empty means core signals carry it. */ + modules: readonly ModuleKey[]; + /** Display order. The first one with data is the topic's headline. */ + signals: readonly TopicSignalKey[]; + /** The side-by-side chart, top to bottom (at most three). */ + chart: readonly TopicSignalKey[]; + relationships: readonly TopicRelationshipDef[]; + extras: readonly TopicExtra[]; +} + +/** Seven hours, in the minutes `SLEEP_DURATION` is stored in. */ +const SHORT_NIGHT_MINUTES = 420; + +export const TOPICS: Readonly> = { + heart: { + key: "heart", + modules: [], + signals: [ + "RESTING_HEART_RATE", + "HEART_RATE_VARIABILITY", + "VO2_MAX", + "PULSE", + ], + chart: ["RESTING_HEART_RATE", "HEART_RATE_VARIABILITY", "SLEEP_DURATION"], + relationships: [ + { + id: "short-nights-resting-hr", + x: "SLEEP_DURATION", + y: "RESTING_HEART_RATE", + lagDays: 0, + kind: "split", + threshold: SHORT_NIGHT_MINUTES, + }, + { + id: "hrv-resting-hr", + x: "HEART_RATE_VARIABILITY", + y: "RESTING_HEART_RATE", + lagDays: 0, + kind: "correlation", + }, + { + id: "exercise-next-day-hrv", + x: "EXERCISE_MINUTES", + y: "HEART_RATE_VARIABILITY", + lagDays: 1, + kind: "correlation", + }, + ], + extras: ["training"], + }, + body: { + key: "body", + modules: [], + signals: [ + "WEIGHT", + "BODY_FAT", + "BODY_MASS_INDEX", + "MUSCLE_MASS", + "WAIST_CIRCUMFERENCE", + "VISCERAL_FAT", + ], + chart: ["WEIGHT", "BODY_FAT", "MUSCLE_MASS"], + relationships: [ + { + id: "active-energy-next-day-weight", + x: "ACTIVE_ENERGY_BURNED", + y: "WEIGHT", + lagDays: 1, + kind: "correlation", + }, + ], + extras: [], + }, + "blood-pressure": { + key: "blood-pressure", + modules: [], + signals: ["BLOOD_PRESSURE_SYS", "BLOOD_PRESSURE_DIA", "PULSE"], + chart: ["BLOOD_PRESSURE_SYS", "BLOOD_PRESSURE_DIA", "PULSE"], + relationships: [ + { + id: "short-nights-systolic", + x: "SLEEP_DURATION", + y: "BLOOD_PRESSURE_SYS", + lagDays: 0, + kind: "split", + threshold: SHORT_NIGHT_MINUTES, + }, + { + id: "steps-next-day-systolic", + x: "ACTIVITY_STEPS", + y: "BLOOD_PRESSURE_SYS", + lagDays: 1, + kind: "correlation", + }, + ], + extras: [], + }, + sleep: { + key: "sleep", + modules: ["sleep"], + signals: [ + "SLEEP_DURATION", + "RESPIRATORY_RATE", + "OXYGEN_SATURATION", + "BREATHING_DISTURBANCES", + ], + chart: ["SLEEP_DURATION", "RESPIRATORY_RATE", "HEART_RATE_VARIABILITY"], + relationships: [ + { + id: "exercise-sleep", + x: "EXERCISE_MINUTES", + y: "SLEEP_DURATION", + lagDays: 1, + kind: "correlation", + }, + { + id: "sleep-mood", + x: "SLEEP_DURATION", + y: MOOD_SIGNAL, + lagDays: 0, + kind: "correlation", + }, + ], + extras: [], + }, + activity: { + key: "activity", + modules: [], + signals: [ + "ACTIVITY_STEPS", + "EXERCISE_MINUTES", + "ACTIVE_ENERGY_BURNED", + "WALKING_RUNNING_DISTANCE", + "FLIGHTS_CLIMBED", + ], + chart: ["ACTIVITY_STEPS", "EXERCISE_MINUTES", "ACTIVE_ENERGY_BURNED"], + relationships: [ + { + id: "steps-sleep", + x: "ACTIVITY_STEPS", + y: "SLEEP_DURATION", + lagDays: 1, + kind: "correlation", + }, + { + id: "exercise-next-day-resting-hr", + x: "EXERCISE_MINUTES", + y: "RESTING_HEART_RATE", + lagDays: 1, + kind: "correlation", + }, + ], + extras: ["training"], + }, + metabolism: { + key: "metabolism", + modules: ["glucose", "labs"], + signals: ["BLOOD_GLUCOSE"], + chart: ["BLOOD_GLUCOSE"], + relationships: [ + { + id: "steps-glucose", + x: "ACTIVITY_STEPS", + y: "BLOOD_GLUCOSE", + lagDays: 0, + kind: "correlation", + }, + { + id: "short-nights-glucose", + x: "SLEEP_DURATION", + y: "BLOOD_GLUCOSE", + lagDays: 0, + kind: "split", + threshold: SHORT_NIGHT_MINUTES, + }, + ], + extras: ["labs"], + }, + mind: { + key: "mind", + modules: ["mood", "mentalHealth"], + signals: [MOOD_SIGNAL, "PHQ9_SCORE", "GAD7_SCORE", "WHO5_SCORE"], + chart: [MOOD_SIGNAL, "SLEEP_DURATION", "ACTIVITY_STEPS"], + relationships: [ + { + id: "short-nights-mood", + x: "SLEEP_DURATION", + y: MOOD_SIGNAL, + lagDays: 0, + kind: "split", + threshold: SHORT_NIGHT_MINUTES, + }, + { + id: "steps-mood", + x: "ACTIVITY_STEPS", + y: MOOD_SIGNAL, + lagDays: 0, + kind: "correlation", + }, + ], + extras: ["screeners"], + }, + cycle: { + key: "cycle", + modules: ["cycle"], + signals: ["WRIST_TEMPERATURE", "RESTING_HEART_RATE"], + chart: ["WRIST_TEMPERATURE", "RESTING_HEART_RATE"], + relationships: [], + extras: ["cycle"], + }, +}; + +/** The selectable windows of a topic page. */ +export const TOPIC_RANGES = ["7d", "30d", "90d", "1y"] as const; +export type TopicRange = (typeof TOPIC_RANGES)[number]; +export const DEFAULT_TOPIC_RANGE: TopicRange = "30d"; + +export const TOPIC_RANGE_DAYS: Readonly> = { + "7d": 7, + "30d": 30, + "90d": 90, + "1y": 365, +}; + +/** A relationship says nothing below this many paired days. */ +export const RELATIONSHIP_MIN_PAIRED_DAYS = 14; + +/** Each side of a `split` needs this many days to be compared at all. */ +export const SPLIT_MIN_DAYS_PER_SIDE = 3; + +/** The usual range needs this many days of the trailing window. */ +export const USUAL_RANGE_MIN_DAYS = 7; + +/** The trailing window the usual range, the trends and the pairs read. */ +export const TOPIC_BASE_WINDOW_DAYS = 90; + +/** A trend over the base window needs this many days with a value. */ +export const TREND_MIN_DAYS = 14; + +/** Points a relationship ships for its scatter, newest kept. */ +export const RELATIONSHIP_MAX_POINTS = 120; + +/** Points in an overview tile's sparkline. */ +export const SPARKLINE_POINTS = 30; diff --git a/src/lib/topics/service.ts b/src/lib/topics/service.ts new file mode 100644 index 000000000..d57cec13c --- /dev/null +++ b/src/lib/topics/service.ts @@ -0,0 +1,127 @@ +/** + * The topic routes' one entry: who may see the topics, what the record may + * show, and the reads and assembly behind both answers. + * + * Two people are in play and they are kept apart. The preview switch is the + * VIEWER's (`actorId`): a preview is chrome of the person reading, so it is + * their opt-in that decides whether the topics exist for them. Everything the + * page shows is the RECORD's (`userId`): its module switches decide which + * topics, signals and blocks are offered, and the sections the viewer's grant + * opens decide which of them may be read at all. + */ +import { prisma } from "@/lib/db"; +import { resolveModuleMap } from "@/lib/modules/gate"; +import { resolvePreviewFeatures } from "@/lib/settings/preview-features"; +import { actingDomainVisibility } from "@/lib/sharing/acting-domains"; +import { shiftDateKey, userDayKey } from "@/lib/tz/format"; +import { resolveUserTimezone } from "@/lib/tz/resolver"; + +import { + buildDetail, + buildOverview, + detailReadStart, + type TopicDetail, + type TopicOverview, +} from "./build"; +import { readTopicInputs } from "./read"; +import { + TOPIC_BASE_WINDOW_DAYS, + TOPICS, + TOPIC_KEYS, + type TopicExtra, + type TopicKey, + type TopicRange, + type TopicSignalKey, +} from "./registry"; +import { + isExtraVisible, + isTopicOffered, + offeredTopics, + signalsToRead, + visibleExtras, + visibleSignals, + type TopicVisibility, +} from "./visibility"; + +/** Whether the viewer switched the topics preview on for themselves. */ +export async function hasTopicsPreview(actorId: string): Promise { + const row = await prisma.user.findUnique({ + where: { id: actorId }, + select: { previewFeaturesJson: true }, + }); + return resolvePreviewFeatures(row?.previewFeaturesJson).topics; +} + +async function visibilityFor( + userId: string, + grantId: string | null, +): Promise { + const [modules, sees] = await Promise.all([ + resolveModuleMap(userId), + actingDomainVisibility(prisma, grantId), + ]); + return { modules, sees }; +} + +async function today(userId: string, now: Date) { + const tz = await resolveUserTimezone(userId); + return { tz, todayKey: userDayKey(now, tz) }; +} + +export async function loadTopicOverview(args: { + userId: string; + grantId: string | null; + now: Date; +}): Promise { + const ctx = await visibilityFor(args.userId, args.grantId); + const { tz, todayKey } = await today(args.userId, args.now); + const offered = offeredTopics(ctx); + const hidden = TOPIC_KEYS.filter((key) => !offered.includes(key)); + + // The overview needs each offered topic's own list and its blocks, not its + // chart or pairs; the training load stays on the topic page. + const signals = new Set(); + const extras = new Set(); + for (const key of offered) { + const def = TOPICS[key]; + for (const signal of visibleSignals(def, ctx)) signals.add(signal); + for (const extra of def.extras) { + if (extra !== "training" && isExtraVisible(extra, ctx)) extras.add(extra); + } + } + + const inputs = await readTopicInputs({ + userId: args.userId, + tz, + todayKey, + fromKey: shiftDateKey(todayKey, -(TOPIC_BASE_WINDOW_DAYS - 1)), + signals, + extras, + now: args.now, + }); + return buildOverview(offered, hidden, inputs, ctx, todayKey); +} + +/** One topic page, or null when the record's switches do not offer it. */ +export async function loadTopicDetail(args: { + userId: string; + grantId: string | null; + topic: TopicKey; + range: TopicRange; + now: Date; +}): Promise { + const ctx = await visibilityFor(args.userId, args.grantId); + const def = TOPICS[args.topic]; + if (!isTopicOffered(def, ctx)) return null; + const { tz, todayKey } = await today(args.userId, args.now); + const inputs = await readTopicInputs({ + userId: args.userId, + tz, + todayKey, + fromKey: detailReadStart(args.range, todayKey), + signals: signalsToRead(def, ctx), + extras: visibleExtras(def, ctx), + now: args.now, + }); + return buildDetail(def, args.range, inputs, ctx, todayKey); +} diff --git a/src/lib/topics/stats.ts b/src/lib/topics/stats.ts new file mode 100644 index 000000000..72861b572 --- /dev/null +++ b/src/lib/topics/stats.ts @@ -0,0 +1,367 @@ +/** + * The arithmetic of the topic pages, over day-keyed series. Pure: the reads + * live in `read.ts`, the assembly in `build.ts`. + * + * Nothing here invents a figure. A usual range needs a week of days, a trend + * two weeks, a relationship fourteen paired days; below that the answer says + * how many days there are and how many it needs, and carries no number that + * would read as a finding. + * + * - Usual range: the personal band the Insights tiles call "typical for + * you", median ± 3 scaled MAD of the daily values (`buildBaselineBand`), + * over the trailing {@link TOPIC_BASE_WINDOW_DAYS} days. + * - Trend: the least-squares slope of the daily values over the same + * window, reported as the change it amounts to across the window, beside + * weekly means for the sparkline. + * - Relationship: Pearson's r (two-sided p from the t statistic, the one + * correlation the app reports) or, for a `split`, the difference of two + * means with a Welch t test. A finding needs p < 0.05; otherwise it reads + * as "no clear link over n days". Never a cause. + */ +import type { MeasurementType } from "@/generated/prisma/enums"; +import { buildBaselineBand } from "@/lib/insights/derived/baseline"; +import { + MAX_P_VALUE, + pearson, + twoSidedPFromT, +} from "@/lib/insights/correlations"; +import { linearFit } from "@/lib/stats/linear-fit"; +import { variance } from "@/lib/stats/spread"; +import { daysBetweenDateKeys, shiftDateKey } from "@/lib/tz/format"; + +import { + MOOD_SIGNAL, + RELATIONSHIP_MAX_POINTS, + RELATIONSHIP_MIN_PAIRED_DAYS, + SPLIT_MIN_DAYS_PER_SIDE, + TOPIC_BASE_WINDOW_DAYS, + TREND_MIN_DAYS, + USUAL_RANGE_MIN_DAYS, + type TopicRelationshipDef, + type TopicSignalKey, +} from "./registry"; + +/** One signal's values, keyed by local day (`YYYY-MM-DD`). */ +export type DaySeries = ReadonlyMap; + +export interface DayValue { + day: string; + value: number; +} + +export interface UsualRange { + low: number; + high: number; + center: number; + /** Days the band was built from. */ + days: number; +} + +export type SignalState = "within" | "above" | "below" | "learning" | "none"; + +export interface TopicTrend { + key: TopicSignalKey; + status: "ok" | "insufficient"; + /** Days with a value in the trend window. */ + days: number; + /** The fitted change across the window, in the signal's stored unit. */ + change: number | null; + /** Weekly means, oldest first; a week without a value is left out. */ + weekly: DayValue[]; +} + +export interface SplitComparison { + threshold: number; + belowDays: number; + otherDays: number; + belowMean: number; + otherMean: number; + /** `belowMean − otherMean`, in `y`'s stored unit. */ + difference: number; +} + +export interface TopicRelationship { + id: string; + x: TopicSignalKey; + y: TopicSignalKey; + lagDays: 0 | 1; + kind: "correlation" | "split"; + pairedDays: number; + requiredDays: number; + /** + * `insufficient`: below the paired-day floor (or one side of a split is + * too thin). `no_clear_link`: enough days, no difference a test can tell + * from chance. `linked`: p < 0.05, reported as a pattern, never a cause. + */ + status: "insufficient" | "no_clear_link" | "linked"; + r: number | null; + pValue: number | null; + split: SplitComparison | null; + /** Paired values for the scatter, newest kept; empty when insufficient. */ + points: Array<{ x: number; y: number }>; +} + +const round = (value: number, digits: number) => { + const f = 10 ** digits; + return Math.round(value * f) / f; +}; + +/** The days of `series` in `[fromKey, toKey]`, oldest first. */ +export function windowValues( + series: DaySeries | undefined, + fromKey: string, + toKey: string, +): DayValue[] { + if (!series) return []; + const out: DayValue[] = []; + for (const [day, value] of series) { + if (day >= fromKey && day <= toKey && Number.isFinite(value)) { + out.push({ day, value }); + } + } + return out.sort((a, b) => (a.day < b.day ? -1 : a.day > b.day ? 1 : 0)); +} + +/** The newest day with a value on or before `todayKey`. */ +export function latestValue( + series: DaySeries | undefined, + todayKey: string, +): DayValue | null { + if (!series) return null; + let best: DayValue | null = null; + for (const [day, value] of series) { + if (day > todayKey || !Number.isFinite(value)) continue; + if (best === null || day > best.day) best = { day, value }; + } + return best; +} + +/** The first day of the trailing base window that ends on `todayKey`. */ +export function baseWindowStart(todayKey: string): string { + return shiftDateKey(todayKey, -(TOPIC_BASE_WINDOW_DAYS - 1)); +} + +/** + * The personal usual range over the trailing base window, or null below + * {@link USUAL_RANGE_MIN_DAYS} days. The mood score has no measurement type + * and keeps the raw band edges. + */ +export function usualRange( + key: TopicSignalKey, + series: DaySeries | undefined, + todayKey: string, +): UsualRange | null { + const values = windowValues(series, baseWindowStart(todayKey), todayKey).map( + (p) => p.value, + ); + if (values.length < USUAL_RANGE_MIN_DAYS) return null; + const type = key === MOOD_SIGNAL ? null : (key as MeasurementType); + const band = buildBaselineBand(values, type); + if (!band) return null; + return { + low: round(band.low, 2), + high: round(band.high, 2), + center: round(band.center, 2), + days: band.sampleDays, + }; +} + +/** Where a value sits against the usual range. */ +export function stateAgainstRange( + latest: DayValue | null, + range: UsualRange | null, +): SignalState { + if (latest === null) return "none"; + if (range === null) return "learning"; + if (latest.value > range.high) return "above"; + if (latest.value < range.low) return "below"; + return "within"; +} + +/** The Monday-anchored start of the week a day falls in (ISO weeks). */ +function weekStart(day: string): string { + const [y, m, d] = day.split("-").map(Number); + const weekday = new Date(Date.UTC(y, m - 1, d)).getUTCDay(); + const sinceMonday = (weekday + 6) % 7; + return shiftDateKey(day, -sinceMonday); +} + +/** + * The slope of the daily values over the trailing base window, as the change + * it amounts to across the window, beside weekly means. + */ +export function computeTrend( + key: TopicSignalKey, + series: DaySeries | undefined, + todayKey: string, +): TopicTrend { + const fromKey = baseWindowStart(todayKey); + const points = windowValues(series, fromKey, todayKey); + const weeks = new Map(); + for (const p of points) { + const week = weekStart(p.day); + const cell = weeks.get(week) ?? { sum: 0, n: 0 }; + cell.sum += p.value; + cell.n += 1; + weeks.set(week, cell); + } + const weekly = [...weeks.entries()] + .sort(([a], [b]) => (a < b ? -1 : 1)) + .map(([day, cell]) => ({ day, value: round(cell.sum / cell.n, 2) })); + if (points.length < TREND_MIN_DAYS) { + return { + key, + status: "insufficient", + days: points.length, + change: null, + weekly, + }; + } + const fit = linearFit( + points.map((p) => daysBetweenDateKeys(fromKey, p.day)), + points.map((p) => p.value), + ); + if (!fit) { + return { + key, + status: "insufficient", + days: points.length, + change: null, + weekly, + }; + } + return { + key, + status: "ok", + days: points.length, + change: round(fit.slope * (TOPIC_BASE_WINDOW_DAYS - 1), 2), + weekly, + }; +} + +/** `x` on day d beside `y` on day d + lag, both inside the window. */ +export function pairDays( + x: DaySeries | undefined, + y: DaySeries | undefined, + lagDays: number, + fromKey: string, + toKey: string, +): Array<{ day: string; x: number; y: number }> { + if (!x || !y) return []; + const out: Array<{ day: string; x: number; y: number }> = []; + for (const { day, value } of windowValues(x, fromKey, toKey)) { + const target = shiftDateKey(day, lagDays); + if (target > toKey) continue; + const yValue = y.get(target); + if (yValue === undefined || !Number.isFinite(yValue)) continue; + out.push({ day, x: value, y: yValue }); + } + return out; +} + +function mean(values: readonly number[]): number { + return values.reduce((s, v) => s + v, 0) / values.length; +} + +/** Welch's two-sided p for a difference of two means. */ +export function welchPValue( + a: readonly number[], + b: readonly number[], +): number { + const ma = mean(a); + const mb = mean(b); + const va = (variance(a, "sample") ?? 0) / a.length; + const vb = (variance(b, "sample") ?? 0) / b.length; + const se = Math.sqrt(va + vb); + if (se === 0) return ma === mb ? 1 : 0; + const t = Math.abs(ma - mb) / se; + const df = + (va + vb) ** 2 / + ((va * va) / Math.max(1, a.length - 1) + + (vb * vb) / Math.max(1, b.length - 1)); + return twoSidedPFromT(t, df); +} + +function compareSplit( + pairs: ReadonlyArray<{ x: number; y: number }>, + threshold: number, +): { split: SplitComparison; pValue: number } | null { + const below = pairs.filter((p) => p.x < threshold).map((p) => p.y); + const other = pairs.filter((p) => p.x >= threshold).map((p) => p.y); + if (below.length < SPLIT_MIN_DAYS_PER_SIDE) return null; + if (other.length < SPLIT_MIN_DAYS_PER_SIDE) return null; + const belowMean = mean(below); + const otherMean = mean(other); + return { + split: { + threshold, + belowDays: below.length, + otherDays: other.length, + belowMean: round(belowMean, 2), + otherMean: round(otherMean, 2), + difference: round(belowMean - otherMean, 2), + }, + pValue: welchPValue(below, other), + }; +} + +/** + * One relationship over `[fromKey, toKey]`. Below the paired-day floor it + * reports the count and nothing else. + */ +export function computeRelationship( + def: TopicRelationshipDef, + x: DaySeries | undefined, + y: DaySeries | undefined, + fromKey: string, + toKey: string, +): TopicRelationship { + const pairs = pairDays(x, y, def.lagDays, fromKey, toKey); + const base: TopicRelationship = { + id: def.id, + x: def.x, + y: def.y, + lagDays: def.lagDays, + kind: def.kind, + pairedDays: pairs.length, + requiredDays: RELATIONSHIP_MIN_PAIRED_DAYS, + status: "insufficient", + r: null, + pValue: null, + split: null, + points: [], + }; + if (pairs.length < RELATIONSHIP_MIN_PAIRED_DAYS) return base; + const points = pairs + .slice(-RELATIONSHIP_MAX_POINTS) + .map((p) => ({ x: round(p.x, 2), y: round(p.y, 2) })); + + if (def.kind === "split") { + const compared = compareSplit(pairs, def.threshold ?? 0); + if (!compared) return base; + return { + ...base, + status: compared.pValue < MAX_P_VALUE ? "linked" : "no_clear_link", + pValue: round(compared.pValue, 4), + split: compared.split, + points, + }; + } + + const result = pearson({ + xs: pairs.map((p) => p.x), + ys: pairs.map((p) => p.y), + minPairs: RELATIONSHIP_MIN_PAIRED_DAYS, + }); + if (result.status !== "ok") { + // A flat series (no variance) is enough days and no link to report. + return { ...base, status: "no_clear_link", points }; + } + return { + ...base, + status: result.pValue < MAX_P_VALUE ? "linked" : "no_clear_link", + r: round(result.r, 2), + pValue: round(result.pValue, 4), + points, + }; +} diff --git a/src/lib/topics/visibility.ts b/src/lib/topics/visibility.ts new file mode 100644 index 000000000..6a4d864cc --- /dev/null +++ b/src/lib/topics/visibility.ts @@ -0,0 +1,107 @@ +/** + * What a topic may show for the record on screen: the record's module + * switches and the sections the viewer's grant opens. + * + * Both are asked of every signal and every block, not once per topic, so a + * switched-off module disappears from every topic it would otherwise appear + * in (sleep from the heart chart, mood from the sleep pairs) and a section the + * grant does not open is never read. + */ +import type { MeasurementType } from "@/generated/prisma/enums"; +import { moduleForMeasurementType } from "@/lib/modules/measurement-scope"; +import type { ModuleKey } from "@/lib/modules/registry"; +import type { SurfaceModuleMap } from "@/lib/modules/surface"; +import type { ShareScope } from "@/lib/sharing/scope"; + +import { + MOOD_SIGNAL, + TOPIC_EXTRA_MODULE, + TOPICS, + TOPIC_KEYS, + type TopicDefinition, + type TopicExtra, + type TopicKey, + type TopicSignalKey, +} from "./registry"; + +export interface TopicVisibility { + /** The record's resolved module map; only an explicit `false` hides. */ + modules: SurfaceModuleMap; + /** Whether the viewer's grant opens a section (always true on one's own). */ + sees: (scope: ShareScope) => boolean; +} + +const EXTRA_SECTION: Readonly> = { + training: "measurements", + labs: "labs", + screeners: "mind", + cycle: "cycle", +}; + +function moduleOn(key: ModuleKey | null, modules: SurfaceModuleMap): boolean { + return key === null || modules[key] !== false; +} + +export function signalModule(key: TopicSignalKey): ModuleKey | null { + if (key === MOOD_SIGNAL) return "mood"; + return moduleForMeasurementType(key as MeasurementType); +} + +export function isSignalVisible( + key: TopicSignalKey, + ctx: TopicVisibility, +): boolean { + const section: ShareScope = key === MOOD_SIGNAL ? "mind" : "measurements"; + return moduleOn(signalModule(key), ctx.modules) && ctx.sees(section); +} + +export function isExtraVisible( + extra: TopicExtra, + ctx: TopicVisibility, +): boolean { + return ( + moduleOn(TOPIC_EXTRA_MODULE[extra], ctx.modules) && + ctx.sees(EXTRA_SECTION[extra]) + ); +} + +/** Whether the topic is offered at all under the record's module switches. */ +export function isTopicOffered( + def: TopicDefinition, + ctx: TopicVisibility, +): boolean { + if (def.modules.length === 0) return true; + return def.modules.some((key) => ctx.modules[key] !== false); +} + +export function offeredTopics(ctx: TopicVisibility): TopicKey[] { + return TOPIC_KEYS.filter((key) => isTopicOffered(TOPICS[key], ctx)); +} + +/** The signals a topic's own list shows, in display order. */ +export function visibleSignals( + def: TopicDefinition, + ctx: TopicVisibility, +): TopicSignalKey[] { + return def.signals.filter((key) => isSignalVisible(key, ctx)); +} + +/** Every signal a topic page reads: its list, its chart and its pairs. */ +export function signalsToRead( + def: TopicDefinition, + ctx: TopicVisibility, +): Set { + const keys = new Set([...def.signals, ...def.chart]); + for (const rel of def.relationships) { + keys.add(rel.x); + keys.add(rel.y); + } + return new Set([...keys].filter((key) => isSignalVisible(key, ctx))); +} + +export function visibleExtras( + def: TopicDefinition, + ctx: TopicVisibility, +): Set { + return new Set(def.extras.filter((extra) => isExtraVisible(extra, ctx))); +} diff --git a/src/lib/validations/topics.ts b/src/lib/validations/topics.ts new file mode 100644 index 000000000..2f9c07c57 --- /dev/null +++ b/src/lib/validations/topics.ts @@ -0,0 +1,28 @@ +/** + * Request schemas of the topic routes (preview): the topic in the path and + * the window of a topic page. Shared by the routes and the OpenAPI registry. + */ +import { z } from "zod/v4"; + +import { + DEFAULT_TOPIC_RANGE, + TOPIC_KEYS, + TOPIC_RANGES, +} from "@/lib/topics/registry"; + +export const topicKeySchema = z + .enum(TOPIC_KEYS) + .describe("The topic, as its URL slug."); + +export const topicPathSchema = z.object({ topic: topicKeySchema }); + +export const topicDetailQuerySchema = z.object({ + range: z + .enum(TOPIC_RANGES) + .default(DEFAULT_TOPIC_RANGE) + .describe( + "The window the side-by-side series cover. The usual ranges, the trends and the relationships always read at least the trailing 90 days.", + ), +}); + +export type TopicDetailQuery = z.infer; diff --git a/tests/integration/contract-seed.ts b/tests/integration/contract-seed.ts index a08484fd2..ec0b63f4e 100644 --- a/tests/integration/contract-seed.ts +++ b/tests/integration/contract-seed.ts @@ -161,6 +161,12 @@ export async function seedContractAccount( nutrients: true, timeline: true, }); + // The topic pages are a preview the account switches on for itself; the + // topic in their path is a fixed name, not a seeded row. + await write("preview", "PATCH", "api/settings/preview-features", { + topics: true, + }); + ids.topic = "heart"; // ── Measurements: ninety days of the common series ───────────────────── const rows: Array<{ @@ -1061,6 +1067,7 @@ export async function seedContractAccount( /** Which seeded id fills which path parameter. */ const PATH_PARAM_SOURCES: Record> = { "/api/measurements/{id}": { id: "measurement" }, + "/api/topics/{topic}": { topic: "topic" }, "/api/mood-entries/{id}": { id: "mood" }, "/api/medications/{id}": { id: "medication" }, "/api/medications/{id}/glp1": { id: "medication" }, diff --git a/tests/integration/routes/topics.test.ts b/tests/integration/routes/topics.test.ts new file mode 100644 index 000000000..5f7f38f35 --- /dev/null +++ b/tests/integration/routes/topics.test.ts @@ -0,0 +1,384 @@ +/** + * `/api/topics` and `/api/topics/{topic}` — the topic pages (preview), against + * the real database and through the real API kit. + * + * Pinned here: + * - the preview is the VIEWER's opt-in: off answers 404 + * `topics.preview_off` on both routes, for the owner and for a delegate + * whose own switch is off while the owner's is on; + * - the overview gives a topic a tile only when the record holds data for + * it, names the offered empty topics and the module-hidden ones, and + * follows the record's module switches (cycle without the cycle module); + * - a topic page reads the stored values: the latest reading with its usual + * range, a relationship once fourteen paired days exist and its count + * below that, the lab trend from three readings spanning a year and the + * honest "not yet" below it; + * - a switched-off module removes its topic (404 `topics.not_available`) + * and its signal from every other topic; an unknown topic is 404 + * `topics.not_found`, an unknown range 422; + * - a delegate holding the whole record reads the owner's topics, a grant + * scoped to one section is refused, a wildcard Bearer is admitted and a + * narrow one is not; + * - `/api/auth/me` publishes the viewer's own opt-in as `previewFeatures`. + */ +import { beforeEach, describe, expect, it } from "vitest"; + +import { GET as getMe } from "@/app/api/auth/me/route"; +import { PATCH as patchPreview } from "@/app/api/settings/preview-features/route"; +import { GET as getDetail } from "@/app/api/topics/[topic]/route"; +import { GET as getOverview } from "@/app/api/topics/route"; +import type { MeasurementType } from "@/generated/prisma/enums"; +import type { TopicDetail, TopicOverview } from "@/lib/topics/build"; + +import { anonymous, callRoute, type RouteAuth } from "../../kit/call-route"; +import { + makeGrant, + makeMeasurement, + makeSession, + makeToken, + makeUser, +} from "../../kit/factories"; +import { getPrismaClient, truncateAllTables } from "../setup"; + +const prisma = getPrismaClient(); +const DAY = 86_400_000; + +beforeEach(() => truncateAllTables(prisma)); + +async function switchOn(userId: string) { + await prisma.user.update({ + where: { id: userId }, + data: { previewFeaturesJson: { topics: true } }, + }); +} + +/** One reading a day for `days` days, ending yesterday, valued by `f(i)`. */ +async function seedDaily( + userId: string, + type: MeasurementType, + unit: string, + days: number, + f: (i: number) => number, +) { + const now = Date.now(); + await prisma.measurement.createMany({ + data: Array.from({ length: days }, (_, i) => ({ + userId, + type, + unit, + value: f(i), + measuredAt: new Date(now - (i + 1) * DAY - 2 * 3_600_000), + })), + }); +} + +function overview(auth: RouteAuth, headers?: Record) { + return callRoute(getOverview, { + method: "GET", + route: "/api/topics", + auth, + headers, + }); +} + +function detail( + auth: RouteAuth, + topic: string, + range?: string, + headers?: Record, +) { + return callRoute(getDetail, { + method: "GET", + route: "/api/topics/{topic}", + params: { topic }, + query: range ? { range } : undefined, + auth, + headers, + }); +} + +describe("the preview switch", () => { + it("refuses an anonymous caller with 401", async () => { + expect((await overview(anonymous)).status).toBe(401); + }); + + it("answers 404 topics.preview_off on both routes while the switch is off", async () => { + const owner = await makeUser(); + const auth = await makeSession(owner); + const a = await overview(auth); + expect(a.status).toBe(404); + expect(a.meta?.errorCode).toBe("topics.preview_off"); + const b = await detail(auth, "heart"); + expect(b.status).toBe(404); + expect(b.meta?.errorCode).toBe("topics.preview_off"); + }); + + it("is published on /api/auth/me and moves with the Settings switch", async () => { + const owner = await makeUser(); + const auth = await makeSession(owner); + const before = await callRoute<{ previewFeatures: { topics: boolean } }>( + getMe, + { method: "GET", route: "/api/auth/me", auth }, + ); + expect(before.status).toBe(200); + expect(before.data.previewFeatures).toEqual({ topics: false }); + + const saved = await callRoute(patchPreview, { + method: "PATCH", + route: "/api/settings/preview-features", + body: { topics: true }, + auth, + }); + expect(saved.status).toBe(200); + + const after = await callRoute<{ previewFeatures: { topics: boolean } }>( + getMe, + { method: "GET", route: "/api/auth/me", auth }, + ); + expect(after.data.previewFeatures).toEqual({ topics: true }); + expect((await overview(auth)).status).toBe(200); + }); +}); + +describe("GET /api/topics", () => { + it("names every offered topic as empty on a fresh record and hides cycle without its module", async () => { + const owner = await makeUser(); + await switchOn(owner.id); + const res = await overview(await makeSession(owner)); + expect(res.status).toBe(200); + expect(res.data.topics).toEqual([]); + expect(res.data.hiddenByModule).toEqual(["cycle"]); + expect(res.data.empty).toContain("heart"); + expect(res.data.empty).not.toContain("cycle"); + }); + + it("gives a tile to a topic with data, with its headline and sparkline", async () => { + const owner = await makeUser(); + await switchOn(owner.id); + await seedDaily(owner.id, "WEIGHT", "kg", 20, (i) => 80 + (i % 3) * 0.1); + const res = await overview(await makeSession(owner)); + expect(res.status).toBe(200); + const body = res.data.topics.find((t) => t.key === "body"); + expect(body).toBeDefined(); + expect(body!.headline).toMatchObject({ kind: "signal", key: "WEIGHT" }); + expect(body!.sparkline.length).toBeGreaterThanOrEqual(19); + expect(body!.recorded).toBe(1); + expect(res.data.empty).toContain("heart"); + }); + + it("follows the record's module switches", async () => { + const owner = await makeUser({ + modulePreferencesJson: { sleep: false, mood: false, mentalHealth: false }, + }); + await switchOn(owner.id); + const res = await overview(await makeSession(owner)); + expect(res.data.hiddenByModule).toEqual( + expect.arrayContaining(["sleep", "mind", "cycle"]), + ); + }); +}); + +describe("GET /api/topics/{topic}", () => { + it("reads the latest value, its usual range and a relationship once 14 days pair", async () => { + const owner = await makeUser(); + await switchOn(owner.id); + await seedDaily( + owner.id, + "RESTING_HEART_RATE", + "bpm", + 30, + (i) => 55 + (i % 10), + ); + await seedDaily( + owner.id, + "HEART_RATE_VARIABILITY", + "ms", + 30, + (i) => 80 - (i % 10) * 3, + ); + const res = await detail(await makeSession(owner), "heart", "30d"); + expect(res.status).toBe(200); + expect(res.data.key).toBe("heart"); + expect(res.data.range).toBe("30d"); + const rhr = res.data.signals.find((s) => s.key === "RESTING_HEART_RATE"); + expect(rhr?.latest).not.toBeNull(); + expect(rhr?.usual).not.toBeNull(); + expect(rhr?.href).toBe("/insights/resting-pulse"); + const pair = res.data.relationships.find((r) => r.id === "hrv-resting-hr"); + expect(pair).toMatchObject({ status: "linked", pairedDays: 30 }); + expect(pair!.r).toBeLessThan(-0.9); + expect(res.data.missing).toContain("VO2_MAX"); + expect(res.data.status).toBe("partial"); + expect(res.data.series.map((s) => s.key)).toContain("RESTING_HEART_RATE"); + }); + + it("states a pair below 14 paired days with its count and no figure", async () => { + const owner = await makeUser(); + await switchOn(owner.id); + await seedDaily(owner.id, "RESTING_HEART_RATE", "bpm", 10, (i) => 58 + i); + await seedDaily( + owner.id, + "HEART_RATE_VARIABILITY", + "ms", + 10, + (i) => 70 - i, + ); + const res = await detail(await makeSession(owner), "heart"); + const pair = res.data.relationships.find((r) => r.id === "hrv-resting-hr"); + expect(pair).toMatchObject({ + status: "insufficient", + pairedDays: 10, + requiredDays: 14, + r: null, + points: [], + }); + }); + + it("carries the lab trend from three readings over a year, and says when there are fewer", async () => { + const owner = await makeUser(); + await switchOn(owner.id); + const ldl = await prisma.biomarker.create({ + data: { + userId: owner.id, + name: "LDL", + unit: "mg/dL", + lowerBound: 0, + upperBound: 130, + }, + }); + const vitd = await prisma.biomarker.create({ + data: { + userId: owner.id, + name: "Vitamin D", + unit: "ng/mL", + lowerBound: 30, + upperBound: 100, + }, + }); + const now = Date.now(); + for (const [biomarker, value, daysAgo] of [ + [ldl, 140, 800], + [ldl, 120, 400], + [ldl, 98, 20], + [vitd, 24, 30], + ] as const) { + await prisma.labResult.create({ + data: { + userId: owner.id, + biomarkerId: biomarker.id, + analyte: biomarker.name, + unit: biomarker.unit, + value, + takenAt: new Date(now - daysAgo * DAY), + }, + }); + } + const res = await detail(await makeSession(owner), "metabolism"); + expect(res.status).toBe(200); + expect(res.data.labs).not.toBeNull(); + const byName = new Map(res.data.labs!.markers.map((m) => [m.name, m])); + expect(byName.get("LDL")?.trend.status).toBe("ok"); + expect(byName.get("Vitamin D")).toMatchObject({ + status: "below", + trend: { status: "insufficient", n: 1 }, + }); + expect(res.data.summary.code).toBe("labs"); + }); + + it("takes a switched-off module's topic away and its signal out of the others", async () => { + const owner = await makeUser({ modulePreferencesJson: { sleep: false } }); + await switchOn(owner.id); + await seedDaily(owner.id, "RESTING_HEART_RATE", "bpm", 20, () => 58); + await makeMeasurement(owner, { + type: "SLEEP_DURATION", + unit: "min", + value: 420, + measuredAt: new Date(Date.now() - DAY), + }); + const auth = await makeSession(owner); + const sleep = await detail(auth, "sleep"); + expect(sleep.status).toBe(404); + expect(sleep.meta?.errorCode).toBe("topics.not_available"); + const heart = await detail(auth, "heart"); + expect(heart.status).toBe(200); + expect(heart.data.series.map((s) => s.key)).not.toContain("SLEEP_DURATION"); + expect( + heart.data.relationships.some( + (r) => r.x === "SLEEP_DURATION" || r.y === "SLEEP_DURATION", + ), + ).toBe(false); + }); + + it("answers 404 for a name that is not a topic and 422 for an unknown range", async () => { + const owner = await makeUser(); + await switchOn(owner.id); + const auth = await makeSession(owner); + const unknown = await callRoute(getDetail, { + method: "GET", + route: "/api/topics/{topic}", + params: { topic: "kidneys" }, + auth, + contract: { skip: "a path value outside the published enum, on purpose" }, + }); + expect(unknown.status).toBe(404); + expect(unknown.meta?.errorCode).toBe("topics.not_found"); + const range = await detail(auth, "heart", "2y"); + expect(range.status).toBe(422); + }); +}); + +describe("who may read the topics", () => { + async function switchedDelegate(scopeJson: string[] | null) { + const owner = await makeUser(); + const delegate = await makeUser(); + await seedDaily(owner.id, "WEIGHT", "kg", 15, () => 80); + await makeGrant(owner, delegate, scopeJson ? { scopeJson } : {}); + const auth = await makeSession(delegate); + const switched = await prisma.session.update({ + where: { id: auth.sessionId }, + data: { actingAsUserId: owner.id }, + }); + return { + owner, + delegate, + auth, + headers: { + "x-healthlog-record-epoch": String(switched.recordEpoch), + "x-healthlog-record-scope": owner.id, + }, + }; + } + + it("follows the delegate's own switch, not the owner's", async () => { + const { owner, auth, headers } = await switchedDelegate(null); + await switchOn(owner.id); + const res = await overview(auth, headers); + expect(res.status).toBe(404); + expect(res.meta?.errorCode).toBe("topics.preview_off"); + }); + + it("shows a whole-record delegate the owner's topics", async () => { + const { delegate, auth, headers } = await switchedDelegate(null); + await switchOn(delegate.id); + const res = await overview(auth, headers); + expect(res.status).toBe(200); + expect(res.data.topics.map((t) => t.key)).toContain("body"); + }); + + it("refuses a grant scoped to one section", async () => { + const { delegate, auth, headers } = await switchedDelegate([ + "measurements", + ]); + await switchOn(delegate.id); + expect((await overview(auth, headers)).status).toBe(403); + expect((await detail(auth, "body", undefined, headers)).status).toBe(403); + }); + + it("admits a wildcard Bearer and refuses a narrow one", async () => { + const owner = await makeUser(); + await switchOn(owner.id); + expect((await overview(await makeToken(owner))).status).toBe(200); + const narrow = await makeToken(owner, { permissions: ["health:read"] }); + expect((await overview(narrow)).status).toBe(403); + }); +});