diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 574eeb12f..96cbdf70c 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -26754,95 +26754,6 @@ 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: [] @@ -38095,11 +38006,10 @@ 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; - 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 — 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, @@ -72772,8 +72682,7 @@ components: properties: topics: type: boolean - 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." + description: Topic overview pages above the single-metric Insights pages. Off by default. required: - topics additionalProperties: false @@ -85298,8 +85207,6 @@ components: $ref: "#/components/schemas/AiAccountBlock" coachReasoning: $ref: "#/components/schemas/CoachReasoningState" - previewFeatures: - $ref: "#/components/schemas/PreviewFeatures" recordSession: anyOf: - $ref: "#/components/schemas/RecordSessionState" @@ -85330,7 +85237,6 @@ components: - moduleAccess - ai - coachReasoning - - previewFeatures - recordSession - notificationPrefs - onboarding @@ -92363,665 +92269,6 @@ 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 418fe22ac..e78805032 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -388,10 +388,9 @@ 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`). 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. + /// 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. 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 caf7b3a4e..b818a899f 100644 --- a/src/__tests__/sharing-surface-guard.test.ts +++ b/src/__tests__/sharing-surface-guard.test.ts @@ -1074,17 +1074,6 @@ 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.", @@ -2208,11 +2197,8 @@ 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 = 275; +const FROZEN_ENTRY_COUNT = 273; /** * 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 24d43f989..ef1167630 100644 --- a/src/app/api/auth/me/route.ts +++ b/src/app/api/auth/me/route.ts @@ -44,7 +44,6 @@ 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"; @@ -94,21 +93,6 @@ 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; @@ -254,11 +238,10 @@ 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. The preview opt-ins ride the same point read of the caller's - // own row. + // shows it. prisma.user.findUnique({ where: { id: user.id }, - select: { insuranceNumberEncrypted: true, previewFeaturesJson: true }, + select: { insuranceNumberEncrypted: true }, }), ]); @@ -306,9 +289,18 @@ export const GET = apiHandler(async () => { sectionsOpen(sections, "cycle") && isCycleEnabled(record?.gender ?? null, cycleProfile); - const insuranceNumber = decryptInsuranceNumber( - identity?.insuranceNumberEncrypted ?? null, - ); + // 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; + } + } return apiSuccess({ id: user.id, @@ -455,12 +447,6 @@ 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 deleted file mode 100644 index f539f55ba..000000000 --- a/src/app/api/topics/[topic]/route.ts +++ /dev/null @@ -1,80 +0,0 @@ -/** - * `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 deleted file mode 100644 index f50eb3839..000000000 --- a/src/app/api/topics/route.ts +++ /dev/null @@ -1,54 +0,0 @@ -/** - * `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 5949f1e5c..d80fdcbfd 100644 --- a/src/hooks/use-auth.ts +++ b/src/hooks/use-auth.ts @@ -24,10 +24,6 @@ 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"; @@ -258,14 +254,6 @@ 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 @@ -489,8 +477,6 @@ 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 d4f950a74..b91330fb2 100644 --- a/src/lib/openapi/error-codes.ts +++ b/src/lib/openapi/error-codes.ts @@ -452,7 +452,6 @@ 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 fa82f8963..a42bc4a88 100644 --- a/src/lib/openapi/routes/account-sharing.ts +++ b/src/lib/openapi/routes/account-sharing.ts @@ -44,7 +44,6 @@ import { } from "@/lib/validations/managed-profile-handover"; import { onboardingStateResource } from "./onboarding"; import { aiAccountBlock, moduleAccessMap } from "./profile"; -import { previewFeaturesSchema } from "./settings"; import { dataEnvelope, errorEnvelope, @@ -563,11 +562,6 @@ 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 1e3e75e0b..fc850c598 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.", }); -export const biomarkerTrendSchema = z +const labTrend = z .union([ z.object({ status: z.literal("ok"), @@ -140,9 +140,7 @@ export const biomarkerTrendSchema = z const biomarkerDetail = biomarkerRow .extend({ - trend: biomarkerTrendSchema - .optional() - .describe("v1.44 — absent on older servers."), + trend: labTrend.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 9c77328ca..31da5dec6 100644 --- a/src/lib/openapi/routes/index.ts +++ b/src/lib/openapi/routes/index.ts @@ -56,7 +56,6 @@ 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"; @@ -205,9 +204,6 @@ 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 ab18f30fb..ffae0a242 100644 --- a/src/lib/openapi/routes/mental-health.ts +++ b/src/lib/openapi/routes/mental-health.ts @@ -115,7 +115,7 @@ const changeVerdict = z .enum(["improved", "worsened", "within_threshold"]) .nullable(); -export const assessmentTrendSchema = z +const assessmentTrend = z .object({ instrument: z.enum(["PHQ9", "GAD7", "WHO5"]), n: z.number().int(), @@ -143,7 +143,7 @@ const listResponse = z .object({ assessments: z.array(assessmentRow), trends: z - .array(assessmentTrendSchema) + .array(assessmentTrend) .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 c27ea3cd9..5b1ac7c2b 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.", }); -export const previewFeaturesSchema = z +const previewFeaturesSchema = z .object({ topics: z .boolean() .describe( - "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.", + "Topic overview pages above the single-metric Insights pages. Off by default.", ), }) .meta({ diff --git a/src/lib/openapi/routes/topics.ts b/src/lib/openapi/routes/topics.ts deleted file mode 100644 index 1b0e87e65..000000000 --- a/src/lib/openapi/routes/topics.ts +++ /dev/null @@ -1,324 +0,0 @@ -/** - * 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 aa5d5a3dd..1fec9ab70 100644 --- a/src/lib/openapi/routes/workouts.ts +++ b/src/lib/openapi/routes/workouts.ts @@ -122,7 +122,7 @@ const trainingLoadPoint = z.object({ ), }); -export const trainingLoadSchema = z +const trainingLoad = 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: trainingLoadSchema + trainingLoad: trainingLoad .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 2d47e0944..514b55fd1 100644 --- a/src/lib/settings/preview-features.ts +++ b/src/lib/settings/preview-features.ts @@ -6,11 +6,10 @@ * adds it beside what is there and never replaces an existing page. * * Flags: - * - `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. + * - `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. * * 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 deleted file mode 100644 index 125529ff2..000000000 --- a/src/lib/topics/__tests__/build.test.ts +++ /dev/null @@ -1,161 +0,0 @@ -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 deleted file mode 100644 index 89adb8c99..000000000 --- a/src/lib/topics/__tests__/labs.test.ts +++ /dev/null @@ -1,83 +0,0 @@ -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 deleted file mode 100644 index b62c678a8..000000000 --- a/src/lib/topics/__tests__/stats.test.ts +++ /dev/null @@ -1,222 +0,0 @@ -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 deleted file mode 100644 index f406b4368..000000000 --- a/src/lib/topics/__tests__/visibility.test.ts +++ /dev/null @@ -1,52 +0,0 @@ -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 deleted file mode 100644 index 930231595..000000000 --- a/src/lib/topics/build.ts +++ /dev/null @@ -1,386 +0,0 @@ -/** - * 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 deleted file mode 100644 index dec8ca028..000000000 --- a/src/lib/topics/labs.ts +++ /dev/null @@ -1,164 +0,0 @@ -/** - * 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 deleted file mode 100644 index fe63c51bd..000000000 --- a/src/lib/topics/read.ts +++ /dev/null @@ -1,225 +0,0 @@ -/** - * 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 deleted file mode 100644 index fca3dbdf8..000000000 --- a/src/lib/topics/registry.ts +++ /dev/null @@ -1,321 +0,0 @@ -/** - * 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 deleted file mode 100644 index d57cec13c..000000000 --- a/src/lib/topics/service.ts +++ /dev/null @@ -1,127 +0,0 @@ -/** - * 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 deleted file mode 100644 index 72861b572..000000000 --- a/src/lib/topics/stats.ts +++ /dev/null @@ -1,367 +0,0 @@ -/** - * 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 deleted file mode 100644 index 6a4d864cc..000000000 --- a/src/lib/topics/visibility.ts +++ /dev/null @@ -1,107 +0,0 @@ -/** - * 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 deleted file mode 100644 index 2f9c07c57..000000000 --- a/src/lib/validations/topics.ts +++ /dev/null @@ -1,28 +0,0 @@ -/** - * 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 ec0b63f4e..a08484fd2 100644 --- a/tests/integration/contract-seed.ts +++ b/tests/integration/contract-seed.ts @@ -161,12 +161,6 @@ 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<{ @@ -1067,7 +1061,6 @@ 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 deleted file mode 100644 index 5f7f38f35..000000000 --- a/tests/integration/routes/topics.test.ts +++ /dev/null @@ -1,384 +0,0 @@ -/** - * `/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); - }); -});