Revert "feat(topics): compute topic overviews and topic pages on the server"

This reverts commit cc04e0944d.
This commit is contained in:
Marc-André Bombeck committed 2026-10-23 10:46:55 +02:00
1 parent c1911e71e3
commit 0182f91092
30 files changed
+35 -3937

No files matched your search

+5 -758
View File
@@ -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
+3 -4
View File
@@ -388,10 +388,9 @@ model User {
// Shape: { "version": 1, "labs": { "view": "list", "order": ["<id>"], "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
+1 -15
View File
@@ -1074,17 +1074,6 @@ const DELEGABLE_ROUTES: Record<string, DelegableEntry> = {
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<string, string> = {
* 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` —
+14 -28
View File
@@ -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
-80
View File
@@ -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);
},
);
-54
View File
@@ -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);
});
-14
View File
@@ -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<AuthUser> {
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,
};
-1
View File
@@ -452,7 +452,6 @@ export const ERROR_CODE_CATALOGUE: Readonly<Record<string, readonly string[]>> =
"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",
@@ -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
+2 -4
View File
@@ -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" });
-4
View File
@@ -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<ZodOpenApiObject["paths"]> = {
...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).
//
+2 -2
View File
@@ -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.",
+2 -2
View File
@@ -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({
-324
View File
@@ -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<ZodOpenApiObject["paths"]> = {
"/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(),
},
},
},
};
+2 -2
View File
@@ -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.",
+4 -5
View File
@@ -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.
-161
View File
@@ -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<string, number>();
for (let i = 0; i < n; i++) out.set(shiftDateKey(TODAY, -(n - 1 - i)), f(i));
return out;
}
function inputs(
entries: Array<[TopicSignalKey, Map<string, number>]>,
extra: Partial<TopicInputs> = {},
): 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();
});
});
-83
View File
@@ -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([]);
});
});
-222
View File
@@ -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<string, number>();
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);
});
});
@@ -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([]);
});
});
-386
View File
@@ -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,
};
}
-164
View File
@@ -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<string, ReadingRow[]>();
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<TopicLabPanel | null> {
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);
}
-225
View File
@@ -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<TopicSignalKey>;
extras: ReadonlySet<TopicExtra>;
now: Date;
}
export interface TopicInputs {
series: Map<TopicSignalKey, DaySeries>;
labs: TopicLabPanel | null;
training: TrainingLoad | null;
screeners: AssessmentTrend[] | null;
cycle: TopicCycleSummary | null;
}
async function readMeasurementDays(
req: TopicReadRequest,
types: MeasurementType[],
priorityJson: unknown,
): Promise<Map<TopicSignalKey, DaySeries>> {
const out = new Map<TopicSignalKey, DaySeries>();
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<DaySeries | null> {
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<string, number>();
for (const [day, cell] of cells) out.set(day, cell.value);
return out;
}
async function readMoodDays(req: TopicReadRequest): Promise<DaySeries | null> {
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<string, number>();
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<AssessmentTrend[]> {
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<TopicCycleSummary | null> {
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<TopicInputs> {
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 };
}
-321
View File
@@ -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<Record<TopicExtra, ModuleKey>> = {
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<Record<TopicKey, TopicDefinition>> = {
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<Record<TopicRange, number>> = {
"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;
-127
View File
@@ -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<boolean> {
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<TopicVisibility> {
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<TopicOverview> {
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<TopicSignalKey>();
const extras = new Set<TopicExtra>();
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<TopicDetail | null> {
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);
}
-367
View File
@@ -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<string, number>;
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<string, { sum: number; n: number }>();
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,
};
}
-107
View File
@@ -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<Record<TopicExtra, ShareScope>> = {
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<TopicSignalKey> {
const keys = new Set<TopicSignalKey>([...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<TopicExtra> {
return new Set(def.extras.filter((extra) => isExtraVisible(extra, ctx)));
}
-28
View File
@@ -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<typeof topicDetailQuerySchema>;
-7
View File
@@ -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<string, Record<string, string>> = {
"/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" },
-384
View File
@@ -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<string, string>) {
return callRoute<TopicOverview>(getOverview, {
method: "GET",
route: "/api/topics",
auth,
headers,
});
}
function detail(
auth: RouteAuth,
topic: string,
range?: string,
headers?: Record<string, string>,
) {
return callRoute<TopicDetail>(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);
});
});