mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-07 04:12:04 +00:00
feat(topics): compute topic overviews and topic pages on the server
GET /api/topics answers one entry per topic the record holds data for:
a headline value, a rule-based sentence and a 30-day sparkline.
GET /api/topics/{topic} answers a topic's signals with their usual
ranges, side-by-side series for a chosen window, the relationships
between its signals, 90-day trends, what it holds nothing for yet, and
its blocks (training load, lab markers with their trend, screener
trends, the cycle day).
Every figure comes from reads the metric pages already make: the local
daily values, the reconstructed nights, the mood score, computeLabTrend,
the training load and the screener trends. No model is involved. A
relationship says nothing below 14 paired days and is only called a
pattern at p < 0.05; a lab trend needs three readings spanning a year.
The preview is the viewer's own opt-in: with it off both routes answer
404 topics.preview_off. Topics, signals and blocks follow the record's
module switches and the sections the viewer's grant opens; the routes
read across sections, so a scoped grant is refused like the timeline.
/api/auth/me publishes the caller's opt-ins as previewFeatures.
This commit is contained in:
1 parent
14d0184b63
commit
cc04e0944d
30 files changed
+3937
-35
No files matched your search
+758
-5
@@ -26754,6 +26754,95 @@ paths:
|
||||
$ref: "#/components/schemas/ErrorEnvelope"
|
||||
"422": *a4
|
||||
"429": *a5
|
||||
/api/topics:
|
||||
get:
|
||||
tags:
|
||||
- Insights
|
||||
summary: Topic overview (preview)
|
||||
description: One entry per topic the record holds data for, with a headline value, a rule-based sentence and a 30-day
|
||||
sparkline, computed from the same daily values the metric pages draw. No model, no AI gate. Topics follow the
|
||||
record's module switches. Reads across sections, so a grant scoped to some of them is refused.
|
||||
responses:
|
||||
"200":
|
||||
description: The overview.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/TopicOverviewEnvelope"
|
||||
"401": *a1
|
||||
"403": *a15
|
||||
"404":
|
||||
description: "`topics.preview_off`: the person reading has not switched the topics preview on (Settings > System >
|
||||
Preview features). The preview is per account and off by default."
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ErrorEnvelope"
|
||||
"422": *a4
|
||||
"429": *a5
|
||||
/api/topics/{topic}:
|
||||
get:
|
||||
tags:
|
||||
- Insights
|
||||
summary: One topic (preview)
|
||||
description: "A topic's signals with their usual ranges, the side-by-side series for `range`, relationships between its
|
||||
signals (nothing is said below 14 paired days), 90-day trends, the signals it holds nothing for yet, and its
|
||||
blocks: training load, lab markers with their trend (three readings spanning a year), screener trends, today's
|
||||
cycle day. No model, no AI gate."
|
||||
parameters:
|
||||
- in: path
|
||||
name: topic
|
||||
schema:
|
||||
type: string
|
||||
enum:
|
||||
- heart
|
||||
- body
|
||||
- blood-pressure
|
||||
- sleep
|
||||
- activity
|
||||
- metabolism
|
||||
- mind
|
||||
- cycle
|
||||
description: The topic, as its URL slug.
|
||||
required: true
|
||||
description: The topic, as its URL slug.
|
||||
- in: query
|
||||
name: range
|
||||
schema:
|
||||
default: 30d
|
||||
description: The window the side-by-side series cover. The usual ranges, the trends and the relationships always read at
|
||||
least the trailing 90 days.
|
||||
type: string
|
||||
enum:
|
||||
- 7d
|
||||
- 30d
|
||||
- 90d
|
||||
- 1y
|
||||
description: The window the side-by-side series cover. The usual ranges, the trends and the relationships always read at
|
||||
least the trailing 90 days.
|
||||
responses:
|
||||
"200":
|
||||
description: The topic.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/TopicDetailEnvelope"
|
||||
"401": *a1
|
||||
"403": *a15
|
||||
"404":
|
||||
description: >-
|
||||
`topics.preview_off`: the person reading has not switched the topics preview on (Settings > System > Preview
|
||||
features). The preview is per account and off by default.
|
||||
|
||||
|
||||
`topics.not_found`: no topic by that name. `topics.not_available`: the record's module switches do not offer
|
||||
this topic.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ErrorEnvelope"
|
||||
"422": *a4
|
||||
"429": *a5
|
||||
/api/bugreport:
|
||||
post:
|
||||
security: []
|
||||
@@ -38003,10 +38092,11 @@ components:
|
||||
symptoms.definition.limitReached, symptoms.definition.notFound, symptoms.episode.notOpen,
|
||||
symptoms.event.notFound, symptoms.invalid; timeline — timeline.invalid; tokens —
|
||||
tokens.documents.ceiling_reached, tokens.measurements.ceiling_reached, tokens.workouts.ceiling_reached;
|
||||
vaccination — vaccination.booster-invalid, vaccination.booster-no-antigen,
|
||||
vaccination.booster-out-of-scope, vaccination.custom-vaccine-not-found, vaccination.custom.invalid,
|
||||
vaccination.custom.name-taken, vaccination.custom.not-found, vaccination.encounter-not-found,
|
||||
vaccination.identity-required, vaccination.invalid, vaccination.link.invalid, vaccination.not-found,
|
||||
topics — topics.not_available, topics.not_found, topics.preview_off; vaccination —
|
||||
vaccination.booster-invalid, vaccination.booster-no-antigen, vaccination.booster-out-of-scope,
|
||||
vaccination.custom-vaccine-not-found, vaccination.custom.invalid, vaccination.custom.name-taken,
|
||||
vaccination.custom.not-found, vaccination.encounter-not-found, vaccination.identity-required,
|
||||
vaccination.invalid, vaccination.link.invalid, vaccination.not-found,
|
||||
vaccination.practitioner-not-found; workout — workout.batch.invalid, workout.batch.payload_too_large,
|
||||
workout.batch.source_not_permitted, workout.batch.too_large, workout.delete.synced_source; (unprefixed)
|
||||
— about_me_conflict, ai_response_truncated, coach_prefs_conflict, dashboard_layout_conflict, empty_file,
|
||||
@@ -72671,7 +72761,8 @@ components:
|
||||
properties:
|
||||
topics:
|
||||
type: boolean
|
||||
description: Topic overview pages above the single-metric Insights pages. Off by default.
|
||||
description: "The Topics area (`/api/topics`): overview pages that bring related signals together above the
|
||||
single-metric Insights pages, which stay as they are. Off by default."
|
||||
required:
|
||||
- topics
|
||||
additionalProperties: false
|
||||
@@ -85168,6 +85259,8 @@ components:
|
||||
$ref: "#/components/schemas/AiAccountBlock"
|
||||
coachReasoning:
|
||||
$ref: "#/components/schemas/CoachReasoningState"
|
||||
previewFeatures:
|
||||
$ref: "#/components/schemas/PreviewFeatures"
|
||||
recordSession:
|
||||
anyOf:
|
||||
- $ref: "#/components/schemas/RecordSessionState"
|
||||
@@ -85198,6 +85291,7 @@ components:
|
||||
- moduleAccess
|
||||
- ai
|
||||
- coachReasoning
|
||||
- previewFeatures
|
||||
- recordSession
|
||||
- notificationPrefs
|
||||
- onboarding
|
||||
@@ -92230,6 +92324,665 @@ components:
|
||||
- data
|
||||
- error
|
||||
additionalProperties: false
|
||||
TopicOverviewEnvelope:
|
||||
type: object
|
||||
properties:
|
||||
data:
|
||||
$ref: "#/components/schemas/TopicOverview"
|
||||
error:
|
||||
type: "null"
|
||||
meta:
|
||||
type: object
|
||||
properties:
|
||||
requestId:
|
||||
type: string
|
||||
additionalProperties: false
|
||||
required:
|
||||
- data
|
||||
- error
|
||||
additionalProperties: false
|
||||
TopicOverview:
|
||||
type: object
|
||||
properties:
|
||||
topics:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/TopicOverviewEntry"
|
||||
empty:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/TopicKey"
|
||||
description: Offered topics with nothing recorded yet.
|
||||
hiddenByModule:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/TopicKey"
|
||||
description: Topics whose every module is switched off for the record.
|
||||
required:
|
||||
- topics
|
||||
- empty
|
||||
- hiddenByModule
|
||||
additionalProperties: false
|
||||
TopicOverviewEntry:
|
||||
type: object
|
||||
properties:
|
||||
key:
|
||||
$ref: "#/components/schemas/TopicKey"
|
||||
headline:
|
||||
anyOf:
|
||||
- $ref: "#/components/schemas/TopicHeadline"
|
||||
- type: "null"
|
||||
summary:
|
||||
$ref: "#/components/schemas/TopicSummary"
|
||||
tone:
|
||||
type: string
|
||||
enum:
|
||||
- steady
|
||||
- watch
|
||||
- learning
|
||||
sparkline:
|
||||
type: array
|
||||
items:
|
||||
type: number
|
||||
description: The headline signal's daily values over the last 30 days, oldest first; empty when the headline is a block.
|
||||
recorded:
|
||||
type: integer
|
||||
minimum: -9007199254740991
|
||||
maximum: 9007199254740991
|
||||
total:
|
||||
type: integer
|
||||
minimum: -9007199254740991
|
||||
maximum: 9007199254740991
|
||||
required:
|
||||
- key
|
||||
- headline
|
||||
- summary
|
||||
- tone
|
||||
- sparkline
|
||||
- recorded
|
||||
- total
|
||||
additionalProperties: false
|
||||
TopicKey:
|
||||
type: string
|
||||
enum:
|
||||
- heart
|
||||
- body
|
||||
- blood-pressure
|
||||
- sleep
|
||||
- activity
|
||||
- metabolism
|
||||
- mind
|
||||
- cycle
|
||||
description: A topic, as its URL slug.
|
||||
TopicHeadline:
|
||||
oneOf:
|
||||
- type: object
|
||||
properties:
|
||||
kind:
|
||||
type: string
|
||||
const: signal
|
||||
key:
|
||||
$ref: "#/components/schemas/TopicSignalKey"
|
||||
value:
|
||||
type: number
|
||||
day:
|
||||
type: string
|
||||
description: A local calendar day, `YYYY-MM-DD`.
|
||||
required:
|
||||
- kind
|
||||
- key
|
||||
- value
|
||||
- day
|
||||
additionalProperties: false
|
||||
- type: object
|
||||
properties:
|
||||
kind:
|
||||
type: string
|
||||
const: labs
|
||||
inRange:
|
||||
type: integer
|
||||
minimum: -9007199254740991
|
||||
maximum: 9007199254740991
|
||||
total:
|
||||
type: integer
|
||||
minimum: -9007199254740991
|
||||
maximum: 9007199254740991
|
||||
required:
|
||||
- kind
|
||||
- inRange
|
||||
- total
|
||||
additionalProperties: false
|
||||
- type: object
|
||||
properties:
|
||||
kind:
|
||||
type: string
|
||||
const: cycleDay
|
||||
dayOfCycle:
|
||||
type: integer
|
||||
minimum: -9007199254740991
|
||||
maximum: 9007199254740991
|
||||
required:
|
||||
- kind
|
||||
- dayOfCycle
|
||||
additionalProperties: false
|
||||
- type: object
|
||||
properties:
|
||||
kind:
|
||||
type: string
|
||||
const: screener
|
||||
instrument:
|
||||
type: string
|
||||
score:
|
||||
type: number
|
||||
required:
|
||||
- kind
|
||||
- instrument
|
||||
- score
|
||||
additionalProperties: false
|
||||
type: object
|
||||
TopicSignalKey:
|
||||
anyOf:
|
||||
- $ref: "#/components/schemas/MeasurementType"
|
||||
- type: string
|
||||
const: MOOD
|
||||
description: "A daily series a topic reads: a `MeasurementType`, or `MOOD` for the day's mean mood score (1 to 5).
|
||||
Values are in the type's stored unit (sleep in minutes asleep per night, keyed by the day the night ended);
|
||||
clients convert for display as they do for the metric pages."
|
||||
TopicSummary:
|
||||
type: object
|
||||
properties:
|
||||
code:
|
||||
type: string
|
||||
enum:
|
||||
- outside
|
||||
- labs
|
||||
- cycleDay
|
||||
- steady
|
||||
- learning
|
||||
- empty
|
||||
signal:
|
||||
anyOf:
|
||||
- $ref: "#/components/schemas/TopicSignalKey"
|
||||
- type: "null"
|
||||
direction:
|
||||
anyOf:
|
||||
- type: string
|
||||
enum:
|
||||
- above
|
||||
- below
|
||||
- type: "null"
|
||||
count:
|
||||
anyOf:
|
||||
- type: integer
|
||||
minimum: -9007199254740991
|
||||
maximum: 9007199254740991
|
||||
- type: "null"
|
||||
total:
|
||||
anyOf:
|
||||
- type: integer
|
||||
minimum: -9007199254740991
|
||||
maximum: 9007199254740991
|
||||
- type: "null"
|
||||
required:
|
||||
- code
|
||||
- signal
|
||||
- direction
|
||||
- count
|
||||
- total
|
||||
additionalProperties: false
|
||||
description: "The topic's one sentence as a code with parameters, picked by a fixed rule (never model text): `outside`
|
||||
names the first signal outside its usual range and the direction; `labs` counts lab markers inside their window
|
||||
(`count` of `total`); `cycleDay` gives today's cycle day in `count`; `steady` counts signals inside their range
|
||||
(`count` of `total` recorded); `learning` while no range is established; `empty` without any data."
|
||||
TopicDetailEnvelope:
|
||||
type: object
|
||||
properties:
|
||||
data:
|
||||
$ref: "#/components/schemas/TopicDetail"
|
||||
error:
|
||||
type: "null"
|
||||
meta:
|
||||
type: object
|
||||
properties:
|
||||
requestId:
|
||||
type: string
|
||||
additionalProperties: false
|
||||
required:
|
||||
- data
|
||||
- error
|
||||
additionalProperties: false
|
||||
TopicDetail:
|
||||
type: object
|
||||
properties:
|
||||
key:
|
||||
$ref: "#/components/schemas/TopicKey"
|
||||
range:
|
||||
type: string
|
||||
enum:
|
||||
- 7d
|
||||
- 30d
|
||||
- 90d
|
||||
- 1y
|
||||
from:
|
||||
type: string
|
||||
description: A local calendar day, `YYYY-MM-DD`.
|
||||
to:
|
||||
type: string
|
||||
description: A local calendar day, `YYYY-MM-DD`.
|
||||
status:
|
||||
type: string
|
||||
enum:
|
||||
- ready
|
||||
- partial
|
||||
- empty
|
||||
summary:
|
||||
$ref: "#/components/schemas/TopicSummary"
|
||||
signals:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/TopicSignal"
|
||||
series:
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
properties:
|
||||
key:
|
||||
$ref: "#/components/schemas/TopicSignalKey"
|
||||
usual:
|
||||
anyOf:
|
||||
- $ref: "#/components/schemas/TopicUsualRange"
|
||||
- type: "null"
|
||||
points:
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
properties:
|
||||
day:
|
||||
type: string
|
||||
description: A local calendar day, `YYYY-MM-DD`.
|
||||
value:
|
||||
type: number
|
||||
required:
|
||||
- day
|
||||
- value
|
||||
additionalProperties: false
|
||||
required:
|
||||
- key
|
||||
- usual
|
||||
- points
|
||||
additionalProperties: false
|
||||
relationships:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/TopicRelationship"
|
||||
trends:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/TopicTrend"
|
||||
missing:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/TopicSignalKey"
|
||||
description: Signals the topic could show that hold no value yet.
|
||||
labs:
|
||||
anyOf:
|
||||
- $ref: "#/components/schemas/TopicLabPanel"
|
||||
- type: "null"
|
||||
training:
|
||||
anyOf:
|
||||
- $ref: "#/components/schemas/WorkoutTrainingLoad"
|
||||
- type: "null"
|
||||
screeners:
|
||||
anyOf:
|
||||
- type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/MentalHealthAssessmentTrend"
|
||||
- type: "null"
|
||||
cycle:
|
||||
anyOf:
|
||||
- $ref: "#/components/schemas/TopicCycleSummary"
|
||||
- type: "null"
|
||||
required:
|
||||
- key
|
||||
- range
|
||||
- from
|
||||
- to
|
||||
- status
|
||||
- summary
|
||||
- signals
|
||||
- series
|
||||
- relationships
|
||||
- trends
|
||||
- missing
|
||||
- labs
|
||||
- training
|
||||
- screeners
|
||||
- cycle
|
||||
additionalProperties: false
|
||||
TopicSignal:
|
||||
type: object
|
||||
properties:
|
||||
key:
|
||||
$ref: "#/components/schemas/TopicSignalKey"
|
||||
latest:
|
||||
anyOf:
|
||||
- type: object
|
||||
properties:
|
||||
day:
|
||||
type: string
|
||||
description: A local calendar day, `YYYY-MM-DD`.
|
||||
value:
|
||||
type: number
|
||||
required:
|
||||
- day
|
||||
- value
|
||||
additionalProperties: false
|
||||
- type: "null"
|
||||
usual:
|
||||
anyOf:
|
||||
- $ref: "#/components/schemas/TopicUsualRange"
|
||||
- type: "null"
|
||||
state:
|
||||
type: string
|
||||
enum:
|
||||
- within
|
||||
- above
|
||||
- below
|
||||
- learning
|
||||
- none
|
||||
description: "`latest` against `usual`: `learning` while the range needs more days, `none` without a value."
|
||||
href:
|
||||
description: The in-app single-metric page of this signal.
|
||||
type:
|
||||
- string
|
||||
- "null"
|
||||
required:
|
||||
- key
|
||||
- latest
|
||||
- usual
|
||||
- state
|
||||
- href
|
||||
additionalProperties: false
|
||||
TopicUsualRange:
|
||||
type: object
|
||||
properties:
|
||||
low:
|
||||
type: number
|
||||
high:
|
||||
type: number
|
||||
center:
|
||||
type: number
|
||||
days:
|
||||
type: integer
|
||||
minimum: -9007199254740991
|
||||
maximum: 9007199254740991
|
||||
description: Days the band was built from.
|
||||
required:
|
||||
- low
|
||||
- high
|
||||
- center
|
||||
- days
|
||||
additionalProperties: false
|
||||
description: "The personal usual range over the trailing 90 days: median ± 3 scaled MAD of the daily values, never
|
||||
narrower than the measurement's own wobble. Needs 7 days, else null."
|
||||
TopicRelationship:
|
||||
type: object
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
x:
|
||||
$ref: "#/components/schemas/TopicSignalKey"
|
||||
y:
|
||||
$ref: "#/components/schemas/TopicSignalKey"
|
||||
lagDays:
|
||||
anyOf:
|
||||
- type: number
|
||||
const: 0
|
||||
- type: number
|
||||
const: 1
|
||||
description: "`x` on day d is held against `y` on day d + `lagDays`."
|
||||
kind:
|
||||
type: string
|
||||
enum:
|
||||
- correlation
|
||||
- split
|
||||
pairedDays:
|
||||
type: integer
|
||||
minimum: -9007199254740991
|
||||
maximum: 9007199254740991
|
||||
requiredDays:
|
||||
type: integer
|
||||
minimum: -9007199254740991
|
||||
maximum: 9007199254740991
|
||||
description: "Paired days needed before anything is said: 14."
|
||||
status:
|
||||
type: string
|
||||
enum:
|
||||
- insufficient
|
||||
- no_clear_link
|
||||
- linked
|
||||
description: "`linked` only at p < 0.05 (Pearson, or Welch for a split); a pattern, never a cause."
|
||||
r:
|
||||
type:
|
||||
- number
|
||||
- "null"
|
||||
pValue:
|
||||
type:
|
||||
- number
|
||||
- "null"
|
||||
split:
|
||||
anyOf:
|
||||
- type: object
|
||||
properties:
|
||||
threshold:
|
||||
type: number
|
||||
belowDays:
|
||||
type: integer
|
||||
minimum: -9007199254740991
|
||||
maximum: 9007199254740991
|
||||
otherDays:
|
||||
type: integer
|
||||
minimum: -9007199254740991
|
||||
maximum: 9007199254740991
|
||||
belowMean:
|
||||
type: number
|
||||
otherMean:
|
||||
type: number
|
||||
difference:
|
||||
type: number
|
||||
required:
|
||||
- threshold
|
||||
- belowDays
|
||||
- otherDays
|
||||
- belowMean
|
||||
- otherMean
|
||||
- difference
|
||||
additionalProperties: false
|
||||
- type: "null"
|
||||
description: "For a split: the mean of `y` on days `x` sat below `threshold` against the other days."
|
||||
points:
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
properties:
|
||||
x:
|
||||
type: number
|
||||
y:
|
||||
type: number
|
||||
required:
|
||||
- x
|
||||
- y
|
||||
additionalProperties: false
|
||||
description: Paired values for a scatter, at most 120; empty when insufficient.
|
||||
required:
|
||||
- id
|
||||
- x
|
||||
- y
|
||||
- lagDays
|
||||
- kind
|
||||
- pairedDays
|
||||
- requiredDays
|
||||
- status
|
||||
- r
|
||||
- pValue
|
||||
- split
|
||||
- points
|
||||
additionalProperties: false
|
||||
TopicTrend:
|
||||
type: object
|
||||
properties:
|
||||
key:
|
||||
$ref: "#/components/schemas/TopicSignalKey"
|
||||
status:
|
||||
type: string
|
||||
enum:
|
||||
- ok
|
||||
- insufficient
|
||||
days:
|
||||
type: integer
|
||||
minimum: -9007199254740991
|
||||
maximum: 9007199254740991
|
||||
change:
|
||||
description: The least-squares change across the trailing 90 days, in the stored unit. Needs 14 days.
|
||||
type:
|
||||
- number
|
||||
- "null"
|
||||
weekly:
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
properties:
|
||||
day:
|
||||
type: string
|
||||
description: A local calendar day, `YYYY-MM-DD`.
|
||||
value:
|
||||
type: number
|
||||
required:
|
||||
- day
|
||||
- value
|
||||
additionalProperties: false
|
||||
description: Weekly means, each keyed by its Monday.
|
||||
required:
|
||||
- key
|
||||
- status
|
||||
- days
|
||||
- change
|
||||
- weekly
|
||||
additionalProperties: false
|
||||
TopicLabPanel:
|
||||
type: object
|
||||
properties:
|
||||
markers:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/TopicLabMarker"
|
||||
description: At most 24, newest reading first.
|
||||
inRange:
|
||||
type: integer
|
||||
minimum: -9007199254740991
|
||||
maximum: 9007199254740991
|
||||
withRange:
|
||||
type: integer
|
||||
minimum: -9007199254740991
|
||||
maximum: 9007199254740991
|
||||
latestOn:
|
||||
anyOf:
|
||||
- type: string
|
||||
description: A local calendar day, `YYYY-MM-DD`.
|
||||
- type: "null"
|
||||
required:
|
||||
- markers
|
||||
- inRange
|
||||
- withRange
|
||||
- latestOn
|
||||
additionalProperties: false
|
||||
TopicLabMarker:
|
||||
type: object
|
||||
properties:
|
||||
biomarkerId:
|
||||
type: string
|
||||
name:
|
||||
type: string
|
||||
unit:
|
||||
type: string
|
||||
latest:
|
||||
type: number
|
||||
takenOn:
|
||||
type: string
|
||||
description: A local calendar day, `YYYY-MM-DD`.
|
||||
low:
|
||||
type:
|
||||
- number
|
||||
- "null"
|
||||
high:
|
||||
type:
|
||||
- number
|
||||
- "null"
|
||||
status:
|
||||
type: string
|
||||
enum:
|
||||
- in-range
|
||||
- below
|
||||
- above
|
||||
- unknown
|
||||
readings:
|
||||
type: integer
|
||||
minimum: -9007199254740991
|
||||
maximum: 9007199254740991
|
||||
trend:
|
||||
$ref: "#/components/schemas/BiomarkerTrend"
|
||||
required:
|
||||
- biomarkerId
|
||||
- name
|
||||
- unit
|
||||
- latest
|
||||
- takenOn
|
||||
- low
|
||||
- high
|
||||
- status
|
||||
- readings
|
||||
- trend
|
||||
additionalProperties: false
|
||||
TopicCycleSummary:
|
||||
type: object
|
||||
properties:
|
||||
today:
|
||||
anyOf:
|
||||
- type: object
|
||||
properties:
|
||||
dayOfCycle:
|
||||
type: integer
|
||||
minimum: -9007199254740991
|
||||
maximum: 9007199254740991
|
||||
phase:
|
||||
type:
|
||||
- string
|
||||
- "null"
|
||||
required:
|
||||
- dayOfCycle
|
||||
- phase
|
||||
additionalProperties: false
|
||||
- type: "null"
|
||||
lengths:
|
||||
type: array
|
||||
items:
|
||||
type: integer
|
||||
minimum: -9007199254740991
|
||||
maximum: 9007199254740991
|
||||
averageLength:
|
||||
type:
|
||||
- number
|
||||
- "null"
|
||||
periodsLogged:
|
||||
type: integer
|
||||
minimum: -9007199254740991
|
||||
maximum: 9007199254740991
|
||||
required:
|
||||
- today
|
||||
- lengths
|
||||
- averageLength
|
||||
- periodsLogged
|
||||
additionalProperties: false
|
||||
__schema0:
|
||||
anyOf:
|
||||
- type: string
|
||||
|
||||
@@ -388,9 +388,10 @@ 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`). Read and written
|
||||
/// through `/api/settings/preview-features` and carried by the backup.
|
||||
/// @pending-consumer(topics preview) the `topics` flag is stored, but no surface reads it and the Settings > System switch is unlisted until the topic overview pages ship.
|
||||
/// default (`src/lib/settings/preview-features.ts`). Written by Settings >
|
||||
/// System > Preview features through `/api/settings/preview-features`,
|
||||
/// published on `/api/auth/me`, read by the navigation and the topic routes
|
||||
/// (`topics`), and carried by the backup.
|
||||
previewFeaturesJson Json? @map("preview_features_json")
|
||||
/// Optional activity goals the person set for themselves: workouts, active
|
||||
/// minutes and steps per week, steps per month. Null or an absent key = no
|
||||
|
||||
@@ -1074,6 +1074,17 @@ 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.",
|
||||
@@ -2197,8 +2208,11 @@ 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 = 273;
|
||||
const FROZEN_ENTRY_COUNT = 275;
|
||||
|
||||
/**
|
||||
* The two surfaces that authenticate a Bearer token outside `requireAuth` —
|
||||
|
||||
@@ -44,6 +44,7 @@
|
||||
import { apiSuccess } from "@/lib/api-response";
|
||||
import { apiHandler, requireActorAuth } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { resolvePreviewFeatures } from "@/lib/settings/preview-features";
|
||||
import { setOnboardingPendingCookie } from "@/lib/auth/session";
|
||||
import { syncMfaEnrollCookie } from "@/lib/auth/mfa-enrollment";
|
||||
import { buildAvatarUrl } from "@/lib/avatar";
|
||||
@@ -93,6 +94,21 @@ const SERVER_FEATURES = {
|
||||
lifeCircumstances: true,
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* v1.7.0 — patient-identity fields for the health-record export. The KVNR is
|
||||
* stored encrypted; decrypt fail-soft so a key-rotation gap on one row never
|
||||
* 500s the whole profile fetch (the field just reads null and the user
|
||||
* re-enters it).
|
||||
*/
|
||||
function decryptInsuranceNumber(encrypted: string | null): string | null {
|
||||
if (!encrypted) return null;
|
||||
try {
|
||||
return decrypt(encrypted);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export const GET = apiHandler(async () => {
|
||||
const auth = await requireActorAuth();
|
||||
const { user } = auth;
|
||||
@@ -238,10 +254,11 @@ export const GET = apiHandler(async () => {
|
||||
}),
|
||||
// The encrypted insurance number is not on the per-request user
|
||||
// (`SESSION_USER_SELECT`); it is read here, for the one response that
|
||||
// shows it.
|
||||
// shows it. The preview opt-ins ride the same point read of the caller's
|
||||
// own row.
|
||||
prisma.user.findUnique({
|
||||
where: { id: user.id },
|
||||
select: { insuranceNumberEncrypted: true },
|
||||
select: { insuranceNumberEncrypted: true, previewFeaturesJson: true },
|
||||
}),
|
||||
]);
|
||||
|
||||
@@ -289,18 +306,9 @@ export const GET = apiHandler(async () => {
|
||||
sectionsOpen(sections, "cycle") &&
|
||||
isCycleEnabled(record?.gender ?? null, cycleProfile);
|
||||
|
||||
// v1.7.0 — patient-identity fields for the health-record export. The
|
||||
// KVNR is stored encrypted; decrypt fail-soft so a key-rotation gap on
|
||||
// one row never 500s the whole profile fetch (the field just reads
|
||||
// null and the user re-enters it).
|
||||
let insuranceNumber: string | null = null;
|
||||
if (identity?.insuranceNumberEncrypted) {
|
||||
try {
|
||||
insuranceNumber = decrypt(identity.insuranceNumberEncrypted);
|
||||
} catch {
|
||||
insuranceNumber = null;
|
||||
}
|
||||
}
|
||||
const insuranceNumber = decryptInsuranceNumber(
|
||||
identity?.insuranceNumberEncrypted ?? null,
|
||||
);
|
||||
|
||||
return apiSuccess({
|
||||
id: user.id,
|
||||
@@ -447,6 +455,12 @@ export const GET = apiHandler(async () => {
|
||||
// `offIsReal` whether the lowest option is a real off or "Minimal", and
|
||||
// `source` which layer decided. Clients never recompute it.
|
||||
coachReasoning,
|
||||
// The caller's own preview opt-ins (Settings > System > Preview
|
||||
// features), resolved: every flag present, off unless switched on. The
|
||||
// navigation offers the Topics entry from `topics`. The ACTOR's, like the
|
||||
// Coach preferences above, so it does not move under a switch: a preview is
|
||||
// chrome of the person reading, never a property of the record on screen.
|
||||
previewFeatures: resolvePreviewFeatures(identity?.previewFeaturesJson),
|
||||
// v1.36.0 — account sharing, resolved. `accounts` is the switcher's menu,
|
||||
// `active` is the record this session is inside (null when it is in its
|
||||
// own), `canSwitch` and per-entry `canWrite` are the booleans the UI binds
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
/**
|
||||
* `GET /api/topics/{topic}` — one topic page (preview).
|
||||
*
|
||||
* The topic's signals with their usual ranges, the side-by-side series for the
|
||||
* chosen window, the relationships between its signals (14 paired days before
|
||||
* anything is said), the 90-day trends, the signals it could show but holds
|
||||
* nothing for, and its extra blocks (training load, lab markers with their
|
||||
* trend, screener trends, today's place in the cycle). Every figure is
|
||||
* computed on the server from existing reads; no model is involved.
|
||||
*
|
||||
* 404 `topics.preview_off` when the viewer has not switched the preview on,
|
||||
* `topics.not_available` when the record's module switches do not offer the
|
||||
* topic. Admission declares the whole record, like the overview.
|
||||
*/
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
|
||||
import { apiError, apiSuccess, returnAllZodIssues } from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { checkAnalyticsReadRateLimit } from "@/lib/rate-limit";
|
||||
import { hasTopicsPreview, loadTopicDetail } from "@/lib/topics/service";
|
||||
import {
|
||||
topicDetailQuerySchema,
|
||||
topicPathSchema,
|
||||
} from "@/lib/validations/topics";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
type RouteParams = { params: Promise<{ topic: string }> };
|
||||
|
||||
export const GET = apiHandler(
|
||||
async (request: NextRequest, { params }: RouteParams) => {
|
||||
const { user, actor, grantId } = await requireRecordAuth("read", "record");
|
||||
annotate({ action: { name: "topics.detail.read" } });
|
||||
|
||||
if (!(await hasTopicsPreview(actor.id))) {
|
||||
return apiError("Topics are a preview that is switched off", 404, {
|
||||
errorCode: "topics.preview_off",
|
||||
});
|
||||
}
|
||||
|
||||
const path = topicPathSchema.safeParse(await params);
|
||||
if (!path.success) {
|
||||
return apiError("No such topic", 404, { errorCode: "topics.not_found" });
|
||||
}
|
||||
const query = topicDetailQuerySchema.safeParse({
|
||||
range: request.nextUrl.searchParams.get("range") ?? undefined,
|
||||
});
|
||||
if (!query.success) return returnAllZodIssues(query.error, 422);
|
||||
|
||||
const rl = await checkAnalyticsReadRateLimit(actor.id);
|
||||
if (!rl.allowed) {
|
||||
return apiError("Too many analytics requests. Please retry later.", 429);
|
||||
}
|
||||
|
||||
const detail = await loadTopicDetail({
|
||||
userId: user.id,
|
||||
grantId,
|
||||
topic: path.data.topic,
|
||||
range: query.data.range,
|
||||
now: new Date(),
|
||||
});
|
||||
if (!detail) {
|
||||
return apiError("This topic is not available for this record", 404, {
|
||||
errorCode: "topics.not_available",
|
||||
});
|
||||
}
|
||||
annotate({
|
||||
meta: {
|
||||
topic: detail.key,
|
||||
topic_range: detail.range,
|
||||
topic_status: detail.status,
|
||||
topic_relationships_linked: detail.relationships.filter(
|
||||
(r) => r.status === "linked",
|
||||
).length,
|
||||
},
|
||||
});
|
||||
return apiSuccess(detail);
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* `GET /api/topics` — the topic overview (preview).
|
||||
*
|
||||
* One tile per topic the record holds data for: a headline value, a rule-based
|
||||
* sentence and a sparkline, computed on the server from the same daily values
|
||||
* the single-metric pages draw. No model is involved and no AI gate applies.
|
||||
*
|
||||
* The preview is the viewer's own opt-in (Settings > System > Preview
|
||||
* features): with it off the route answers 404 `topics.preview_off`, the same
|
||||
* as for a path that does not exist. The topics, signals and blocks follow the
|
||||
* RECORD's module switches; a topic whose every module is off is named in
|
||||
* `hiddenByModule`, an offered topic with nothing recorded in `empty`.
|
||||
*
|
||||
* Admission declares the whole record, like the timeline: a topic lays several
|
||||
* sections side by side, so a grant scoped to some of them does not open it.
|
||||
*/
|
||||
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
|
||||
import { apiError, apiSuccess } from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { checkAnalyticsReadRateLimit } from "@/lib/rate-limit";
|
||||
import { hasTopicsPreview, loadTopicOverview } from "@/lib/topics/service";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export const GET = apiHandler(async () => {
|
||||
const { user, actor, grantId } = await requireRecordAuth("read", "record");
|
||||
annotate({ action: { name: "topics.overview.read" } });
|
||||
|
||||
if (!(await hasTopicsPreview(actor.id))) {
|
||||
return apiError("Topics are a preview that is switched off", 404, {
|
||||
errorCode: "topics.preview_off",
|
||||
});
|
||||
}
|
||||
|
||||
// The shared analytics-read budget, spent by the person reading.
|
||||
const rl = await checkAnalyticsReadRateLimit(actor.id);
|
||||
if (!rl.allowed) {
|
||||
return apiError("Too many analytics requests. Please retry later.", 429);
|
||||
}
|
||||
|
||||
const overview = await loadTopicOverview({
|
||||
userId: user.id,
|
||||
grantId,
|
||||
now: new Date(),
|
||||
});
|
||||
annotate({
|
||||
meta: {
|
||||
topics_shown: overview.topics.length,
|
||||
topics_empty: overview.empty.length,
|
||||
topics_hidden: overview.hiddenByModule.length,
|
||||
},
|
||||
});
|
||||
return apiSuccess(overview);
|
||||
});
|
||||
@@ -24,6 +24,10 @@ import { storeTimezone } from "@/lib/timezone-mirror";
|
||||
import type { ModuleKey } from "@/lib/modules/registry";
|
||||
import type { AiCapabilities } from "@/lib/ai/capabilities/types";
|
||||
import type { CoachReasoningState } from "@/lib/ai/reasoning/resolve";
|
||||
import {
|
||||
resolvePreviewFeatures,
|
||||
type PreviewFeatures,
|
||||
} from "@/lib/settings/preview-features";
|
||||
import type { ModuleAccessState } from "@/lib/sharing/module-disclosure";
|
||||
import type { OnboardingStateDto } from "@/lib/onboarding/needs";
|
||||
import type { TourProgress } from "@/lib/onboarding/tour-progress";
|
||||
@@ -254,6 +258,14 @@ export interface AuthUser {
|
||||
* server image that does not publish it.
|
||||
*/
|
||||
coachReasoning?: CoachReasoningState | null;
|
||||
/**
|
||||
* The person's own preview opt-ins, resolved on the server: every
|
||||
* flag present, off unless switched on in Settings > System > Preview
|
||||
* features. Read through `usePreviewFeature`. Optional so existing
|
||||
* fixtures stay valid; `fetchMe` coerces an absent block (an older server)
|
||||
* to every flag off.
|
||||
*/
|
||||
previewFeatures?: PreviewFeatures;
|
||||
/**
|
||||
* v1.36.0 — account sharing, resolved server-side. `accounts` is the
|
||||
* switcher's menu, `active` is the record this browser is inside (null when
|
||||
@@ -477,6 +489,8 @@ export async function fetchMe(): Promise<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,
|
||||
};
|
||||
|
||||
@@ -452,6 +452,7 @@ 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,6 +44,7 @@ import {
|
||||
} from "@/lib/validations/managed-profile-handover";
|
||||
import { onboardingStateResource } from "./onboarding";
|
||||
import { aiAccountBlock, moduleAccessMap } from "./profile";
|
||||
import { previewFeaturesSchema } from "./settings";
|
||||
import {
|
||||
dataEnvelope,
|
||||
errorEnvelope,
|
||||
@@ -562,6 +563,11 @@ const accountPayload = z
|
||||
ai: aiAccountBlock,
|
||||
// v1.41 — enumerated for the reason `ai` is: the settings decide on it.
|
||||
coachReasoning: coachReasoningBlock,
|
||||
// Enumerated because the navigation decides on it: the Topics
|
||||
// entry exists only while the person reading has switched it on. The
|
||||
// caller's own opt-in, never the record's, so it does not move under a
|
||||
// switch. The same object `GET /api/settings/preview-features` answers.
|
||||
previewFeatures: previewFeaturesSchema,
|
||||
recordSession: recordSessionState.nullable(),
|
||||
// Enumerated because a client DECIDES on it rather than displays it: the
|
||||
// medication surface hides the server-side reminder switch when
|
||||
|
||||
@@ -77,7 +77,7 @@ const biomarkerRow = z
|
||||
"A user-scoped catalog marker. `context` is the decrypted per-marker note (or null); `hasContext` flags its presence. `analyteKey` is the analyte the marker was recognised as: the catalog slug its name belongs to (e.g. `fasting-glucose`, `ldl`, `hdl`), or a key of the unit-conversion registry for an analyte with no catalog seed (e.g. `urea`, `uric-acid`), or null when its name matches none. It is derived from `name` and `unit`, never chosen: it is set on create and re-derived (or cleared) when an update changes either. A reading in another unit is converted only for a marker that has one. `hidden` drops the marker from the active catalog list and the lab-entry pickers without deleting it (its readings and unit/range survive). A `LabResult` linking this marker resolves its unit + reference bounds from here.",
|
||||
});
|
||||
|
||||
const labTrend = z
|
||||
export const biomarkerTrendSchema = z
|
||||
.union([
|
||||
z.object({
|
||||
status: z.literal("ok"),
|
||||
@@ -140,7 +140,9 @@ const labTrend = z
|
||||
|
||||
const biomarkerDetail = biomarkerRow
|
||||
.extend({
|
||||
trend: labTrend.optional().describe("v1.44 — absent on older servers."),
|
||||
trend: biomarkerTrendSchema
|
||||
.optional()
|
||||
.describe("v1.44 — absent on older servers."),
|
||||
})
|
||||
.meta({ id: "BiomarkerDetail" });
|
||||
|
||||
|
||||
@@ -56,6 +56,7 @@ import { cyclePaths } from "./cycle";
|
||||
import { dashboardWidgetPaths } from "./dashboard";
|
||||
import { dayPaths } from "./day";
|
||||
import { searchPaths } from "./search";
|
||||
import { topicPaths } from "./topics";
|
||||
import { biomarkerPaths } from "./biomarkers";
|
||||
import { inboundDocumentPaths } from "./documents";
|
||||
import { documentSourcePaths } from "./document-sources";
|
||||
@@ -204,6 +205,9 @@ const registeredPaths: NonNullable<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).
|
||||
//
|
||||
|
||||
@@ -108,7 +108,7 @@ const changeVerdict = z
|
||||
.enum(["improved", "worsened", "within_threshold"])
|
||||
.nullable();
|
||||
|
||||
const assessmentTrend = z
|
||||
export const assessmentTrendSchema = z
|
||||
.object({
|
||||
instrument: z.enum(["PHQ9", "GAD7", "WHO5"]),
|
||||
n: z.number().int(),
|
||||
@@ -136,7 +136,7 @@ const listResponse = z
|
||||
.object({
|
||||
assessments: z.array(assessmentRow),
|
||||
trends: z
|
||||
.array(assessmentTrend)
|
||||
.array(assessmentTrendSchema)
|
||||
.optional()
|
||||
.describe(
|
||||
"v1.44 — one per PHQ-9 / GAD-7 / WHO-5 with at least one administration. Absent on older servers.",
|
||||
|
||||
@@ -72,12 +72,12 @@ const moduleListLayoutSchema = z
|
||||
"Per-user presentation of the labs, illness and checkups lists. Display-only; a client that does not render a list ignores it.",
|
||||
});
|
||||
|
||||
const previewFeaturesSchema = z
|
||||
export const previewFeaturesSchema = z
|
||||
.object({
|
||||
topics: z
|
||||
.boolean()
|
||||
.describe(
|
||||
"Topic overview pages above the single-metric Insights pages. Off by default.",
|
||||
"The Topics area (`/api/topics`): overview pages that bring related signals together above the single-metric Insights pages, which stay as they are. Off by default.",
|
||||
),
|
||||
})
|
||||
.meta({
|
||||
|
||||
@@ -0,0 +1,324 @@
|
||||
/**
|
||||
* OpenAPI route table for the topic pages (preview): `/api/topics` and
|
||||
* `/api/topics/{topic}`.
|
||||
*
|
||||
* Part of the OpenAPI route table; aggregated in `./index.ts`. The request
|
||||
* schemas are the routes' own (`@/lib/validations/topics`); the response
|
||||
* shapes mirror `src/lib/topics/build.ts` and reuse the published lab-trend,
|
||||
* training-load and screener-trend components rather than restating them.
|
||||
*/
|
||||
import type { ZodOpenApiObject } from "zod-openapi";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { TOPIC_KEYS, TOPIC_RANGES } from "@/lib/topics/registry";
|
||||
import {
|
||||
topicDetailQuerySchema,
|
||||
topicPathSchema,
|
||||
} from "@/lib/validations/topics";
|
||||
|
||||
import { biomarkerTrendSchema } from "./biomarkers";
|
||||
import { assessmentTrendSchema } from "./mental-health";
|
||||
import {
|
||||
dataEnvelope,
|
||||
errorEnvelope,
|
||||
measurementTypeEnum,
|
||||
recordRefusal,
|
||||
stdResponses,
|
||||
} from "./shared";
|
||||
import { trainingLoadSchema } from "./workouts";
|
||||
|
||||
const dayKey = z.string().describe("A local calendar day, `YYYY-MM-DD`.");
|
||||
|
||||
const topicKey = z.enum(TOPIC_KEYS).meta({
|
||||
id: "TopicKey",
|
||||
description: "A topic, as its URL slug.",
|
||||
});
|
||||
|
||||
const signalKey = z.union([measurementTypeEnum, z.literal("MOOD")]).meta({
|
||||
id: "TopicSignalKey",
|
||||
description:
|
||||
"A daily series a topic reads: a `MeasurementType`, or `MOOD` for the day's mean mood score (1 to 5). Values are in the type's stored unit (sleep in minutes asleep per night, keyed by the day the night ended); clients convert for display as they do for the metric pages.",
|
||||
});
|
||||
|
||||
const dayValue = z.object({ day: dayKey, value: z.number() });
|
||||
|
||||
const usualRange = z
|
||||
.object({
|
||||
low: z.number(),
|
||||
high: z.number(),
|
||||
center: z.number(),
|
||||
days: z.number().int().describe("Days the band was built from."),
|
||||
})
|
||||
.meta({
|
||||
id: "TopicUsualRange",
|
||||
description:
|
||||
"The personal usual range over the trailing 90 days: median ± 3 scaled MAD of the daily values, never narrower than the measurement's own wobble. Needs 7 days, else null.",
|
||||
});
|
||||
|
||||
const signal = z
|
||||
.object({
|
||||
key: signalKey,
|
||||
latest: dayValue.nullable(),
|
||||
usual: usualRange.nullable(),
|
||||
state: z
|
||||
.enum(["within", "above", "below", "learning", "none"])
|
||||
.describe(
|
||||
"`latest` against `usual`: `learning` while the range needs more days, `none` without a value.",
|
||||
),
|
||||
href: z
|
||||
.string()
|
||||
.nullable()
|
||||
.describe("The in-app single-metric page of this signal."),
|
||||
})
|
||||
.meta({ id: "TopicSignal" });
|
||||
|
||||
const summary = z
|
||||
.object({
|
||||
code: z.enum([
|
||||
"outside",
|
||||
"labs",
|
||||
"cycleDay",
|
||||
"steady",
|
||||
"learning",
|
||||
"empty",
|
||||
]),
|
||||
signal: signalKey.nullable(),
|
||||
direction: z.enum(["above", "below"]).nullable(),
|
||||
count: z.number().int().nullable(),
|
||||
total: z.number().int().nullable(),
|
||||
})
|
||||
.meta({
|
||||
id: "TopicSummary",
|
||||
description:
|
||||
"The topic's one sentence as a code with parameters, picked by a fixed rule (never model text): `outside` names the first signal outside its usual range and the direction; `labs` counts lab markers inside their window (`count` of `total`); `cycleDay` gives today's cycle day in `count`; `steady` counts signals inside their range (`count` of `total` recorded); `learning` while no range is established; `empty` without any data.",
|
||||
});
|
||||
|
||||
const headline = z
|
||||
.discriminatedUnion("kind", [
|
||||
z.object({
|
||||
kind: z.literal("signal"),
|
||||
key: signalKey,
|
||||
value: z.number(),
|
||||
day: dayKey,
|
||||
}),
|
||||
z.object({
|
||||
kind: z.literal("labs"),
|
||||
inRange: z.number().int(),
|
||||
total: z.number().int(),
|
||||
}),
|
||||
z.object({ kind: z.literal("cycleDay"), dayOfCycle: z.number().int() }),
|
||||
z.object({
|
||||
kind: z.literal("screener"),
|
||||
instrument: z.string(),
|
||||
score: z.number(),
|
||||
}),
|
||||
])
|
||||
.meta({ id: "TopicHeadline" });
|
||||
|
||||
const overviewEntry = z
|
||||
.object({
|
||||
key: topicKey,
|
||||
headline: headline.nullable(),
|
||||
summary,
|
||||
tone: z.enum(["steady", "watch", "learning"]),
|
||||
sparkline: z
|
||||
.array(z.number())
|
||||
.describe(
|
||||
"The headline signal's daily values over the last 30 days, oldest first; empty when the headline is a block.",
|
||||
),
|
||||
recorded: z.number().int(),
|
||||
total: z.number().int(),
|
||||
})
|
||||
.meta({ id: "TopicOverviewEntry" });
|
||||
|
||||
const overviewSchema = z
|
||||
.object({
|
||||
topics: z.array(overviewEntry),
|
||||
empty: z
|
||||
.array(topicKey)
|
||||
.describe("Offered topics with nothing recorded yet."),
|
||||
hiddenByModule: z
|
||||
.array(topicKey)
|
||||
.describe("Topics whose every module is switched off for the record."),
|
||||
})
|
||||
.meta({ id: "TopicOverview" });
|
||||
|
||||
const relationship = z
|
||||
.object({
|
||||
id: z.string(),
|
||||
x: signalKey,
|
||||
y: signalKey,
|
||||
lagDays: z
|
||||
.union([z.literal(0), z.literal(1)])
|
||||
.describe("`x` on day d is held against `y` on day d + `lagDays`."),
|
||||
kind: z.enum(["correlation", "split"]),
|
||||
pairedDays: z.number().int(),
|
||||
requiredDays: z
|
||||
.number()
|
||||
.int()
|
||||
.describe("Paired days needed before anything is said: 14."),
|
||||
status: z
|
||||
.enum(["insufficient", "no_clear_link", "linked"])
|
||||
.describe(
|
||||
"`linked` only at p < 0.05 (Pearson, or Welch for a split); a pattern, never a cause.",
|
||||
),
|
||||
r: z.number().nullable(),
|
||||
pValue: z.number().nullable(),
|
||||
split: z
|
||||
.object({
|
||||
threshold: z.number(),
|
||||
belowDays: z.number().int(),
|
||||
otherDays: z.number().int(),
|
||||
belowMean: z.number(),
|
||||
otherMean: z.number(),
|
||||
difference: z.number(),
|
||||
})
|
||||
.nullable()
|
||||
.describe(
|
||||
"For a split: the mean of `y` on days `x` sat below `threshold` against the other days.",
|
||||
),
|
||||
points: z
|
||||
.array(z.object({ x: z.number(), y: z.number() }))
|
||||
.describe(
|
||||
"Paired values for a scatter, at most 120; empty when insufficient.",
|
||||
),
|
||||
})
|
||||
.meta({ id: "TopicRelationship" });
|
||||
|
||||
const trend = z
|
||||
.object({
|
||||
key: signalKey,
|
||||
status: z.enum(["ok", "insufficient"]),
|
||||
days: z.number().int(),
|
||||
change: z
|
||||
.number()
|
||||
.nullable()
|
||||
.describe(
|
||||
"The least-squares change across the trailing 90 days, in the stored unit. Needs 14 days.",
|
||||
),
|
||||
weekly: z
|
||||
.array(dayValue)
|
||||
.describe("Weekly means, each keyed by its Monday."),
|
||||
})
|
||||
.meta({ id: "TopicTrend" });
|
||||
|
||||
const labMarker = z
|
||||
.object({
|
||||
biomarkerId: z.string(),
|
||||
name: z.string(),
|
||||
unit: z.string(),
|
||||
latest: z.number(),
|
||||
takenOn: dayKey,
|
||||
low: z.number().nullable(),
|
||||
high: z.number().nullable(),
|
||||
status: z.enum(["in-range", "below", "above", "unknown"]),
|
||||
readings: z.number().int(),
|
||||
trend: biomarkerTrendSchema,
|
||||
})
|
||||
.meta({ id: "TopicLabMarker" });
|
||||
|
||||
const labPanel = z
|
||||
.object({
|
||||
markers: z.array(labMarker).describe("At most 24, newest reading first."),
|
||||
inRange: z.number().int(),
|
||||
withRange: z.number().int(),
|
||||
latestOn: dayKey.nullable(),
|
||||
})
|
||||
.meta({ id: "TopicLabPanel" });
|
||||
|
||||
const cycleSummary = z
|
||||
.object({
|
||||
today: z
|
||||
.object({
|
||||
dayOfCycle: z.number().int(),
|
||||
phase: z.string().nullable(),
|
||||
})
|
||||
.nullable(),
|
||||
lengths: z.array(z.number().int()),
|
||||
averageLength: z.number().nullable(),
|
||||
periodsLogged: z.number().int(),
|
||||
})
|
||||
.meta({ id: "TopicCycleSummary" });
|
||||
|
||||
const detailSchema = z
|
||||
.object({
|
||||
key: topicKey,
|
||||
range: z.enum(TOPIC_RANGES),
|
||||
from: dayKey,
|
||||
to: dayKey,
|
||||
status: z.enum(["ready", "partial", "empty"]),
|
||||
summary,
|
||||
signals: z.array(signal),
|
||||
series: z.array(
|
||||
z.object({
|
||||
key: signalKey,
|
||||
usual: usualRange.nullable(),
|
||||
points: z.array(dayValue),
|
||||
}),
|
||||
),
|
||||
relationships: z.array(relationship),
|
||||
trends: z.array(trend),
|
||||
missing: z
|
||||
.array(signalKey)
|
||||
.describe("Signals the topic could show that hold no value yet."),
|
||||
labs: labPanel.nullable(),
|
||||
training: trainingLoadSchema.nullable(),
|
||||
screeners: z.array(assessmentTrendSchema).nullable(),
|
||||
cycle: cycleSummary.nullable(),
|
||||
})
|
||||
.meta({ id: "TopicDetail" });
|
||||
|
||||
const PREVIEW_OFF =
|
||||
"`topics.preview_off`: the person reading has not switched the topics preview on (Settings > System > Preview features). The preview is per account and off by default.";
|
||||
|
||||
export const topicPaths: NonNullable<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(),
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -122,7 +122,7 @@ const trainingLoadPoint = z.object({
|
||||
),
|
||||
});
|
||||
|
||||
const trainingLoad = z
|
||||
export const trainingLoadSchema = z
|
||||
.union([
|
||||
z.object({
|
||||
status: z.literal("ok"),
|
||||
@@ -174,7 +174,7 @@ const workoutListResponse = z
|
||||
offset: z.number().int().nonnegative(),
|
||||
droppedDuplicates: z.number().int().nonnegative(),
|
||||
}),
|
||||
trainingLoad: trainingLoad
|
||||
trainingLoad: trainingLoadSchema
|
||||
.optional()
|
||||
.describe(
|
||||
"v1.44 — on the first page (`offset=0`) only, account-wide whatever the filter. Absent on later pages and older servers.",
|
||||
|
||||
@@ -6,10 +6,11 @@
|
||||
* adds it beside what is there and never replaces an existing page.
|
||||
*
|
||||
* Flags:
|
||||
* - `topics`: topic overview pages above the single-metric Insights pages.
|
||||
* Stored, served by `/api/settings/preview-features` and carried by the
|
||||
* backup. The Settings > System switch is not listed until the topic pages
|
||||
* ship, because nothing reads the flag yet.
|
||||
* - `topics`: the Topics area (`/topics`, `/api/topics`), overview pages
|
||||
* that bring related signals together above the single-metric Insights
|
||||
* pages, which stay exactly as they are. Switched in Settings > System >
|
||||
* Preview features, published on `/api/auth/me` as `previewFeatures`, read
|
||||
* by the navigation and refused by the topic routes while off.
|
||||
*
|
||||
* Unknown keys in a stored blob are dropped on read, so a flag retired later
|
||||
* disappears cleanly and a malformed blob reads as everything off.
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { shiftDateKey } from "@/lib/tz/format";
|
||||
|
||||
import { buildDetail, buildOverview, summarise } from "../build";
|
||||
import type { TopicInputs } from "../read";
|
||||
import { TOPICS, type TopicSignalKey } from "../registry";
|
||||
import type { TopicVisibility } from "../visibility";
|
||||
|
||||
const TODAY = "2026-10-23";
|
||||
|
||||
function series(n: number, f: (i: number) => number) {
|
||||
const out = new Map<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();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,83 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { completedCycleLengths } from "../read";
|
||||
import { shapeLabPanel } from "../labs";
|
||||
|
||||
const NOW = new Date("2026-10-23T12:00:00Z");
|
||||
|
||||
function reading(biomarkerId: string, value: number, takenAt: string) {
|
||||
return {
|
||||
biomarkerId,
|
||||
value,
|
||||
takenAt: new Date(takenAt),
|
||||
sourceReferenceLow: null,
|
||||
sourceReferenceHigh: null,
|
||||
sourceReferenceText: null,
|
||||
sourceUnit: null,
|
||||
};
|
||||
}
|
||||
|
||||
describe("shapeLabPanel", () => {
|
||||
const markers = [
|
||||
{ id: "ldl", name: "LDL", unit: "mg/dL", lowerBound: 0, upperBound: 130 },
|
||||
{
|
||||
id: "vitd",
|
||||
name: "Vitamin D",
|
||||
unit: "ng/mL",
|
||||
lowerBound: 30,
|
||||
upperBound: 100,
|
||||
},
|
||||
{
|
||||
id: "none",
|
||||
name: "Unused",
|
||||
unit: "x",
|
||||
lowerBound: null,
|
||||
upperBound: null,
|
||||
},
|
||||
];
|
||||
|
||||
it("judges the newest reading and carries the marker's trend", () => {
|
||||
const panel = shapeLabPanel(
|
||||
markers,
|
||||
[
|
||||
reading("ldl", 98, "2026-10-01T08:00:00Z"),
|
||||
reading("ldl", 120, "2025-09-01T08:00:00Z"),
|
||||
reading("ldl", 140, "2024-08-01T08:00:00Z"),
|
||||
reading("vitd", 24, "2026-09-10T08:00:00Z"),
|
||||
],
|
||||
NOW,
|
||||
);
|
||||
expect(panel).not.toBeNull();
|
||||
expect(panel!.markers.map((m) => m.biomarkerId)).toEqual(["ldl", "vitd"]);
|
||||
const ldl = panel!.markers[0];
|
||||
expect(ldl).toMatchObject({ latest: 98, status: "in-range", readings: 3 });
|
||||
expect(ldl.trend.status).toBe("ok");
|
||||
const vitd = panel!.markers[1];
|
||||
expect(vitd.status).toBe("below");
|
||||
// One reading is not a trend; the panel says so rather than drawing one.
|
||||
expect(vitd.trend).toMatchObject({ status: "insufficient", n: 1 });
|
||||
expect(panel).toMatchObject({
|
||||
inRange: 1,
|
||||
withRange: 2,
|
||||
latestOn: "2026-10-01",
|
||||
});
|
||||
});
|
||||
|
||||
it("is absent without a single numeric reading", () => {
|
||||
expect(shapeLabPanel(markers, [], NOW)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("completedCycleLengths", () => {
|
||||
it("measures between logged starts and skips gaps in logging", () => {
|
||||
expect(
|
||||
completedCycleLengths([
|
||||
"2026-06-01",
|
||||
"2026-06-29",
|
||||
"2026-07-28",
|
||||
"2026-10-20",
|
||||
]),
|
||||
).toEqual([28, 29]);
|
||||
expect(completedCycleLengths(["2026-06-01"])).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,222 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { shiftDateKey } from "@/lib/tz/format";
|
||||
|
||||
import {
|
||||
RELATIONSHIP_MIN_PAIRED_DAYS,
|
||||
type TopicRelationshipDef,
|
||||
} from "../registry";
|
||||
import {
|
||||
computeRelationship,
|
||||
computeTrend,
|
||||
latestValue,
|
||||
pairDays,
|
||||
stateAgainstRange,
|
||||
usualRange,
|
||||
welchPValue,
|
||||
windowValues,
|
||||
} from "../stats";
|
||||
|
||||
const TODAY = "2026-10-23";
|
||||
|
||||
/** `n` consecutive days ending today, valued by `f(i)` (i = 0 oldest). */
|
||||
function series(n: number, f: (i: number) => number, end = TODAY) {
|
||||
const out = new Map<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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,52 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { TOPICS } from "../registry";
|
||||
import {
|
||||
isSignalVisible,
|
||||
offeredTopics,
|
||||
signalsToRead,
|
||||
visibleExtras,
|
||||
} from "../visibility";
|
||||
|
||||
const sees = () => true;
|
||||
|
||||
describe("topic visibility", () => {
|
||||
it("offers every topic when nothing is switched off", () => {
|
||||
expect(offeredTopics({ modules: {}, sees })).toEqual([
|
||||
"heart",
|
||||
"body",
|
||||
"blood-pressure",
|
||||
"sleep",
|
||||
"activity",
|
||||
"metabolism",
|
||||
"mind",
|
||||
"cycle",
|
||||
]);
|
||||
});
|
||||
|
||||
it("follows the modules: cycle only with the cycle module, mind with either of its two", () => {
|
||||
const off = offeredTopics({
|
||||
modules: { cycle: false, mood: false, mentalHealth: false, sleep: false },
|
||||
sees,
|
||||
});
|
||||
expect(off).not.toContain("cycle");
|
||||
expect(off).not.toContain("mind");
|
||||
expect(off).not.toContain("sleep");
|
||||
expect(off).toContain("heart");
|
||||
expect(offeredTopics({ modules: { mood: false }, sees })).toContain("mind");
|
||||
});
|
||||
|
||||
it("never reads a signal or a block whose module is off or whose section is closed", () => {
|
||||
const ctx = {
|
||||
modules: { sleep: false, workouts: false },
|
||||
sees: (scope: string) => scope !== "mind",
|
||||
};
|
||||
expect(isSignalVisible("SLEEP_DURATION", ctx)).toBe(false);
|
||||
expect(isSignalVisible("MOOD", ctx)).toBe(false);
|
||||
expect(isSignalVisible("PULSE", ctx)).toBe(true);
|
||||
expect([...signalsToRead(TOPICS.heart, ctx)]).not.toContain(
|
||||
"SLEEP_DURATION",
|
||||
);
|
||||
expect([...visibleExtras(TOPICS.heart, ctx)]).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,386 @@
|
||||
/**
|
||||
* Assemble the topic overview and a topic page from what `read.ts` returned.
|
||||
* Pure: every input arrives as an argument, so a fixture covers every state.
|
||||
*
|
||||
* The summary sentence is a rule, never model text: the first signal outside
|
||||
* its usual range names itself; otherwise the lab panel or the cycle day
|
||||
* speaks for its topic; otherwise the count of signals inside their range;
|
||||
* otherwise "still learning". The client words the code; the server only
|
||||
* decides which one applies.
|
||||
*/
|
||||
import { insightsHrefForMetric } from "@/lib/insights/metric-deep-link";
|
||||
import type { AssessmentTrend } from "@/lib/mental-health/assessment-trend";
|
||||
import type { TrainingLoad } from "@/lib/insights/training-load";
|
||||
import { shiftDateKey } from "@/lib/tz/format";
|
||||
|
||||
import type { TopicLabPanel } from "./labs";
|
||||
import type { TopicCycleSummary, TopicInputs } from "./read";
|
||||
import {
|
||||
MOOD_SIGNAL,
|
||||
SPARKLINE_POINTS,
|
||||
TOPIC_BASE_WINDOW_DAYS,
|
||||
TOPIC_RANGE_DAYS,
|
||||
TOPICS,
|
||||
type TopicDefinition,
|
||||
type TopicKey,
|
||||
type TopicRange,
|
||||
type TopicSignalKey,
|
||||
} from "./registry";
|
||||
import {
|
||||
computeRelationship,
|
||||
computeTrend,
|
||||
latestValue,
|
||||
stateAgainstRange,
|
||||
usualRange,
|
||||
windowValues,
|
||||
type DayValue,
|
||||
type SignalState,
|
||||
type TopicRelationship,
|
||||
type TopicTrend,
|
||||
type UsualRange,
|
||||
} from "./stats";
|
||||
import {
|
||||
isExtraVisible,
|
||||
isSignalVisible,
|
||||
visibleSignals,
|
||||
type TopicVisibility,
|
||||
} from "./visibility";
|
||||
|
||||
export interface TopicSignal {
|
||||
key: TopicSignalKey;
|
||||
latest: DayValue | null;
|
||||
usual: UsualRange | null;
|
||||
state: SignalState;
|
||||
/** The single-metric page this signal opens, when it has one. */
|
||||
href: string | null;
|
||||
}
|
||||
|
||||
export type TopicSummaryCode =
|
||||
"outside" | "labs" | "cycleDay" | "steady" | "learning" | "empty";
|
||||
|
||||
export interface TopicSummary {
|
||||
code: TopicSummaryCode;
|
||||
signal: TopicSignalKey | null;
|
||||
direction: "above" | "below" | null;
|
||||
count: number | null;
|
||||
total: number | null;
|
||||
}
|
||||
|
||||
export type TopicHeadline =
|
||||
| { kind: "signal"; key: TopicSignalKey; value: number; day: string }
|
||||
| { kind: "labs"; inRange: number; total: number }
|
||||
| { kind: "cycleDay"; dayOfCycle: number }
|
||||
| { kind: "screener"; instrument: string; score: number };
|
||||
|
||||
export type TopicTone = "steady" | "watch" | "learning";
|
||||
|
||||
export interface TopicOverviewEntry {
|
||||
key: TopicKey;
|
||||
headline: TopicHeadline | null;
|
||||
summary: TopicSummary;
|
||||
tone: TopicTone;
|
||||
/** The headline signal's last values, oldest first (empty for a block headline). */
|
||||
sparkline: number[];
|
||||
/** Signals of the topic's list that hold a value. */
|
||||
recorded: number;
|
||||
/** Signals of the topic's list the record could hold. */
|
||||
total: number;
|
||||
}
|
||||
|
||||
export interface TopicOverview {
|
||||
topics: TopicOverviewEntry[];
|
||||
/** Offered topics with nothing recorded yet. */
|
||||
empty: TopicKey[];
|
||||
/** Topics whose every module is switched off. */
|
||||
hiddenByModule: TopicKey[];
|
||||
}
|
||||
|
||||
export interface TopicSeries {
|
||||
key: TopicSignalKey;
|
||||
usual: UsualRange | null;
|
||||
points: DayValue[];
|
||||
}
|
||||
|
||||
export interface TopicDetail {
|
||||
key: TopicKey;
|
||||
range: TopicRange;
|
||||
from: string;
|
||||
to: string;
|
||||
status: "ready" | "partial" | "empty";
|
||||
summary: TopicSummary;
|
||||
signals: TopicSignal[];
|
||||
series: TopicSeries[];
|
||||
relationships: TopicRelationship[];
|
||||
trends: TopicTrend[];
|
||||
/** Signals the topic could show that hold no value yet. */
|
||||
missing: TopicSignalKey[];
|
||||
labs: TopicLabPanel | null;
|
||||
training: TrainingLoad | null;
|
||||
screeners: AssessmentTrend[] | null;
|
||||
cycle: TopicCycleSummary | null;
|
||||
}
|
||||
|
||||
function signalHref(key: TopicSignalKey): string | null {
|
||||
if (key === MOOD_SIGNAL) return "/insights/mood";
|
||||
return insightsHrefForMetric(key);
|
||||
}
|
||||
|
||||
export function buildSignals(
|
||||
keys: readonly TopicSignalKey[],
|
||||
inputs: TopicInputs,
|
||||
todayKey: string,
|
||||
): TopicSignal[] {
|
||||
return keys.map((key) => {
|
||||
const series = inputs.series.get(key);
|
||||
const latest = latestValue(series, todayKey);
|
||||
const usual = usualRange(key, series, todayKey);
|
||||
return {
|
||||
key,
|
||||
latest,
|
||||
usual,
|
||||
state: stateAgainstRange(latest, usual),
|
||||
href: signalHref(key),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function hasBlockData(inputs: TopicInputs): boolean {
|
||||
return (
|
||||
inputs.labs !== null ||
|
||||
(inputs.screeners !== null && inputs.screeners.length > 0) ||
|
||||
inputs.cycle !== null ||
|
||||
(inputs.training !== null && inputs.training.status === "ok")
|
||||
);
|
||||
}
|
||||
|
||||
/** The rule that picks the topic's one sentence. */
|
||||
export function summarise(
|
||||
signals: readonly TopicSignal[],
|
||||
inputs: TopicInputs,
|
||||
): TopicSummary {
|
||||
const none: TopicSummary = {
|
||||
code: "empty",
|
||||
signal: null,
|
||||
direction: null,
|
||||
count: null,
|
||||
total: null,
|
||||
};
|
||||
const recorded = signals.filter((s) => s.latest !== null);
|
||||
if (recorded.length === 0 && !hasBlockData(inputs)) return none;
|
||||
|
||||
const outside = recorded.find(
|
||||
(s) => s.state === "above" || s.state === "below",
|
||||
);
|
||||
if (outside) {
|
||||
return {
|
||||
...none,
|
||||
code: "outside",
|
||||
signal: outside.key,
|
||||
direction: outside.state === "above" ? "above" : "below",
|
||||
};
|
||||
}
|
||||
if (inputs.labs && inputs.labs.withRange > 0) {
|
||||
return {
|
||||
...none,
|
||||
code: "labs",
|
||||
count: inputs.labs.inRange,
|
||||
total: inputs.labs.withRange,
|
||||
};
|
||||
}
|
||||
if (inputs.cycle?.today) {
|
||||
return { ...none, code: "cycleDay", count: inputs.cycle.today.dayOfCycle };
|
||||
}
|
||||
const within = recorded.filter((s) => s.state === "within");
|
||||
if (within.length > 0) {
|
||||
return {
|
||||
...none,
|
||||
code: "steady",
|
||||
count: within.length,
|
||||
total: recorded.length,
|
||||
};
|
||||
}
|
||||
return { ...none, code: "learning" };
|
||||
}
|
||||
|
||||
function toneOf(summary: TopicSummary, labs: TopicLabPanel | null): TopicTone {
|
||||
if (summary.code === "outside") return "watch";
|
||||
if (summary.code === "labs") {
|
||||
return labs && labs.inRange < labs.withRange ? "watch" : "steady";
|
||||
}
|
||||
if (summary.code === "steady" || summary.code === "cycleDay") return "steady";
|
||||
return "learning";
|
||||
}
|
||||
|
||||
function headlineOf(
|
||||
signals: readonly TopicSignal[],
|
||||
inputs: TopicInputs,
|
||||
): TopicHeadline | null {
|
||||
const first = signals.find((s) => s.latest !== null);
|
||||
if (first?.latest) {
|
||||
return {
|
||||
kind: "signal",
|
||||
key: first.key,
|
||||
value: first.latest.value,
|
||||
day: first.latest.day,
|
||||
};
|
||||
}
|
||||
if (inputs.labs) {
|
||||
return {
|
||||
kind: "labs",
|
||||
inRange: inputs.labs.inRange,
|
||||
total: inputs.labs.withRange,
|
||||
};
|
||||
}
|
||||
if (inputs.cycle?.today) {
|
||||
return { kind: "cycleDay", dayOfCycle: inputs.cycle.today.dayOfCycle };
|
||||
}
|
||||
const screener = inputs.screeners?.[0];
|
||||
if (screener) {
|
||||
return {
|
||||
kind: "screener",
|
||||
instrument: screener.instrument,
|
||||
score: screener.latest.score,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** The topic's slice of the shared read, with blocks it may not show removed. */
|
||||
export function inputsForTopic(
|
||||
def: TopicDefinition,
|
||||
inputs: TopicInputs,
|
||||
ctx: TopicVisibility,
|
||||
): TopicInputs {
|
||||
const allowed = (extra: "labs" | "training" | "screeners" | "cycle") =>
|
||||
def.extras.includes(extra) && isExtraVisible(extra, ctx);
|
||||
return {
|
||||
series: inputs.series,
|
||||
labs: allowed("labs") ? inputs.labs : null,
|
||||
training: allowed("training") ? inputs.training : null,
|
||||
screeners: allowed("screeners") ? inputs.screeners : null,
|
||||
cycle: allowed("cycle") ? inputs.cycle : null,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildOverviewEntry(
|
||||
def: TopicDefinition,
|
||||
inputs: TopicInputs,
|
||||
ctx: TopicVisibility,
|
||||
todayKey: string,
|
||||
): TopicOverviewEntry | null {
|
||||
const own = inputsForTopic(def, inputs, ctx);
|
||||
const signals = buildSignals(visibleSignals(def, ctx), own, todayKey);
|
||||
const summary = summarise(signals, own);
|
||||
if (summary.code === "empty") return null;
|
||||
const headline = headlineOf(signals, own);
|
||||
const sparkline =
|
||||
headline?.kind === "signal"
|
||||
? windowValues(
|
||||
own.series.get(headline.key),
|
||||
shiftDateKey(todayKey, -(SPARKLINE_POINTS - 1)),
|
||||
todayKey,
|
||||
).map((p) => p.value)
|
||||
: [];
|
||||
return {
|
||||
key: def.key,
|
||||
headline,
|
||||
summary,
|
||||
tone: toneOf(summary, own.labs),
|
||||
sparkline,
|
||||
recorded: signals.filter((s) => s.latest !== null).length,
|
||||
total: signals.length,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildOverview(
|
||||
offered: readonly TopicKey[],
|
||||
hiddenByModule: readonly TopicKey[],
|
||||
inputs: TopicInputs,
|
||||
ctx: TopicVisibility,
|
||||
todayKey: string,
|
||||
): TopicOverview {
|
||||
const topics: TopicOverviewEntry[] = [];
|
||||
const empty: TopicKey[] = [];
|
||||
for (const key of offered) {
|
||||
const entry = buildOverviewEntry(TOPICS[key], inputs, ctx, todayKey);
|
||||
if (entry) topics.push(entry);
|
||||
else empty.push(key);
|
||||
}
|
||||
return { topics, empty, hiddenByModule: [...hiddenByModule] };
|
||||
}
|
||||
|
||||
/** First day of the read a topic page needs for its range. */
|
||||
export function detailReadStart(range: TopicRange, todayKey: string): string {
|
||||
const days = Math.max(TOPIC_RANGE_DAYS[range], TOPIC_BASE_WINDOW_DAYS);
|
||||
return shiftDateKey(todayKey, -days);
|
||||
}
|
||||
|
||||
export function buildDetail(
|
||||
def: TopicDefinition,
|
||||
range: TopicRange,
|
||||
inputs: TopicInputs,
|
||||
ctx: TopicVisibility,
|
||||
todayKey: string,
|
||||
): TopicDetail {
|
||||
const own = inputsForTopic(def, inputs, ctx);
|
||||
const signalKeys = visibleSignals(def, ctx);
|
||||
const signals = buildSignals(signalKeys, own, todayKey);
|
||||
const summary = summarise(signals, own);
|
||||
const from = shiftDateKey(todayKey, -(TOPIC_RANGE_DAYS[range] - 1));
|
||||
const pairFrom = detailReadStart(range, todayKey);
|
||||
|
||||
const series: TopicSeries[] = [];
|
||||
for (const key of def.chart) {
|
||||
if (!isSignalVisible(key, ctx)) continue;
|
||||
const values = own.series.get(key);
|
||||
const points = windowValues(values, from, todayKey);
|
||||
if (points.length === 0) continue;
|
||||
series.push({ key, usual: usualRange(key, values, todayKey), points });
|
||||
}
|
||||
|
||||
// A pair shows when both sides may be shown and both hold values: that is
|
||||
// when "needs 14 paired days, you have n" tells the reader something. A
|
||||
// pair with a switched-off or never-recorded side is not named.
|
||||
const relationships = def.relationships
|
||||
.filter((rel) => isSignalVisible(rel.x, ctx) && isSignalVisible(rel.y, ctx))
|
||||
.filter((rel) => own.series.has(rel.x) && own.series.has(rel.y))
|
||||
.map((rel) =>
|
||||
computeRelationship(
|
||||
rel,
|
||||
own.series.get(rel.x),
|
||||
own.series.get(rel.y),
|
||||
pairFrom,
|
||||
todayKey,
|
||||
),
|
||||
);
|
||||
|
||||
const trends = signals
|
||||
.filter((s) => s.latest !== null)
|
||||
.map((s) => computeTrend(s.key, own.series.get(s.key), todayKey));
|
||||
|
||||
const missing = signals.filter((s) => s.latest === null).map((s) => s.key);
|
||||
const status =
|
||||
summary.code === "empty"
|
||||
? "empty"
|
||||
: missing.length > 0
|
||||
? "partial"
|
||||
: "ready";
|
||||
|
||||
return {
|
||||
key: def.key,
|
||||
range,
|
||||
from,
|
||||
to: todayKey,
|
||||
status,
|
||||
summary,
|
||||
signals,
|
||||
series,
|
||||
relationships,
|
||||
trends,
|
||||
missing,
|
||||
labs: own.labs,
|
||||
training: own.training,
|
||||
screeners: own.screeners,
|
||||
cycle: own.cycle,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
/**
|
||||
* The lab block of the metabolism topic: each marker's latest reading against
|
||||
* the reference window in force for it, and the marker's trend line.
|
||||
*
|
||||
* The window is the one the labs list judges the reading by
|
||||
* (`resolveEffectiveReferenceRange`: the range the report printed, else the
|
||||
* marker's catalog band); the trend is `computeLabTrend` over every numeric
|
||||
* reading of the marker against the marker's band, exactly as the marker page
|
||||
* computes it. A trend needs three readings spanning a year; below that it is
|
||||
* `insufficient` and the topic says what is missing.
|
||||
*/
|
||||
import { prisma } from "@/lib/db";
|
||||
import { computeLabTrend, type LabTrend } from "@/lib/labs/lab-trend";
|
||||
import {
|
||||
classifyReferenceRange,
|
||||
resolveEffectiveReferenceRange,
|
||||
type ReferenceRangeStatus,
|
||||
} from "@/lib/labs/reference-range";
|
||||
import { dateOnlyKey } from "@/lib/tz/date-only";
|
||||
|
||||
/** Markers the panel shows, the most recently measured first. */
|
||||
export const LAB_PANEL_MAX_MARKERS = 24;
|
||||
|
||||
/** Readings the panel reads; far above any real history, the cap falls on the oldest. */
|
||||
const LAB_READ_CAP = 4000;
|
||||
|
||||
export interface TopicLabMarker {
|
||||
biomarkerId: string;
|
||||
name: string;
|
||||
unit: string;
|
||||
latest: number;
|
||||
/** Calendar day of the newest reading. */
|
||||
takenOn: string;
|
||||
low: number | null;
|
||||
high: number | null;
|
||||
status: ReferenceRangeStatus;
|
||||
readings: number;
|
||||
trend: LabTrend;
|
||||
}
|
||||
|
||||
export interface TopicLabPanel {
|
||||
markers: TopicLabMarker[];
|
||||
/** Markers whose latest reading sits inside its window. */
|
||||
inRange: number;
|
||||
/** Markers with a window to sit inside. */
|
||||
withRange: number;
|
||||
/** Calendar day of the newest reading across the panel. */
|
||||
latestOn: string | null;
|
||||
}
|
||||
|
||||
interface MarkerRow {
|
||||
id: string;
|
||||
name: string;
|
||||
unit: string;
|
||||
lowerBound: number | null;
|
||||
upperBound: number | null;
|
||||
}
|
||||
|
||||
interface ReadingRow {
|
||||
biomarkerId: string | null;
|
||||
value: number | null;
|
||||
takenAt: Date;
|
||||
sourceReferenceLow: number | null;
|
||||
sourceReferenceHigh: number | null;
|
||||
sourceReferenceText: string | null;
|
||||
sourceUnit: string | null;
|
||||
}
|
||||
|
||||
/** Shape the panel from the rows. Pure; `readings` arrive newest first. */
|
||||
export function shapeLabPanel(
|
||||
markers: readonly MarkerRow[],
|
||||
readings: readonly ReadingRow[],
|
||||
now: Date,
|
||||
): TopicLabPanel | null {
|
||||
const byMarker = new Map<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);
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
/**
|
||||
* The reads behind the topic pages. Every one is a read the single-metric
|
||||
* pages already make, so a topic never shows a value its metric page would
|
||||
* not:
|
||||
*
|
||||
* - measurement days: `readLocalDailyStats`, the canonical-source local-day
|
||||
* value the charts draw (a cumulative type's day total, a pulse-like
|
||||
* type's mean of hourly means), in one query for every type a page needs;
|
||||
* - sleep: the night's time asleep as the sleep page reconstructs it
|
||||
* (`readNightCells`), keyed by the day the night ended;
|
||||
* - mood: the day's mean of its entries' 1–5 score;
|
||||
* - the extra blocks: the training load the workouts page shows, the lab
|
||||
* markers with the trend the marker page shows, the screener trends of the
|
||||
* check-in page, and today's place in the cycle as the cycle page decides
|
||||
* it.
|
||||
*
|
||||
* The caller decides what may be read (module switches, the grant's
|
||||
* sections); a signal or block it leaves out is never queried.
|
||||
*/
|
||||
import type { MeasurementType } from "@/generated/prisma/enums";
|
||||
import { readTodayCycle } from "@/lib/cycle/today-verdict";
|
||||
import { readLocalDailyStats } from "@/lib/day/daily-stats";
|
||||
import { prisma } from "@/lib/db";
|
||||
import {
|
||||
readTrainingLoad,
|
||||
type TrainingLoad,
|
||||
} from "@/lib/insights/training-load";
|
||||
import {
|
||||
computeAssessmentTrends,
|
||||
TREND_INSTRUMENTS,
|
||||
type AssessmentTrend,
|
||||
} from "@/lib/mental-health/assessment-trend";
|
||||
import { loadUserSourcePriority } from "@/lib/rollups/measurement-read";
|
||||
import { readNightCells } from "@/lib/timeline/series";
|
||||
import { daysBetweenDateKeys, shiftDateKey } from "@/lib/tz/format";
|
||||
import { startOfLocalDayKey } from "@/lib/tz/local-day";
|
||||
|
||||
import { readLabPanel, type TopicLabPanel } from "./labs";
|
||||
import { MOOD_SIGNAL, type TopicExtra, type TopicSignalKey } from "./registry";
|
||||
import type { DaySeries } from "./stats";
|
||||
|
||||
/** Screener administrations the trends read; the cap falls on the oldest. */
|
||||
const SCREENER_READ_CAP = 1500;
|
||||
|
||||
/** Cycle lengths the cycle block reports, newest kept. */
|
||||
const CYCLE_LENGTHS_SHOWN = 6;
|
||||
|
||||
/** A plausible cycle length; anything outside is a gap in logging. */
|
||||
const CYCLE_LENGTH_MIN_DAYS = 15;
|
||||
const CYCLE_LENGTH_MAX_DAYS = 60;
|
||||
|
||||
export interface TopicCycleSummary {
|
||||
/** Today's cycle day and phase, or null while the cycle is still learned. */
|
||||
today: { dayOfCycle: number; phase: string | null } | null;
|
||||
/** Lengths of the completed cycles, oldest first, at most six. */
|
||||
lengths: number[];
|
||||
/** Mean of `lengths`, or null with none. */
|
||||
averageLength: number | null;
|
||||
/** Logged period starts on record. */
|
||||
periodsLogged: number;
|
||||
}
|
||||
|
||||
export interface TopicReadRequest {
|
||||
userId: string;
|
||||
tz: string;
|
||||
todayKey: string;
|
||||
/** First local day to read (inclusive); the last is `todayKey`. */
|
||||
fromKey: string;
|
||||
signals: ReadonlySet<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 };
|
||||
}
|
||||
@@ -0,0 +1,321 @@
|
||||
/**
|
||||
* The topic pages (preview): which signals each topic brings together, which
|
||||
* pairs it checks against each other and which extra blocks it carries.
|
||||
*
|
||||
* A topic is an overview layer above the single-metric Insights pages. It
|
||||
* never owns a number of its own: every value is the same daily value the
|
||||
* metric page draws, and every topic links into those pages. This file is the
|
||||
* one declaration; the server builds from it (`build.ts`) and the browser
|
||||
* imports only the keys and labels, so it carries constants and types only.
|
||||
*
|
||||
* Module ownership is not restated here. A measurement signal follows its
|
||||
* type's owner (`moduleForMeasurementType`), the mood signal follows `mood`,
|
||||
* and the extra blocks name their module in {@link TOPIC_EXTRA_MODULE}. A
|
||||
* topic is offered when at least one of its {@link TopicDefinition.modules}
|
||||
* is on (an empty list means it rides core signals and is always offered);
|
||||
* inside it, each signal and block shows only when its own owner is on.
|
||||
*/
|
||||
import type { MeasurementType } from "@/generated/prisma/enums";
|
||||
import type { ModuleKey } from "@/lib/modules/registry";
|
||||
|
||||
/** The topics, in the overview's reading order. Also the URL slugs. */
|
||||
export const TOPIC_KEYS = [
|
||||
"heart",
|
||||
"body",
|
||||
"blood-pressure",
|
||||
"sleep",
|
||||
"activity",
|
||||
"metabolism",
|
||||
"mind",
|
||||
"cycle",
|
||||
] as const;
|
||||
|
||||
export type TopicKey = (typeof TOPIC_KEYS)[number];
|
||||
|
||||
export function isTopicKey(value: unknown): value is TopicKey {
|
||||
return (
|
||||
typeof value === "string" &&
|
||||
(TOPIC_KEYS as readonly string[]).includes(value)
|
||||
);
|
||||
}
|
||||
|
||||
/** The mood score (1 to 5, the day's mean of its entries) as a signal. */
|
||||
export const MOOD_SIGNAL = "MOOD";
|
||||
|
||||
/** A daily series a topic reads: a measurement type or the mood score. */
|
||||
export type TopicSignalKey = MeasurementType | typeof MOOD_SIGNAL;
|
||||
|
||||
/** The extra blocks a topic may carry beside its signals. */
|
||||
export type TopicExtra = "training" | "labs" | "screeners" | "cycle";
|
||||
|
||||
/** The module each extra block follows. */
|
||||
export const TOPIC_EXTRA_MODULE: Readonly<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;
|
||||
@@ -0,0 +1,127 @@
|
||||
/**
|
||||
* The topic routes' one entry: who may see the topics, what the record may
|
||||
* show, and the reads and assembly behind both answers.
|
||||
*
|
||||
* Two people are in play and they are kept apart. The preview switch is the
|
||||
* VIEWER's (`actorId`): a preview is chrome of the person reading, so it is
|
||||
* their opt-in that decides whether the topics exist for them. Everything the
|
||||
* page shows is the RECORD's (`userId`): its module switches decide which
|
||||
* topics, signals and blocks are offered, and the sections the viewer's grant
|
||||
* opens decide which of them may be read at all.
|
||||
*/
|
||||
import { prisma } from "@/lib/db";
|
||||
import { resolveModuleMap } from "@/lib/modules/gate";
|
||||
import { resolvePreviewFeatures } from "@/lib/settings/preview-features";
|
||||
import { actingDomainVisibility } from "@/lib/sharing/acting-domains";
|
||||
import { shiftDateKey, userDayKey } from "@/lib/tz/format";
|
||||
import { resolveUserTimezone } from "@/lib/tz/resolver";
|
||||
|
||||
import {
|
||||
buildDetail,
|
||||
buildOverview,
|
||||
detailReadStart,
|
||||
type TopicDetail,
|
||||
type TopicOverview,
|
||||
} from "./build";
|
||||
import { readTopicInputs } from "./read";
|
||||
import {
|
||||
TOPIC_BASE_WINDOW_DAYS,
|
||||
TOPICS,
|
||||
TOPIC_KEYS,
|
||||
type TopicExtra,
|
||||
type TopicKey,
|
||||
type TopicRange,
|
||||
type TopicSignalKey,
|
||||
} from "./registry";
|
||||
import {
|
||||
isExtraVisible,
|
||||
isTopicOffered,
|
||||
offeredTopics,
|
||||
signalsToRead,
|
||||
visibleExtras,
|
||||
visibleSignals,
|
||||
type TopicVisibility,
|
||||
} from "./visibility";
|
||||
|
||||
/** Whether the viewer switched the topics preview on for themselves. */
|
||||
export async function hasTopicsPreview(actorId: string): Promise<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);
|
||||
}
|
||||
@@ -0,0 +1,367 @@
|
||||
/**
|
||||
* The arithmetic of the topic pages, over day-keyed series. Pure: the reads
|
||||
* live in `read.ts`, the assembly in `build.ts`.
|
||||
*
|
||||
* Nothing here invents a figure. A usual range needs a week of days, a trend
|
||||
* two weeks, a relationship fourteen paired days; below that the answer says
|
||||
* how many days there are and how many it needs, and carries no number that
|
||||
* would read as a finding.
|
||||
*
|
||||
* - Usual range: the personal band the Insights tiles call "typical for
|
||||
* you", median ± 3 scaled MAD of the daily values (`buildBaselineBand`),
|
||||
* over the trailing {@link TOPIC_BASE_WINDOW_DAYS} days.
|
||||
* - Trend: the least-squares slope of the daily values over the same
|
||||
* window, reported as the change it amounts to across the window, beside
|
||||
* weekly means for the sparkline.
|
||||
* - Relationship: Pearson's r (two-sided p from the t statistic, the one
|
||||
* correlation the app reports) or, for a `split`, the difference of two
|
||||
* means with a Welch t test. A finding needs p < 0.05; otherwise it reads
|
||||
* as "no clear link over n days". Never a cause.
|
||||
*/
|
||||
import type { MeasurementType } from "@/generated/prisma/enums";
|
||||
import { buildBaselineBand } from "@/lib/insights/derived/baseline";
|
||||
import {
|
||||
MAX_P_VALUE,
|
||||
pearson,
|
||||
twoSidedPFromT,
|
||||
} from "@/lib/insights/correlations";
|
||||
import { linearFit } from "@/lib/stats/linear-fit";
|
||||
import { variance } from "@/lib/stats/spread";
|
||||
import { daysBetweenDateKeys, shiftDateKey } from "@/lib/tz/format";
|
||||
|
||||
import {
|
||||
MOOD_SIGNAL,
|
||||
RELATIONSHIP_MAX_POINTS,
|
||||
RELATIONSHIP_MIN_PAIRED_DAYS,
|
||||
SPLIT_MIN_DAYS_PER_SIDE,
|
||||
TOPIC_BASE_WINDOW_DAYS,
|
||||
TREND_MIN_DAYS,
|
||||
USUAL_RANGE_MIN_DAYS,
|
||||
type TopicRelationshipDef,
|
||||
type TopicSignalKey,
|
||||
} from "./registry";
|
||||
|
||||
/** One signal's values, keyed by local day (`YYYY-MM-DD`). */
|
||||
export type DaySeries = ReadonlyMap<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,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
/**
|
||||
* What a topic may show for the record on screen: the record's module
|
||||
* switches and the sections the viewer's grant opens.
|
||||
*
|
||||
* Both are asked of every signal and every block, not once per topic, so a
|
||||
* switched-off module disappears from every topic it would otherwise appear
|
||||
* in (sleep from the heart chart, mood from the sleep pairs) and a section the
|
||||
* grant does not open is never read.
|
||||
*/
|
||||
import type { MeasurementType } from "@/generated/prisma/enums";
|
||||
import { moduleForMeasurementType } from "@/lib/modules/measurement-scope";
|
||||
import type { ModuleKey } from "@/lib/modules/registry";
|
||||
import type { SurfaceModuleMap } from "@/lib/modules/surface";
|
||||
import type { ShareScope } from "@/lib/sharing/scope";
|
||||
|
||||
import {
|
||||
MOOD_SIGNAL,
|
||||
TOPIC_EXTRA_MODULE,
|
||||
TOPICS,
|
||||
TOPIC_KEYS,
|
||||
type TopicDefinition,
|
||||
type TopicExtra,
|
||||
type TopicKey,
|
||||
type TopicSignalKey,
|
||||
} from "./registry";
|
||||
|
||||
export interface TopicVisibility {
|
||||
/** The record's resolved module map; only an explicit `false` hides. */
|
||||
modules: SurfaceModuleMap;
|
||||
/** Whether the viewer's grant opens a section (always true on one's own). */
|
||||
sees: (scope: ShareScope) => boolean;
|
||||
}
|
||||
|
||||
const EXTRA_SECTION: Readonly<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)));
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* Request schemas of the topic routes (preview): the topic in the path and
|
||||
* the window of a topic page. Shared by the routes and the OpenAPI registry.
|
||||
*/
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import {
|
||||
DEFAULT_TOPIC_RANGE,
|
||||
TOPIC_KEYS,
|
||||
TOPIC_RANGES,
|
||||
} from "@/lib/topics/registry";
|
||||
|
||||
export const topicKeySchema = z
|
||||
.enum(TOPIC_KEYS)
|
||||
.describe("The topic, as its URL slug.");
|
||||
|
||||
export const topicPathSchema = z.object({ topic: topicKeySchema });
|
||||
|
||||
export const topicDetailQuerySchema = z.object({
|
||||
range: z
|
||||
.enum(TOPIC_RANGES)
|
||||
.default(DEFAULT_TOPIC_RANGE)
|
||||
.describe(
|
||||
"The window the side-by-side series cover. The usual ranges, the trends and the relationships always read at least the trailing 90 days.",
|
||||
),
|
||||
});
|
||||
|
||||
export type TopicDetailQuery = z.infer<typeof topicDetailQuerySchema>;
|
||||
@@ -161,6 +161,12 @@ export async function seedContractAccount(
|
||||
nutrients: true,
|
||||
timeline: true,
|
||||
});
|
||||
// The topic pages are a preview the account switches on for itself; the
|
||||
// topic in their path is a fixed name, not a seeded row.
|
||||
await write("preview", "PATCH", "api/settings/preview-features", {
|
||||
topics: true,
|
||||
});
|
||||
ids.topic = "heart";
|
||||
|
||||
// ── Measurements: ninety days of the common series ─────────────────────
|
||||
const rows: Array<{
|
||||
@@ -1061,6 +1067,7 @@ export async function seedContractAccount(
|
||||
/** Which seeded id fills which path parameter. */
|
||||
const PATH_PARAM_SOURCES: Record<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" },
|
||||
|
||||
@@ -0,0 +1,384 @@
|
||||
/**
|
||||
* `/api/topics` and `/api/topics/{topic}` — the topic pages (preview), against
|
||||
* the real database and through the real API kit.
|
||||
*
|
||||
* Pinned here:
|
||||
* - the preview is the VIEWER's opt-in: off answers 404
|
||||
* `topics.preview_off` on both routes, for the owner and for a delegate
|
||||
* whose own switch is off while the owner's is on;
|
||||
* - the overview gives a topic a tile only when the record holds data for
|
||||
* it, names the offered empty topics and the module-hidden ones, and
|
||||
* follows the record's module switches (cycle without the cycle module);
|
||||
* - a topic page reads the stored values: the latest reading with its usual
|
||||
* range, a relationship once fourteen paired days exist and its count
|
||||
* below that, the lab trend from three readings spanning a year and the
|
||||
* honest "not yet" below it;
|
||||
* - a switched-off module removes its topic (404 `topics.not_available`)
|
||||
* and its signal from every other topic; an unknown topic is 404
|
||||
* `topics.not_found`, an unknown range 422;
|
||||
* - a delegate holding the whole record reads the owner's topics, a grant
|
||||
* scoped to one section is refused, a wildcard Bearer is admitted and a
|
||||
* narrow one is not;
|
||||
* - `/api/auth/me` publishes the viewer's own opt-in as `previewFeatures`.
|
||||
*/
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
|
||||
import { GET as getMe } from "@/app/api/auth/me/route";
|
||||
import { PATCH as patchPreview } from "@/app/api/settings/preview-features/route";
|
||||
import { GET as getDetail } from "@/app/api/topics/[topic]/route";
|
||||
import { GET as getOverview } from "@/app/api/topics/route";
|
||||
import type { MeasurementType } from "@/generated/prisma/enums";
|
||||
import type { TopicDetail, TopicOverview } from "@/lib/topics/build";
|
||||
|
||||
import { anonymous, callRoute, type RouteAuth } from "../../kit/call-route";
|
||||
import {
|
||||
makeGrant,
|
||||
makeMeasurement,
|
||||
makeSession,
|
||||
makeToken,
|
||||
makeUser,
|
||||
} from "../../kit/factories";
|
||||
import { getPrismaClient, truncateAllTables } from "../setup";
|
||||
|
||||
const prisma = getPrismaClient();
|
||||
const DAY = 86_400_000;
|
||||
|
||||
beforeEach(() => truncateAllTables(prisma));
|
||||
|
||||
async function switchOn(userId: string) {
|
||||
await prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: { previewFeaturesJson: { topics: true } },
|
||||
});
|
||||
}
|
||||
|
||||
/** One reading a day for `days` days, ending yesterday, valued by `f(i)`. */
|
||||
async function seedDaily(
|
||||
userId: string,
|
||||
type: MeasurementType,
|
||||
unit: string,
|
||||
days: number,
|
||||
f: (i: number) => number,
|
||||
) {
|
||||
const now = Date.now();
|
||||
await prisma.measurement.createMany({
|
||||
data: Array.from({ length: days }, (_, i) => ({
|
||||
userId,
|
||||
type,
|
||||
unit,
|
||||
value: f(i),
|
||||
measuredAt: new Date(now - (i + 1) * DAY - 2 * 3_600_000),
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
function overview(auth: RouteAuth, headers?: Record<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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user