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