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

GET /api/topics answers one entry per topic the record holds data for:
a headline value, a rule-based sentence and a 30-day sparkline.
GET /api/topics/{topic} answers a topic's signals with their usual
ranges, side-by-side series for a chosen window, the relationships
between its signals, 90-day trends, what it holds nothing for yet, and
its blocks (training load, lab markers with their trend, screener
trends, the cycle day).

Every figure comes from reads the metric pages already make: the local
daily values, the reconstructed nights, the mood score, computeLabTrend,
the training load and the screener trends. No model is involved. A
relationship says nothing below 14 paired days and is only called a
pattern at p < 0.05; a lab trend needs three readings spanning a year.

The preview is the viewer's own opt-in: with it off both routes answer
404 topics.preview_off. Topics, signals and blocks follow the record's
module switches and the sections the viewer's grant opens; the routes
read across sections, so a scoped grant is refused like the timeline.
/api/auth/me publishes the caller's opt-ins as previewFeatures.
This commit is contained in:
Marc-André Bombeck committed 2026-10-23 08:18:43 +02:00
1 parent 14d0184b63
commit cc04e0944d
30 files changed
+3937 -35

No files matched your search

+758 -5
View File
@@ -26754,6 +26754,95 @@ paths:
$ref: "#/components/schemas/ErrorEnvelope"
"422": *a4
"429": *a5
/api/topics:
get:
tags:
- Insights
summary: Topic overview (preview)
description: One entry per topic the record holds data for, with a headline value, a rule-based sentence and a 30-day
sparkline, computed from the same daily values the metric pages draw. No model, no AI gate. Topics follow the
record's module switches. Reads across sections, so a grant scoped to some of them is refused.
responses:
"200":
description: The overview.
content:
application/json:
schema:
$ref: "#/components/schemas/TopicOverviewEnvelope"
"401": *a1
"403": *a15
"404":
description: "`topics.preview_off`: the person reading has not switched the topics preview on (Settings > System >
Preview features). The preview is per account and off by default."
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorEnvelope"
"422": *a4
"429": *a5
/api/topics/{topic}:
get:
tags:
- Insights
summary: One topic (preview)
description: "A topic's signals with their usual ranges, the side-by-side series for `range`, relationships between its
signals (nothing is said below 14 paired days), 90-day trends, the signals it holds nothing for yet, and its
blocks: training load, lab markers with their trend (three readings spanning a year), screener trends, today's
cycle day. No model, no AI gate."
parameters:
- in: path
name: topic
schema:
type: string
enum:
- heart
- body
- blood-pressure
- sleep
- activity
- metabolism
- mind
- cycle
description: The topic, as its URL slug.
required: true
description: The topic, as its URL slug.
- in: query
name: range
schema:
default: 30d
description: The window the side-by-side series cover. The usual ranges, the trends and the relationships always read at
least the trailing 90 days.
type: string
enum:
- 7d
- 30d
- 90d
- 1y
description: The window the side-by-side series cover. The usual ranges, the trends and the relationships always read at
least the trailing 90 days.
responses:
"200":
description: The topic.
content:
application/json:
schema:
$ref: "#/components/schemas/TopicDetailEnvelope"
"401": *a1
"403": *a15
"404":
description: >-
`topics.preview_off`: the person reading has not switched the topics preview on (Settings > System > Preview
features). The preview is per account and off by default.
`topics.not_found`: no topic by that name. `topics.not_available`: the record's module switches do not offer
this topic.
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorEnvelope"
"422": *a4
"429": *a5
/api/bugreport:
post:
security: []
@@ -38003,10 +38092,11 @@ components:
symptoms.definition.limitReached, symptoms.definition.notFound, symptoms.episode.notOpen,
symptoms.event.notFound, symptoms.invalid; timeline — timeline.invalid; tokens —
tokens.documents.ceiling_reached, tokens.measurements.ceiling_reached, tokens.workouts.ceiling_reached;
vaccination — vaccination.booster-invalid, vaccination.booster-no-antigen,
vaccination.booster-out-of-scope, vaccination.custom-vaccine-not-found, vaccination.custom.invalid,
vaccination.custom.name-taken, vaccination.custom.not-found, vaccination.encounter-not-found,
vaccination.identity-required, vaccination.invalid, vaccination.link.invalid, vaccination.not-found,
topics — topics.not_available, topics.not_found, topics.preview_off; vaccination —
vaccination.booster-invalid, vaccination.booster-no-antigen, vaccination.booster-out-of-scope,
vaccination.custom-vaccine-not-found, vaccination.custom.invalid, vaccination.custom.name-taken,
vaccination.custom.not-found, vaccination.encounter-not-found, vaccination.identity-required,
vaccination.invalid, vaccination.link.invalid, vaccination.not-found,
vaccination.practitioner-not-found; workout — workout.batch.invalid, workout.batch.payload_too_large,
workout.batch.source_not_permitted, workout.batch.too_large, workout.delete.synced_source; (unprefixed)
— about_me_conflict, ai_response_truncated, coach_prefs_conflict, dashboard_layout_conflict, empty_file,
@@ -72671,7 +72761,8 @@ components:
properties:
topics:
type: boolean
description: Topic overview pages above the single-metric Insights pages. Off by default.
description: "The Topics area (`/api/topics`): overview pages that bring related signals together above the
single-metric Insights pages, which stay as they are. Off by default."
required:
- topics
additionalProperties: false
@@ -85168,6 +85259,8 @@ components:
$ref: "#/components/schemas/AiAccountBlock"
coachReasoning:
$ref: "#/components/schemas/CoachReasoningState"
previewFeatures:
$ref: "#/components/schemas/PreviewFeatures"
recordSession:
anyOf:
- $ref: "#/components/schemas/RecordSessionState"
@@ -85198,6 +85291,7 @@ components:
- moduleAccess
- ai
- coachReasoning
- previewFeatures
- recordSession
- notificationPrefs
- onboarding
@@ -92230,6 +92324,665 @@ components:
- data
- error
additionalProperties: false
TopicOverviewEnvelope:
type: object
properties:
data:
$ref: "#/components/schemas/TopicOverview"
error:
type: "null"
meta:
type: object
properties:
requestId:
type: string
additionalProperties: false
required:
- data
- error
additionalProperties: false
TopicOverview:
type: object
properties:
topics:
type: array
items:
$ref: "#/components/schemas/TopicOverviewEntry"
empty:
type: array
items:
$ref: "#/components/schemas/TopicKey"
description: Offered topics with nothing recorded yet.
hiddenByModule:
type: array
items:
$ref: "#/components/schemas/TopicKey"
description: Topics whose every module is switched off for the record.
required:
- topics
- empty
- hiddenByModule
additionalProperties: false
TopicOverviewEntry:
type: object
properties:
key:
$ref: "#/components/schemas/TopicKey"
headline:
anyOf:
- $ref: "#/components/schemas/TopicHeadline"
- type: "null"
summary:
$ref: "#/components/schemas/TopicSummary"
tone:
type: string
enum:
- steady
- watch
- learning
sparkline:
type: array
items:
type: number
description: The headline signal's daily values over the last 30 days, oldest first; empty when the headline is a block.
recorded:
type: integer
minimum: -9007199254740991
maximum: 9007199254740991
total:
type: integer
minimum: -9007199254740991
maximum: 9007199254740991
required:
- key
- headline
- summary
- tone
- sparkline
- recorded
- total
additionalProperties: false
TopicKey:
type: string
enum:
- heart
- body
- blood-pressure
- sleep
- activity
- metabolism
- mind
- cycle
description: A topic, as its URL slug.
TopicHeadline:
oneOf:
- type: object
properties:
kind:
type: string
const: signal
key:
$ref: "#/components/schemas/TopicSignalKey"
value:
type: number
day:
type: string
description: A local calendar day, `YYYY-MM-DD`.
required:
- kind
- key
- value
- day
additionalProperties: false
- type: object
properties:
kind:
type: string
const: labs
inRange:
type: integer
minimum: -9007199254740991
maximum: 9007199254740991
total:
type: integer
minimum: -9007199254740991
maximum: 9007199254740991
required:
- kind
- inRange
- total
additionalProperties: false
- type: object
properties:
kind:
type: string
const: cycleDay
dayOfCycle:
type: integer
minimum: -9007199254740991
maximum: 9007199254740991
required:
- kind
- dayOfCycle
additionalProperties: false
- type: object
properties:
kind:
type: string
const: screener
instrument:
type: string
score:
type: number
required:
- kind
- instrument
- score
additionalProperties: false
type: object
TopicSignalKey:
anyOf:
- $ref: "#/components/schemas/MeasurementType"
- type: string
const: MOOD
description: "A daily series a topic reads: a `MeasurementType`, or `MOOD` for the day's mean mood score (1 to 5).
Values are in the type's stored unit (sleep in minutes asleep per night, keyed by the day the night ended);
clients convert for display as they do for the metric pages."
TopicSummary:
type: object
properties:
code:
type: string
enum:
- outside
- labs
- cycleDay
- steady
- learning
- empty
signal:
anyOf:
- $ref: "#/components/schemas/TopicSignalKey"
- type: "null"
direction:
anyOf:
- type: string
enum:
- above
- below
- type: "null"
count:
anyOf:
- type: integer
minimum: -9007199254740991
maximum: 9007199254740991
- type: "null"
total:
anyOf:
- type: integer
minimum: -9007199254740991
maximum: 9007199254740991
- type: "null"
required:
- code
- signal
- direction
- count
- total
additionalProperties: false
description: "The topic's one sentence as a code with parameters, picked by a fixed rule (never model text): `outside`
names the first signal outside its usual range and the direction; `labs` counts lab markers inside their window
(`count` of `total`); `cycleDay` gives today's cycle day in `count`; `steady` counts signals inside their range
(`count` of `total` recorded); `learning` while no range is established; `empty` without any data."
TopicDetailEnvelope:
type: object
properties:
data:
$ref: "#/components/schemas/TopicDetail"
error:
type: "null"
meta:
type: object
properties:
requestId:
type: string
additionalProperties: false
required:
- data
- error
additionalProperties: false
TopicDetail:
type: object
properties:
key:
$ref: "#/components/schemas/TopicKey"
range:
type: string
enum:
- 7d
- 30d
- 90d
- 1y
from:
type: string
description: A local calendar day, `YYYY-MM-DD`.
to:
type: string
description: A local calendar day, `YYYY-MM-DD`.
status:
type: string
enum:
- ready
- partial
- empty
summary:
$ref: "#/components/schemas/TopicSummary"
signals:
type: array
items:
$ref: "#/components/schemas/TopicSignal"
series:
type: array
items:
type: object
properties:
key:
$ref: "#/components/schemas/TopicSignalKey"
usual:
anyOf:
- $ref: "#/components/schemas/TopicUsualRange"
- type: "null"
points:
type: array
items:
type: object
properties:
day:
type: string
description: A local calendar day, `YYYY-MM-DD`.
value:
type: number
required:
- day
- value
additionalProperties: false
required:
- key
- usual
- points
additionalProperties: false
relationships:
type: array
items:
$ref: "#/components/schemas/TopicRelationship"
trends:
type: array
items:
$ref: "#/components/schemas/TopicTrend"
missing:
type: array
items:
$ref: "#/components/schemas/TopicSignalKey"
description: Signals the topic could show that hold no value yet.
labs:
anyOf:
- $ref: "#/components/schemas/TopicLabPanel"
- type: "null"
training:
anyOf:
- $ref: "#/components/schemas/WorkoutTrainingLoad"
- type: "null"
screeners:
anyOf:
- type: array
items:
$ref: "#/components/schemas/MentalHealthAssessmentTrend"
- type: "null"
cycle:
anyOf:
- $ref: "#/components/schemas/TopicCycleSummary"
- type: "null"
required:
- key
- range
- from
- to
- status
- summary
- signals
- series
- relationships
- trends
- missing
- labs
- training
- screeners
- cycle
additionalProperties: false
TopicSignal:
type: object
properties:
key:
$ref: "#/components/schemas/TopicSignalKey"
latest:
anyOf:
- type: object
properties:
day:
type: string
description: A local calendar day, `YYYY-MM-DD`.
value:
type: number
required:
- day
- value
additionalProperties: false
- type: "null"
usual:
anyOf:
- $ref: "#/components/schemas/TopicUsualRange"
- type: "null"
state:
type: string
enum:
- within
- above
- below
- learning
- none
description: "`latest` against `usual`: `learning` while the range needs more days, `none` without a value."
href:
description: The in-app single-metric page of this signal.
type:
- string
- "null"
required:
- key
- latest
- usual
- state
- href
additionalProperties: false
TopicUsualRange:
type: object
properties:
low:
type: number
high:
type: number
center:
type: number
days:
type: integer
minimum: -9007199254740991
maximum: 9007199254740991
description: Days the band was built from.
required:
- low
- high
- center
- days
additionalProperties: false
description: "The personal usual range over the trailing 90 days: median ± 3 scaled MAD of the daily values, never
narrower than the measurement's own wobble. Needs 7 days, else null."
TopicRelationship:
type: object
properties:
id:
type: string
x:
$ref: "#/components/schemas/TopicSignalKey"
y:
$ref: "#/components/schemas/TopicSignalKey"
lagDays:
anyOf:
- type: number
const: 0
- type: number
const: 1
description: "`x` on day d is held against `y` on day d + `lagDays`."
kind:
type: string
enum:
- correlation
- split
pairedDays:
type: integer
minimum: -9007199254740991
maximum: 9007199254740991
requiredDays:
type: integer
minimum: -9007199254740991
maximum: 9007199254740991
description: "Paired days needed before anything is said: 14."
status:
type: string
enum:
- insufficient
- no_clear_link
- linked
description: "`linked` only at p < 0.05 (Pearson, or Welch for a split); a pattern, never a cause."
r:
type:
- number
- "null"
pValue:
type:
- number
- "null"
split:
anyOf:
- type: object
properties:
threshold:
type: number
belowDays:
type: integer
minimum: -9007199254740991
maximum: 9007199254740991
otherDays:
type: integer
minimum: -9007199254740991
maximum: 9007199254740991
belowMean:
type: number
otherMean:
type: number
difference:
type: number
required:
- threshold
- belowDays
- otherDays
- belowMean
- otherMean
- difference
additionalProperties: false
- type: "null"
description: "For a split: the mean of `y` on days `x` sat below `threshold` against the other days."
points:
type: array
items:
type: object
properties:
x:
type: number
y:
type: number
required:
- x
- y
additionalProperties: false
description: Paired values for a scatter, at most 120; empty when insufficient.
required:
- id
- x
- y
- lagDays
- kind
- pairedDays
- requiredDays
- status
- r
- pValue
- split
- points
additionalProperties: false
TopicTrend:
type: object
properties:
key:
$ref: "#/components/schemas/TopicSignalKey"
status:
type: string
enum:
- ok
- insufficient
days:
type: integer
minimum: -9007199254740991
maximum: 9007199254740991
change:
description: The least-squares change across the trailing 90 days, in the stored unit. Needs 14 days.
type:
- number
- "null"
weekly:
type: array
items:
type: object
properties:
day:
type: string
description: A local calendar day, `YYYY-MM-DD`.
value:
type: number
required:
- day
- value
additionalProperties: false
description: Weekly means, each keyed by its Monday.
required:
- key
- status
- days
- change
- weekly
additionalProperties: false
TopicLabPanel:
type: object
properties:
markers:
type: array
items:
$ref: "#/components/schemas/TopicLabMarker"
description: At most 24, newest reading first.
inRange:
type: integer
minimum: -9007199254740991
maximum: 9007199254740991
withRange:
type: integer
minimum: -9007199254740991
maximum: 9007199254740991
latestOn:
anyOf:
- type: string
description: A local calendar day, `YYYY-MM-DD`.
- type: "null"
required:
- markers
- inRange
- withRange
- latestOn
additionalProperties: false
TopicLabMarker:
type: object
properties:
biomarkerId:
type: string
name:
type: string
unit:
type: string
latest:
type: number
takenOn:
type: string
description: A local calendar day, `YYYY-MM-DD`.
low:
type:
- number
- "null"
high:
type:
- number
- "null"
status:
type: string
enum:
- in-range
- below
- above
- unknown
readings:
type: integer
minimum: -9007199254740991
maximum: 9007199254740991
trend:
$ref: "#/components/schemas/BiomarkerTrend"
required:
- biomarkerId
- name
- unit
- latest
- takenOn
- low
- high
- status
- readings
- trend
additionalProperties: false
TopicCycleSummary:
type: object
properties:
today:
anyOf:
- type: object
properties:
dayOfCycle:
type: integer
minimum: -9007199254740991
maximum: 9007199254740991
phase:
type:
- string
- "null"
required:
- dayOfCycle
- phase
additionalProperties: false
- type: "null"
lengths:
type: array
items:
type: integer
minimum: -9007199254740991
maximum: 9007199254740991
averageLength:
type:
- number
- "null"
periodsLogged:
type: integer
minimum: -9007199254740991
maximum: 9007199254740991
required:
- today
- lengths
- averageLength
- periodsLogged
additionalProperties: false
__schema0:
anyOf:
- type: string