mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-07 04:12:04 +00:00
Merge branch 'release/v1.46.0' into feat/v146-questionnaires
# Conflicts: # src/__tests__/baselines/source-file-lines.json # src/__tests__/baselines/source-function-lines.json
This commit is contained in:
commit
9de64cae3e
102 files changed
+8731
-260
No files matched your search
@@ -116,6 +116,8 @@
|
||||
"/settings/modules/[module]/page": 374,
|
||||
"/settings/page": 282,
|
||||
"/timeline/page": 331,
|
||||
"/topics/[topic]/page": 298,
|
||||
"/topics/page": 293,
|
||||
"/vaccinations/page": 327
|
||||
}
|
||||
}
|
||||
+758
-5
@@ -26754,6 +26754,95 @@ paths:
|
||||
$ref: "#/components/schemas/ErrorEnvelope"
|
||||
"422": *a4
|
||||
"429": *a5
|
||||
/api/topics:
|
||||
get:
|
||||
tags:
|
||||
- Insights
|
||||
summary: Topic overview (preview)
|
||||
description: One entry per topic the record holds data for, with a headline value, a rule-based sentence and a 30-day
|
||||
sparkline, computed from the same daily values the metric pages draw. No model, no AI gate. Topics follow the
|
||||
record's module switches. Reads across sections, so a grant scoped to some of them is refused.
|
||||
responses:
|
||||
"200":
|
||||
description: The overview.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/TopicOverviewEnvelope"
|
||||
"401": *a1
|
||||
"403": *a15
|
||||
"404":
|
||||
description: "`topics.preview_off`: the person reading has not switched the topics preview on (Settings > System >
|
||||
Preview features). The preview is per account and off by default."
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ErrorEnvelope"
|
||||
"422": *a4
|
||||
"429": *a5
|
||||
/api/topics/{topic}:
|
||||
get:
|
||||
tags:
|
||||
- Insights
|
||||
summary: One topic (preview)
|
||||
description: "A topic's signals with their usual ranges, the side-by-side series for `range`, relationships between its
|
||||
signals (nothing is said below 14 paired days), 90-day trends, the signals it holds nothing for yet, and its
|
||||
blocks: training load, lab markers with their trend (three readings spanning a year), screener trends, today's
|
||||
cycle day. No model, no AI gate."
|
||||
parameters:
|
||||
- in: path
|
||||
name: topic
|
||||
schema:
|
||||
type: string
|
||||
enum:
|
||||
- heart
|
||||
- body
|
||||
- blood-pressure
|
||||
- sleep
|
||||
- activity
|
||||
- metabolism
|
||||
- mind
|
||||
- cycle
|
||||
description: The topic, as its URL slug.
|
||||
required: true
|
||||
description: The topic, as its URL slug.
|
||||
- in: query
|
||||
name: range
|
||||
schema:
|
||||
default: 30d
|
||||
description: The window the side-by-side series cover. The usual ranges, the trends and the relationships always read at
|
||||
least the trailing 90 days.
|
||||
type: string
|
||||
enum:
|
||||
- 7d
|
||||
- 30d
|
||||
- 90d
|
||||
- 1y
|
||||
description: The window the side-by-side series cover. The usual ranges, the trends and the relationships always read at
|
||||
least the trailing 90 days.
|
||||
responses:
|
||||
"200":
|
||||
description: The topic.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/TopicDetailEnvelope"
|
||||
"401": *a1
|
||||
"403": *a15
|
||||
"404":
|
||||
description: >-
|
||||
`topics.preview_off`: the person reading has not switched the topics preview on (Settings > System > Preview
|
||||
features). The preview is per account and off by default.
|
||||
|
||||
|
||||
`topics.not_found`: no topic by that name. `topics.not_available`: the record's module switches do not offer
|
||||
this topic.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ErrorEnvelope"
|
||||
"422": *a4
|
||||
"429": *a5
|
||||
/api/bugreport:
|
||||
post:
|
||||
security: []
|
||||
@@ -38006,10 +38095,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,
|
||||
@@ -72682,7 +72772,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
|
||||
@@ -85207,6 +85298,8 @@ components:
|
||||
$ref: "#/components/schemas/AiAccountBlock"
|
||||
coachReasoning:
|
||||
$ref: "#/components/schemas/CoachReasoningState"
|
||||
previewFeatures:
|
||||
$ref: "#/components/schemas/PreviewFeatures"
|
||||
recordSession:
|
||||
anyOf:
|
||||
- $ref: "#/components/schemas/RecordSessionState"
|
||||
@@ -85237,6 +85330,7 @@ components:
|
||||
- moduleAccess
|
||||
- ai
|
||||
- coachReasoning
|
||||
- previewFeatures
|
||||
- recordSession
|
||||
- notificationPrefs
|
||||
- onboarding
|
||||
@@ -92269,6 +92363,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
|
||||
|
||||
+47
-1
@@ -2,7 +2,10 @@ import AxeBuilder from "@axe-core/playwright";
|
||||
import type { Locator, Page, Route } from "@playwright/test";
|
||||
import { expect, test } from "./setup/test";
|
||||
|
||||
import { STORAGE_STATE_PATH } from "./setup/global-setup";
|
||||
import {
|
||||
MOBILE_ROUTES_STORAGE_STATE_PATH,
|
||||
STORAGE_STATE_PATH,
|
||||
} from "./setup/global-setup";
|
||||
import { aiBlockAvailable, serveAiBlock } from "./setup/ai-capabilities";
|
||||
import { openMenu } from "./open-menu";
|
||||
import { POPULATED_SUMMARIES } from "./utils/mock-dashboard-snapshot";
|
||||
@@ -979,6 +982,49 @@ const ADMIN_AND_BASELINE_ROUTES: readonly RouteCase[] = [
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* The topic pages (preview), on the phone-sweep account: its preview is on
|
||||
* and never switched, and its record holds a little of every kind, so the
|
||||
* overview draws tiles and the pages draw both their data and their
|
||||
* below-threshold states. Gated on the grid and the page body, which live in
|
||||
* other components than the `<h1>` the scan checks.
|
||||
*/
|
||||
const TOPIC_ROUTES: readonly RouteCase[] = [
|
||||
{
|
||||
name: "/topics overview",
|
||||
path: "/topics",
|
||||
painted: (page) => page.locator('[data-slot="topics-grid"]:visible'),
|
||||
},
|
||||
{
|
||||
name: "/topics/heart",
|
||||
path: "/topics/heart",
|
||||
painted: (page) => page.locator('[data-slot="topic-detail-body"]:visible'),
|
||||
},
|
||||
{
|
||||
name: "/topics/metabolism",
|
||||
path: "/topics/metabolism",
|
||||
painted: (page) => page.locator('[data-slot="topic-detail-body"]:visible'),
|
||||
},
|
||||
];
|
||||
|
||||
test.describe("axe-core topic pages (preview)", () => {
|
||||
test.use({ storageState: MOBILE_ROUTES_STORAGE_STATE_PATH });
|
||||
|
||||
for (const theme of THEMES) {
|
||||
test(`theme=${theme} routes: /topics, /topics/heart, /topics/metabolism`, async ({
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(120_000);
|
||||
await useExplicitTheme(page, theme);
|
||||
const blocking: AxeViolation[] = [];
|
||||
for (const routeCase of TOPIC_ROUTES) {
|
||||
blocking.push(...(await visitAndScan(page, theme, routeCase)));
|
||||
}
|
||||
expectNoViolations(theme, "topic route matrix", blocking);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test.describe("axe-core public surfaces", () => {
|
||||
for (const theme of THEMES) {
|
||||
test(`theme=${theme} /auth/login`, async ({ page }) => {
|
||||
|
||||
@@ -6,6 +6,7 @@ import type { APIRequestContext } from "@playwright/test";
|
||||
import { ADMIN_SECTION_SLUGS } from "@/components/admin/section-slugs";
|
||||
import { MODULE_SETTINGS_PAGE_IDS } from "@/components/settings/module-settings-page-ids";
|
||||
import { SETTINGS_SECTION_SLUGS } from "@/lib/settings/section-slugs";
|
||||
import { TOPIC_KEYS } from "@/lib/topics/registry";
|
||||
|
||||
import { expect, test } from "./setup/test";
|
||||
import { MOBILE_ROUTES_STORAGE_STATE_PATH } from "./setup/global-setup";
|
||||
@@ -172,6 +173,9 @@ const DYNAMIC_ROUTES: Record<string, Resolver> = {
|
||||
)}`,
|
||||
];
|
||||
},
|
||||
// Every topic (preview). The sweep account has the preview on; a topic its
|
||||
// modules do not offer still renders its frame and the refusal card.
|
||||
"/topics/[topic]": async () => TOPIC_KEYS.map((topic) => `/topics/${topic}`),
|
||||
// An ECG strip only arrives through a device sync; no route writes one. The
|
||||
// detail page is swept in its not-found state, which is still the frame,
|
||||
// header and back link a phone renders.
|
||||
|
||||
@@ -58,6 +58,7 @@ const SETTINGS_ROUTES = [
|
||||
"/settings/api",
|
||||
"/settings/mcp",
|
||||
"/settings/advanced",
|
||||
"/settings/preview",
|
||||
"/settings/about",
|
||||
] as const;
|
||||
|
||||
|
||||
@@ -35,6 +35,7 @@ import pg from "pg";
|
||||
|
||||
import { seedDayDoorsRecord } from "./day-doors-fixture";
|
||||
import { seedMobileRoutesRecord } from "./mobile-routes-fixture";
|
||||
import { seedTopicsRecord } from "./topics-fixture";
|
||||
|
||||
// Resolve relative to the project root rather than __dirname/import.meta.url —
|
||||
// Playwright's TS loader runs files as CJS without `"type":"module"`, and
|
||||
@@ -289,6 +290,27 @@ export const SCORE_HISTORY_STORAGE_STATE_PATH = resolve(
|
||||
"e2e/setup/storageStateScoreHistory.json",
|
||||
);
|
||||
|
||||
/**
|
||||
* The topic pages' own account (`topics-preview.spec.ts`).
|
||||
*
|
||||
* The journey switches the Topics preview on and off, which adds and removes
|
||||
* a navigation entry for the whole account; on the shared account a parallel
|
||||
* spec would see its More sheet change underneath it. The preview starts off,
|
||||
* the way every account starts, and the record is written after the login
|
||||
* capture (`topics-fixture.ts`).
|
||||
*/
|
||||
export const E2E_TOPICS = {
|
||||
email: "e2e-topics@healthlog.test",
|
||||
username: "e2e-topics",
|
||||
password: "Tp8!Hq4nVw7zKc3M",
|
||||
role: "USER",
|
||||
} as const;
|
||||
|
||||
export const TOPICS_STORAGE_STATE_PATH = resolve(
|
||||
process.cwd(),
|
||||
"e2e/setup/storageStateTopics.json",
|
||||
);
|
||||
|
||||
export const AI_OPTIONAL_STORAGE_STATE_PATH = resolve(
|
||||
process.cwd(),
|
||||
"e2e/setup/storageStateAiOptional.json",
|
||||
@@ -908,15 +930,17 @@ export default async function globalSetup(config: FullConfig): Promise<void> {
|
||||
|
||||
// The phone-width route sweep's account. Every opt-in module is on, so
|
||||
// the sweep reaches the glucose, documents, wellbeing, nutrients and
|
||||
// environment surfaces; its record is written after the login capture.
|
||||
// environment surfaces, and the Topics preview is on, so the topic pages
|
||||
// render rather than leave for Insights; its record is written after the
|
||||
// login capture.
|
||||
await pool.query(
|
||||
`INSERT INTO users
|
||||
(id, username, email, password_hash, role, created_at, updated_at,
|
||||
height_cm, date_of_birth, gender,
|
||||
onboarding_completed_at, onboarding_tour_completed,
|
||||
module_preferences_json)
|
||||
module_preferences_json, preview_features_json)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $6, 168, $7, 'FEMALE', $6, true,
|
||||
$8::jsonb)
|
||||
$8::jsonb, '{"topics": true}'::jsonb)
|
||||
ON CONFLICT (username) DO UPDATE SET
|
||||
email = EXCLUDED.email,
|
||||
password_hash = EXCLUDED.password_hash,
|
||||
@@ -924,7 +948,8 @@ export default async function globalSetup(config: FullConfig): Promise<void> {
|
||||
updated_at = EXCLUDED.updated_at,
|
||||
onboarding_completed_at = EXCLUDED.onboarding_completed_at,
|
||||
onboarding_tour_completed = EXCLUDED.onboarding_tour_completed,
|
||||
module_preferences_json = EXCLUDED.module_preferences_json`,
|
||||
module_preferences_json = EXCLUDED.module_preferences_json,
|
||||
preview_features_json = EXCLUDED.preview_features_json`,
|
||||
[
|
||||
cuid(),
|
||||
E2E_MOBILE_ROUTES.username,
|
||||
@@ -990,6 +1015,31 @@ export default async function globalSetup(config: FullConfig): Promise<void> {
|
||||
],
|
||||
);
|
||||
|
||||
// The topic pages' account. Default modules, the preview off; its record
|
||||
// is written after the login capture.
|
||||
await pool.query(
|
||||
`INSERT INTO users
|
||||
(id, username, email, password_hash, role, created_at, updated_at,
|
||||
onboarding_completed_at, onboarding_tour_completed,
|
||||
module_preferences_json, preview_features_json)
|
||||
VALUES ($1, $2, $3, $4, 'USER', $5, $5, $5, true, NULL, NULL)
|
||||
ON CONFLICT (username) DO UPDATE SET
|
||||
email = EXCLUDED.email,
|
||||
password_hash = EXCLUDED.password_hash,
|
||||
updated_at = EXCLUDED.updated_at,
|
||||
onboarding_completed_at = EXCLUDED.onboarding_completed_at,
|
||||
onboarding_tour_completed = EXCLUDED.onboarding_tour_completed,
|
||||
module_preferences_json = NULL,
|
||||
preview_features_json = NULL`,
|
||||
[
|
||||
cuid(),
|
||||
E2E_TOPICS.username,
|
||||
E2E_TOPICS.email,
|
||||
await hashPassword(E2E_TOPICS.password),
|
||||
now,
|
||||
],
|
||||
);
|
||||
|
||||
// The day-doors journey's account. Every module the journey reaches is
|
||||
// on; its record is written after the login capture.
|
||||
await pool.query(
|
||||
@@ -1650,6 +1700,14 @@ export default async function globalSetup(config: FullConfig): Promise<void> {
|
||||
await capture(E2E_DAY_DOORS, DAY_DOORS_STORAGE_STATE_PATH);
|
||||
await seedDayDoorsRecord(baseURL, DAY_DOORS_STORAGE_STATE_PATH);
|
||||
|
||||
// The topic pages' jar, then its record — see `E2E_TOPICS`.
|
||||
await capture(E2E_TOPICS, TOPICS_STORAGE_STATE_PATH);
|
||||
await seedTopicsRecord(
|
||||
baseURL,
|
||||
TOPICS_STORAGE_STATE_PATH,
|
||||
E2E_TOPICS.username,
|
||||
);
|
||||
|
||||
// The score-history journey's jar — see `E2E_SCORE_HISTORY`.
|
||||
await capture(E2E_SCORE_HISTORY, SCORE_HISTORY_STORAGE_STATE_PATH);
|
||||
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
/**
|
||||
* The record `topics-preview.spec.ts` reads its topics from.
|
||||
*
|
||||
* Enough of each kind that every topic the account is offered has something
|
||||
* to show, and some of it deliberately short of the thresholds, so the page
|
||||
* states what is missing rather than a figure:
|
||||
*
|
||||
* - 40 days of resting heart rate, heart-rate variability and sleep, with
|
||||
* every fourth night under seven hours and the morning after it higher:
|
||||
* the heart pairs reach 14 paired days and say something;
|
||||
* - 30 days of weight, 25 of blood pressure;
|
||||
* - 12 days of steps: the steps pairs stay below 14 and say so;
|
||||
* - 10 mood entries: the mind pairs stay below 14 and say so;
|
||||
* - LDL three times over two years (a trend line) and vitamin D once
|
||||
* (below its range, no trend yet).
|
||||
*
|
||||
* The readings go straight into Postgres: the topics read the live table, and
|
||||
* a few hundred route calls would only slow `globalSetup` down. Mood entries
|
||||
* and lab results go through the app's routes, which own their encryption and
|
||||
* the marker catalogue. An account that already holds the LDL marker is taken
|
||||
* as seeded, so a local re-run against a reused database does not double it.
|
||||
*/
|
||||
import { randomUUID } from "node:crypto";
|
||||
|
||||
import { request as playwrightRequest } from "@playwright/test";
|
||||
import pg from "pg";
|
||||
|
||||
export const TOPICS_LAB_MARKER = "LDL";
|
||||
|
||||
const DAY_MS = 86_400_000;
|
||||
|
||||
type Row = [type: string, unit: string, value: number, measuredAt: Date];
|
||||
|
||||
/** The readings, one per local morning, ending yesterday. */
|
||||
function readings(now: number): Row[] {
|
||||
const at = (daysAgo: number, hourUtc: number) => {
|
||||
const d = new Date(now - daysAgo * DAY_MS);
|
||||
d.setUTCHours(hourUtc, 30, 0, 0);
|
||||
return d;
|
||||
};
|
||||
const rows: Row[] = [];
|
||||
for (let i = 1; i <= 40; i++) {
|
||||
const shortNight = i % 4 === 0;
|
||||
rows.push(["SLEEP_DURATION", "min", shortNight ? 360 : 480, at(i, 5)]);
|
||||
rows.push([
|
||||
"RESTING_HEART_RATE",
|
||||
"bpm",
|
||||
55 + (i % 10) + (shortNight ? 5 : 0),
|
||||
at(i, 6),
|
||||
]);
|
||||
rows.push(["HEART_RATE_VARIABILITY", "ms", 80 - (i % 10) * 3, at(i, 6)]);
|
||||
}
|
||||
for (let i = 1; i <= 30; i++) {
|
||||
rows.push(["WEIGHT", "kg", 82 - (30 - i) * 0.03, at(i, 6)]);
|
||||
}
|
||||
for (let i = 1; i <= 25; i++) {
|
||||
rows.push(["BLOOD_PRESSURE_SYS", "mmHg", 118 + (i % 5), at(i, 7)]);
|
||||
rows.push(["BLOOD_PRESSURE_DIA", "mmHg", 76 + (i % 3), at(i, 7)]);
|
||||
rows.push(["PULSE", "bpm", 64 + (i % 4), at(i, 7)]);
|
||||
}
|
||||
for (let i = 1; i <= 12; i++) {
|
||||
rows.push(["ACTIVITY_STEPS", "steps", 7000 + (i % 5) * 900, at(i, 18)]);
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
export async function seedTopicsRecord(
|
||||
baseURL: string,
|
||||
storageStatePath: string,
|
||||
username: string,
|
||||
): Promise<void> {
|
||||
const ctx = await playwrightRequest.newContext({
|
||||
baseURL,
|
||||
storageState: storageStatePath,
|
||||
});
|
||||
const url = process.env.DATABASE_URL;
|
||||
if (!url) throw new Error("[topics-fixture] DATABASE_URL is not set");
|
||||
const pool = new pg.Pool({ connectionString: url });
|
||||
try {
|
||||
const labs = await ctx.get("/api/labs");
|
||||
const body = (await labs.json()) as { data: unknown };
|
||||
if (JSON.stringify(body.data ?? null).includes(`"${TOPICS_LAB_MARKER}"`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const user = await pool.query<{ id: string }>(
|
||||
"SELECT id FROM users WHERE username = $1",
|
||||
[username],
|
||||
);
|
||||
const userId = user.rows[0]?.id;
|
||||
if (!userId) throw new Error("[topics-fixture] topics account not seeded");
|
||||
|
||||
const now = Date.now();
|
||||
for (const [type, unit, value, measuredAt] of readings(now)) {
|
||||
await pool.query(
|
||||
`INSERT INTO measurements
|
||||
(id, user_id, type, value, unit, source, measured_at, updated_at)
|
||||
VALUES ($1, $2, $3::"measurement_type", $4, $5, 'MANUAL', $6, now())`,
|
||||
[randomUUID(), userId, type, value, unit, measuredAt],
|
||||
);
|
||||
}
|
||||
|
||||
const post = async (path: string, data: unknown): Promise<void> => {
|
||||
const res = await ctx.post(path, { data });
|
||||
if (res.status() === 409 || res.ok()) return;
|
||||
throw new Error(
|
||||
`[topics-fixture] ${path} answered ${res.status()}: ${(await res.text()).slice(0, 200)}`,
|
||||
);
|
||||
};
|
||||
for (let i = 1; i <= 10; i++) {
|
||||
const at = new Date(now - i * DAY_MS);
|
||||
at.setUTCHours(19, 0, 0, 0);
|
||||
await post("/api/mood-entries", {
|
||||
mood: i % 3 === 0 ? "OKAY" : "GUT",
|
||||
moodLoggedAt: at.toISOString(),
|
||||
});
|
||||
}
|
||||
for (const [analyte, unit, value, daysAgo, low, high] of [
|
||||
[TOPICS_LAB_MARKER, "mg/dL", 140, 760, 0, 130],
|
||||
[TOPICS_LAB_MARKER, "mg/dL", 121, 390, 0, 130],
|
||||
[TOPICS_LAB_MARKER, "mg/dL", 98, 20, 0, 130],
|
||||
["Vitamin D", "ng/mL", 24, 25, 30, 100],
|
||||
] as const) {
|
||||
await post("/api/labs", {
|
||||
analyte,
|
||||
panel: "Topics",
|
||||
value,
|
||||
unit,
|
||||
referenceLow: low,
|
||||
referenceHigh: high,
|
||||
takenAt: new Date(now - daysAgo * DAY_MS).toISOString(),
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
await pool.end();
|
||||
await ctx.dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
import type { Page } from "@playwright/test";
|
||||
|
||||
import { expect, test } from "./setup/test";
|
||||
import { TOPICS_STORAGE_STATE_PATH } from "./setup/global-setup";
|
||||
import { expectNoHorizontalOverflow } from "./utils/horizontal-overflow";
|
||||
|
||||
/**
|
||||
* The Topics preview end to end, on its own account (`E2E_TOPICS`, seeded by
|
||||
* `topics-fixture.ts`):
|
||||
*
|
||||
* - switched off (every account's start): no More tile, no sidebar entry,
|
||||
* the pages leave for Insights and the routes answer 404;
|
||||
* - switched on in Settings > System > Preview features: the More sheet
|
||||
* leads with a Topics tile carrying the Beta badge, the desktop sidebar
|
||||
* has the entry directly below Insights;
|
||||
* - every offered topic renders from the seeded record, with the relationship
|
||||
* that has fourteen paired days stated and the ones short of it saying how
|
||||
* far they are, the lab trend drawn for the marker with three readings over
|
||||
* a year and named as missing for the one with a single reading.
|
||||
*
|
||||
* Phone project only, in one serial run: the switch is account-wide, so two
|
||||
* projects toggling it at once would read each other's state.
|
||||
*/
|
||||
test.describe.configure({ mode: "serial" });
|
||||
|
||||
async function setPreview(page: Page, topics: boolean) {
|
||||
const res = await page.request.patch("/api/settings/preview-features", {
|
||||
data: { topics },
|
||||
});
|
||||
expect(res.ok(), `preview PATCH answered ${res.status()}`).toBe(true);
|
||||
}
|
||||
|
||||
async function openMoreSheet(page: Page) {
|
||||
await page.locator('[data-testid="bottom-nav-more"]').click();
|
||||
const sheet = page.locator('[data-testid="bottom-nav-more-sheet"]');
|
||||
await expect(sheet).toBeVisible();
|
||||
return sheet;
|
||||
}
|
||||
|
||||
test.describe("Topics (preview)", () => {
|
||||
test.use({
|
||||
storageState: TOPICS_STORAGE_STATE_PATH,
|
||||
viewport: { width: 390, height: 844 },
|
||||
});
|
||||
|
||||
test.beforeEach(async ({}, testInfo) => {
|
||||
test.skip(testInfo.project.name !== "chromium-mobile", "phone journey");
|
||||
});
|
||||
|
||||
test.afterAll(async ({ browser }, testInfo) => {
|
||||
if (testInfo.project.name !== "chromium-mobile") return;
|
||||
const context = await browser.newContext({
|
||||
storageState: TOPICS_STORAGE_STATE_PATH,
|
||||
baseURL: testInfo.project.use.baseURL,
|
||||
});
|
||||
// Leave the account as every account starts: the preview off.
|
||||
await context.request.patch("/api/settings/preview-features", {
|
||||
data: { topics: false },
|
||||
});
|
||||
await context.close();
|
||||
});
|
||||
|
||||
test("switched off: no tile, no entry, the pages leave and the routes refuse", async ({
|
||||
page,
|
||||
}) => {
|
||||
await setPreview(page, false);
|
||||
await page.goto("/");
|
||||
const sheet = await openMoreSheet(page);
|
||||
await expect(sheet.locator('a[href="/topics"]')).toHaveCount(0);
|
||||
await page.keyboard.press("Escape");
|
||||
|
||||
await page.goto("/topics");
|
||||
await page.waitForURL(/\/insights$/);
|
||||
await page.goto("/topics/heart");
|
||||
await page.waitForURL(/\/insights$/);
|
||||
|
||||
const api = await page.request.get("/api/topics");
|
||||
expect(api.status()).toBe(404);
|
||||
expect(
|
||||
((await api.json()) as { meta?: { errorCode?: string } }).meta,
|
||||
).toMatchObject({ errorCode: "topics.preview_off" });
|
||||
});
|
||||
|
||||
test("switched on in Settings: the More sheet leads with the Topics tile", async ({
|
||||
page,
|
||||
}) => {
|
||||
await setPreview(page, false);
|
||||
await page.goto("/settings/preview");
|
||||
const toggle = page.locator('[data-slot="settings-preview-topics-switch"]');
|
||||
await expect(toggle).toHaveAttribute("aria-checked", "false");
|
||||
await toggle.click();
|
||||
await expect(toggle).toHaveAttribute("aria-checked", "true");
|
||||
await expect(
|
||||
page.locator('[data-slot="settings-preview-topics-open"]'),
|
||||
).toBeVisible();
|
||||
|
||||
const sheet = await openMoreSheet(page);
|
||||
const tiles = sheet.locator("a");
|
||||
await expect(tiles.first()).toHaveAttribute("href", "/topics");
|
||||
await expect(
|
||||
tiles.first().locator('[data-slot="preview-badge"]'),
|
||||
).toBeVisible();
|
||||
await tiles.first().click();
|
||||
await page.waitForURL(/\/topics$/);
|
||||
await expect(page.locator('[data-slot="topics-grid"]')).toBeVisible();
|
||||
});
|
||||
|
||||
test("every offered topic renders from the record, below-threshold states included", async ({
|
||||
page,
|
||||
}) => {
|
||||
await setPreview(page, true);
|
||||
await page.goto("/topics");
|
||||
const grid = page.locator('[data-slot="topics-grid"]');
|
||||
await expect(grid).toBeVisible();
|
||||
for (const key of [
|
||||
"heart",
|
||||
"body",
|
||||
"blood-pressure",
|
||||
"sleep",
|
||||
"activity",
|
||||
"metabolism",
|
||||
"mind",
|
||||
]) {
|
||||
await expect(
|
||||
grid.locator(`[data-slot="topic-tile"][data-topic="${key}"]`),
|
||||
).toBeVisible();
|
||||
}
|
||||
// No cycle module for this account: named once, never a tile.
|
||||
await expect(
|
||||
grid.locator('[data-slot="topic-tile"][data-topic="cycle"]'),
|
||||
).toHaveCount(0);
|
||||
await expect(
|
||||
page.locator('[data-slot="topics-hidden-list"]'),
|
||||
).toBeVisible();
|
||||
await expectNoHorizontalOverflow(page, "/topics");
|
||||
|
||||
// The heart: the pair with 40 paired days speaks, the steps pair cannot.
|
||||
await grid
|
||||
.locator('[data-topic="heart"] [data-slot="topic-tile-link"]')
|
||||
.click();
|
||||
await page.waitForURL(/\/topics\/heart$/);
|
||||
const body = page.locator('[data-slot="topic-detail-body"]');
|
||||
await expect(body).toBeVisible();
|
||||
await expect(
|
||||
body.locator(
|
||||
'[data-slot="topic-relationship"][data-relationship="hrv-resting-hr"]',
|
||||
),
|
||||
).toHaveAttribute("data-status", "linked");
|
||||
await expect(
|
||||
body.locator(
|
||||
'[data-slot="topic-signal"][data-signal="RESTING_HEART_RATE"]',
|
||||
),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
body.locator('[data-slot="topic-chart-row"]').first(),
|
||||
).toBeVisible();
|
||||
await expect(body.locator('[data-slot="topic-missing"]')).toBeVisible();
|
||||
await expectNoHorizontalOverflow(page, "/topics/heart");
|
||||
|
||||
// The range switch re-reads the window.
|
||||
await page
|
||||
.locator('[data-slot="topic-range"]')
|
||||
.getByRole("radio", { name: /90/ })
|
||||
.click();
|
||||
await expect(body).toBeVisible();
|
||||
|
||||
await page.goto("/topics/activity");
|
||||
await expect(
|
||||
page.locator(
|
||||
'[data-slot="topic-relationship"][data-relationship="steps-sleep"]',
|
||||
),
|
||||
).toHaveAttribute("data-status", "insufficient");
|
||||
|
||||
await page.goto("/topics/mind");
|
||||
await expect(
|
||||
page.locator(
|
||||
'[data-slot="topic-relationship"][data-relationship="short-nights-mood"]',
|
||||
),
|
||||
).toHaveAttribute("data-status", "insufficient");
|
||||
|
||||
await page.goto("/topics/metabolism");
|
||||
const labs = page.locator('[data-slot="topic-labs"]');
|
||||
await expect(labs).toBeVisible();
|
||||
await expect(labs.locator('[data-slot="topic-lab-trend"]')).toHaveCount(1);
|
||||
await expect(
|
||||
labs.locator('[data-slot="topic-lab-trend-short"]'),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
labs.locator('[data-slot="topic-lab-marker"][data-status="below"]'),
|
||||
).toHaveCount(1);
|
||||
await expectNoHorizontalOverflow(page, "/topics/metabolism");
|
||||
|
||||
for (const key of ["body", "blood-pressure", "sleep"]) {
|
||||
await page.goto(`/topics/${key}`);
|
||||
await expect(
|
||||
page.locator('[data-slot="topic-detail-body"]'),
|
||||
).toBeVisible();
|
||||
await expectNoHorizontalOverflow(page, `/topics/${key}`);
|
||||
}
|
||||
});
|
||||
|
||||
test("the desktop sidebar carries the entry directly below Insights", async ({
|
||||
page,
|
||||
}) => {
|
||||
await setPreview(page, true);
|
||||
await page.setViewportSize({ width: 1280, height: 800 });
|
||||
await page.goto("/topics");
|
||||
const nav = page.locator('[data-slot="sidebar-column"] nav');
|
||||
const links = nav.locator("a");
|
||||
await expect(nav.locator('a[href="/topics"]')).toBeVisible();
|
||||
const hrefs = await links.evaluateAll((els) =>
|
||||
els.map((el) => el.getAttribute("href")),
|
||||
);
|
||||
expect(hrefs[hrefs.indexOf("/insights") + 1]).toBe("/topics");
|
||||
await expect(
|
||||
nav.locator('a[href="/topics"] [data-slot="preview-badge"]'),
|
||||
).toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -236,6 +236,7 @@ const eslintConfig = defineConfig([
|
||||
"src/components/charts/workout-elevation-chart.tsx",
|
||||
"src/components/insights/coach-panel/result-chart.tsx",
|
||||
"src/components/insights/derived/score-trend-chart.tsx",
|
||||
"src/components/topics/topic-side-by-side-chart.tsx",
|
||||
],
|
||||
rules: {
|
||||
"no-restricted-imports": [
|
||||
|
||||
+184
-1
@@ -510,7 +510,9 @@
|
||||
"documents": "Dokumente",
|
||||
"vaccinations": "Impfungen",
|
||||
"mentalWellbeing": "Seelisches Wohlbefinden",
|
||||
"timeline": "Zeitleiste"
|
||||
"timeline": "Zeitleiste",
|
||||
"topics": "Themen",
|
||||
"previewBadge": "Beta"
|
||||
},
|
||||
"auth": {
|
||||
"login": "Anmelden",
|
||||
@@ -6143,6 +6145,10 @@
|
||||
"access": {
|
||||
"title": "Geteilter Zugriff",
|
||||
"subtitle": "Wer deine Gesundheitsakte öffnen kann und wessen Akte du öffnen kannst."
|
||||
},
|
||||
"preview": {
|
||||
"title": "Vorschau-Funktionen",
|
||||
"subtitle": "Probier aus, was noch nicht fertig ist. Alles hier bleibt aus, bis du es einschaltest."
|
||||
}
|
||||
},
|
||||
"profile": "Profil",
|
||||
@@ -7628,6 +7634,17 @@
|
||||
"passkeyRemovedToast": "Passkey entfernt.",
|
||||
"integrationDisconnected": "{provider} getrennt.",
|
||||
"integrationDisconnectFailed": "{provider} konnte nicht getrennt werden.",
|
||||
"preview": {
|
||||
"topics": {
|
||||
"title": "Themen",
|
||||
"description": "Fügt einen Bereich Themen hinzu, der zusammengehörige Werte bündelt, neben deinen Insights-Seiten.",
|
||||
"detail": "Acht Themen, von Herz bis Zyklus, zeigen deine Werte nebeneinander, wie sie zusammenhängen, und führen in die Einzelseiten, die du schon kennst. Deine Insights-Seiten, die Navigation und alle Links bleiben genau, wie sie sind. Alles wird aus deinen eigenen Werten berechnet, ohne KI. Ausschalten entfernt den Eintrag und löscht nichts.",
|
||||
"savedOn": "Themen-Vorschau eingeschaltet.",
|
||||
"savedOff": "Themen-Vorschau ausgeschaltet.",
|
||||
"open": "Themen öffnen"
|
||||
},
|
||||
"loadError": "Deine Vorschau-Einstellungen konnten nicht geladen werden."
|
||||
},
|
||||
"timezoneSaved": "Zeitzone gespeichert.",
|
||||
"moduleState": {
|
||||
"on": "An",
|
||||
@@ -13765,5 +13782,171 @@
|
||||
"startsOn": "Beginn",
|
||||
"endsOn": "Ende"
|
||||
}
|
||||
},
|
||||
"topics": {
|
||||
"overview": {
|
||||
"title": "Themen",
|
||||
"description": "Zusammengehörige Werte, aus deinen eigenen Daten gebündelt; öffne ein Thema, um sie nebeneinander zu sehen.",
|
||||
"emptyTitle": "Noch nichts zum Bündeln",
|
||||
"emptyDescription": "Themen erscheinen, sobald es Werte dafür gibt: von einem Gerät, aus einem Import oder von dir eingetragen.",
|
||||
"notRecorded": "Noch keine Werte für: {topics}.",
|
||||
"hiddenByModule": "Ausgeblendet, weil das Modul aus ist: {topics}.",
|
||||
"modulesLink": "Module in den Einstellungen ändern"
|
||||
},
|
||||
"detail": {
|
||||
"back": "Alle Themen"
|
||||
},
|
||||
"loadError": "Die Themen konnten nicht geladen werden.",
|
||||
"previewNote": "Das ist eine Vorschau. Sie kann sich zwischen Versionen ändern, und deine Einzelseiten bleiben, wie sie sind.",
|
||||
"recorded": "Erfasste Werte: {count} von {total}",
|
||||
"tone": {
|
||||
"steady": "Stabil",
|
||||
"watch": "Einen Blick wert",
|
||||
"learning": "Lernt noch"
|
||||
},
|
||||
"summary": {
|
||||
"outsideAbove": "{signal} liegt über deinem üblichen Bereich.",
|
||||
"outsideBelow": "{signal} liegt unter deinem üblichen Bereich.",
|
||||
"labs": "{count} von {total} Laborwerten liegen im Referenzbereich.",
|
||||
"cycleDay": "Zyklustag {day}.",
|
||||
"steadyAll": "Alles Erfasste liegt in deinem üblichen Bereich.",
|
||||
"steadySome": "{count} von {total} Werten liegen in deinem üblichen Bereich; für die übrigen lernt HealthLog noch.",
|
||||
"learning": "Dein üblicher Bereich wird noch gelernt. Eine Woche mit Werten reicht.",
|
||||
"empty": "Noch nichts erfasst."
|
||||
},
|
||||
"headline": {
|
||||
"labsValue": "{count}/{total}",
|
||||
"labsUnit": "im Bereich",
|
||||
"cycleDay": "Tag {day}"
|
||||
},
|
||||
"signals": {
|
||||
"mood": "Stimmung"
|
||||
},
|
||||
"units": {
|
||||
"moodScale": "von 5"
|
||||
},
|
||||
"names": {
|
||||
"heart": {
|
||||
"title": "Herz",
|
||||
"description": "Ruhepuls, Herzfrequenzvariabilität und Fitness, mit dem Schlaf und Training, die sie bewegen."
|
||||
},
|
||||
"body": {
|
||||
"title": "Körper",
|
||||
"description": "Gewicht und Körperzusammensetzung im Verlauf."
|
||||
},
|
||||
"bloodPressure": {
|
||||
"title": "Blutdruck",
|
||||
"description": "Systolisch, diastolisch und Puls, mit Schlaf und Aktivität drumherum."
|
||||
},
|
||||
"sleep": {
|
||||
"title": "Schlaf",
|
||||
"description": "Schlafdauer und die Werte, die nachts gemessen werden."
|
||||
},
|
||||
"activity": {
|
||||
"title": "Aktivität & Training",
|
||||
"description": "Schritte, Trainingsminuten, aktive Energie und Trainingslast."
|
||||
},
|
||||
"metabolism": {
|
||||
"title": "Stoffwechsel & Labor",
|
||||
"description": "Blutzucker und deine Laborwerte, mit dem Trend jedes Markers."
|
||||
},
|
||||
"mind": {
|
||||
"title": "Psyche",
|
||||
"description": "Stimmung und Fragebögen neben Schlaf und Aktivität."
|
||||
},
|
||||
"cycle": {
|
||||
"title": "Zyklus",
|
||||
"description": "Dein Zyklus mit der Temperatur und dem Ruhepuls, die ihm folgen."
|
||||
}
|
||||
},
|
||||
"sources": {
|
||||
"heart": "Diese Werte kommen von einer Uhr, einem Ring oder Brustgurt, aus Apple Health oder Health Connect oder von dir eingetragen.",
|
||||
"body": "Diese Werte kommen von einer Waage, aus einem Import oder von dir eingetragen.",
|
||||
"bloodPressure": "Diese Werte kommen von einem Blutdruckmessgerät, aus einem Import oder von dir eingetragen.",
|
||||
"sleep": "Diese Werte kommen von einer Uhr oder einem Ring, aus Apple Health oder Health Connect oder von dir eingetragen.",
|
||||
"activity": "Diese Werte kommen von Handy, Uhr oder Ring oder aus Apple Health oder Health Connect.",
|
||||
"metabolism": "Blutzucker kommt von einem Messgerät, aus einem Sensor-Import oder von dir; Laborwerte aus einem Laborbericht oder von dir eingetragen.",
|
||||
"mind": "Diese Werte kommen aus deinen Stimmungseinträgen und den Fragebögen auf der Seite Seelisches Wohlbefinden.",
|
||||
"cycle": "Diese Werte kommen aus deinem Zyklusprotokoll und einem Temperatursensor am Handgelenk."
|
||||
},
|
||||
"today": {
|
||||
"title": "Heute",
|
||||
"usual": "Üblich {low}–{high}",
|
||||
"learning": "Üblicher Bereich wird noch gelernt",
|
||||
"asOf": "Stand {date}",
|
||||
"details": "Details"
|
||||
},
|
||||
"state": {
|
||||
"within": "Im Bereich",
|
||||
"above": "Über üblich",
|
||||
"below": "Unter üblich",
|
||||
"learning": "Lernt noch"
|
||||
},
|
||||
"chart": {
|
||||
"title": "Nebeneinander",
|
||||
"legend": "Schattiert: dein üblicher Bereich.",
|
||||
"label": "{signals} im gewählten Zeitraum, je eine Zeile."
|
||||
},
|
||||
"range": {
|
||||
"label": "Zeitraum",
|
||||
"7d": "7 T",
|
||||
"30d": "30 T",
|
||||
"90d": "90 T",
|
||||
"1y": "1 J"
|
||||
},
|
||||
"relationships": {
|
||||
"title": "Wie sie zusammenhängen",
|
||||
"insufficient": "{x} und {y}: bisher {count} von {required} gemeinsamen Tagen.",
|
||||
"noClearLink": "Kein klarer Zusammenhang zwischen {x} und {y} über {count} gemeinsame Tage.",
|
||||
"splitHigher": "Wenn {x} unter {threshold} lag, war {y} um {amount} höher als an anderen Tagen.",
|
||||
"splitLower": "Wenn {x} unter {threshold} lag, war {y} um {amount} niedriger als an anderen Tagen.",
|
||||
"splitDays": "Tage darunter: {below}. Andere Tage: {other}.",
|
||||
"correlationSameDayHigher": "Wenn {x} höher liegt, ist auch {y} meist höher (r = {r}).",
|
||||
"correlationSameDayLower": "Wenn {x} höher liegt, ist {y} meist niedriger (r = {r}).",
|
||||
"correlationNextDayHigher": "Wenn {x} höher liegt, ist {y} am nächsten Tag meist höher (r = {r}).",
|
||||
"correlationNextDayLower": "Wenn {x} höher liegt, ist {y} am nächsten Tag meist niedriger (r = {r}).",
|
||||
"pairedDays": "Gemeinsame Tage: {count}",
|
||||
"footnote": "Erscheint, sobald zwei Werte an mindestens 14 Tagen zusammen vorliegen. Ein Muster, keine Ursache.",
|
||||
"scatterCaption": "Jeder Punkt ist ein Tag: {x} nach rechts, {y} nach oben."
|
||||
},
|
||||
"trends": {
|
||||
"title": "Trends",
|
||||
"window": "90 Tage, Wochenmittel",
|
||||
"insufficient": "Braucht 14 Tage, bisher {count}"
|
||||
},
|
||||
"labs": {
|
||||
"title": "Laborwerte",
|
||||
"above": "Über dem Bereich",
|
||||
"below": "Unter dem Bereich",
|
||||
"trendsTitle": "Trends",
|
||||
"trendNeeds": "Ein Trend braucht drei Werte über mindestens ein Jahr. Noch nicht so weit: {markers}."
|
||||
},
|
||||
"screeners": {
|
||||
"title": "Fragebögen",
|
||||
"none": "Noch keine Fragebögen ausgefüllt.",
|
||||
"checkIns": "Ausgefüllt: {count}",
|
||||
"improved": "Besser als beim ersten Mal",
|
||||
"worsened": "Schlechter als beim ersten Mal",
|
||||
"within_threshold": "Im üblichen Schwankungsbereich",
|
||||
"single": "Bisher einmal ausgefüllt",
|
||||
"open": "Seelisches Wohlbefinden öffnen"
|
||||
},
|
||||
"cycle": {
|
||||
"title": "Zyklus",
|
||||
"today": "Zyklustag {day}.",
|
||||
"todayWithPhase": "Zyklustag {day}, {phase}.",
|
||||
"average": "Durchschnittliche Zykluslänge: {days} Tage.",
|
||||
"needs": "Ein Durchschnitt braucht zwei erfasste Periodenbeginne; bisher sind es {count}.",
|
||||
"lengths": "Letzte Zyklen in Tagen: {lengths}",
|
||||
"open": "Zyklus öffnen"
|
||||
},
|
||||
"links": {
|
||||
"title": "Einzelseiten",
|
||||
"workouts": "Workouts"
|
||||
},
|
||||
"missing": {
|
||||
"title": "Noch nicht erfasst: {signals}.",
|
||||
"connect": "Quelle verbinden"
|
||||
}
|
||||
}
|
||||
}
|
||||
+184
-1
@@ -510,7 +510,9 @@
|
||||
"documents": "Documents",
|
||||
"vaccinations": "Vaccinations",
|
||||
"mentalWellbeing": "Mental wellbeing",
|
||||
"timeline": "Timeline"
|
||||
"timeline": "Timeline",
|
||||
"topics": "Topics",
|
||||
"previewBadge": "Beta"
|
||||
},
|
||||
"auth": {
|
||||
"login": "Sign in",
|
||||
@@ -6143,6 +6145,10 @@
|
||||
"access": {
|
||||
"title": "Shared access",
|
||||
"subtitle": "Who can open your health record, and whose you can open."
|
||||
},
|
||||
"preview": {
|
||||
"title": "Preview features",
|
||||
"subtitle": "Try what is not finished yet. Everything here stays off until you switch it on."
|
||||
}
|
||||
},
|
||||
"profile": "Profile",
|
||||
@@ -7628,6 +7634,17 @@
|
||||
"passkeyRemovedToast": "Passkey removed.",
|
||||
"integrationDisconnected": "{provider} disconnected.",
|
||||
"integrationDisconnectFailed": "Could not disconnect {provider}.",
|
||||
"preview": {
|
||||
"topics": {
|
||||
"title": "Topics",
|
||||
"description": "Adds a Topics area that brings related signals together, beside your Insights pages.",
|
||||
"detail": "Eight topics, from heart to cycle, show your signals side by side, how they move together and links into the single-metric pages you already use. Your Insights pages, navigation and links stay exactly as they are. Everything is computed from your own values, without AI. Switching it off removes the entry and deletes nothing.",
|
||||
"savedOn": "Topics preview switched on.",
|
||||
"savedOff": "Topics preview switched off.",
|
||||
"open": "Open Topics"
|
||||
},
|
||||
"loadError": "Could not load your preview settings."
|
||||
},
|
||||
"timezoneSaved": "Timezone saved.",
|
||||
"moduleState": {
|
||||
"on": "On",
|
||||
@@ -13765,5 +13782,171 @@
|
||||
"startsOn": "Start",
|
||||
"endsOn": "End"
|
||||
}
|
||||
},
|
||||
"topics": {
|
||||
"overview": {
|
||||
"title": "Topics",
|
||||
"description": "Related signals brought together from your own values; open one to see them side by side.",
|
||||
"emptyTitle": "Nothing to bring together yet",
|
||||
"emptyDescription": "Topics appear once there are readings for them, from a device, an import or your own entries.",
|
||||
"notRecorded": "Nothing recorded yet for: {topics}.",
|
||||
"hiddenByModule": "Hidden because the module is off: {topics}.",
|
||||
"modulesLink": "Change modules in Settings"
|
||||
},
|
||||
"detail": {
|
||||
"back": "All topics"
|
||||
},
|
||||
"loadError": "Could not load the topics.",
|
||||
"previewNote": "This is a preview. It may change between releases, and your single-metric pages stay as they are.",
|
||||
"recorded": "Signals recorded: {count} of {total}",
|
||||
"tone": {
|
||||
"steady": "Steady",
|
||||
"watch": "Worth a look",
|
||||
"learning": "Learning"
|
||||
},
|
||||
"summary": {
|
||||
"outsideAbove": "{signal} is above your usual range.",
|
||||
"outsideBelow": "{signal} is below your usual range.",
|
||||
"labs": "{count} of {total} lab values sit inside their reference range.",
|
||||
"cycleDay": "Cycle day {day}.",
|
||||
"steadyAll": "Everything recorded sits inside your usual range.",
|
||||
"steadySome": "{count} of {total} signals sit inside your usual range; the others are still being learned.",
|
||||
"learning": "Still learning your usual range. A week of readings is enough.",
|
||||
"empty": "Nothing recorded yet."
|
||||
},
|
||||
"headline": {
|
||||
"labsValue": "{count}/{total}",
|
||||
"labsUnit": "in range",
|
||||
"cycleDay": "Day {day}"
|
||||
},
|
||||
"signals": {
|
||||
"mood": "Mood"
|
||||
},
|
||||
"units": {
|
||||
"moodScale": "of 5"
|
||||
},
|
||||
"names": {
|
||||
"heart": {
|
||||
"title": "Heart",
|
||||
"description": "Resting heart rate, heart-rate variability and cardio fitness, with the sleep and training that move them."
|
||||
},
|
||||
"body": {
|
||||
"title": "Body",
|
||||
"description": "Weight and body composition over time."
|
||||
},
|
||||
"bloodPressure": {
|
||||
"title": "Blood pressure",
|
||||
"description": "Systolic, diastolic and pulse, with the sleep and activity around them."
|
||||
},
|
||||
"sleep": {
|
||||
"title": "Sleep",
|
||||
"description": "Time asleep and the signals measured at night."
|
||||
},
|
||||
"activity": {
|
||||
"title": "Activity & training",
|
||||
"description": "Steps, exercise minutes, active energy and training load."
|
||||
},
|
||||
"metabolism": {
|
||||
"title": "Metabolism & labs",
|
||||
"description": "Blood glucose and your lab values, with each marker's trend."
|
||||
},
|
||||
"mind": {
|
||||
"title": "Mind",
|
||||
"description": "Mood and screener check-ins beside sleep and activity."
|
||||
},
|
||||
"cycle": {
|
||||
"title": "Cycle",
|
||||
"description": "Your cycle with the temperature and resting heart rate that follow it."
|
||||
}
|
||||
},
|
||||
"sources": {
|
||||
"heart": "These come from a watch, ring or chest strap, from Apple Health or Health Connect, or from your own entries.",
|
||||
"body": "These come from a smart scale, an import or your own entries.",
|
||||
"bloodPressure": "These come from a blood pressure monitor, an import or your own entries.",
|
||||
"sleep": "These come from a watch or ring, from Apple Health or Health Connect, or from your own entries.",
|
||||
"activity": "These come from a phone, watch or ring, or from Apple Health or Health Connect.",
|
||||
"metabolism": "Glucose comes from a meter, a sensor import or your own entries; lab values from a lab report or your own entries.",
|
||||
"mind": "These come from mood entries and the check-ins on the mental wellbeing page.",
|
||||
"cycle": "These come from your cycle log and a wrist temperature sensor."
|
||||
},
|
||||
"today": {
|
||||
"title": "Today",
|
||||
"usual": "Usual {low}–{high}",
|
||||
"learning": "Usual range still being learned",
|
||||
"asOf": "As of {date}",
|
||||
"details": "Details"
|
||||
},
|
||||
"state": {
|
||||
"within": "Within",
|
||||
"above": "Above usual",
|
||||
"below": "Below usual",
|
||||
"learning": "Learning"
|
||||
},
|
||||
"chart": {
|
||||
"title": "Side by side",
|
||||
"legend": "Shaded: your usual range.",
|
||||
"label": "{signals} over the selected period, one row each."
|
||||
},
|
||||
"range": {
|
||||
"label": "Period",
|
||||
"7d": "7d",
|
||||
"30d": "30d",
|
||||
"90d": "90d",
|
||||
"1y": "1y"
|
||||
},
|
||||
"relationships": {
|
||||
"title": "How they move together",
|
||||
"insufficient": "{x} and {y}: {count} of {required} paired days so far.",
|
||||
"noClearLink": "No clear link between {x} and {y} over {count} paired days.",
|
||||
"splitHigher": "When {x} was under {threshold}, {y} was {amount} higher than on other days.",
|
||||
"splitLower": "When {x} was under {threshold}, {y} was {amount} lower than on other days.",
|
||||
"splitDays": "Days under: {below}. Other days: {other}.",
|
||||
"correlationSameDayHigher": "When {x} runs higher, {y} tends to be higher too (r = {r}).",
|
||||
"correlationSameDayLower": "When {x} runs higher, {y} tends to be lower (r = {r}).",
|
||||
"correlationNextDayHigher": "When {x} runs higher, {y} the next day tends to be higher (r = {r}).",
|
||||
"correlationNextDayLower": "When {x} runs higher, {y} the next day tends to be lower (r = {r}).",
|
||||
"pairedDays": "Paired days: {count}",
|
||||
"footnote": "Shown once two signals overlap on at least 14 days. A pattern, not a cause.",
|
||||
"scatterCaption": "Each dot is one day: {x} across, {y} up."
|
||||
},
|
||||
"trends": {
|
||||
"title": "Trends",
|
||||
"window": "90 days, weekly means",
|
||||
"insufficient": "Needs 14 days, {count} so far"
|
||||
},
|
||||
"labs": {
|
||||
"title": "Lab values",
|
||||
"above": "Above range",
|
||||
"below": "Below range",
|
||||
"trendsTitle": "Trends",
|
||||
"trendNeeds": "A trend needs three values spanning a year. Not there yet: {markers}."
|
||||
},
|
||||
"screeners": {
|
||||
"title": "Check-ins",
|
||||
"none": "No screener check-ins yet.",
|
||||
"checkIns": "Check-ins: {count}",
|
||||
"improved": "Better than the first",
|
||||
"worsened": "Worse than the first",
|
||||
"within_threshold": "Within the usual wobble",
|
||||
"single": "One check-in so far",
|
||||
"open": "Open mental wellbeing"
|
||||
},
|
||||
"cycle": {
|
||||
"title": "Cycle",
|
||||
"today": "Cycle day {day}.",
|
||||
"todayWithPhase": "Cycle day {day}, {phase} phase.",
|
||||
"average": "Average cycle length: {days} days.",
|
||||
"needs": "An average needs two logged period starts; there are {count} so far.",
|
||||
"lengths": "Recent cycles, in days: {lengths}",
|
||||
"open": "Open the cycle page"
|
||||
},
|
||||
"links": {
|
||||
"title": "Single-metric pages",
|
||||
"workouts": "Workouts"
|
||||
},
|
||||
"missing": {
|
||||
"title": "Not recorded yet: {signals}.",
|
||||
"connect": "Connect a source"
|
||||
}
|
||||
}
|
||||
}
|
||||
+184
-1
@@ -510,7 +510,9 @@
|
||||
"documents": "Documentos",
|
||||
"vaccinations": "Vacunas",
|
||||
"mentalWellbeing": "Bienestar mental",
|
||||
"timeline": "Cronología"
|
||||
"timeline": "Cronología",
|
||||
"topics": "Temas",
|
||||
"previewBadge": "Beta"
|
||||
},
|
||||
"auth": {
|
||||
"login": "Iniciar sesión",
|
||||
@@ -6143,6 +6145,10 @@
|
||||
"access": {
|
||||
"title": "Acceso compartido",
|
||||
"subtitle": "Quién puede abrir tu historial de salud y cuál puedes abrir tú."
|
||||
},
|
||||
"preview": {
|
||||
"title": "Funciones en vista previa",
|
||||
"subtitle": "Prueba lo que aún no está terminado. Todo aquí sigue desactivado hasta que lo actives."
|
||||
}
|
||||
},
|
||||
"profile": "Perfil",
|
||||
@@ -7628,6 +7634,17 @@
|
||||
"passkeyRemovedToast": "Passkey eliminada.",
|
||||
"integrationDisconnected": "{provider} desconectado.",
|
||||
"integrationDisconnectFailed": "No se pudo desconectar {provider}.",
|
||||
"preview": {
|
||||
"topics": {
|
||||
"title": "Temas",
|
||||
"description": "Añade un área Temas que reúne valores relacionados, junto a tus páginas de Insights.",
|
||||
"detail": "Ocho temas, del corazón al ciclo, muestran tus valores uno al lado del otro, cómo se mueven juntos y enlaces a las páginas por métrica que ya usas. Tus páginas de Insights, la navegación y los enlaces quedan exactamente como están. Todo se calcula con tus propios valores, sin IA. Desactivarlo quita la entrada y no borra nada.",
|
||||
"savedOn": "Vista previa de Temas activada.",
|
||||
"savedOff": "Vista previa de Temas desactivada.",
|
||||
"open": "Abrir Temas"
|
||||
},
|
||||
"loadError": "No se pudieron cargar tus ajustes de vista previa."
|
||||
},
|
||||
"timezoneSaved": "Zona horaria guardada.",
|
||||
"moduleState": {
|
||||
"on": "Activado",
|
||||
@@ -13765,5 +13782,171 @@
|
||||
"startsOn": "Inicio",
|
||||
"endsOn": "Fin"
|
||||
}
|
||||
},
|
||||
"topics": {
|
||||
"overview": {
|
||||
"title": "Temas",
|
||||
"description": "Valores relacionados, reunidos a partir de tus propios datos; abre un tema para verlos uno al lado del otro.",
|
||||
"emptyTitle": "Todavía no hay nada que reunir",
|
||||
"emptyDescription": "Los temas aparecen en cuanto hay valores para ellos: de un dispositivo, una importación o tus propios registros.",
|
||||
"notRecorded": "Aún sin registros para: {topics}.",
|
||||
"hiddenByModule": "Ocultos porque el módulo está desactivado: {topics}.",
|
||||
"modulesLink": "Cambiar módulos en los ajustes"
|
||||
},
|
||||
"detail": {
|
||||
"back": "Todos los temas"
|
||||
},
|
||||
"loadError": "No se pudieron cargar los temas.",
|
||||
"previewNote": "Esto es una vista previa. Puede cambiar entre versiones, y tus páginas por métrica siguen como están.",
|
||||
"recorded": "Valores registrados: {count} de {total}",
|
||||
"tone": {
|
||||
"steady": "Estable",
|
||||
"watch": "Merece un vistazo",
|
||||
"learning": "Aprendiendo"
|
||||
},
|
||||
"summary": {
|
||||
"outsideAbove": "{signal} está por encima de tu rango habitual.",
|
||||
"outsideBelow": "{signal} está por debajo de tu rango habitual.",
|
||||
"labs": "{count} de {total} análisis están dentro de su rango de referencia.",
|
||||
"cycleDay": "Día {day} del ciclo.",
|
||||
"steadyAll": "Todo lo registrado está dentro de tu rango habitual.",
|
||||
"steadySome": "{count} de {total} valores están dentro de tu rango habitual; los demás aún se están aprendiendo.",
|
||||
"learning": "Tu rango habitual aún se está aprendiendo. Basta con una semana de registros.",
|
||||
"empty": "Aún no hay nada registrado."
|
||||
},
|
||||
"headline": {
|
||||
"labsValue": "{count}/{total}",
|
||||
"labsUnit": "en rango",
|
||||
"cycleDay": "Día {day}"
|
||||
},
|
||||
"signals": {
|
||||
"mood": "Estado de ánimo"
|
||||
},
|
||||
"units": {
|
||||
"moodScale": "de 5"
|
||||
},
|
||||
"names": {
|
||||
"heart": {
|
||||
"title": "Corazón",
|
||||
"description": "Frecuencia cardíaca en reposo, variabilidad y forma cardiovascular, con el sueño y el entrenamiento que las mueven."
|
||||
},
|
||||
"body": {
|
||||
"title": "Cuerpo",
|
||||
"description": "Peso y composición corporal a lo largo del tiempo."
|
||||
},
|
||||
"bloodPressure": {
|
||||
"title": "Presión arterial",
|
||||
"description": "Sistólica, diastólica y pulso, con el sueño y la actividad a su alrededor."
|
||||
},
|
||||
"sleep": {
|
||||
"title": "Sueño",
|
||||
"description": "Tiempo dormido y los valores medidos por la noche."
|
||||
},
|
||||
"activity": {
|
||||
"title": "Actividad y entrenamiento",
|
||||
"description": "Pasos, minutos de ejercicio, energía activa y carga de entrenamiento."
|
||||
},
|
||||
"metabolism": {
|
||||
"title": "Metabolismo y análisis",
|
||||
"description": "Glucosa y tus análisis, con la tendencia de cada marcador."
|
||||
},
|
||||
"mind": {
|
||||
"title": "Mente",
|
||||
"description": "Estado de ánimo y cuestionarios junto al sueño y la actividad."
|
||||
},
|
||||
"cycle": {
|
||||
"title": "Ciclo",
|
||||
"description": "Tu ciclo con la temperatura y la frecuencia cardíaca en reposo que lo acompañan."
|
||||
}
|
||||
},
|
||||
"sources": {
|
||||
"heart": "Estos valores vienen de un reloj, un anillo o una banda pectoral, de Apple Salud o Health Connect, o de tus propios registros.",
|
||||
"body": "Estos valores vienen de una báscula inteligente, una importación o tus propios registros.",
|
||||
"bloodPressure": "Estos valores vienen de un tensiómetro, una importación o tus propios registros.",
|
||||
"sleep": "Estos valores vienen de un reloj o un anillo, de Apple Salud o Health Connect, o de tus propios registros.",
|
||||
"activity": "Estos valores vienen de un teléfono, un reloj o un anillo, o de Apple Salud o Health Connect.",
|
||||
"metabolism": "La glucosa viene de un medidor, una importación de sensor o tus registros; los análisis, de un informe de laboratorio o de tus registros.",
|
||||
"mind": "Estos valores vienen de tus registros de ánimo y de los cuestionarios de la página Bienestar mental.",
|
||||
"cycle": "Estos valores vienen de tu registro del ciclo y de un sensor de temperatura en la muñeca."
|
||||
},
|
||||
"today": {
|
||||
"title": "Hoy",
|
||||
"usual": "Habitual {low}–{high}",
|
||||
"learning": "Rango habitual aún en aprendizaje",
|
||||
"asOf": "A fecha de {date}",
|
||||
"details": "Detalles"
|
||||
},
|
||||
"state": {
|
||||
"within": "Dentro",
|
||||
"above": "Por encima",
|
||||
"below": "Por debajo",
|
||||
"learning": "Aprendiendo"
|
||||
},
|
||||
"chart": {
|
||||
"title": "Uno al lado del otro",
|
||||
"legend": "Sombreado: tu rango habitual.",
|
||||
"label": "{signals} en el periodo elegido, una fila cada uno."
|
||||
},
|
||||
"range": {
|
||||
"label": "Periodo",
|
||||
"7d": "7 d",
|
||||
"30d": "30 d",
|
||||
"90d": "90 d",
|
||||
"1y": "1 a"
|
||||
},
|
||||
"relationships": {
|
||||
"title": "Cómo se mueven juntos",
|
||||
"insufficient": "{x} y {y}: {count} de {required} días emparejados por ahora.",
|
||||
"noClearLink": "Sin relación clara entre {x} y {y} en {count} días emparejados.",
|
||||
"splitHigher": "Cuando {x} estuvo por debajo de {threshold}, {y} fue {amount} más alto que otros días.",
|
||||
"splitLower": "Cuando {x} estuvo por debajo de {threshold}, {y} fue {amount} más bajo que otros días.",
|
||||
"splitDays": "Días por debajo: {below}. Otros días: {other}.",
|
||||
"correlationSameDayHigher": "Cuando {x} sube, {y} también suele estar más alto (r = {r}).",
|
||||
"correlationSameDayLower": "Cuando {x} sube, {y} suele estar más bajo (r = {r}).",
|
||||
"correlationNextDayHigher": "Cuando {x} sube, {y} suele estar más alto al día siguiente (r = {r}).",
|
||||
"correlationNextDayLower": "Cuando {x} sube, {y} suele estar más bajo al día siguiente (r = {r}).",
|
||||
"pairedDays": "Días emparejados: {count}",
|
||||
"footnote": "Se muestra cuando dos valores coinciden en al menos 14 días. Un patrón, no una causa.",
|
||||
"scatterCaption": "Cada punto es un día: {x} en horizontal, {y} en vertical."
|
||||
},
|
||||
"trends": {
|
||||
"title": "Tendencias",
|
||||
"window": "90 días, medias semanales",
|
||||
"insufficient": "Necesita 14 días, {count} por ahora"
|
||||
},
|
||||
"labs": {
|
||||
"title": "Análisis",
|
||||
"above": "Por encima del rango",
|
||||
"below": "Por debajo del rango",
|
||||
"trendsTitle": "Tendencias",
|
||||
"trendNeeds": "Una tendencia necesita tres valores a lo largo de al menos un año. Aún no: {markers}."
|
||||
},
|
||||
"screeners": {
|
||||
"title": "Cuestionarios",
|
||||
"none": "Aún no has completado ningún cuestionario.",
|
||||
"checkIns": "Completados: {count}",
|
||||
"improved": "Mejor que la primera vez",
|
||||
"worsened": "Peor que la primera vez",
|
||||
"within_threshold": "Dentro de la variación habitual",
|
||||
"single": "Completado una vez por ahora",
|
||||
"open": "Abrir Bienestar mental"
|
||||
},
|
||||
"cycle": {
|
||||
"title": "Ciclo",
|
||||
"today": "Día {day} del ciclo.",
|
||||
"todayWithPhase": "Día {day} del ciclo, fase {phase}.",
|
||||
"average": "Duración media del ciclo: {days} días.",
|
||||
"needs": "Una media necesita dos inicios de periodo registrados; por ahora hay {count}.",
|
||||
"lengths": "Últimos ciclos, en días: {lengths}",
|
||||
"open": "Abrir la página del ciclo"
|
||||
},
|
||||
"links": {
|
||||
"title": "Páginas por métrica",
|
||||
"workouts": "Entrenamientos"
|
||||
},
|
||||
"missing": {
|
||||
"title": "Aún sin registrar: {signals}.",
|
||||
"connect": "Conectar una fuente"
|
||||
}
|
||||
}
|
||||
}
|
||||
+184
-1
@@ -510,7 +510,9 @@
|
||||
"documents": "Documents",
|
||||
"vaccinations": "Vaccinations",
|
||||
"mentalWellbeing": "Bien-être mental",
|
||||
"timeline": "Chronologie"
|
||||
"timeline": "Chronologie",
|
||||
"topics": "Thèmes",
|
||||
"previewBadge": "Bêta"
|
||||
},
|
||||
"auth": {
|
||||
"login": "Se connecter",
|
||||
@@ -6143,6 +6145,10 @@
|
||||
"access": {
|
||||
"title": "Accès partagé",
|
||||
"subtitle": "Qui peut ouvrir votre dossier de santé, et lequel vous pouvez ouvrir."
|
||||
},
|
||||
"preview": {
|
||||
"title": "Fonctions en avant-première",
|
||||
"subtitle": "Essayez ce qui n'est pas encore terminé. Tout reste désactivé ici jusqu'à ce que vous l'activiez."
|
||||
}
|
||||
},
|
||||
"profile": "Profil",
|
||||
@@ -7628,6 +7634,17 @@
|
||||
"passkeyRemovedToast": "Passkey supprimée.",
|
||||
"integrationDisconnected": "{provider} déconnecté.",
|
||||
"integrationDisconnectFailed": "Impossible de déconnecter {provider}.",
|
||||
"preview": {
|
||||
"topics": {
|
||||
"title": "Thèmes",
|
||||
"description": "Ajoute un espace Thèmes qui rassemble les mesures liées, à côté de vos pages Insights.",
|
||||
"detail": "Huit thèmes, du cœur au cycle, montrent vos mesures côte à côte, la façon dont elles évoluent ensemble et des liens vers les pages par mesure que vous utilisez déjà. Vos pages Insights, la navigation et les liens restent exactement tels quels. Tout est calculé à partir de vos propres valeurs, sans IA. Désactiver retire l'entrée et ne supprime rien.",
|
||||
"savedOn": "Aperçu Thèmes activé.",
|
||||
"savedOff": "Aperçu Thèmes désactivé.",
|
||||
"open": "Ouvrir Thèmes"
|
||||
},
|
||||
"loadError": "Impossible de charger vos réglages d'avant-première."
|
||||
},
|
||||
"timezoneSaved": "Fuseau horaire enregistré.",
|
||||
"moduleState": {
|
||||
"on": "Activé",
|
||||
@@ -13765,5 +13782,171 @@
|
||||
"startsOn": "Début",
|
||||
"endsOn": "Fin"
|
||||
}
|
||||
},
|
||||
"topics": {
|
||||
"overview": {
|
||||
"title": "Thèmes",
|
||||
"description": "Des mesures liées, rassemblées à partir de vos propres valeurs ; ouvrez un thème pour les voir côte à côte.",
|
||||
"emptyTitle": "Rien à rassembler pour l'instant",
|
||||
"emptyDescription": "Les thèmes apparaissent dès qu'il y a des mesures pour eux : d'un appareil, d'un import ou saisies par vous.",
|
||||
"notRecorded": "Rien d'enregistré pour l'instant : {topics}.",
|
||||
"hiddenByModule": "Masqués car le module est désactivé : {topics}.",
|
||||
"modulesLink": "Modifier les modules dans les réglages"
|
||||
},
|
||||
"detail": {
|
||||
"back": "Tous les thèmes"
|
||||
},
|
||||
"loadError": "Impossible de charger les thèmes.",
|
||||
"previewNote": "Ceci est un aperçu. Il peut changer d'une version à l'autre, et vos pages par mesure restent telles quelles.",
|
||||
"recorded": "Mesures enregistrées : {count} sur {total}",
|
||||
"tone": {
|
||||
"steady": "Stable",
|
||||
"watch": "À regarder",
|
||||
"learning": "En apprentissage"
|
||||
},
|
||||
"summary": {
|
||||
"outsideAbove": "{signal} est au-dessus de votre plage habituelle.",
|
||||
"outsideBelow": "{signal} est en dessous de votre plage habituelle.",
|
||||
"labs": "{count} analyses sur {total} se situent dans leur plage de référence.",
|
||||
"cycleDay": "Jour {day} du cycle.",
|
||||
"steadyAll": "Tout ce qui est enregistré se situe dans votre plage habituelle.",
|
||||
"steadySome": "{count} mesures sur {total} se situent dans votre plage habituelle ; les autres sont encore en apprentissage.",
|
||||
"learning": "Votre plage habituelle est encore en apprentissage. Une semaine de mesures suffit.",
|
||||
"empty": "Rien d'enregistré pour l'instant."
|
||||
},
|
||||
"headline": {
|
||||
"labsValue": "{count}/{total}",
|
||||
"labsUnit": "dans la plage",
|
||||
"cycleDay": "Jour {day}"
|
||||
},
|
||||
"signals": {
|
||||
"mood": "Humeur"
|
||||
},
|
||||
"units": {
|
||||
"moodScale": "sur 5"
|
||||
},
|
||||
"names": {
|
||||
"heart": {
|
||||
"title": "Cœur",
|
||||
"description": "Fréquence cardiaque au repos, variabilité et condition cardio, avec le sommeil et l'entraînement qui les font varier."
|
||||
},
|
||||
"body": {
|
||||
"title": "Corps",
|
||||
"description": "Poids et composition corporelle dans le temps."
|
||||
},
|
||||
"bloodPressure": {
|
||||
"title": "Tension artérielle",
|
||||
"description": "Systolique, diastolique et pouls, avec le sommeil et l'activité autour."
|
||||
},
|
||||
"sleep": {
|
||||
"title": "Sommeil",
|
||||
"description": "Temps de sommeil et mesures prises la nuit."
|
||||
},
|
||||
"activity": {
|
||||
"title": "Activité et entraînement",
|
||||
"description": "Pas, minutes d'exercice, énergie active et charge d'entraînement."
|
||||
},
|
||||
"metabolism": {
|
||||
"title": "Métabolisme et analyses",
|
||||
"description": "Glycémie et vos analyses, avec la tendance de chaque marqueur."
|
||||
},
|
||||
"mind": {
|
||||
"title": "Psychisme",
|
||||
"description": "Humeur et questionnaires à côté du sommeil et de l'activité."
|
||||
},
|
||||
"cycle": {
|
||||
"title": "Cycle",
|
||||
"description": "Votre cycle avec la température et la fréquence cardiaque au repos qui le suivent."
|
||||
}
|
||||
},
|
||||
"sources": {
|
||||
"heart": "Ces valeurs viennent d'une montre, d'une bague ou d'une ceinture thoracique, d'Apple Santé ou de Health Connect, ou de vos saisies.",
|
||||
"body": "Ces valeurs viennent d'une balance connectée, d'un import ou de vos saisies.",
|
||||
"bloodPressure": "Ces valeurs viennent d'un tensiomètre, d'un import ou de vos saisies.",
|
||||
"sleep": "Ces valeurs viennent d'une montre ou d'une bague, d'Apple Santé ou de Health Connect, ou de vos saisies.",
|
||||
"activity": "Ces valeurs viennent d'un téléphone, d'une montre ou d'une bague, ou d'Apple Santé ou de Health Connect.",
|
||||
"metabolism": "La glycémie vient d'un lecteur, d'un import de capteur ou de vos saisies ; les analyses d'un compte rendu de laboratoire ou de vos saisies.",
|
||||
"mind": "Ces valeurs viennent de vos saisies d'humeur et des questionnaires de la page Bien-être mental.",
|
||||
"cycle": "Ces valeurs viennent de votre journal de cycle et d'un capteur de température au poignet."
|
||||
},
|
||||
"today": {
|
||||
"title": "Aujourd'hui",
|
||||
"usual": "Habituel {low}–{high}",
|
||||
"learning": "Plage habituelle encore en apprentissage",
|
||||
"asOf": "Au {date}",
|
||||
"details": "Détails"
|
||||
},
|
||||
"state": {
|
||||
"within": "Dans la plage",
|
||||
"above": "Au-dessus",
|
||||
"below": "En dessous",
|
||||
"learning": "En apprentissage"
|
||||
},
|
||||
"chart": {
|
||||
"title": "Côte à côte",
|
||||
"legend": "Zone ombrée : votre plage habituelle.",
|
||||
"label": "{signals} sur la période choisie, une ligne chacune."
|
||||
},
|
||||
"range": {
|
||||
"label": "Période",
|
||||
"7d": "7 j",
|
||||
"30d": "30 j",
|
||||
"90d": "90 j",
|
||||
"1y": "1 a"
|
||||
},
|
||||
"relationships": {
|
||||
"title": "Comment elles évoluent ensemble",
|
||||
"insufficient": "{x} et {y} : {count} jours communs sur {required} pour l'instant.",
|
||||
"noClearLink": "Pas de lien net entre {x} et {y} sur {count} jours communs.",
|
||||
"splitHigher": "Quand {x} était sous {threshold}, {y} était plus élevé de {amount} que les autres jours.",
|
||||
"splitLower": "Quand {x} était sous {threshold}, {y} était plus bas de {amount} que les autres jours.",
|
||||
"splitDays": "Jours en dessous : {below}. Autres jours : {other}.",
|
||||
"correlationSameDayHigher": "Quand {x} est plus élevé, {y} tend à l'être aussi (r = {r}).",
|
||||
"correlationSameDayLower": "Quand {x} est plus élevé, {y} tend à être plus bas (r = {r}).",
|
||||
"correlationNextDayHigher": "Quand {x} est plus élevé, {y} tend à être plus élevé le lendemain (r = {r}).",
|
||||
"correlationNextDayLower": "Quand {x} est plus élevé, {y} tend à être plus bas le lendemain (r = {r}).",
|
||||
"pairedDays": "Jours communs : {count}",
|
||||
"footnote": "Affiché dès que deux mesures se recoupent sur au moins 14 jours. Une tendance, pas une cause.",
|
||||
"scatterCaption": "Chaque point est un jour : {x} en abscisse, {y} en ordonnée."
|
||||
},
|
||||
"trends": {
|
||||
"title": "Tendances",
|
||||
"window": "90 jours, moyennes hebdomadaires",
|
||||
"insufficient": "Il faut 14 jours, {count} pour l'instant"
|
||||
},
|
||||
"labs": {
|
||||
"title": "Analyses",
|
||||
"above": "Au-dessus de la plage",
|
||||
"below": "En dessous de la plage",
|
||||
"trendsTitle": "Tendances",
|
||||
"trendNeeds": "Une tendance demande trois valeurs sur au moins un an. Pas encore : {markers}."
|
||||
},
|
||||
"screeners": {
|
||||
"title": "Questionnaires",
|
||||
"none": "Aucun questionnaire rempli pour l'instant.",
|
||||
"checkIns": "Remplis : {count}",
|
||||
"improved": "Mieux que la première fois",
|
||||
"worsened": "Moins bien que la première fois",
|
||||
"within_threshold": "Dans la variation habituelle",
|
||||
"single": "Rempli une fois pour l'instant",
|
||||
"open": "Ouvrir Bien-être mental"
|
||||
},
|
||||
"cycle": {
|
||||
"title": "Cycle",
|
||||
"today": "Jour {day} du cycle.",
|
||||
"todayWithPhase": "Jour {day} du cycle, phase {phase}.",
|
||||
"average": "Durée moyenne du cycle : {days} jours.",
|
||||
"needs": "Une moyenne demande deux débuts de règles enregistrés ; il y en a {count} pour l'instant.",
|
||||
"lengths": "Derniers cycles, en jours : {lengths}",
|
||||
"open": "Ouvrir la page Cycle"
|
||||
},
|
||||
"links": {
|
||||
"title": "Pages par mesure",
|
||||
"workouts": "Entraînements"
|
||||
},
|
||||
"missing": {
|
||||
"title": "Pas encore enregistré : {signals}.",
|
||||
"connect": "Connecter une source"
|
||||
}
|
||||
}
|
||||
}
|
||||
+184
-1
@@ -510,7 +510,9 @@
|
||||
"documents": "Documenti",
|
||||
"vaccinations": "Vaccinazioni",
|
||||
"mentalWellbeing": "Benessere mentale",
|
||||
"timeline": "Cronologia"
|
||||
"timeline": "Cronologia",
|
||||
"topics": "Temi",
|
||||
"previewBadge": "Beta"
|
||||
},
|
||||
"auth": {
|
||||
"login": "Accedi",
|
||||
@@ -6143,6 +6145,10 @@
|
||||
"access": {
|
||||
"title": "Accesso condiviso",
|
||||
"subtitle": "Chi può aprire la tua cartella sanitaria e quale puoi aprire tu."
|
||||
},
|
||||
"preview": {
|
||||
"title": "Funzioni in anteprima",
|
||||
"subtitle": "Prova ciò che non è ancora finito. Tutto qui resta spento finché non lo attivi."
|
||||
}
|
||||
},
|
||||
"profile": "Profilo",
|
||||
@@ -7628,6 +7634,17 @@
|
||||
"passkeyRemovedToast": "Passkey rimossa.",
|
||||
"integrationDisconnected": "{provider} disconnesso.",
|
||||
"integrationDisconnectFailed": "Impossibile disconnettere {provider}.",
|
||||
"preview": {
|
||||
"topics": {
|
||||
"title": "Temi",
|
||||
"description": "Aggiunge un'area Temi che raccoglie valori collegati, accanto alle tue pagine Insights.",
|
||||
"detail": "Otto temi, dal cuore al ciclo, mostrano i tuoi valori affiancati, come si muovono insieme e collegamenti alle pagine per metrica che già usi. Le tue pagine Insights, la navigazione e i link restano esattamente come sono. Tutto è calcolato dai tuoi valori, senza IA. Disattivarla toglie la voce e non cancella nulla.",
|
||||
"savedOn": "Anteprima Temi attivata.",
|
||||
"savedOff": "Anteprima Temi disattivata.",
|
||||
"open": "Apri Temi"
|
||||
},
|
||||
"loadError": "Impossibile caricare le impostazioni delle anteprime."
|
||||
},
|
||||
"timezoneSaved": "Fuso orario salvato.",
|
||||
"moduleState": {
|
||||
"on": "Attivo",
|
||||
@@ -13765,5 +13782,171 @@
|
||||
"startsOn": "Inizio",
|
||||
"endsOn": "Fine"
|
||||
}
|
||||
},
|
||||
"topics": {
|
||||
"overview": {
|
||||
"title": "Temi",
|
||||
"description": "Valori collegati, raccolti dai tuoi dati; apri un tema per vederli affiancati.",
|
||||
"emptyTitle": "Ancora niente da raccogliere",
|
||||
"emptyDescription": "I temi compaiono appena ci sono valori per loro: da un dispositivo, da un'importazione o inseriti da te.",
|
||||
"notRecorded": "Ancora nessun valore per: {topics}.",
|
||||
"hiddenByModule": "Nascosti perché il modulo è spento: {topics}.",
|
||||
"modulesLink": "Cambia i moduli nelle impostazioni"
|
||||
},
|
||||
"detail": {
|
||||
"back": "Tutti i temi"
|
||||
},
|
||||
"loadError": "Impossibile caricare i temi.",
|
||||
"previewNote": "Questa è un'anteprima. Può cambiare tra una versione e l'altra, e le tue pagine per metrica restano come sono.",
|
||||
"recorded": "Valori registrati: {count} di {total}",
|
||||
"tone": {
|
||||
"steady": "Stabile",
|
||||
"watch": "Da guardare",
|
||||
"learning": "In apprendimento"
|
||||
},
|
||||
"summary": {
|
||||
"outsideAbove": "{signal} è sopra il tuo intervallo abituale.",
|
||||
"outsideBelow": "{signal} è sotto il tuo intervallo abituale.",
|
||||
"labs": "{count} esami su {total} rientrano nel loro intervallo di riferimento.",
|
||||
"cycleDay": "Giorno {day} del ciclo.",
|
||||
"steadyAll": "Tutto ciò che è registrato rientra nel tuo intervallo abituale.",
|
||||
"steadySome": "{count} valori su {total} rientrano nel tuo intervallo abituale; gli altri sono ancora in apprendimento.",
|
||||
"learning": "Il tuo intervallo abituale è ancora in apprendimento. Basta una settimana di valori.",
|
||||
"empty": "Ancora niente di registrato."
|
||||
},
|
||||
"headline": {
|
||||
"labsValue": "{count}/{total}",
|
||||
"labsUnit": "nell'intervallo",
|
||||
"cycleDay": "Giorno {day}"
|
||||
},
|
||||
"signals": {
|
||||
"mood": "Umore"
|
||||
},
|
||||
"units": {
|
||||
"moodScale": "su 5"
|
||||
},
|
||||
"names": {
|
||||
"heart": {
|
||||
"title": "Cuore",
|
||||
"description": "Frequenza cardiaca a riposo, variabilità e forma cardio, con il sonno e l'allenamento che le influenzano."
|
||||
},
|
||||
"body": {
|
||||
"title": "Corpo",
|
||||
"description": "Peso e composizione corporea nel tempo."
|
||||
},
|
||||
"bloodPressure": {
|
||||
"title": "Pressione arteriosa",
|
||||
"description": "Sistolica, diastolica e polso, con il sonno e l'attività intorno."
|
||||
},
|
||||
"sleep": {
|
||||
"title": "Sonno",
|
||||
"description": "Tempo di sonno e i valori misurati di notte."
|
||||
},
|
||||
"activity": {
|
||||
"title": "Attività e allenamento",
|
||||
"description": "Passi, minuti di esercizio, energia attiva e carico di allenamento."
|
||||
},
|
||||
"metabolism": {
|
||||
"title": "Metabolismo ed esami",
|
||||
"description": "Glicemia e i tuoi esami, con l'andamento di ogni marcatore."
|
||||
},
|
||||
"mind": {
|
||||
"title": "Mente",
|
||||
"description": "Umore e questionari accanto a sonno e attività."
|
||||
},
|
||||
"cycle": {
|
||||
"title": "Ciclo",
|
||||
"description": "Il tuo ciclo con la temperatura e la frequenza cardiaca a riposo che lo seguono."
|
||||
}
|
||||
},
|
||||
"sources": {
|
||||
"heart": "Questi valori arrivano da un orologio, un anello o una fascia toracica, da Apple Salute o Health Connect, o dai tuoi inserimenti.",
|
||||
"body": "Questi valori arrivano da una bilancia smart, da un'importazione o dai tuoi inserimenti.",
|
||||
"bloodPressure": "Questi valori arrivano da un misuratore di pressione, da un'importazione o dai tuoi inserimenti.",
|
||||
"sleep": "Questi valori arrivano da un orologio o un anello, da Apple Salute o Health Connect, o dai tuoi inserimenti.",
|
||||
"activity": "Questi valori arrivano da telefono, orologio o anello, o da Apple Salute o Health Connect.",
|
||||
"metabolism": "La glicemia arriva da un glucometro, da un'importazione del sensore o dai tuoi inserimenti; gli esami da un referto o dai tuoi inserimenti.",
|
||||
"mind": "Questi valori arrivano dalle tue registrazioni dell'umore e dai questionari della pagina Benessere mentale.",
|
||||
"cycle": "Questi valori arrivano dal tuo diario del ciclo e da un sensore di temperatura al polso."
|
||||
},
|
||||
"today": {
|
||||
"title": "Oggi",
|
||||
"usual": "Abituale {low}–{high}",
|
||||
"learning": "Intervallo abituale ancora in apprendimento",
|
||||
"asOf": "Al {date}",
|
||||
"details": "Dettagli"
|
||||
},
|
||||
"state": {
|
||||
"within": "Nell'intervallo",
|
||||
"above": "Sopra il solito",
|
||||
"below": "Sotto il solito",
|
||||
"learning": "In apprendimento"
|
||||
},
|
||||
"chart": {
|
||||
"title": "Affiancati",
|
||||
"legend": "Area ombreggiata: il tuo intervallo abituale.",
|
||||
"label": "{signals} nel periodo scelto, una riga ciascuno."
|
||||
},
|
||||
"range": {
|
||||
"label": "Periodo",
|
||||
"7d": "7 g",
|
||||
"30d": "30 g",
|
||||
"90d": "90 g",
|
||||
"1y": "1 a"
|
||||
},
|
||||
"relationships": {
|
||||
"title": "Come si muovono insieme",
|
||||
"insufficient": "{x} e {y}: finora {count} giorni abbinati su {required}.",
|
||||
"noClearLink": "Nessun legame chiaro tra {x} e {y} su {count} giorni abbinati.",
|
||||
"splitHigher": "Quando {x} era sotto {threshold}, {y} era più alto di {amount} rispetto agli altri giorni.",
|
||||
"splitLower": "Quando {x} era sotto {threshold}, {y} era più basso di {amount} rispetto agli altri giorni.",
|
||||
"splitDays": "Giorni sotto: {below}. Altri giorni: {other}.",
|
||||
"correlationSameDayHigher": "Quando {x} è più alto, anche {y} tende a esserlo (r = {r}).",
|
||||
"correlationSameDayLower": "Quando {x} è più alto, {y} tende a essere più basso (r = {r}).",
|
||||
"correlationNextDayHigher": "Quando {x} è più alto, il giorno dopo {y} tende a essere più alto (r = {r}).",
|
||||
"correlationNextDayLower": "Quando {x} è più alto, il giorno dopo {y} tende a essere più basso (r = {r}).",
|
||||
"pairedDays": "Giorni abbinati: {count}",
|
||||
"footnote": "Compare quando due valori si sovrappongono per almeno 14 giorni. Uno schema, non una causa.",
|
||||
"scatterCaption": "Ogni punto è un giorno: {x} in orizzontale, {y} in verticale."
|
||||
},
|
||||
"trends": {
|
||||
"title": "Andamenti",
|
||||
"window": "90 giorni, medie settimanali",
|
||||
"insufficient": "Servono 14 giorni, finora {count}"
|
||||
},
|
||||
"labs": {
|
||||
"title": "Esami",
|
||||
"above": "Sopra l'intervallo",
|
||||
"below": "Sotto l'intervallo",
|
||||
"trendsTitle": "Andamenti",
|
||||
"trendNeeds": "Un andamento richiede tre valori nell'arco di almeno un anno. Non ancora: {markers}."
|
||||
},
|
||||
"screeners": {
|
||||
"title": "Questionari",
|
||||
"none": "Ancora nessun questionario compilato.",
|
||||
"checkIns": "Compilati: {count}",
|
||||
"improved": "Meglio della prima volta",
|
||||
"worsened": "Peggio della prima volta",
|
||||
"within_threshold": "Entro la variazione abituale",
|
||||
"single": "Compilato una volta finora",
|
||||
"open": "Apri Benessere mentale"
|
||||
},
|
||||
"cycle": {
|
||||
"title": "Ciclo",
|
||||
"today": "Giorno {day} del ciclo.",
|
||||
"todayWithPhase": "Giorno {day} del ciclo, fase {phase}.",
|
||||
"average": "Durata media del ciclo: {days} giorni.",
|
||||
"needs": "Una media richiede due inizi di mestruazione registrati; finora sono {count}.",
|
||||
"lengths": "Ultimi cicli, in giorni: {lengths}",
|
||||
"open": "Apri la pagina del ciclo"
|
||||
},
|
||||
"links": {
|
||||
"title": "Pagine per metrica",
|
||||
"workouts": "Allenamenti"
|
||||
},
|
||||
"missing": {
|
||||
"title": "Non ancora registrato: {signals}.",
|
||||
"connect": "Collega una fonte"
|
||||
}
|
||||
}
|
||||
}
|
||||
+184
-1
@@ -510,7 +510,9 @@
|
||||
"documents": "문서",
|
||||
"vaccinations": "예방접종",
|
||||
"mentalWellbeing": "정신 건강",
|
||||
"timeline": "타임라인"
|
||||
"timeline": "타임라인",
|
||||
"topics": "주제",
|
||||
"previewBadge": "베타"
|
||||
},
|
||||
"auth": {
|
||||
"login": "로그인",
|
||||
@@ -6143,6 +6145,10 @@
|
||||
"access": {
|
||||
"title": "공유 접근",
|
||||
"subtitle": "내 건강 기록을 누가 열 수 있고, 내가 누구의 기록을 열 수 있는지예요."
|
||||
},
|
||||
"preview": {
|
||||
"title": "미리 보기 기능",
|
||||
"subtitle": "아직 완성되지 않은 기능을 써 보세요. 여기 있는 기능은 직접 켜기 전까지 꺼져 있습니다."
|
||||
}
|
||||
},
|
||||
"profile": "프로필",
|
||||
@@ -7628,6 +7634,17 @@
|
||||
"passkeyRemovedToast": "패스키를 삭제했어요.",
|
||||
"integrationDisconnected": "{provider} 연결을 해제했어요.",
|
||||
"integrationDisconnectFailed": "{provider} 연결을 해제하지 못했어요.",
|
||||
"preview": {
|
||||
"topics": {
|
||||
"title": "주제",
|
||||
"description": "관련된 수치를 한데 모은 주제 영역을 인사이트 페이지 옆에 추가합니다.",
|
||||
"detail": "심장부터 주기까지 여덟 가지 주제가 수치를 나란히 보여 주고, 함께 어떻게 움직이는지와 이미 쓰고 있는 개별 지표 페이지로 가는 링크를 보여 줍니다. 인사이트 페이지, 내비게이션, 링크는 그대로 유지됩니다. 모든 계산은 AI 없이 본인의 수치로 합니다. 끄면 메뉴 항목만 사라지고 아무것도 삭제되지 않습니다.",
|
||||
"savedOn": "주제 미리 보기를 켰습니다.",
|
||||
"savedOff": "주제 미리 보기를 껐습니다.",
|
||||
"open": "주제 열기"
|
||||
},
|
||||
"loadError": "미리 보기 설정을 불러오지 못했습니다."
|
||||
},
|
||||
"timezoneSaved": "시간대를 저장했습니다.",
|
||||
"moduleState": {
|
||||
"on": "켜짐",
|
||||
@@ -13765,5 +13782,171 @@
|
||||
"startsOn": "시작",
|
||||
"endsOn": "종료"
|
||||
}
|
||||
},
|
||||
"topics": {
|
||||
"overview": {
|
||||
"title": "주제",
|
||||
"description": "본인의 수치에서 관련 지표를 모았습니다. 주제를 열면 나란히 볼 수 있습니다.",
|
||||
"emptyTitle": "아직 모을 수치가 없습니다",
|
||||
"emptyDescription": "기기, 가져오기 또는 직접 입력으로 수치가 생기면 주제가 나타납니다.",
|
||||
"notRecorded": "아직 기록이 없는 주제: {topics}.",
|
||||
"hiddenByModule": "모듈이 꺼져 있어 숨김: {topics}.",
|
||||
"modulesLink": "설정에서 모듈 변경"
|
||||
},
|
||||
"detail": {
|
||||
"back": "모든 주제"
|
||||
},
|
||||
"loadError": "주제를 불러오지 못했습니다.",
|
||||
"previewNote": "미리 보기 기능입니다. 버전마다 바뀔 수 있으며, 개별 지표 페이지는 그대로입니다.",
|
||||
"recorded": "기록된 지표: {total}개 중 {count}개",
|
||||
"tone": {
|
||||
"steady": "안정적",
|
||||
"watch": "살펴볼 만함",
|
||||
"learning": "학습 중"
|
||||
},
|
||||
"summary": {
|
||||
"outsideAbove": "{signal}이(가) 평소 범위보다 높습니다.",
|
||||
"outsideBelow": "{signal}이(가) 평소 범위보다 낮습니다.",
|
||||
"labs": "검사 수치 {total}개 중 {count}개가 참고 범위 안에 있습니다.",
|
||||
"cycleDay": "주기 {day}일째.",
|
||||
"steadyAll": "기록된 모든 수치가 평소 범위 안에 있습니다.",
|
||||
"steadySome": "{total}개 중 {count}개 지표가 평소 범위 안에 있으며, 나머지는 아직 학습 중입니다.",
|
||||
"learning": "평소 범위를 아직 학습 중입니다. 일주일 치 기록이면 충분합니다.",
|
||||
"empty": "아직 기록이 없습니다."
|
||||
},
|
||||
"headline": {
|
||||
"labsValue": "{count}/{total}",
|
||||
"labsUnit": "범위 내",
|
||||
"cycleDay": "{day}일째"
|
||||
},
|
||||
"signals": {
|
||||
"mood": "기분"
|
||||
},
|
||||
"units": {
|
||||
"moodScale": "/ 5"
|
||||
},
|
||||
"names": {
|
||||
"heart": {
|
||||
"title": "심장",
|
||||
"description": "안정 시 심박수, 심박 변이도, 심폐 체력과 이에 영향을 주는 수면과 운동."
|
||||
},
|
||||
"body": {
|
||||
"title": "신체",
|
||||
"description": "시간에 따른 체중과 체성분."
|
||||
},
|
||||
"bloodPressure": {
|
||||
"title": "혈압",
|
||||
"description": "수축기, 이완기 혈압과 맥박, 그리고 주변의 수면과 활동."
|
||||
},
|
||||
"sleep": {
|
||||
"title": "수면",
|
||||
"description": "수면 시간과 밤에 측정되는 수치."
|
||||
},
|
||||
"activity": {
|
||||
"title": "활동 및 운동",
|
||||
"description": "걸음 수, 운동 시간, 활동 에너지와 훈련 부하."
|
||||
},
|
||||
"metabolism": {
|
||||
"title": "대사 및 검사",
|
||||
"description": "혈당과 검사 수치, 그리고 각 지표의 추세."
|
||||
},
|
||||
"mind": {
|
||||
"title": "마음",
|
||||
"description": "수면과 활동 옆에 놓인 기분과 설문."
|
||||
},
|
||||
"cycle": {
|
||||
"title": "주기",
|
||||
"description": "주기와 이를 따라 변하는 체온, 안정 시 심박수."
|
||||
}
|
||||
},
|
||||
"sources": {
|
||||
"heart": "이 수치는 워치, 링, 가슴 스트랩, Apple 건강 또는 Health Connect, 직접 입력에서 옵니다.",
|
||||
"body": "이 수치는 스마트 체중계, 가져오기 또는 직접 입력에서 옵니다.",
|
||||
"bloodPressure": "이 수치는 혈압계, 가져오기 또는 직접 입력에서 옵니다.",
|
||||
"sleep": "이 수치는 워치나 링, Apple 건강 또는 Health Connect, 직접 입력에서 옵니다.",
|
||||
"activity": "이 수치는 휴대폰, 워치, 링 또는 Apple 건강이나 Health Connect에서 옵니다.",
|
||||
"metabolism": "혈당은 혈당 측정기, 센서 가져오기 또는 직접 입력에서, 검사 수치는 검사 보고서나 직접 입력에서 옵니다.",
|
||||
"mind": "이 수치는 기분 기록과 정신 건강 페이지의 설문에서 옵니다.",
|
||||
"cycle": "이 수치는 주기 기록과 손목 온도 센서에서 옵니다."
|
||||
},
|
||||
"today": {
|
||||
"title": "오늘",
|
||||
"usual": "평소 {low}–{high}",
|
||||
"learning": "평소 범위 학습 중",
|
||||
"asOf": "{date} 기준",
|
||||
"details": "자세히"
|
||||
},
|
||||
"state": {
|
||||
"within": "범위 내",
|
||||
"above": "평소보다 높음",
|
||||
"below": "평소보다 낮음",
|
||||
"learning": "학습 중"
|
||||
},
|
||||
"chart": {
|
||||
"title": "나란히 보기",
|
||||
"legend": "음영: 평소 범위.",
|
||||
"label": "선택한 기간의 {signals}, 지표마다 한 줄."
|
||||
},
|
||||
"range": {
|
||||
"label": "기간",
|
||||
"7d": "7일",
|
||||
"30d": "30일",
|
||||
"90d": "90일",
|
||||
"1y": "1년"
|
||||
},
|
||||
"relationships": {
|
||||
"title": "함께 움직이는 방식",
|
||||
"insufficient": "{x}와(과) {y}: 지금까지 {required}일 중 {count}일이 겹칩니다.",
|
||||
"noClearLink": "겹치는 {count}일 동안 {x}와(과) {y} 사이에 뚜렷한 관계가 없습니다.",
|
||||
"splitHigher": "{x}이(가) {threshold} 미만일 때 {y}이(가) 다른 날보다 {amount} 높았습니다.",
|
||||
"splitLower": "{x}이(가) {threshold} 미만일 때 {y}이(가) 다른 날보다 {amount} 낮았습니다.",
|
||||
"splitDays": "미만인 날: {below}. 다른 날: {other}.",
|
||||
"correlationSameDayHigher": "{x}이(가) 높을수록 {y}도 높은 경향이 있습니다 (r = {r}).",
|
||||
"correlationSameDayLower": "{x}이(가) 높을수록 {y}은(는) 낮은 경향이 있습니다 (r = {r}).",
|
||||
"correlationNextDayHigher": "{x}이(가) 높으면 다음 날 {y}도 높은 경향이 있습니다 (r = {r}).",
|
||||
"correlationNextDayLower": "{x}이(가) 높으면 다음 날 {y}은(는) 낮은 경향이 있습니다 (r = {r}).",
|
||||
"pairedDays": "겹치는 날: {count}",
|
||||
"footnote": "두 지표가 14일 이상 겹치면 표시됩니다. 패턴일 뿐 원인이 아닙니다.",
|
||||
"scatterCaption": "점 하나가 하루입니다. 가로는 {x}, 세로는 {y}."
|
||||
},
|
||||
"trends": {
|
||||
"title": "추세",
|
||||
"window": "90일, 주간 평균",
|
||||
"insufficient": "14일이 필요합니다. 지금까지 {count}일"
|
||||
},
|
||||
"labs": {
|
||||
"title": "검사 수치",
|
||||
"above": "범위 초과",
|
||||
"below": "범위 미만",
|
||||
"trendsTitle": "추세",
|
||||
"trendNeeds": "추세에는 1년 이상에 걸친 수치 세 개가 필요합니다. 아직 부족: {markers}."
|
||||
},
|
||||
"screeners": {
|
||||
"title": "설문",
|
||||
"none": "아직 작성한 설문이 없습니다.",
|
||||
"checkIns": "작성 횟수: {count}",
|
||||
"improved": "처음보다 좋아짐",
|
||||
"worsened": "처음보다 나빠짐",
|
||||
"within_threshold": "평소 변동 범위 내",
|
||||
"single": "지금까지 한 번 작성",
|
||||
"open": "정신 건강 열기"
|
||||
},
|
||||
"cycle": {
|
||||
"title": "주기",
|
||||
"today": "주기 {day}일째.",
|
||||
"todayWithPhase": "주기 {day}일째, {phase}.",
|
||||
"average": "평균 주기 길이: {days}일.",
|
||||
"needs": "평균을 내려면 생리 시작일 기록이 두 번 필요합니다. 지금까지 {count}번입니다.",
|
||||
"lengths": "최근 주기(일): {lengths}",
|
||||
"open": "주기 페이지 열기"
|
||||
},
|
||||
"links": {
|
||||
"title": "개별 지표 페이지",
|
||||
"workouts": "운동"
|
||||
},
|
||||
"missing": {
|
||||
"title": "아직 기록되지 않음: {signals}.",
|
||||
"connect": "데이터 소스 연결"
|
||||
}
|
||||
}
|
||||
}
|
||||
+184
-1
@@ -510,7 +510,9 @@
|
||||
"documents": "Documenten",
|
||||
"vaccinations": "Vaccinaties",
|
||||
"mentalWellbeing": "Mentaal welzijn",
|
||||
"timeline": "Tijdlijn"
|
||||
"timeline": "Tijdlijn",
|
||||
"topics": "Thema's",
|
||||
"previewBadge": "Bèta"
|
||||
},
|
||||
"auth": {
|
||||
"login": "Inloggen",
|
||||
@@ -6143,6 +6145,10 @@
|
||||
"access": {
|
||||
"title": "Gedeelde toegang",
|
||||
"subtitle": "Wie je gezondheidsdossier kan openen, en wiens dossier jij kunt openen."
|
||||
},
|
||||
"preview": {
|
||||
"title": "Voorvertoningen",
|
||||
"subtitle": "Probeer wat nog niet af is. Alles hier blijft uit tot je het aanzet."
|
||||
}
|
||||
},
|
||||
"profile": "Profiel",
|
||||
@@ -7628,6 +7634,17 @@
|
||||
"passkeyRemovedToast": "Passkey verwijderd.",
|
||||
"integrationDisconnected": "{provider} ontkoppeld.",
|
||||
"integrationDisconnectFailed": "{provider} kon niet worden ontkoppeld.",
|
||||
"preview": {
|
||||
"topics": {
|
||||
"title": "Thema's",
|
||||
"description": "Voegt een gedeelte Thema's toe dat samenhangende waarden bundelt, naast je Inzichten-pagina's.",
|
||||
"detail": "Acht thema's, van hart tot cyclus, tonen je waarden naast elkaar, hoe ze samen bewegen en links naar de pagina's per meetwaarde die je al gebruikt. Je Inzichten-pagina's, de navigatie en alle links blijven precies zoals ze zijn. Alles wordt uit je eigen waarden berekend, zonder AI. Uitzetten haalt de ingang weg en verwijdert niets.",
|
||||
"savedOn": "Voorvertoning Thema's aangezet.",
|
||||
"savedOff": "Voorvertoning Thema's uitgezet.",
|
||||
"open": "Thema's openen"
|
||||
},
|
||||
"loadError": "Je voorvertoningsinstellingen konden niet worden geladen."
|
||||
},
|
||||
"timezoneSaved": "Tijdzone opgeslagen.",
|
||||
"moduleState": {
|
||||
"on": "Aan",
|
||||
@@ -13765,5 +13782,171 @@
|
||||
"startsOn": "Begin",
|
||||
"endsOn": "Einde"
|
||||
}
|
||||
},
|
||||
"topics": {
|
||||
"overview": {
|
||||
"title": "Thema's",
|
||||
"description": "Samenhangende waarden, gebundeld uit je eigen gegevens; open een thema om ze naast elkaar te zien.",
|
||||
"emptyTitle": "Nog niets om te bundelen",
|
||||
"emptyDescription": "Thema's verschijnen zodra er waarden voor zijn: van een apparaat, uit een import of door jou ingevoerd.",
|
||||
"notRecorded": "Nog niets vastgelegd voor: {topics}.",
|
||||
"hiddenByModule": "Verborgen omdat de module uit staat: {topics}.",
|
||||
"modulesLink": "Modules wijzigen in de instellingen"
|
||||
},
|
||||
"detail": {
|
||||
"back": "Alle thema's"
|
||||
},
|
||||
"loadError": "De thema's konden niet worden geladen.",
|
||||
"previewNote": "Dit is een voorvertoning. Die kan per versie veranderen, en je pagina's per meetwaarde blijven zoals ze zijn.",
|
||||
"recorded": "Vastgelegde waarden: {count} van {total}",
|
||||
"tone": {
|
||||
"steady": "Stabiel",
|
||||
"watch": "Het bekijken waard",
|
||||
"learning": "Leert nog"
|
||||
},
|
||||
"summary": {
|
||||
"outsideAbove": "{signal} ligt boven je gebruikelijke bereik.",
|
||||
"outsideBelow": "{signal} ligt onder je gebruikelijke bereik.",
|
||||
"labs": "{count} van {total} labwaarden liggen binnen hun referentiebereik.",
|
||||
"cycleDay": "Cyclusdag {day}.",
|
||||
"steadyAll": "Alles wat is vastgelegd ligt binnen je gebruikelijke bereik.",
|
||||
"steadySome": "{count} van {total} waarden liggen binnen je gebruikelijke bereik; de andere worden nog geleerd.",
|
||||
"learning": "Je gebruikelijke bereik wordt nog geleerd. Een week aan metingen is genoeg.",
|
||||
"empty": "Nog niets vastgelegd."
|
||||
},
|
||||
"headline": {
|
||||
"labsValue": "{count}/{total}",
|
||||
"labsUnit": "binnen bereik",
|
||||
"cycleDay": "Dag {day}"
|
||||
},
|
||||
"signals": {
|
||||
"mood": "Stemming"
|
||||
},
|
||||
"units": {
|
||||
"moodScale": "van 5"
|
||||
},
|
||||
"names": {
|
||||
"heart": {
|
||||
"title": "Hart",
|
||||
"description": "Rusthartslag, hartslagvariabiliteit en conditie, met de slaap en training die ze beïnvloeden."
|
||||
},
|
||||
"body": {
|
||||
"title": "Lichaam",
|
||||
"description": "Gewicht en lichaamssamenstelling door de tijd."
|
||||
},
|
||||
"bloodPressure": {
|
||||
"title": "Bloeddruk",
|
||||
"description": "Systolisch, diastolisch en hartslag, met de slaap en activiteit eromheen."
|
||||
},
|
||||
"sleep": {
|
||||
"title": "Slaap",
|
||||
"description": "Slaapduur en de waarden die 's nachts worden gemeten."
|
||||
},
|
||||
"activity": {
|
||||
"title": "Activiteit en training",
|
||||
"description": "Stappen, trainingsminuten, actieve energie en trainingsbelasting."
|
||||
},
|
||||
"metabolism": {
|
||||
"title": "Stofwisseling en lab",
|
||||
"description": "Bloedglucose en je labwaarden, met de trend van elke marker."
|
||||
},
|
||||
"mind": {
|
||||
"title": "Geest",
|
||||
"description": "Stemming en vragenlijsten naast slaap en activiteit."
|
||||
},
|
||||
"cycle": {
|
||||
"title": "Cyclus",
|
||||
"description": "Je cyclus met de temperatuur en rusthartslag die ermee meebewegen."
|
||||
}
|
||||
},
|
||||
"sources": {
|
||||
"heart": "Deze waarden komen van een horloge, ring of borstband, uit Apple Gezondheid of Health Connect, of van je eigen invoer.",
|
||||
"body": "Deze waarden komen van een slimme weegschaal, een import of je eigen invoer.",
|
||||
"bloodPressure": "Deze waarden komen van een bloeddrukmeter, een import of je eigen invoer.",
|
||||
"sleep": "Deze waarden komen van een horloge of ring, uit Apple Gezondheid of Health Connect, of van je eigen invoer.",
|
||||
"activity": "Deze waarden komen van een telefoon, horloge of ring, of uit Apple Gezondheid of Health Connect.",
|
||||
"metabolism": "Glucose komt van een meter, een sensorimport of je eigen invoer; labwaarden uit een labrapport of je eigen invoer.",
|
||||
"mind": "Deze waarden komen uit je stemmingsregistraties en de vragenlijsten op de pagina Mentaal welzijn.",
|
||||
"cycle": "Deze waarden komen uit je cycluslogboek en een temperatuursensor aan de pols."
|
||||
},
|
||||
"today": {
|
||||
"title": "Vandaag",
|
||||
"usual": "Gebruikelijk {low}–{high}",
|
||||
"learning": "Gebruikelijk bereik wordt nog geleerd",
|
||||
"asOf": "Per {date}",
|
||||
"details": "Details"
|
||||
},
|
||||
"state": {
|
||||
"within": "Binnen bereik",
|
||||
"above": "Boven gebruikelijk",
|
||||
"below": "Onder gebruikelijk",
|
||||
"learning": "Leert nog"
|
||||
},
|
||||
"chart": {
|
||||
"title": "Naast elkaar",
|
||||
"legend": "Gearceerd: je gebruikelijke bereik.",
|
||||
"label": "{signals} in de gekozen periode, elk op een eigen rij."
|
||||
},
|
||||
"range": {
|
||||
"label": "Periode",
|
||||
"7d": "7 d",
|
||||
"30d": "30 d",
|
||||
"90d": "90 d",
|
||||
"1y": "1 j"
|
||||
},
|
||||
"relationships": {
|
||||
"title": "Hoe ze samen bewegen",
|
||||
"insufficient": "{x} en {y}: tot nu toe {count} van {required} gedeelde dagen.",
|
||||
"noClearLink": "Geen duidelijk verband tussen {x} en {y} over {count} gedeelde dagen.",
|
||||
"splitHigher": "Als {x} onder {threshold} lag, was {y} {amount} hoger dan op andere dagen.",
|
||||
"splitLower": "Als {x} onder {threshold} lag, was {y} {amount} lager dan op andere dagen.",
|
||||
"splitDays": "Dagen eronder: {below}. Andere dagen: {other}.",
|
||||
"correlationSameDayHigher": "Als {x} hoger is, is {y} meestal ook hoger (r = {r}).",
|
||||
"correlationSameDayLower": "Als {x} hoger is, is {y} meestal lager (r = {r}).",
|
||||
"correlationNextDayHigher": "Als {x} hoger is, is {y} de volgende dag meestal hoger (r = {r}).",
|
||||
"correlationNextDayLower": "Als {x} hoger is, is {y} de volgende dag meestal lager (r = {r}).",
|
||||
"pairedDays": "Gedeelde dagen: {count}",
|
||||
"footnote": "Verschijnt zodra twee waarden op minstens 14 dagen samenvallen. Een patroon, geen oorzaak.",
|
||||
"scatterCaption": "Elke stip is één dag: {x} horizontaal, {y} verticaal."
|
||||
},
|
||||
"trends": {
|
||||
"title": "Trends",
|
||||
"window": "90 dagen, weekgemiddelden",
|
||||
"insufficient": "Heeft 14 dagen nodig, nu {count}"
|
||||
},
|
||||
"labs": {
|
||||
"title": "Labwaarden",
|
||||
"above": "Boven het bereik",
|
||||
"below": "Onder het bereik",
|
||||
"trendsTitle": "Trends",
|
||||
"trendNeeds": "Een trend heeft drie waarden over minstens een jaar nodig. Nog niet: {markers}."
|
||||
},
|
||||
"screeners": {
|
||||
"title": "Vragenlijsten",
|
||||
"none": "Nog geen vragenlijsten ingevuld.",
|
||||
"checkIns": "Ingevuld: {count}",
|
||||
"improved": "Beter dan de eerste keer",
|
||||
"worsened": "Slechter dan de eerste keer",
|
||||
"within_threshold": "Binnen de gebruikelijke schommeling",
|
||||
"single": "Tot nu toe één keer ingevuld",
|
||||
"open": "Mentaal welzijn openen"
|
||||
},
|
||||
"cycle": {
|
||||
"title": "Cyclus",
|
||||
"today": "Cyclusdag {day}.",
|
||||
"todayWithPhase": "Cyclusdag {day}, {phase}.",
|
||||
"average": "Gemiddelde cycluslengte: {days} dagen.",
|
||||
"needs": "Een gemiddelde heeft twee vastgelegde menstruatiestarts nodig; nu zijn het er {count}.",
|
||||
"lengths": "Recente cycli in dagen: {lengths}",
|
||||
"open": "Cycluspagina openen"
|
||||
},
|
||||
"links": {
|
||||
"title": "Pagina's per meetwaarde",
|
||||
"workouts": "Workouts"
|
||||
},
|
||||
"missing": {
|
||||
"title": "Nog niet vastgelegd: {signals}.",
|
||||
"connect": "Een bron koppelen"
|
||||
}
|
||||
}
|
||||
}
|
||||
+184
-1
@@ -510,7 +510,9 @@
|
||||
"documents": "Dokumenty",
|
||||
"vaccinations": "Szczepienia",
|
||||
"mentalWellbeing": "Dobrostan psychiczny",
|
||||
"timeline": "Oś czasu"
|
||||
"timeline": "Oś czasu",
|
||||
"topics": "Tematy",
|
||||
"previewBadge": "Beta"
|
||||
},
|
||||
"auth": {
|
||||
"login": "Zaloguj się",
|
||||
@@ -6143,6 +6145,10 @@
|
||||
"access": {
|
||||
"title": "Udostępniony dostęp",
|
||||
"subtitle": "Kto może otworzyć twoją kartę zdrowia i czyją kartę możesz otworzyć ty."
|
||||
},
|
||||
"preview": {
|
||||
"title": "Funkcje w wersji próbnej",
|
||||
"subtitle": "Wypróbuj to, co jeszcze nie jest gotowe. Wszystko tutaj pozostaje wyłączone, dopóki tego nie włączysz."
|
||||
}
|
||||
},
|
||||
"profile": "Profil",
|
||||
@@ -7628,6 +7634,17 @@
|
||||
"passkeyRemovedToast": "Usunięto passkey.",
|
||||
"integrationDisconnected": "Rozłączono {provider}.",
|
||||
"integrationDisconnectFailed": "Nie udało się rozłączyć {provider}.",
|
||||
"preview": {
|
||||
"topics": {
|
||||
"title": "Tematy",
|
||||
"description": "Dodaje obszar Tematy, który zbiera powiązane wartości, obok Twoich stron Insights.",
|
||||
"detail": "Osiem tematów, od serca po cykl, pokazuje Twoje wartości obok siebie, jak zmieniają się razem, oraz linki do stron pojedynczych wskaźników, których już używasz. Strony Insights, nawigacja i linki zostają dokładnie takie, jakie są. Wszystko jest liczone z Twoich własnych wartości, bez AI. Wyłączenie usuwa wpis i niczego nie kasuje.",
|
||||
"savedOn": "Wersja próbna Tematów włączona.",
|
||||
"savedOff": "Wersja próbna Tematów wyłączona.",
|
||||
"open": "Otwórz Tematy"
|
||||
},
|
||||
"loadError": "Nie udało się wczytać ustawień wersji próbnych."
|
||||
},
|
||||
"timezoneSaved": "Strefa czasowa zapisana.",
|
||||
"moduleState": {
|
||||
"on": "Włączone",
|
||||
@@ -13765,5 +13782,171 @@
|
||||
"startsOn": "Początek",
|
||||
"endsOn": "Koniec"
|
||||
}
|
||||
},
|
||||
"topics": {
|
||||
"overview": {
|
||||
"title": "Tematy",
|
||||
"description": "Powiązane wartości zebrane z Twoich danych; otwórz temat, aby zobaczyć je obok siebie.",
|
||||
"emptyTitle": "Na razie nie ma czego zebrać",
|
||||
"emptyDescription": "Tematy pojawiają się, gdy są dla nich wartości: z urządzenia, z importu lub wpisane przez Ciebie.",
|
||||
"notRecorded": "Brak jeszcze wartości dla: {topics}.",
|
||||
"hiddenByModule": "Ukryte, bo moduł jest wyłączony: {topics}.",
|
||||
"modulesLink": "Zmień moduły w ustawieniach"
|
||||
},
|
||||
"detail": {
|
||||
"back": "Wszystkie tematy"
|
||||
},
|
||||
"loadError": "Nie udało się wczytać tematów.",
|
||||
"previewNote": "To wersja próbna. Może się zmieniać między wydaniami, a strony pojedynczych wskaźników zostają bez zmian.",
|
||||
"recorded": "Zapisane wartości: {count} z {total}",
|
||||
"tone": {
|
||||
"steady": "Stabilnie",
|
||||
"watch": "Warto zerknąć",
|
||||
"learning": "W trakcie nauki"
|
||||
},
|
||||
"summary": {
|
||||
"outsideAbove": "{signal} jest powyżej Twojego zwykłego zakresu.",
|
||||
"outsideBelow": "{signal} jest poniżej Twojego zwykłego zakresu.",
|
||||
"labs": "{count} z {total} wyników badań mieści się w zakresie referencyjnym.",
|
||||
"cycleDay": "Dzień cyklu {day}.",
|
||||
"steadyAll": "Wszystko, co zapisane, mieści się w Twoim zwykłym zakresie.",
|
||||
"steadySome": "{count} z {total} wartości mieści się w Twoim zwykłym zakresie; pozostałe są jeszcze poznawane.",
|
||||
"learning": "Twój zwykły zakres jest jeszcze poznawany. Wystarczy tydzień pomiarów.",
|
||||
"empty": "Nic jeszcze nie zapisano."
|
||||
},
|
||||
"headline": {
|
||||
"labsValue": "{count}/{total}",
|
||||
"labsUnit": "w zakresie",
|
||||
"cycleDay": "Dzień {day}"
|
||||
},
|
||||
"signals": {
|
||||
"mood": "Nastrój"
|
||||
},
|
||||
"units": {
|
||||
"moodScale": "z 5"
|
||||
},
|
||||
"names": {
|
||||
"heart": {
|
||||
"title": "Serce",
|
||||
"description": "Tętno spoczynkowe, zmienność rytmu serca i wydolność, wraz ze snem i treningiem, które na nie wpływają."
|
||||
},
|
||||
"body": {
|
||||
"title": "Ciało",
|
||||
"description": "Masa ciała i skład ciała w czasie."
|
||||
},
|
||||
"bloodPressure": {
|
||||
"title": "Ciśnienie krwi",
|
||||
"description": "Skurczowe, rozkurczowe i tętno, wraz ze snem i aktywnością wokół nich."
|
||||
},
|
||||
"sleep": {
|
||||
"title": "Sen",
|
||||
"description": "Czas snu i wartości mierzone w nocy."
|
||||
},
|
||||
"activity": {
|
||||
"title": "Aktywność i trening",
|
||||
"description": "Kroki, minuty ćwiczeń, aktywna energia i obciążenie treningowe."
|
||||
},
|
||||
"metabolism": {
|
||||
"title": "Metabolizm i badania",
|
||||
"description": "Glukoza i Twoje wyniki badań, z trendem każdego markera."
|
||||
},
|
||||
"mind": {
|
||||
"title": "Psychika",
|
||||
"description": "Nastrój i kwestionariusze obok snu i aktywności."
|
||||
},
|
||||
"cycle": {
|
||||
"title": "Cykl",
|
||||
"description": "Twój cykl wraz z temperaturą i tętnem spoczynkowym, które za nim podążają."
|
||||
}
|
||||
},
|
||||
"sources": {
|
||||
"heart": "Te wartości pochodzą z zegarka, pierścienia lub pasa piersiowego, z Apple Zdrowie lub Health Connect albo z Twoich wpisów.",
|
||||
"body": "Te wartości pochodzą z inteligentnej wagi, z importu lub z Twoich wpisów.",
|
||||
"bloodPressure": "Te wartości pochodzą z ciśnieniomierza, z importu lub z Twoich wpisów.",
|
||||
"sleep": "Te wartości pochodzą z zegarka lub pierścienia, z Apple Zdrowie lub Health Connect albo z Twoich wpisów.",
|
||||
"activity": "Te wartości pochodzą z telefonu, zegarka lub pierścienia albo z Apple Zdrowie lub Health Connect.",
|
||||
"metabolism": "Glukoza pochodzi z glukometru, importu z sensora lub Twoich wpisów; wyniki badań z raportu laboratoryjnego lub Twoich wpisów.",
|
||||
"mind": "Te wartości pochodzą z Twoich wpisów nastroju i kwestionariuszy na stronie Dobrostan psychiczny.",
|
||||
"cycle": "Te wartości pochodzą z Twojego dziennika cyklu i czujnika temperatury na nadgarstku."
|
||||
},
|
||||
"today": {
|
||||
"title": "Dziś",
|
||||
"usual": "Zwykle {low}–{high}",
|
||||
"learning": "Zwykły zakres jest jeszcze poznawany",
|
||||
"asOf": "Stan na {date}",
|
||||
"details": "Szczegóły"
|
||||
},
|
||||
"state": {
|
||||
"within": "W zakresie",
|
||||
"above": "Powyżej zwykłego",
|
||||
"below": "Poniżej zwykłego",
|
||||
"learning": "W trakcie nauki"
|
||||
},
|
||||
"chart": {
|
||||
"title": "Obok siebie",
|
||||
"legend": "Zacieniowane: Twój zwykły zakres.",
|
||||
"label": "{signals} w wybranym okresie, każdy w osobnym wierszu."
|
||||
},
|
||||
"range": {
|
||||
"label": "Okres",
|
||||
"7d": "7 d",
|
||||
"30d": "30 d",
|
||||
"90d": "90 d",
|
||||
"1y": "1 r"
|
||||
},
|
||||
"relationships": {
|
||||
"title": "Jak zmieniają się razem",
|
||||
"insufficient": "{x} i {y}: na razie {count} z {required} wspólnych dni.",
|
||||
"noClearLink": "Brak wyraźnego związku między {x} a {y} w ciągu {count} wspólnych dni.",
|
||||
"splitHigher": "Gdy {x} było poniżej {threshold}, {y} było o {amount} wyższe niż w inne dni.",
|
||||
"splitLower": "Gdy {x} było poniżej {threshold}, {y} było o {amount} niższe niż w inne dni.",
|
||||
"splitDays": "Dni poniżej: {below}. Pozostałe dni: {other}.",
|
||||
"correlationSameDayHigher": "Gdy {x} jest wyższe, {y} zwykle też jest wyższe (r = {r}).",
|
||||
"correlationSameDayLower": "Gdy {x} jest wyższe, {y} zwykle jest niższe (r = {r}).",
|
||||
"correlationNextDayHigher": "Gdy {x} jest wyższe, następnego dnia {y} zwykle jest wyższe (r = {r}).",
|
||||
"correlationNextDayLower": "Gdy {x} jest wyższe, następnego dnia {y} zwykle jest niższe (r = {r}).",
|
||||
"pairedDays": "Wspólne dni: {count}",
|
||||
"footnote": "Pokazywane, gdy dwie wartości pokrywają się przez co najmniej 14 dni. Wzorzec, nie przyczyna.",
|
||||
"scatterCaption": "Każdy punkt to jeden dzień: {x} w poziomie, {y} w pionie."
|
||||
},
|
||||
"trends": {
|
||||
"title": "Trendy",
|
||||
"window": "90 dni, średnie tygodniowe",
|
||||
"insufficient": "Potrzeba 14 dni, na razie {count}"
|
||||
},
|
||||
"labs": {
|
||||
"title": "Wyniki badań",
|
||||
"above": "Powyżej zakresu",
|
||||
"below": "Poniżej zakresu",
|
||||
"trendsTitle": "Trendy",
|
||||
"trendNeeds": "Trend wymaga trzech wartości w ciągu co najmniej roku. Jeszcze nie: {markers}."
|
||||
},
|
||||
"screeners": {
|
||||
"title": "Kwestionariusze",
|
||||
"none": "Nie wypełniono jeszcze żadnego kwestionariusza.",
|
||||
"checkIns": "Wypełnione: {count}",
|
||||
"improved": "Lepiej niż za pierwszym razem",
|
||||
"worsened": "Gorzej niż za pierwszym razem",
|
||||
"within_threshold": "W granicach zwykłych wahań",
|
||||
"single": "Na razie wypełniony raz",
|
||||
"open": "Otwórz Dobrostan psychiczny"
|
||||
},
|
||||
"cycle": {
|
||||
"title": "Cykl",
|
||||
"today": "Dzień cyklu {day}.",
|
||||
"todayWithPhase": "Dzień cyklu {day}, faza: {phase}.",
|
||||
"average": "Średnia długość cyklu: {days} dni.",
|
||||
"needs": "Średnia wymaga dwóch zapisanych początków miesiączki; na razie jest ich {count}.",
|
||||
"lengths": "Ostatnie cykle w dniach: {lengths}",
|
||||
"open": "Otwórz stronę cyklu"
|
||||
},
|
||||
"links": {
|
||||
"title": "Strony wskaźników",
|
||||
"workouts": "Treningi"
|
||||
},
|
||||
"missing": {
|
||||
"title": "Jeszcze nie zapisano: {signals}.",
|
||||
"connect": "Połącz źródło"
|
||||
}
|
||||
}
|
||||
}
|
||||
+184
-1
@@ -510,7 +510,9 @@
|
||||
"documents": "Documentos",
|
||||
"vaccinations": "Vacinas",
|
||||
"mentalWellbeing": "Bem-estar mental",
|
||||
"timeline": "Linha do tempo"
|
||||
"timeline": "Linha do tempo",
|
||||
"topics": "Temas",
|
||||
"previewBadge": "Beta"
|
||||
},
|
||||
"auth": {
|
||||
"login": "Entrar",
|
||||
@@ -6143,6 +6145,10 @@
|
||||
"access": {
|
||||
"title": "Acesso compartilhado",
|
||||
"subtitle": "Quem pode abrir seu registro de saúde e de quem você pode abrir."
|
||||
},
|
||||
"preview": {
|
||||
"title": "Recursos em prévia",
|
||||
"subtitle": "Experimente o que ainda não está pronto. Tudo aqui fica desligado até você ativar."
|
||||
}
|
||||
},
|
||||
"profile": "Perfil",
|
||||
@@ -7628,6 +7634,17 @@
|
||||
"passkeyRemovedToast": "Chave de acesso removida.",
|
||||
"integrationDisconnected": "{provider} desconectado.",
|
||||
"integrationDisconnectFailed": "Não foi possível desconectar {provider}.",
|
||||
"preview": {
|
||||
"topics": {
|
||||
"title": "Temas",
|
||||
"description": "Adiciona uma área Temas que reúne valores relacionados, ao lado das suas páginas de Análises.",
|
||||
"detail": "Oito temas, do coração ao ciclo, mostram seus valores lado a lado, como eles variam juntos e links para as páginas por métrica que você já usa. Suas páginas de Análises, a navegação e os links continuam exatamente como estão. Tudo é calculado a partir dos seus próprios valores, sem IA. Desativar remove a entrada e não apaga nada.",
|
||||
"savedOn": "Prévia de Temas ativada.",
|
||||
"savedOff": "Prévia de Temas desativada.",
|
||||
"open": "Abrir Temas"
|
||||
},
|
||||
"loadError": "Não foi possível carregar suas configurações de prévia."
|
||||
},
|
||||
"timezoneSaved": "Fuso horário salvo.",
|
||||
"moduleState": {
|
||||
"on": "Ligado",
|
||||
@@ -13765,5 +13782,171 @@
|
||||
"startsOn": "Início",
|
||||
"endsOn": "Fim"
|
||||
}
|
||||
},
|
||||
"topics": {
|
||||
"overview": {
|
||||
"title": "Temas",
|
||||
"description": "Valores relacionados reunidos a partir dos seus dados; abra um tema para vê-los lado a lado.",
|
||||
"emptyTitle": "Ainda não há o que reunir",
|
||||
"emptyDescription": "Os temas aparecem assim que houver valores para eles: de um dispositivo, de uma importação ou registrados por você.",
|
||||
"notRecorded": "Ainda sem registros para: {topics}.",
|
||||
"hiddenByModule": "Ocultos porque o módulo está desligado: {topics}.",
|
||||
"modulesLink": "Alterar módulos nas configurações"
|
||||
},
|
||||
"detail": {
|
||||
"back": "Todos os temas"
|
||||
},
|
||||
"loadError": "Não foi possível carregar os temas.",
|
||||
"previewNote": "Isto é uma prévia. Pode mudar entre versões, e suas páginas por métrica continuam como estão.",
|
||||
"recorded": "Valores registrados: {count} de {total}",
|
||||
"tone": {
|
||||
"steady": "Estável",
|
||||
"watch": "Vale uma olhada",
|
||||
"learning": "Aprendendo"
|
||||
},
|
||||
"summary": {
|
||||
"outsideAbove": "{signal} está acima da sua faixa habitual.",
|
||||
"outsideBelow": "{signal} está abaixo da sua faixa habitual.",
|
||||
"labs": "{count} de {total} exames estão dentro da faixa de referência.",
|
||||
"cycleDay": "Dia {day} do ciclo.",
|
||||
"steadyAll": "Tudo o que foi registrado está dentro da sua faixa habitual.",
|
||||
"steadySome": "{count} de {total} valores estão dentro da sua faixa habitual; os demais ainda estão sendo aprendidos.",
|
||||
"learning": "Sua faixa habitual ainda está sendo aprendida. Uma semana de registros basta.",
|
||||
"empty": "Nada registrado ainda."
|
||||
},
|
||||
"headline": {
|
||||
"labsValue": "{count}/{total}",
|
||||
"labsUnit": "na faixa",
|
||||
"cycleDay": "Dia {day}"
|
||||
},
|
||||
"signals": {
|
||||
"mood": "Humor"
|
||||
},
|
||||
"units": {
|
||||
"moodScale": "de 5"
|
||||
},
|
||||
"names": {
|
||||
"heart": {
|
||||
"title": "Coração",
|
||||
"description": "Frequência cardíaca em repouso, variabilidade e condicionamento, com o sono e o treino que os influenciam."
|
||||
},
|
||||
"body": {
|
||||
"title": "Corpo",
|
||||
"description": "Peso e composição corporal ao longo do tempo."
|
||||
},
|
||||
"bloodPressure": {
|
||||
"title": "Pressão arterial",
|
||||
"description": "Sistólica, diastólica e pulso, com o sono e a atividade ao redor."
|
||||
},
|
||||
"sleep": {
|
||||
"title": "Sono",
|
||||
"description": "Tempo dormindo e os valores medidos à noite."
|
||||
},
|
||||
"activity": {
|
||||
"title": "Atividade e treino",
|
||||
"description": "Passos, minutos de exercício, energia ativa e carga de treino."
|
||||
},
|
||||
"metabolism": {
|
||||
"title": "Metabolismo e exames",
|
||||
"description": "Glicose e seus exames, com a tendência de cada marcador."
|
||||
},
|
||||
"mind": {
|
||||
"title": "Mente",
|
||||
"description": "Humor e questionários ao lado do sono e da atividade."
|
||||
},
|
||||
"cycle": {
|
||||
"title": "Ciclo",
|
||||
"description": "Seu ciclo com a temperatura e a frequência cardíaca em repouso que o acompanham."
|
||||
}
|
||||
},
|
||||
"sources": {
|
||||
"heart": "Esses valores vêm de um relógio, anel ou cinta peitoral, do Apple Saúde ou do Health Connect, ou dos seus registros.",
|
||||
"body": "Esses valores vêm de uma balança inteligente, de uma importação ou dos seus registros.",
|
||||
"bloodPressure": "Esses valores vêm de um medidor de pressão, de uma importação ou dos seus registros.",
|
||||
"sleep": "Esses valores vêm de um relógio ou anel, do Apple Saúde ou do Health Connect, ou dos seus registros.",
|
||||
"activity": "Esses valores vêm de um celular, relógio ou anel, ou do Apple Saúde ou do Health Connect.",
|
||||
"metabolism": "A glicose vem de um glicosímetro, de uma importação do sensor ou dos seus registros; os exames, de um laudo ou dos seus registros.",
|
||||
"mind": "Esses valores vêm dos seus registros de humor e dos questionários da página Bem-estar mental.",
|
||||
"cycle": "Esses valores vêm do seu registro do ciclo e de um sensor de temperatura no pulso."
|
||||
},
|
||||
"today": {
|
||||
"title": "Hoje",
|
||||
"usual": "Habitual {low}–{high}",
|
||||
"learning": "Faixa habitual ainda em aprendizado",
|
||||
"asOf": "Em {date}",
|
||||
"details": "Detalhes"
|
||||
},
|
||||
"state": {
|
||||
"within": "Dentro",
|
||||
"above": "Acima do habitual",
|
||||
"below": "Abaixo do habitual",
|
||||
"learning": "Aprendendo"
|
||||
},
|
||||
"chart": {
|
||||
"title": "Lado a lado",
|
||||
"legend": "Sombreado: sua faixa habitual.",
|
||||
"label": "{signals} no período escolhido, uma linha cada."
|
||||
},
|
||||
"range": {
|
||||
"label": "Período",
|
||||
"7d": "7d",
|
||||
"30d": "30d",
|
||||
"90d": "90d",
|
||||
"1y": "1a"
|
||||
},
|
||||
"relationships": {
|
||||
"title": "Como eles variam juntos",
|
||||
"insufficient": "{x} e {y}: {count} de {required} dias pareados até agora.",
|
||||
"noClearLink": "Nenhuma relação clara entre {x} e {y} em {count} dias pareados.",
|
||||
"splitHigher": "Quando {x} ficou abaixo de {threshold}, {y} foi {amount} mais alto do que nos outros dias.",
|
||||
"splitLower": "Quando {x} ficou abaixo de {threshold}, {y} foi {amount} mais baixo do que nos outros dias.",
|
||||
"splitDays": "Dias abaixo: {below}. Outros dias: {other}.",
|
||||
"correlationSameDayHigher": "Quando {x} sobe, {y} também tende a ficar mais alto (r = {r}).",
|
||||
"correlationSameDayLower": "Quando {x} sobe, {y} tende a ficar mais baixo (r = {r}).",
|
||||
"correlationNextDayHigher": "Quando {x} sobe, {y} tende a ficar mais alto no dia seguinte (r = {r}).",
|
||||
"correlationNextDayLower": "Quando {x} sobe, {y} tende a ficar mais baixo no dia seguinte (r = {r}).",
|
||||
"pairedDays": "Dias pareados: {count}",
|
||||
"footnote": "Aparece quando dois valores coincidem em pelo menos 14 dias. Um padrão, não uma causa.",
|
||||
"scatterCaption": "Cada ponto é um dia: {x} na horizontal, {y} na vertical."
|
||||
},
|
||||
"trends": {
|
||||
"title": "Tendências",
|
||||
"window": "90 dias, médias semanais",
|
||||
"insufficient": "Precisa de 14 dias, {count} até agora"
|
||||
},
|
||||
"labs": {
|
||||
"title": "Exames",
|
||||
"above": "Acima da faixa",
|
||||
"below": "Abaixo da faixa",
|
||||
"trendsTitle": "Tendências",
|
||||
"trendNeeds": "Uma tendência precisa de três valores ao longo de pelo menos um ano. Ainda não: {markers}."
|
||||
},
|
||||
"screeners": {
|
||||
"title": "Questionários",
|
||||
"none": "Nenhum questionário respondido ainda.",
|
||||
"checkIns": "Respondidos: {count}",
|
||||
"improved": "Melhor que na primeira vez",
|
||||
"worsened": "Pior que na primeira vez",
|
||||
"within_threshold": "Dentro da variação habitual",
|
||||
"single": "Respondido uma vez até agora",
|
||||
"open": "Abrir Bem-estar mental"
|
||||
},
|
||||
"cycle": {
|
||||
"title": "Ciclo",
|
||||
"today": "Dia {day} do ciclo.",
|
||||
"todayWithPhase": "Dia {day} do ciclo, fase {phase}.",
|
||||
"average": "Duração média do ciclo: {days} dias.",
|
||||
"needs": "Uma média precisa de dois inícios de menstruação registrados; até agora são {count}.",
|
||||
"lengths": "Ciclos recentes, em dias: {lengths}",
|
||||
"open": "Abrir a página do ciclo"
|
||||
},
|
||||
"links": {
|
||||
"title": "Páginas por métrica",
|
||||
"workouts": "Treinos"
|
||||
},
|
||||
"missing": {
|
||||
"title": "Ainda não registrado: {signals}.",
|
||||
"connect": "Conectar uma fonte"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -388,9 +388,10 @@ model User {
|
||||
// Shape: { "version": 1, "labs": { "view": "list", "order": ["<id>"], "sortDir": "manual" }, ... }
|
||||
moduleListLayoutJson Json? @map("module_list_layout_json")
|
||||
/// Per-user opt-in to preview features, one boolean per flag, all off by
|
||||
/// default (`src/lib/settings/preview-features.ts`). Read and written
|
||||
/// through `/api/settings/preview-features` and carried by the backup.
|
||||
/// @pending-consumer(topics preview) the `topics` flag is stored, but no surface reads it and the Settings > System switch is unlisted until the topic overview pages ship.
|
||||
/// default (`src/lib/settings/preview-features.ts`). Written by Settings >
|
||||
/// System > Preview features through `/api/settings/preview-features`,
|
||||
/// published on `/api/auth/me`, read by the navigation and the topic routes
|
||||
/// (`topics`), and carried by the backup.
|
||||
previewFeaturesJson Json? @map("preview_features_json")
|
||||
/// Optional activity goals the person set for themselves: workouts, active
|
||||
/// minutes and steps per week, steps per month. Null or an absent key = no
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$comment": "Production files over 800 lines and their length. Maintained by src/__tests__/source-size-ratchet.test.ts.",
|
||||
"count": 122,
|
||||
"total": 144625,
|
||||
"count": 121,
|
||||
"total": 143770,
|
||||
"entries": {
|
||||
"app/api/dashboard/summary/route.ts": 1032,
|
||||
"app/api/documents/inbound/route.ts": 811,
|
||||
@@ -25,7 +25,6 @@
|
||||
"components/insights/derived/vitals-dashboard.tsx": 1033,
|
||||
"components/insights/health-score-card.tsx": 1023,
|
||||
"components/insights/insights-tab-strip.tsx": 1086,
|
||||
"components/layout/sidebar-nav.tsx": 855,
|
||||
"components/measurement-reminders/vorsorge-section.tsx": 1536,
|
||||
"components/measurements/measurement-form.tsx": 815,
|
||||
"components/measurements/measurement-list.tsx": 2019,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$comment": "Production functions over 200 lines, keyed \"file :: name\", and their length. Maintained by src/__tests__/source-size-ratchet.test.ts.",
|
||||
"count": 410,
|
||||
"total": 147860,
|
||||
"total": 147795,
|
||||
"entries": {
|
||||
"app/achievements/page.tsx :: AchievementsPage": 206,
|
||||
"app/api/admin/backups/upload/route.ts :: apiHandler(arg)": 314,
|
||||
@@ -9,7 +9,7 @@
|
||||
"app/api/admin/settings/route.ts :: apiHandler(arg) #2": 217,
|
||||
"app/api/analytics/route.ts :: buildAnalyticsResponse": 446,
|
||||
"app/api/auth/login/route.ts :: apiHandler(arg)": 262,
|
||||
"app/api/auth/me/route.ts :: apiHandler(arg)": 386,
|
||||
"app/api/auth/me/route.ts :: apiHandler(arg)": 384,
|
||||
"app/api/auth/oidc/callback/route.ts :: apiHandler(arg)": 499,
|
||||
"app/api/auth/register/route.ts :: apiHandler(arg)": 275,
|
||||
"app/api/coach/about-me/route.ts :: apiHandler(arg) #2": 309,
|
||||
@@ -173,9 +173,9 @@
|
||||
"components/labs/ocr-review-dialog.tsx :: OcrReviewDialog": 359,
|
||||
"components/labs/ocr-row-editor.tsx :: OcrRowEditor": 393,
|
||||
"components/layout/auth-shell.tsx :: AuthShell": 520,
|
||||
"components/layout/bottom-nav.tsx :: BottomNav": 262,
|
||||
"components/layout/bottom-nav.tsx :: BottomNav": 229,
|
||||
"components/layout/capture-picker.tsx :: CapturePicker": 235,
|
||||
"components/layout/sidebar-nav.tsx :: SidebarNav": 372,
|
||||
"components/layout/sidebar-nav.tsx :: SidebarNav": 342,
|
||||
"components/layout/top-bar.tsx :: TopBar": 212,
|
||||
"components/links/entity-link-picker.tsx :: EntityLinkPicker": 392,
|
||||
"components/measurement-reminders/vorsorge-dashboard-card.tsx :: VorsorgeDashboardCard": 215,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$comment": "as never casts in tests, per test file under src/. Maintained by src/__tests__/test-realism-ratchet.test.ts.",
|
||||
"count": 524,
|
||||
"total": 5495,
|
||||
"count": 523,
|
||||
"total": 5494,
|
||||
"entries": {
|
||||
"__tests__/api/auth/login/mfa-branch.test.ts": 10,
|
||||
"__tests__/api/auth/mfa/verify.test.ts": 9,
|
||||
@@ -273,7 +273,6 @@
|
||||
"components/medications/__tests__/use-medication-intake.test.ts": 1,
|
||||
"components/medications/detail/efficacy/__tests__/change-at-start-summary.test.tsx": 1,
|
||||
"components/mood/__tests__/mood-list-url-sync.test.tsx": 1,
|
||||
"components/settings/__tests__/settings-shell.test.tsx": 1,
|
||||
"components/settings/integrations/__tests__/dawarich-card.test.tsx": 1,
|
||||
"components/timeline/__tests__/medication-rows.test.ts": 1,
|
||||
"lib/__tests__/acting-account-resolver.test.ts": 5,
|
||||
|
||||
@@ -2807,7 +2807,7 @@
|
||||
"lib/rollups/mood-rollups.ts": 4,
|
||||
"lib/rollups/tiered-context.ts": 6,
|
||||
"lib/score-config/rows.ts": 1,
|
||||
"lib/settings/section-slugs.ts": 20,
|
||||
"lib/settings/section-slugs.ts": 19,
|
||||
"lib/sharing/__tests__/account-access-parse.test.ts": 3,
|
||||
"lib/sharing/__tests__/account-access.test.ts": 2,
|
||||
"lib/sharing/__tests__/account-label.test.ts": 1,
|
||||
|
||||
@@ -3,8 +3,9 @@
|
||||
*
|
||||
* The workout-route switch is off for new accounts, which is what its detail
|
||||
* says rather than "off by default": an account that already held tracks at
|
||||
* the upgrade started with it on (migration 0434). (The Topics preview switch
|
||||
* this file also held is unlisted until its pages ship, and its copy with it.)
|
||||
* the upgrade started with it on (migration 0434). The Topics preview switch
|
||||
* this file also held describes pages that now exist, so its copy is in the
|
||||
* present tense like every other switch.
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
|
||||
@@ -54,6 +54,8 @@ const HISTORICAL_SETTINGS_PATHS: readonly string[] = [
|
||||
"/settings/export",
|
||||
"/settings/advanced",
|
||||
"/settings/privacy",
|
||||
// Preview features (Settings > System).
|
||||
"/settings/preview",
|
||||
"/settings/about",
|
||||
// The per-module personalisation slugs before the layout hub.
|
||||
"/settings/dashboard",
|
||||
|
||||
@@ -1074,6 +1074,17 @@ const DELEGABLE_ROUTES: Record<string, DelegableEntry> = {
|
||||
domain: "record",
|
||||
why: "One search over the record: hits from every section with an in-app path each. Read-only, scoped to the resolved user, every source narrowed to the record's module switches and to the sections the grant covers, the query never logged.",
|
||||
},
|
||||
// The topic pages (preview) lay several sections side by side, like the
|
||||
// timeline, so they stay whole-record reads. The preview switch they check
|
||||
// is the ACTOR's own row and decides nothing about the record.
|
||||
"app/api/topics/route.ts": {
|
||||
domain: "record",
|
||||
why: "The topic overview: a headline value, a rule-based sentence and a sparkline per topic, computed from the record's daily values, lab markers, screener totals and cycle starts. Read-only, no model, scoped to the resolved user, every signal and block narrowed to the record's module switches and to the sections the grant covers.",
|
||||
},
|
||||
"app/api/topics/[topic]/route.ts": {
|
||||
domain: "record",
|
||||
why: "One topic page: signals with usual ranges, side-by-side series, relationships, trends and the topic's blocks. Read-only, no model, scoped to the resolved user, narrowed per signal and block like the overview.",
|
||||
},
|
||||
"app/api/workouts/route.ts": {
|
||||
domain: "measurements",
|
||||
why: "The record's workout list. Verified to carry no AI-written paragraph: the single-workout route beside it does, and stays refused for exactly that reason.",
|
||||
@@ -2197,8 +2208,11 @@ const ACTOR_ROUTES: Record<string, string> = {
|
||||
* side). The activity goals route does not: setting or changing goals stays
|
||||
* owner-only, and a delegate sees the goals only as the calendar reports them.
|
||||
* 270 -> 273.
|
||||
*
|
||||
* The topic overview and the topic page (preview) join the record list as
|
||||
* whole-record reads, like the timeline. 273 -> 275.
|
||||
*/
|
||||
const FROZEN_ENTRY_COUNT = 273;
|
||||
const FROZEN_ENTRY_COUNT = 275;
|
||||
|
||||
/**
|
||||
* The two surfaces that authenticate a Bearer token outside `requireAuth` —
|
||||
|
||||
@@ -44,6 +44,7 @@
|
||||
import { apiSuccess } from "@/lib/api-response";
|
||||
import { apiHandler, requireActorAuth } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { resolvePreviewFeatures } from "@/lib/settings/preview-features";
|
||||
import { setOnboardingPendingCookie } from "@/lib/auth/session";
|
||||
import { syncMfaEnrollCookie } from "@/lib/auth/mfa-enrollment";
|
||||
import { buildAvatarUrl } from "@/lib/avatar";
|
||||
@@ -93,6 +94,21 @@ const SERVER_FEATURES = {
|
||||
lifeCircumstances: true,
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* v1.7.0 — patient-identity fields for the health-record export. The KVNR is
|
||||
* stored encrypted; decrypt fail-soft so a key-rotation gap on one row never
|
||||
* 500s the whole profile fetch (the field just reads null and the user
|
||||
* re-enters it).
|
||||
*/
|
||||
function decryptInsuranceNumber(encrypted: string | null): string | null {
|
||||
if (!encrypted) return null;
|
||||
try {
|
||||
return decrypt(encrypted);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export const GET = apiHandler(async () => {
|
||||
const auth = await requireActorAuth();
|
||||
const { user } = auth;
|
||||
@@ -238,10 +254,11 @@ export const GET = apiHandler(async () => {
|
||||
}),
|
||||
// The encrypted insurance number is not on the per-request user
|
||||
// (`SESSION_USER_SELECT`); it is read here, for the one response that
|
||||
// shows it.
|
||||
// shows it. The preview opt-ins ride the same point read of the caller's
|
||||
// own row.
|
||||
prisma.user.findUnique({
|
||||
where: { id: user.id },
|
||||
select: { insuranceNumberEncrypted: true },
|
||||
select: { insuranceNumberEncrypted: true, previewFeaturesJson: true },
|
||||
}),
|
||||
]);
|
||||
|
||||
@@ -289,18 +306,9 @@ export const GET = apiHandler(async () => {
|
||||
sectionsOpen(sections, "cycle") &&
|
||||
isCycleEnabled(record?.gender ?? null, cycleProfile);
|
||||
|
||||
// v1.7.0 — patient-identity fields for the health-record export. The
|
||||
// KVNR is stored encrypted; decrypt fail-soft so a key-rotation gap on
|
||||
// one row never 500s the whole profile fetch (the field just reads
|
||||
// null and the user re-enters it).
|
||||
let insuranceNumber: string | null = null;
|
||||
if (identity?.insuranceNumberEncrypted) {
|
||||
try {
|
||||
insuranceNumber = decrypt(identity.insuranceNumberEncrypted);
|
||||
} catch {
|
||||
insuranceNumber = null;
|
||||
}
|
||||
}
|
||||
const insuranceNumber = decryptInsuranceNumber(
|
||||
identity?.insuranceNumberEncrypted ?? null,
|
||||
);
|
||||
|
||||
return apiSuccess({
|
||||
id: user.id,
|
||||
@@ -447,6 +455,12 @@ export const GET = apiHandler(async () => {
|
||||
// `offIsReal` whether the lowest option is a real off or "Minimal", and
|
||||
// `source` which layer decided. Clients never recompute it.
|
||||
coachReasoning,
|
||||
// The caller's own preview opt-ins (Settings > System > Preview
|
||||
// features), resolved: every flag present, off unless switched on. The
|
||||
// navigation offers the Topics entry from `topics`. The ACTOR's, like the
|
||||
// Coach preferences above, so it does not move under a switch: a preview is
|
||||
// chrome of the person reading, never a property of the record on screen.
|
||||
previewFeatures: resolvePreviewFeatures(identity?.previewFeaturesJson),
|
||||
// v1.36.0 — account sharing, resolved. `accounts` is the switcher's menu,
|
||||
// `active` is the record this session is inside (null when it is in its
|
||||
// own), `canSwitch` and per-entry `canWrite` are the booleans the UI binds
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
/**
|
||||
* `GET /api/topics/{topic}` — one topic page (preview).
|
||||
*
|
||||
* The topic's signals with their usual ranges, the side-by-side series for the
|
||||
* chosen window, the relationships between its signals (14 paired days before
|
||||
* anything is said), the 90-day trends, the signals it could show but holds
|
||||
* nothing for, and its extra blocks (training load, lab markers with their
|
||||
* trend, screener trends, today's place in the cycle). Every figure is
|
||||
* computed on the server from existing reads; no model is involved.
|
||||
*
|
||||
* 404 `topics.preview_off` when the viewer has not switched the preview on,
|
||||
* `topics.not_available` when the record's module switches do not offer the
|
||||
* topic. Admission declares the whole record, like the overview.
|
||||
*/
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
|
||||
import { apiError, apiSuccess, returnAllZodIssues } from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { checkAnalyticsReadRateLimit } from "@/lib/rate-limit";
|
||||
import { hasTopicsPreview, loadTopicDetail } from "@/lib/topics/service";
|
||||
import {
|
||||
topicDetailQuerySchema,
|
||||
topicPathSchema,
|
||||
} from "@/lib/validations/topics";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
type RouteParams = { params: Promise<{ topic: string }> };
|
||||
|
||||
export const GET = apiHandler(
|
||||
async (request: NextRequest, { params }: RouteParams) => {
|
||||
const { user, actor, grantId } = await requireRecordAuth("read", "record");
|
||||
annotate({ action: { name: "topics.detail.read" } });
|
||||
|
||||
if (!(await hasTopicsPreview(actor.id))) {
|
||||
return apiError("Topics are a preview that is switched off", 404, {
|
||||
errorCode: "topics.preview_off",
|
||||
});
|
||||
}
|
||||
|
||||
const path = topicPathSchema.safeParse(await params);
|
||||
if (!path.success) {
|
||||
return apiError("No such topic", 404, { errorCode: "topics.not_found" });
|
||||
}
|
||||
const query = topicDetailQuerySchema.safeParse({
|
||||
range: request.nextUrl.searchParams.get("range") ?? undefined,
|
||||
});
|
||||
if (!query.success) return returnAllZodIssues(query.error, 422);
|
||||
|
||||
const rl = await checkAnalyticsReadRateLimit(actor.id);
|
||||
if (!rl.allowed) {
|
||||
return apiError("Too many analytics requests. Please retry later.", 429);
|
||||
}
|
||||
|
||||
const detail = await loadTopicDetail({
|
||||
userId: user.id,
|
||||
grantId,
|
||||
topic: path.data.topic,
|
||||
range: query.data.range,
|
||||
now: new Date(),
|
||||
});
|
||||
if (!detail) {
|
||||
return apiError("This topic is not available for this record", 404, {
|
||||
errorCode: "topics.not_available",
|
||||
});
|
||||
}
|
||||
annotate({
|
||||
meta: {
|
||||
topic: detail.key,
|
||||
topic_range: detail.range,
|
||||
topic_status: detail.status,
|
||||
topic_relationships_linked: detail.relationships.filter(
|
||||
(r) => r.status === "linked",
|
||||
).length,
|
||||
},
|
||||
});
|
||||
return apiSuccess(detail);
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* `GET /api/topics` — the topic overview (preview).
|
||||
*
|
||||
* One tile per topic the record holds data for: a headline value, a rule-based
|
||||
* sentence and a sparkline, computed on the server from the same daily values
|
||||
* the single-metric pages draw. No model is involved and no AI gate applies.
|
||||
*
|
||||
* The preview is the viewer's own opt-in (Settings > System > Preview
|
||||
* features): with it off the route answers 404 `topics.preview_off`, the same
|
||||
* as for a path that does not exist. The topics, signals and blocks follow the
|
||||
* RECORD's module switches; a topic whose every module is off is named in
|
||||
* `hiddenByModule`, an offered topic with nothing recorded in `empty`.
|
||||
*
|
||||
* Admission declares the whole record, like the timeline: a topic lays several
|
||||
* sections side by side, so a grant scoped to some of them does not open it.
|
||||
*/
|
||||
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
|
||||
import { apiError, apiSuccess } from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { checkAnalyticsReadRateLimit } from "@/lib/rate-limit";
|
||||
import { hasTopicsPreview, loadTopicOverview } from "@/lib/topics/service";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export const GET = apiHandler(async () => {
|
||||
const { user, actor, grantId } = await requireRecordAuth("read", "record");
|
||||
annotate({ action: { name: "topics.overview.read" } });
|
||||
|
||||
if (!(await hasTopicsPreview(actor.id))) {
|
||||
return apiError("Topics are a preview that is switched off", 404, {
|
||||
errorCode: "topics.preview_off",
|
||||
});
|
||||
}
|
||||
|
||||
// The shared analytics-read budget, spent by the person reading.
|
||||
const rl = await checkAnalyticsReadRateLimit(actor.id);
|
||||
if (!rl.allowed) {
|
||||
return apiError("Too many analytics requests. Please retry later.", 429);
|
||||
}
|
||||
|
||||
const overview = await loadTopicOverview({
|
||||
userId: user.id,
|
||||
grantId,
|
||||
now: new Date(),
|
||||
});
|
||||
annotate({
|
||||
meta: {
|
||||
topics_shown: overview.topics.length,
|
||||
topics_empty: overview.empty.length,
|
||||
topics_hidden: overview.hiddenByModule.length,
|
||||
},
|
||||
});
|
||||
return apiSuccess(overview);
|
||||
});
|
||||
@@ -0,0 +1,24 @@
|
||||
import { notFound } from "next/navigation";
|
||||
|
||||
import { TopicDetailView } from "@/components/topics/topic-detail";
|
||||
import { TopicsPreviewGate } from "@/components/topics/topics-preview-gate";
|
||||
import { isTopicKey } from "@/lib/topics/registry";
|
||||
|
||||
/**
|
||||
* `/topics/<topic>` (preview): one topic page. A name that is not a topic is
|
||||
* a 404 here, before anything renders; the preview switch is checked by the
|
||||
* gate, the record's modules by the route behind the page.
|
||||
*/
|
||||
export default async function TopicPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ topic: string }>;
|
||||
}) {
|
||||
const { topic } = await params;
|
||||
if (!isTopicKey(topic)) notFound();
|
||||
return (
|
||||
<TopicsPreviewGate>
|
||||
<TopicDetailView topic={topic} />
|
||||
</TopicsPreviewGate>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { TopicsOverview } from "@/components/topics/topics-overview";
|
||||
import { TopicsPreviewGate } from "@/components/topics/topics-preview-gate";
|
||||
|
||||
/**
|
||||
* `/topics` (preview): the topic overview. Exists only for a person who
|
||||
* switched the preview on; see `TopicsPreviewGate`.
|
||||
*/
|
||||
export default function TopicsPage() {
|
||||
return (
|
||||
<TopicsPreviewGate>
|
||||
<TopicsOverview />
|
||||
</TopicsPreviewGate>
|
||||
);
|
||||
}
|
||||
@@ -58,3 +58,4 @@ export { NutrientDailyBarChart } from "./nutrient-daily-bar-chart";
|
||||
export { WorkoutHrChart } from "./workout-hr-chart";
|
||||
export { WorkoutElevationChart } from "./workout-elevation-chart";
|
||||
export { CoachResultChart } from "@/components/insights/coach-panel/result-chart";
|
||||
export { TopicSideBySideChart } from "@/components/topics/topic-side-by-side-chart";
|
||||
@@ -12,6 +12,7 @@ function index(over: Partial<PaletteIndexInput> = {}) {
|
||||
t,
|
||||
modules: {},
|
||||
navModules: {},
|
||||
previews: null,
|
||||
inSharedRecord: false,
|
||||
sections: null,
|
||||
isAdmin: false,
|
||||
@@ -27,6 +28,16 @@ function index(over: Partial<PaletteIndexInput> = {}) {
|
||||
|
||||
const ids = (entries: ReturnType<typeof index>) => entries.map((e) => e.id);
|
||||
|
||||
describe("buildPaletteIndex — previews", () => {
|
||||
it("offers the Topics page only while the person switched the preview on", () => {
|
||||
expect(ids(index())).not.toContain("nav:/topics");
|
||||
expect(ids(index({ previews: { topics: false } }))).not.toContain(
|
||||
"nav:/topics",
|
||||
);
|
||||
expect(ids(index({ previews: { topics: true } }))).toContain("nav:/topics");
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildPaletteIndex — modules", () => {
|
||||
it("a switched-off module takes its page, its Insights page and its cards along", () => {
|
||||
const off = { mood: false, timeline: false, environment: false };
|
||||
|
||||
@@ -18,6 +18,7 @@ import { SETTINGS_SECTIONS } from "@/components/settings/settings-shell";
|
||||
import { SUB_PAGE_SLUGS } from "@/lib/insights/sub-page-metric";
|
||||
import { MODULE_REGISTRY, type ModuleKey } from "@/lib/modules/registry";
|
||||
import { isSurfaceVisible, type SurfaceModuleMap } from "@/lib/modules/surface";
|
||||
import type { PreviewFeatures } from "@/lib/settings/preview-features";
|
||||
import type { ShareDomain } from "@/lib/sharing/scope";
|
||||
|
||||
import {
|
||||
@@ -74,6 +75,8 @@ export interface PaletteIndexInput {
|
||||
modules: SurfaceModuleMap | undefined;
|
||||
/** The same map with `coach` following the AI capability (`useNavModules`). */
|
||||
navModules: SurfaceModuleMap | undefined;
|
||||
/** The person's own preview opt-ins; null offers no preview page. */
|
||||
previews: PreviewFeatures | null;
|
||||
inSharedRecord: boolean;
|
||||
/** The shared record's covered sections, when there is one. */
|
||||
sections: ReadonlyArray<ShareDomain> | null;
|
||||
@@ -121,6 +124,7 @@ export function buildPaletteIndex(input: PaletteIndexInput): PaletteEntry[] {
|
||||
true,
|
||||
input.inSharedRecord,
|
||||
input.sections,
|
||||
input.previews,
|
||||
);
|
||||
const navHrefs = new Set(nav.map((d) => d.href));
|
||||
for (const d of nav) {
|
||||
|
||||
@@ -10,6 +10,7 @@ import { useAiCapability } from "@/hooks/use-ai-capability";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { useModuleEnabledState } from "@/hooks/use-module-toggle";
|
||||
import { useNavModules } from "@/hooks/use-nav-modules";
|
||||
import { usePreviewFeatures } from "@/hooks/use-preview-features";
|
||||
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
|
||||
import {
|
||||
buildPaletteIndex,
|
||||
@@ -32,6 +33,7 @@ export function usePaletteIndex(): PaletteEntry[] {
|
||||
const { t } = useTranslations();
|
||||
const { user } = useAuth();
|
||||
const navModules = useNavModules();
|
||||
const previews = usePreviewFeatures();
|
||||
const capabilities = useRecordCapabilities();
|
||||
const { inSharedRecord, sections, recordKind, level } = capabilities;
|
||||
const coach = useAiCapability("coach");
|
||||
@@ -64,6 +66,7 @@ export function usePaletteIndex(): PaletteEntry[] {
|
||||
t,
|
||||
modules: user?.modules,
|
||||
navModules,
|
||||
previews,
|
||||
inSharedRecord,
|
||||
sections,
|
||||
isAdmin: user?.role === "ADMIN",
|
||||
@@ -85,6 +88,7 @@ export function usePaletteIndex(): PaletteEntry[] {
|
||||
user?.modules,
|
||||
user?.role,
|
||||
navModules,
|
||||
previews,
|
||||
inSharedRecord,
|
||||
sections,
|
||||
recordKind,
|
||||
|
||||
@@ -6,6 +6,7 @@ import { Card, CardContent, CardHeader } from "@/components/ui/card";
|
||||
import { TileHeader } from "@/components/insights/tile-header";
|
||||
import { useFormatters, useTranslations } from "@/lib/i18n/context";
|
||||
|
||||
import { labTrendLines } from "./lab-trend-lines";
|
||||
import type { BiomarkerTrendDto } from "./types";
|
||||
|
||||
/**
|
||||
@@ -27,44 +28,12 @@ export function LabTrendCard({
|
||||
const fmt = useFormatters();
|
||||
if (!trend || trend.status !== "ok") return null;
|
||||
|
||||
const digits = Math.abs(trend.slopePerYear) >= 10 ? 0 : 2;
|
||||
const per = (v: number) => fmt.number(Math.abs(v), digits);
|
||||
const direction =
|
||||
trend.slopePerYear > 0
|
||||
? "rising"
|
||||
: trend.slopePerYear < 0
|
||||
? "falling"
|
||||
: "flat";
|
||||
const slopeLine = t(`labs.trend.slope.${direction}`, {
|
||||
amount: per(trend.slopePerYear),
|
||||
const { slope: slopeLine, projection: projectionLine } = labTrendLines(
|
||||
trend,
|
||||
unit,
|
||||
n: trend.n,
|
||||
since: fmt.dateShortSmartCalendar(trend.firstAt),
|
||||
});
|
||||
const projectionLine =
|
||||
trend.projection === "exits" && trend.exitDate
|
||||
? t(`labs.trend.projection.exits.${trend.exitBound ?? "high"}`, {
|
||||
date: fmt.dateShortSmartCalendar(trend.exitDate),
|
||||
})
|
||||
: trend.projection === "no_band"
|
||||
? null
|
||||
: trend.projection === "stale"
|
||||
? t("labs.trend.projection.stale", {
|
||||
date: fmt.dateShortSmartCalendar(trend.lastAt),
|
||||
})
|
||||
: trend.projection === "beyond_horizon"
|
||||
? t(
|
||||
trend.horizonCapped
|
||||
? "labs.trend.projection.beyond_horizon_capped"
|
||||
: "labs.trend.projection.beyond_horizon",
|
||||
{
|
||||
years: fmt.number(
|
||||
trend.horizonYears,
|
||||
trend.horizonCapped ? 0 : 1,
|
||||
),
|
||||
},
|
||||
)
|
||||
: t(`labs.trend.projection.${trend.projection}`);
|
||||
t,
|
||||
fmt,
|
||||
);
|
||||
|
||||
return (
|
||||
<Card data-slot="lab-trend">
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import type { Formatters } from "@/lib/format-locale";
|
||||
|
||||
import type { BiomarkerTrendDto } from "./types";
|
||||
|
||||
type Translate = (
|
||||
key: string,
|
||||
params?: Record<string, string | number>,
|
||||
) => string;
|
||||
|
||||
/**
|
||||
* The two sentences a lab trend reads as: the slope across the readings, and
|
||||
* where the straight line would meet the reference band (or why it is not
|
||||
* drawn). Shared by the marker page's trend card and the metabolism topic, so
|
||||
* one trend is never worded two ways.
|
||||
*/
|
||||
export function labTrendLines(
|
||||
trend: Extract<BiomarkerTrendDto, { status: "ok" }>,
|
||||
unit: string,
|
||||
t: Translate,
|
||||
fmt: Formatters,
|
||||
): { slope: string; projection: string | null } {
|
||||
const digits = Math.abs(trend.slopePerYear) >= 10 ? 0 : 2;
|
||||
const direction =
|
||||
trend.slopePerYear > 0
|
||||
? "rising"
|
||||
: trend.slopePerYear < 0
|
||||
? "falling"
|
||||
: "flat";
|
||||
const slope = t(`labs.trend.slope.${direction}`, {
|
||||
amount: fmt.number(Math.abs(trend.slopePerYear), digits),
|
||||
unit,
|
||||
n: trend.n,
|
||||
since: fmt.dateShortSmartCalendar(trend.firstAt),
|
||||
});
|
||||
const projection =
|
||||
trend.projection === "exits" && trend.exitDate
|
||||
? t(`labs.trend.projection.exits.${trend.exitBound ?? "high"}`, {
|
||||
date: fmt.dateShortSmartCalendar(trend.exitDate),
|
||||
})
|
||||
: trend.projection === "no_band"
|
||||
? null
|
||||
: trend.projection === "stale"
|
||||
? t("labs.trend.projection.stale", {
|
||||
date: fmt.dateShortSmartCalendar(trend.lastAt),
|
||||
})
|
||||
: trend.projection === "beyond_horizon"
|
||||
? t(
|
||||
trend.horizonCapped
|
||||
? "labs.trend.projection.beyond_horizon_capped"
|
||||
: "labs.trend.projection.beyond_horizon",
|
||||
{
|
||||
years: fmt.number(
|
||||
trend.horizonYears,
|
||||
trend.horizonCapped ? 0 : 1,
|
||||
),
|
||||
},
|
||||
)
|
||||
: t(`labs.trend.projection.${trend.projection}`);
|
||||
return { slope, projection };
|
||||
}
|
||||
@@ -105,13 +105,31 @@ describe("the destination list under a switch", () => {
|
||||
});
|
||||
|
||||
it("changes nothing for a session in its own record", () => {
|
||||
const own = visibleNavDestinations(ALL_MODULES, true).map((d) => d.href);
|
||||
const own = visibleNavDestinations(ALL_MODULES, true, false, null, {
|
||||
topics: true,
|
||||
}).map((d) => d.href);
|
||||
expect(own).toEqual(
|
||||
NAV_DESTINATIONS.filter((d) => !d.sharedRecordOnly).map((d) => d.href),
|
||||
);
|
||||
expect(own).not.toContain("/profile");
|
||||
});
|
||||
|
||||
it("offers Topics inside a whole-record share only with the viewer's own preview on", () => {
|
||||
const share = (previews: { topics: boolean } | null) =>
|
||||
visibleNavDestinations(ALL_MODULES, true, true, null, previews).map(
|
||||
(d) => d.href,
|
||||
);
|
||||
expect(share({ topics: true })).toContain("/topics");
|
||||
expect(share({ topics: false })).not.toContain("/topics");
|
||||
expect(share(null)).not.toContain("/topics");
|
||||
// A grant scoped to sections never opens a page that lays them side by side.
|
||||
expect(
|
||||
visibleNavDestinations(ALL_MODULES, true, true, ["measurements"], {
|
||||
topics: true,
|
||||
}).map((d) => d.href),
|
||||
).not.toContain("/topics");
|
||||
});
|
||||
|
||||
it("offers the read-only profile summary only inside a shared record", () => {
|
||||
expect(hrefsInSharedRecord()).toContain("/profile");
|
||||
});
|
||||
|
||||
@@ -215,6 +215,42 @@ describe("mobileMoreHubDestinations — the F-1 mobile invariant (feature-only h
|
||||
});
|
||||
});
|
||||
|
||||
describe("a preview entry (Topics)", () => {
|
||||
it("exists only while the person reading switched the preview on", () => {
|
||||
const hrefs = (previews: { topics: boolean } | null, mounted = true) =>
|
||||
visibleNavDestinations({}, mounted, false, null, previews).map(
|
||||
(d) => d.href,
|
||||
);
|
||||
expect(hrefs(null)).not.toContain("/topics");
|
||||
expect(hrefs({ topics: false })).not.toContain("/topics");
|
||||
expect(hrefs({ topics: true })).toContain("/topics");
|
||||
// Fail-closed before mount, so SSR and the first paint agree.
|
||||
expect(hrefs({ topics: true }, false)).not.toContain("/topics");
|
||||
});
|
||||
|
||||
it("sits directly below Insights in the sidebar", () => {
|
||||
const hrefs = visibleNavDestinations({}, true, false, null, {
|
||||
topics: true,
|
||||
}).map((d) => d.href);
|
||||
expect(hrefs[hrefs.indexOf("/insights") + 1]).toBe("/topics");
|
||||
});
|
||||
|
||||
it("leads the phone's More hub with its badge and never takes a bottom-bar slot", () => {
|
||||
const hub = mobileMoreHubDestinations({
|
||||
modules: {},
|
||||
previews: { topics: true },
|
||||
});
|
||||
expect(hub[0]).toMatchObject({ href: "/topics", preview: "topics" });
|
||||
expect(BOTTOM_NAV_PRIMARY_SLOT_HREFS).not.toContain("/topics");
|
||||
expect(
|
||||
mobileMoreHubDestinations({
|
||||
modules: {},
|
||||
previews: { topics: false },
|
||||
}).map((d) => d.href),
|
||||
).not.toContain("/topics");
|
||||
});
|
||||
});
|
||||
|
||||
describe("isNavDestinationActive most-specific resolution", () => {
|
||||
it("matches the dashboard only on an exact path", () => {
|
||||
expect(isNavDestinationActive("/", "/")).toBe(true);
|
||||
|
||||
@@ -21,6 +21,8 @@ import { isSurfaceVisible } from "@/lib/modules/surface";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { useNavModules } from "@/hooks/use-nav-modules";
|
||||
import { usePreviewFeatures } from "@/hooks/use-preview-features";
|
||||
import { PreviewBadge } from "@/components/topics/preview-badge";
|
||||
import { useMounted } from "@/hooks/use-mounted";
|
||||
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
|
||||
import {
|
||||
@@ -42,6 +44,8 @@ interface NavLink {
|
||||
href: string;
|
||||
tKey: string;
|
||||
icon: LucideIcon;
|
||||
/** A preview entry; its tile carries the Beta badge. */
|
||||
preview?: string;
|
||||
}
|
||||
|
||||
// v1.12.x — iOS-parity bottom bar (additive middle-path).
|
||||
@@ -111,6 +115,7 @@ export function BottomNav() {
|
||||
// The Coach entry follows the `coach` AI capability (a Coach that cannot
|
||||
// answer is not offered); the sidebar reads the same map.
|
||||
const navModules = useNavModules();
|
||||
const previews = usePreviewFeatures();
|
||||
const moreHub = useMemo<ReadonlyArray<NavLink>>(
|
||||
() =>
|
||||
mobileMoreHubDestinations({
|
||||
@@ -118,8 +123,9 @@ export function BottomNav() {
|
||||
mounted,
|
||||
sharedRecord,
|
||||
sections,
|
||||
previews,
|
||||
}),
|
||||
[navModules, mounted, sharedRecord, sections],
|
||||
[navModules, mounted, sharedRecord, sections, previews],
|
||||
);
|
||||
|
||||
// Every slot but Home asks two things: does sharing cover it, and does its
|
||||
@@ -289,52 +295,72 @@ export function BottomNav() {
|
||||
second line instead of clipping the last character at the tile
|
||||
edge. */}
|
||||
<div className="grid grid-cols-2 gap-2 px-3 pt-4 pb-4">
|
||||
{moreHub.map((item) => {
|
||||
const isActive = isActiveLink(item.href);
|
||||
return (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
onClick={() => setMoreOpen(false)}
|
||||
aria-current={isActive ? "page" : undefined}
|
||||
// v1.12 — `min-h-14` matches the capture-picker tiles so
|
||||
// the equivalent tappable rows share one height at this
|
||||
// tier (and the larger row is a more comfortable target).
|
||||
// v1.22.1 — trim the inner padding/gap (`px-3`, `gap-2.5`)
|
||||
// to hand the label more horizontal room without changing
|
||||
// the shared tile height.
|
||||
className={cn(
|
||||
"border-border flex min-h-14 items-center gap-2.5 rounded-lg border px-3 py-3 transition-colors",
|
||||
isActive
|
||||
? "text-primary bg-primary/5"
|
||||
: "text-foreground hover:bg-accent/40",
|
||||
)}
|
||||
>
|
||||
<item.icon className="h-5 w-5 shrink-0" aria-hidden="true" />
|
||||
{/* v1.18.10 — show the FULL label, never an ellipsis
|
||||
that hides meaning. The W7 `truncate` clipped long words
|
||||
("Benachrichtigungen" → "Benachrichtigun…"); the goal is a
|
||||
readable label, not a balanced tile. Shrink the type to
|
||||
`text-xs` so long single-word locales (de/es/fr/it) fit on
|
||||
one line where they can, and allow a 2-line `line-clamp-2`
|
||||
fallback (with `[overflow-wrap:anywhere]` so a long word
|
||||
breaks rather than overflowing) for the rare label that
|
||||
still cannot fit one line. `min-w-0` lets the flex child
|
||||
shrink below its content width. v1.22.1 — with the wider
|
||||
text area above, "Benachrichtigungen" wraps fully inside
|
||||
two lines down to a 320 px viewport. */}
|
||||
<span
|
||||
className="line-clamp-2 min-w-0 text-xs leading-tight font-medium [overflow-wrap:anywhere]"
|
||||
title={t(item.tKey)}
|
||||
>
|
||||
{t(item.tKey)}
|
||||
</span>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
{moreHub.map((item) => (
|
||||
<MoreHubTile
|
||||
key={item.href}
|
||||
item={item}
|
||||
active={isActiveLink(item.href)}
|
||||
onNavigate={() => setMoreOpen(false)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/** One destination tile in the More sheet. */
|
||||
function MoreHubTile({
|
||||
item,
|
||||
active,
|
||||
onNavigate,
|
||||
}: {
|
||||
item: NavLink;
|
||||
active: boolean;
|
||||
onNavigate: () => void;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
return (
|
||||
<Link
|
||||
href={item.href}
|
||||
onClick={onNavigate}
|
||||
aria-current={active ? "page" : undefined}
|
||||
// v1.12 — `min-h-14` matches the capture-picker tiles so
|
||||
// the equivalent tappable rows share one height at this
|
||||
// tier (and the larger row is a more comfortable target).
|
||||
// v1.22.1 — trim the inner padding/gap (`px-3`, `gap-2.5`)
|
||||
// to hand the label more horizontal room without changing
|
||||
// the shared tile height.
|
||||
className={cn(
|
||||
"border-border flex min-h-14 items-center gap-2.5 rounded-lg border px-3 py-3 transition-colors",
|
||||
active
|
||||
? "text-primary bg-primary/5"
|
||||
: "text-foreground hover:bg-accent/40",
|
||||
)}
|
||||
>
|
||||
<item.icon className="h-5 w-5 shrink-0" aria-hidden="true" />
|
||||
{/* v1.18.10 — show the FULL label, never an ellipsis
|
||||
that hides meaning. The W7 `truncate` clipped long words
|
||||
("Benachrichtigungen" → "Benachrichtigun…"); the goal is a
|
||||
readable label, not a balanced tile. Shrink the type to
|
||||
`text-xs` so long single-word locales (de/es/fr/it) fit on
|
||||
one line where they can, and allow a 2-line `line-clamp-2`
|
||||
fallback (with `[overflow-wrap:anywhere]` so a long word
|
||||
breaks rather than overflowing) for the rare label that
|
||||
still cannot fit one line. `min-w-0` lets the flex child
|
||||
shrink below its content width. v1.22.1 — with the wider
|
||||
text area above, "Benachrichtigungen" wraps fully inside
|
||||
two lines down to a 320 px viewport. */}
|
||||
<span
|
||||
className="line-clamp-2 min-w-0 text-xs leading-tight font-medium [overflow-wrap:anywhere]"
|
||||
title={t(item.tKey)}
|
||||
>
|
||||
{t(item.tKey)}
|
||||
</span>
|
||||
{item.preview ? (
|
||||
<PreviewBadge label={t("nav.previewBadge")} className="ml-auto" />
|
||||
) : null}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
} from "@/components/layout/nav-model";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { useNavModules } from "@/hooks/use-nav-modules";
|
||||
import { usePreviewFeatures } from "@/hooks/use-preview-features";
|
||||
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
|
||||
import {
|
||||
createShortcutReader,
|
||||
@@ -103,6 +104,7 @@ export function openCapturePicker(): void {
|
||||
function useShortcutOffer(): ShortcutOffer & { canCapture: boolean } {
|
||||
const { user } = useAuth();
|
||||
const navModules = useNavModules();
|
||||
const previews = usePreviewFeatures();
|
||||
const capabilities = useRecordCapabilities();
|
||||
const { inSharedRecord, sections, recordKind, level } = capabilities;
|
||||
const manageableDomains = user?.accountAccess?.active?.manageableDomains;
|
||||
@@ -111,10 +113,14 @@ function useShortcutOffer(): ShortcutOffer & { canCapture: boolean } {
|
||||
.length > 0;
|
||||
const navHrefs = useMemo(
|
||||
() =>
|
||||
visibleNavDestinations(navModules, true, inSharedRecord, sections).map(
|
||||
(d) => d.href,
|
||||
),
|
||||
[navModules, inSharedRecord, sections],
|
||||
visibleNavDestinations(
|
||||
navModules,
|
||||
true,
|
||||
inSharedRecord,
|
||||
sections,
|
||||
previews,
|
||||
).map((d) => d.href),
|
||||
[navModules, inSharedRecord, sections, previews],
|
||||
);
|
||||
const settingsHref = useMemo(
|
||||
() =>
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
FlaskConical,
|
||||
History,
|
||||
Home,
|
||||
Layers,
|
||||
Lightbulb,
|
||||
MessagesSquare,
|
||||
Pill,
|
||||
@@ -29,6 +30,10 @@ import {
|
||||
type SettingsRecordContext,
|
||||
} from "@/lib/record-settings/classification";
|
||||
import { isSharedRecordPathPresentable } from "@/lib/navigation/shared-record";
|
||||
import type {
|
||||
PreviewFeatureKey,
|
||||
PreviewFeatures,
|
||||
} from "@/lib/settings/preview-features";
|
||||
import type { ShareDomain } from "@/lib/sharing/scope";
|
||||
|
||||
/**
|
||||
@@ -78,6 +83,14 @@ export interface NavDestination {
|
||||
sharedRecord?: boolean;
|
||||
/** A record-only view, intentionally absent from the actor's own nav. */
|
||||
sharedRecordOnly?: boolean;
|
||||
/**
|
||||
* A preview the person switches on for themselves (Settings >
|
||||
* System > Preview features). The entry exists only while that flag is on
|
||||
* for the person reading, carries a Beta badge, and leads the phone's More
|
||||
* hub so a freshly switched-on preview is found. Fail-closed: hidden until
|
||||
* the account payload has said yes.
|
||||
*/
|
||||
preview?: PreviewFeatureKey;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -250,6 +263,21 @@ export const NAV_DESTINATIONS: ReadonlyArray<NavDestination> = [
|
||||
icon: Lightbulb,
|
||||
tourId: "nav-insights",
|
||||
},
|
||||
// Topics (preview): overview pages that bring related signals
|
||||
// together and link into the single-metric Insights pages, which stay
|
||||
// exactly as they are. Directly below Insights in the sidebar; on a phone it
|
||||
// lands in the More hub (first), never in the bottom bar. Only while the
|
||||
// person reading switched the preview on. A record read across sections,
|
||||
// like the timeline: a delegate holding the whole record sees it (with their
|
||||
// own preview on), a scoped grant never does.
|
||||
{
|
||||
href: "/topics",
|
||||
sharedRecord: true,
|
||||
tKey: "nav.topics",
|
||||
icon: Layers,
|
||||
tourId: "nav-topics",
|
||||
preview: "topics",
|
||||
},
|
||||
// v1.17.1 — the Coach finally gets a single labeled nav home. It
|
||||
// was reachable from seven scattered entry points (FAB, hero CTA, empty
|
||||
// states, per-metric icons …) but nowhere in the nav, so a new user
|
||||
@@ -377,7 +405,11 @@ function isNavDestinationVisible(
|
||||
mounted = true,
|
||||
sharedRecord = false,
|
||||
sections: readonly ShareDomain[] | null = null,
|
||||
previews: PreviewFeatures | null = null,
|
||||
): boolean {
|
||||
// A preview entry exists only once the payload has said the person reading
|
||||
// switched it on: fail-closed before mount and while the payload is absent.
|
||||
if (d.preview && (!mounted || previews?.[d.preview] !== true)) return false;
|
||||
if (
|
||||
sharedRecord &&
|
||||
(d.sharedRecord !== true ||
|
||||
@@ -440,9 +472,17 @@ export function visibleNavDestinations(
|
||||
mounted = true,
|
||||
sharedRecord = false,
|
||||
sections: readonly ShareDomain[] | null = null,
|
||||
previews: PreviewFeatures | null = null,
|
||||
): NavDestination[] {
|
||||
return NAV_DESTINATIONS.filter((d) =>
|
||||
isNavDestinationVisible(d, modules, mounted, sharedRecord, sections),
|
||||
isNavDestinationVisible(
|
||||
d,
|
||||
modules,
|
||||
mounted,
|
||||
sharedRecord,
|
||||
sections,
|
||||
previews,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -463,6 +503,8 @@ export interface MobileMoreHubEntry {
|
||||
href: string;
|
||||
tKey: string;
|
||||
icon: LucideIcon;
|
||||
/** Set for a preview entry, which the tile marks with a Beta badge. */
|
||||
preview?: PreviewFeatureKey;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -483,15 +525,28 @@ export function mobileMoreHubDestinations(opts: {
|
||||
sharedRecord?: boolean;
|
||||
/** Server-resolved scope for the active shared record. */
|
||||
sections?: readonly ShareDomain[] | null;
|
||||
/** The person's own preview opt-ins; absent hides every preview. */
|
||||
previews?: PreviewFeatures | null;
|
||||
}): MobileMoreHubEntry[] {
|
||||
return visibleNavDestinations(
|
||||
const entries = visibleNavDestinations(
|
||||
opts.modules,
|
||||
opts.mounted ?? true,
|
||||
opts.sharedRecord ?? false,
|
||||
opts.sections ?? null,
|
||||
opts.previews ?? null,
|
||||
)
|
||||
.filter((d) => !BOTTOM_NAV_PRIMARY_SLOT_HREFS.includes(d.href))
|
||||
.map((d) => ({ href: d.href, tKey: d.tKey, icon: d.icon }));
|
||||
.map((d) => ({
|
||||
href: d.href,
|
||||
tKey: d.tKey,
|
||||
icon: d.icon,
|
||||
...(d.preview ? { preview: d.preview } : {}),
|
||||
}));
|
||||
// A preview someone just switched on leads the hub, where it is found; the
|
||||
// rest keep model order. `Array.prototype.sort` is stable.
|
||||
return entries.sort(
|
||||
(a, b) => Number(b.preview !== undefined) - Number(a.preview !== undefined),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -27,11 +27,18 @@ import {
|
||||
visibleUtilityDestinations,
|
||||
} from "@/components/layout/nav-model";
|
||||
import { SHELL_HEADER_BAND } from "@/components/layout/shell-metrics";
|
||||
import {
|
||||
RAIL_LIST_GAP,
|
||||
RAIL_SHORT_PY,
|
||||
railRowClass,
|
||||
SidebarNavItem,
|
||||
} from "@/components/layout/sidebar-rail";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Logo } from "@/components/ui/logo";
|
||||
import { useAuth, useLogout } from "@/hooks/use-auth";
|
||||
import { useIsMobile } from "@/hooks/use-is-mobile";
|
||||
import { useNavModules } from "@/hooks/use-nav-modules";
|
||||
import { usePreviewFeatures } from "@/hooks/use-preview-features";
|
||||
import { useMounted } from "@/hooks/use-mounted";
|
||||
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
|
||||
import { useTheme } from "@/components/providers";
|
||||
@@ -328,45 +335,6 @@ function SidebarUserSection({ collapsed }: { collapsed: boolean }) {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Height-based density for the rail. A 13-inch laptop at 100 % zoom leaves
|
||||
* the browser roughly 700–860 px, and the full destination list plus the
|
||||
* footer rows did not fit: the list scrolled, and on a classic-scrollbar
|
||||
* platform (Windows) the rail grew a native scrollbar with arrow buttons.
|
||||
* On short windows the rows close their gaps first, then step their height
|
||||
* down; tall windows keep the roomy default. Desktop-only by construction —
|
||||
* the rail is hidden below `md`, so the phone layout never sees these steps.
|
||||
*
|
||||
* Steps (rows are full-width, so the click target is the whole row):
|
||||
* - default: 40 px rows, 4 px apart
|
||||
* - max-height 940 px: gaps close (40 px pitch), list and user padding tighten
|
||||
* - max-height 820 px: 36 px rows
|
||||
* - max-height 740 px: 32 px rows — the one step under 40 px, taken only
|
||||
* where the alternative is rows hidden behind a scroll edge
|
||||
*/
|
||||
// Disjoint height ranges, not nested max-heights: two arbitrary media
|
||||
// variants on one property cascade in stylesheet order, not by how narrow
|
||||
// the query is, so a nested pair let the 36 px step win on a 720 px window.
|
||||
// The height steps apply to a mouse only. Under a finger (a touch tablet)
|
||||
// the rows keep the 44 px touch floor and the list scrolls behind its fades
|
||||
// instead. A phone held sideways never sees the rail: it keeps the phone
|
||||
// shell (`shell-mobile`).
|
||||
const RAIL_ROW_HEIGHT =
|
||||
"min-h-10 pointer-coarse:min-h-11 [@media(hover:hover)_and_(pointer:fine)_and_(740px<height<=820px)]:min-h-9 [@media(hover:hover)_and_(pointer:fine)_and_(height<=740px)]:min-h-8";
|
||||
const RAIL_LIST_GAP = "space-y-1 [@media(max-height:940px)]:space-y-0";
|
||||
const RAIL_SHORT_PY = "[@media(max-height:940px)]:py-1.5";
|
||||
|
||||
/** Rail row, collapsed (icon only) or expanded (icon + label). */
|
||||
function railRowClass(collapsed: boolean): string {
|
||||
return cn(
|
||||
"flex items-center rounded-lg transition-colors",
|
||||
RAIL_ROW_HEIGHT,
|
||||
collapsed
|
||||
? "justify-center px-2.5"
|
||||
: "gap-3 px-3 py-1.5 text-sm font-medium",
|
||||
);
|
||||
}
|
||||
|
||||
/** Height of the fade at a scroll edge that has more content beyond it. */
|
||||
const RAIL_FADE = "1.5rem";
|
||||
|
||||
@@ -525,9 +493,17 @@ export function SidebarNav() {
|
||||
// The Coach entry follows the `coach` AI capability, like every other
|
||||
// Coach entry point; the rest of the map is `modules` unchanged.
|
||||
const navModules = useNavModules();
|
||||
const previews = usePreviewFeatures();
|
||||
const visibleNavItems = useMemo(
|
||||
() => visibleNavDestinations(navModules, mounted, sharedRecord, sections),
|
||||
[navModules, mounted, sharedRecord, sections],
|
||||
() =>
|
||||
visibleNavDestinations(
|
||||
navModules,
|
||||
mounted,
|
||||
sharedRecord,
|
||||
sections,
|
||||
previews,
|
||||
),
|
||||
[navModules, mounted, sharedRecord, sections, previews],
|
||||
);
|
||||
// Collapsed = the user's stored choice, or — with no stored choice — a
|
||||
// viewport default: tablet widths (md–lg, e.g. an iPad held upright)
|
||||
@@ -757,59 +733,21 @@ export function SidebarNav() {
|
||||
}}
|
||||
>
|
||||
<div className={RAIL_LIST_GAP}>
|
||||
{visibleNavItems.map((item) => {
|
||||
const isActive = isNavDestinationActive(
|
||||
item.href,
|
||||
pathname,
|
||||
visibleNavItems,
|
||||
);
|
||||
const label = t(item.tKey);
|
||||
|
||||
if (collapsed) {
|
||||
return (
|
||||
<Tooltip key={item.href}>
|
||||
<TooltipTrigger asChild>
|
||||
<Link
|
||||
href={item.href}
|
||||
aria-current={isActive ? "page" : undefined}
|
||||
data-tour-id={item.tourId}
|
||||
{...(item.href === "/medications" ? medsIntent : {})}
|
||||
className={cn(
|
||||
railRowClass(true),
|
||||
isActive
|
||||
? "bg-primary/10 text-primary"
|
||||
: "text-foreground hover:bg-accent",
|
||||
)}
|
||||
>
|
||||
<item.icon className="h-4 w-4" />
|
||||
</Link>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right" sideOffset={8}>
|
||||
{label}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
aria-current={isActive ? "page" : undefined}
|
||||
data-tour-id={item.tourId}
|
||||
{...(item.href === "/medications" ? medsIntent : {})}
|
||||
className={cn(
|
||||
railRowClass(false),
|
||||
isActive
|
||||
? "bg-primary/10 text-primary"
|
||||
: "text-foreground hover:bg-accent",
|
||||
)}
|
||||
>
|
||||
<item.icon className="h-4 w-4" />
|
||||
{label}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
{visibleNavItems.map((item) => (
|
||||
<SidebarNavItem
|
||||
key={item.href}
|
||||
item={item}
|
||||
label={t(item.tKey)}
|
||||
badge={item.preview ? t("nav.previewBadge") : null}
|
||||
active={isNavDestinationActive(
|
||||
item.href,
|
||||
pathname,
|
||||
visibleNavItems,
|
||||
)}
|
||||
collapsed={collapsed}
|
||||
intent={item.href === "/medications" ? medsIntent : undefined}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* The rail's rows: the height steps every row shares and one destination
|
||||
* row, collapsed or expanded. Split from `sidebar-nav.tsx`, which keeps the
|
||||
* shell, the footer and the user menu.
|
||||
*/
|
||||
import Link from "next/link";
|
||||
|
||||
import type { NavDestination } from "@/components/layout/nav-model";
|
||||
import { PreviewBadge } from "@/components/topics/preview-badge";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import type { medicationsPrefetchIntentProps } from "@/lib/queries/prefetch-medications";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/**
|
||||
* Height-based density for the rail. A 13-inch laptop at 100 % zoom leaves
|
||||
* the browser roughly 700–860 px, and the full destination list plus the
|
||||
* footer rows did not fit: the list scrolled, and on a classic-scrollbar
|
||||
* platform (Windows) the rail grew a native scrollbar with arrow buttons.
|
||||
* On short windows the rows close their gaps first, then step their height
|
||||
* down; tall windows keep the roomy default. Desktop-only by construction —
|
||||
* the rail is hidden below `md`, so the phone layout never sees these steps.
|
||||
*
|
||||
* Steps (rows are full-width, so the click target is the whole row):
|
||||
* - default: 40 px rows, 4 px apart
|
||||
* - max-height 940 px: gaps close (40 px pitch), list and user padding tighten
|
||||
* - max-height 820 px: 36 px rows
|
||||
* - max-height 740 px: 32 px rows — the one step under 40 px, taken only
|
||||
* where the alternative is rows hidden behind a scroll edge
|
||||
*/
|
||||
// Disjoint height ranges, not nested max-heights: two arbitrary media
|
||||
// variants on one property cascade in stylesheet order, not by how narrow
|
||||
// the query is, so a nested pair let the 36 px step win on a 720 px window.
|
||||
// The height steps apply to a mouse only. Under a finger (a touch tablet)
|
||||
// the rows keep the 44 px touch floor and the list scrolls behind its fades
|
||||
// instead. A phone held sideways never sees the rail: it keeps the phone
|
||||
// shell (`shell-mobile`).
|
||||
const RAIL_ROW_HEIGHT =
|
||||
"min-h-10 pointer-coarse:min-h-11 [@media(hover:hover)_and_(pointer:fine)_and_(740px<height<=820px)]:min-h-9 [@media(hover:hover)_and_(pointer:fine)_and_(height<=740px)]:min-h-8";
|
||||
export const RAIL_LIST_GAP = "space-y-1 [@media(max-height:940px)]:space-y-0";
|
||||
export const RAIL_SHORT_PY = "[@media(max-height:940px)]:py-1.5";
|
||||
|
||||
/** Rail row, collapsed (icon only) or expanded (icon + label). */
|
||||
export function railRowClass(collapsed: boolean): string {
|
||||
return cn(
|
||||
"flex items-center rounded-lg transition-colors",
|
||||
RAIL_ROW_HEIGHT,
|
||||
collapsed
|
||||
? "justify-center px-2.5"
|
||||
: "gap-3 px-3 py-1.5 text-sm font-medium",
|
||||
);
|
||||
}
|
||||
|
||||
/** One destination in the rail: an icon row when collapsed, icon and label when expanded. */
|
||||
export function SidebarNavItem({
|
||||
item,
|
||||
label,
|
||||
badge,
|
||||
active,
|
||||
collapsed,
|
||||
intent,
|
||||
}: {
|
||||
item: NavDestination;
|
||||
label: string;
|
||||
/** The Beta mark of a preview entry, or null. */
|
||||
badge: string | null;
|
||||
active: boolean;
|
||||
collapsed: boolean;
|
||||
/** The medications prefetch handlers, on that one row. */
|
||||
intent?: ReturnType<typeof medicationsPrefetchIntentProps>;
|
||||
}) {
|
||||
const className = cn(
|
||||
railRowClass(collapsed),
|
||||
active ? "bg-primary/10 text-primary" : "text-foreground hover:bg-accent",
|
||||
);
|
||||
if (collapsed) {
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Link
|
||||
href={item.href}
|
||||
aria-current={active ? "page" : undefined}
|
||||
data-tour-id={item.tourId}
|
||||
{...intent}
|
||||
className={className}
|
||||
>
|
||||
<item.icon className="h-4 w-4" />
|
||||
</Link>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right" sideOffset={8}>
|
||||
{label}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Link
|
||||
href={item.href}
|
||||
aria-current={active ? "page" : undefined}
|
||||
data-tour-id={item.tourId}
|
||||
{...intent}
|
||||
className={className}
|
||||
>
|
||||
<item.icon className="h-4 w-4" />
|
||||
{label}
|
||||
{badge ? <PreviewBadge label={badge} className="ml-auto" /> : null}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { PreviewSection } from "../preview-section";
|
||||
import { primeLocales } from "@/__tests__/helpers/prime-locales";
|
||||
|
||||
primeLocales("de");
|
||||
|
||||
function render(topics: boolean | undefined, locale: "en" | "de" = "en") {
|
||||
const client = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false, refetchOnMount: false } },
|
||||
});
|
||||
if (topics !== undefined) {
|
||||
client.setQueryData(queryKeys.previewFeatures(), { topics });
|
||||
}
|
||||
return renderToStaticMarkup(
|
||||
<I18nProvider initialLocale={locale}>
|
||||
<QueryClientProvider client={client}>
|
||||
<PreviewSection />
|
||||
</QueryClientProvider>
|
||||
</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
const switchState = (html: string, state: "true" | "false") =>
|
||||
new RegExp(
|
||||
`data-slot="settings-preview-topics-switch"[^>]*aria-checked="${state}"|aria-checked="${state}"[^>]*data-slot="settings-preview-topics-switch"`,
|
||||
).test(html);
|
||||
|
||||
describe("<PreviewSection>", () => {
|
||||
it("shows the Topics switch off by default, saved on change", () => {
|
||||
const html = render(false);
|
||||
expect(switchState(html, "false")).toBe(true);
|
||||
expect(html).toContain("Topics");
|
||||
expect(html).toContain('data-slot="preview-badge"');
|
||||
// A switch, no Save: one save model per card.
|
||||
expect(html).not.toContain('type="submit"');
|
||||
// Nothing to open while it is off.
|
||||
expect(html).not.toContain('data-slot="settings-preview-topics-open"');
|
||||
});
|
||||
|
||||
it("reads on once the person switched it on and offers the way in", () => {
|
||||
const html = render(true);
|
||||
expect(switchState(html, "true")).toBe(true);
|
||||
expect(html).toMatch(
|
||||
/data-slot="settings-preview-topics-open"[^>]*href="\/topics"|href="\/topics"[^>]*data-slot="settings-preview-topics-open"/,
|
||||
);
|
||||
});
|
||||
|
||||
it("names the switch by its visible title and leaks no raw key", () => {
|
||||
const html = render(false, "de");
|
||||
expect(html).toContain('aria-labelledby="settings-preview-topics-title"');
|
||||
expect(html).toContain("Themen");
|
||||
expect(html).not.toContain("settings.preview.");
|
||||
expect(html).not.toContain("nav.previewBadge");
|
||||
});
|
||||
});
|
||||
@@ -170,19 +170,12 @@ describe("SETTINGS_SECTION_SLUGS", () => {
|
||||
// v1.23 — "Data & Privacy" assembles the export / deletion / retention /
|
||||
// encryption / session / activity pieces into one pane, after `advanced`.
|
||||
"privacy",
|
||||
// Per-user preview opt-ins, in the System group before About.
|
||||
"preview",
|
||||
"about",
|
||||
]);
|
||||
});
|
||||
|
||||
it("lists no Preview features page until the topic pages ship", () => {
|
||||
// The stored flag and its API stay; the page and its nav entry return
|
||||
// with the first preview the switch actually turns on.
|
||||
expect(isSettingsSectionSlug("preview")).toBe(false);
|
||||
expect(SETTINGS_SECTIONS.some((s) => s.slug === ("preview" as never))).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it("`isSettingsSectionSlug` accepts known slugs and rejects others", () => {
|
||||
for (const slug of SETTINGS_SECTION_SLUGS) {
|
||||
expect(isSettingsSectionSlug(slug)).toBe(true);
|
||||
@@ -249,6 +242,7 @@ describe("<SettingsShell>", () => {
|
||||
gesundheitsakte: "data",
|
||||
privacy: "data",
|
||||
advanced: "data",
|
||||
preview: "system",
|
||||
about: "system",
|
||||
});
|
||||
// Every routable section has a nav entry, and nothing else does.
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* Settings > System > Preview features.
|
||||
*
|
||||
* One switch per preview feature, each off until the person turns it on, each
|
||||
* saved the moment it changes (`PATCH /api/settings/preview-features`). A
|
||||
* preview adds something beside what is there and never replaces a page.
|
||||
*
|
||||
* The switch is the person's own and stays theirs while they act on somebody
|
||||
* else's record. Saving refreshes the account payload too, because the
|
||||
* navigation reads the flag from there: the Topics entry appears or goes the
|
||||
* moment the switch moves.
|
||||
*/
|
||||
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import Link from "next/link";
|
||||
import { Layers } from "lucide-react";
|
||||
|
||||
import { toastWrittenOutcome } from "@/components/outcome/outcome-toast";
|
||||
import { SettingsCard } from "@/components/settings/settings-card";
|
||||
import { SettingsCardHeader } from "@/components/settings/_card-header";
|
||||
import { PreviewBadge } from "@/components/topics/preview-badge";
|
||||
import { InlineAction } from "@/components/ui/inline-action";
|
||||
import { QueryErrorRow } from "@/components/ui/query-error-row";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { apiGet, apiPatch } from "@/lib/api/api-fetch";
|
||||
import { showErrorToast } from "@/lib/errors/error-toast";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import {
|
||||
DEFAULT_PREVIEW_FEATURES,
|
||||
type PreviewFeatures,
|
||||
} from "@/lib/settings/preview-features";
|
||||
|
||||
export function PreviewSection() {
|
||||
const { t } = useTranslations();
|
||||
const queryClient = useQueryClient();
|
||||
const key = queryKeys.previewFeatures();
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: key,
|
||||
queryFn: () => apiGet<PreviewFeatures>("/api/settings/preview-features"),
|
||||
});
|
||||
const features = query.data ?? DEFAULT_PREVIEW_FEATURES;
|
||||
|
||||
const toggle = useMutation({
|
||||
mutationKey: key,
|
||||
mutationFn: (topics: boolean) =>
|
||||
apiPatch<PreviewFeatures>("/api/settings/preview-features", { topics }),
|
||||
onMutate: async (topics) => {
|
||||
await queryClient.cancelQueries({ queryKey: key });
|
||||
const previous = queryClient.getQueryData<PreviewFeatures>(key);
|
||||
queryClient.setQueryData<PreviewFeatures>(key, {
|
||||
...(previous ?? DEFAULT_PREVIEW_FEATURES),
|
||||
topics,
|
||||
});
|
||||
return { previous };
|
||||
},
|
||||
onSuccess: (saved, topics) => {
|
||||
if (saved) queryClient.setQueryData(key, saved);
|
||||
void queryClient.invalidateQueries({ queryKey: queryKeys.authMe() });
|
||||
toastWrittenOutcome(
|
||||
"success",
|
||||
topics
|
||||
? t("settings.preview.topics.savedOn")
|
||||
: t("settings.preview.topics.savedOff"),
|
||||
);
|
||||
},
|
||||
onError: (failure, _topics, context) => {
|
||||
queryClient.setQueryData(key, context?.previous);
|
||||
showErrorToast(t("settings.savingError"), { cause: failure });
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<SettingsCard data-slot="settings-preview-topics">
|
||||
<SettingsCardHeader
|
||||
anchor="preview-topics"
|
||||
icon={Layers}
|
||||
title={t("settings.preview.topics.title")}
|
||||
titleId="settings-preview-topics-title"
|
||||
titleAccessory={<PreviewBadge label={t("nav.previewBadge")} />}
|
||||
description={t("settings.preview.topics.description")}
|
||||
status={
|
||||
<Switch
|
||||
data-slot="settings-preview-topics-switch"
|
||||
checked={features.topics}
|
||||
disabled={query.isLoading || toggle.isPending}
|
||||
onCheckedChange={(next) => toggle.mutate(next)}
|
||||
aria-labelledby="settings-preview-topics-title"
|
||||
/>
|
||||
}
|
||||
/>
|
||||
{query.isError ? (
|
||||
<QueryErrorRow
|
||||
inset
|
||||
error={query.error}
|
||||
slot="settings-preview-error"
|
||||
message={t("settings.preview.loadError")}
|
||||
onRetry={() => void query.refetch()}
|
||||
/>
|
||||
) : null}
|
||||
<p className="text-sm">{t("settings.preview.topics.detail")}</p>
|
||||
{features.topics ? (
|
||||
<p className="text-sm">
|
||||
<InlineAction asChild tone="primary" size="sm">
|
||||
<Link href="/topics" data-slot="settings-preview-topics-open">
|
||||
{t("settings.preview.topics.open")}
|
||||
</Link>
|
||||
</InlineAction>
|
||||
</p>
|
||||
) : null}
|
||||
</SettingsCard>
|
||||
);
|
||||
}
|
||||
@@ -124,6 +124,10 @@ const LOADERS: Record<
|
||||
import("@/components/settings/privacy-section").then(
|
||||
(m) => m.PrivacySection,
|
||||
),
|
||||
preview: () =>
|
||||
import("@/components/settings/preview-section").then(
|
||||
(m) => m.PreviewSection,
|
||||
),
|
||||
};
|
||||
|
||||
const SECTIONS: Record<SettingsSectionSlug, ComponentType<SectionProps>> = {
|
||||
@@ -158,6 +162,7 @@ const SECTIONS: Record<SettingsSectionSlug, ComponentType<SectionProps>> = {
|
||||
export: dynamic(LOADERS.export, { loading: () => <SectionLoading /> }),
|
||||
advanced: dynamic(LOADERS.advanced, { loading: () => <SectionLoading /> }),
|
||||
privacy: dynamic(LOADERS.privacy, { loading: () => <SectionLoading /> }),
|
||||
preview: dynamic(LOADERS.preview, { loading: () => <SectionLoading /> }),
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
CloudSun,
|
||||
Download,
|
||||
FileHeart,
|
||||
FlaskConical,
|
||||
Gauge,
|
||||
Info,
|
||||
KeyRound,
|
||||
@@ -274,6 +275,12 @@ export const SETTINGS_SECTIONS: readonly SettingsSection[] = [
|
||||
group: "data",
|
||||
},
|
||||
// ── System ──
|
||||
{
|
||||
slug: "preview",
|
||||
titleKey: "settings.sections.preview.title",
|
||||
icon: FlaskConical,
|
||||
group: "system",
|
||||
},
|
||||
{
|
||||
slug: "about",
|
||||
titleKey: "settings.sections.about.title",
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
/**
|
||||
* The topic pages build several message keys at run time (one per topic,
|
||||
* range, state, tone, verdict). The call-site coverage guard sees literal keys
|
||||
* only, so this holds every built key against every locale.
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { locales } from "@/lib/i18n/config";
|
||||
import { TOPIC_KEYS, TOPIC_RANGES } from "@/lib/topics/registry";
|
||||
|
||||
import {
|
||||
topicDescriptionKey,
|
||||
topicSourcesKey,
|
||||
topicTitleKey,
|
||||
} from "../topic-meta";
|
||||
|
||||
function resolve(bundle: unknown, key: string): unknown {
|
||||
return key
|
||||
.split(".")
|
||||
.reduce<unknown>(
|
||||
(node, part) =>
|
||||
node && typeof node === "object"
|
||||
? (node as Record<string, unknown>)[part]
|
||||
: undefined,
|
||||
bundle,
|
||||
);
|
||||
}
|
||||
|
||||
const BUILT_KEYS = [
|
||||
...TOPIC_KEYS.flatMap((key) => [
|
||||
topicTitleKey(key),
|
||||
topicDescriptionKey(key),
|
||||
topicSourcesKey(key),
|
||||
]),
|
||||
...TOPIC_RANGES.map((range) => `topics.range.${range}`),
|
||||
...["within", "above", "below", "learning"].map((s) => `topics.state.${s}`),
|
||||
...["steady", "watch", "learning"].map((s) => `topics.tone.${s}`),
|
||||
...["improved", "worsened", "within_threshold"].map(
|
||||
(v) => `topics.screeners.${v}`,
|
||||
),
|
||||
...["above", "below"].map((s) => `topics.labs.${s}`),
|
||||
...["SameDay", "NextDay"].flatMap((lag) =>
|
||||
["Higher", "Lower"].map(
|
||||
(dir) => `topics.relationships.correlation${lag}${dir}`,
|
||||
),
|
||||
),
|
||||
];
|
||||
|
||||
describe("topic message keys", () => {
|
||||
it.each(locales)("every built key resolves to text in %s", async (locale) => {
|
||||
const bundle = (await import(`../../../../messages/${locale}.json`))
|
||||
.default;
|
||||
const missing = BUILT_KEYS.filter(
|
||||
(key) => typeof resolve(bundle, key) !== "string",
|
||||
);
|
||||
expect(missing).toEqual([]);
|
||||
});
|
||||
|
||||
it("checks a non-empty set", () => {
|
||||
expect(BUILT_KEYS.length).toBeGreaterThan(40);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,85 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import type { TopicOverviewEntry } from "@/lib/topics/build";
|
||||
import { primeLocales } from "@/__tests__/helpers/prime-locales";
|
||||
|
||||
import { TopicTile } from "../topic-tile";
|
||||
|
||||
primeLocales("de");
|
||||
|
||||
function render(entry: TopicOverviewEntry, locale: "en" | "de" = "en") {
|
||||
const client = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false, enabled: false } },
|
||||
});
|
||||
return renderToStaticMarkup(
|
||||
<I18nProvider initialLocale={locale}>
|
||||
<QueryClientProvider client={client}>
|
||||
<TopicTile entry={entry} />
|
||||
</QueryClientProvider>
|
||||
</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
const heart: TopicOverviewEntry = {
|
||||
key: "heart",
|
||||
headline: {
|
||||
kind: "signal",
|
||||
key: "RESTING_HEART_RATE",
|
||||
value: 59,
|
||||
day: "2026-10-22",
|
||||
},
|
||||
summary: {
|
||||
code: "outside",
|
||||
signal: "RESTING_HEART_RATE",
|
||||
direction: "above",
|
||||
count: null,
|
||||
total: null,
|
||||
},
|
||||
tone: "watch",
|
||||
sparkline: [58, 59, 61],
|
||||
recorded: 2,
|
||||
total: 4,
|
||||
};
|
||||
|
||||
describe("<TopicTile>", () => {
|
||||
it("links the visible title to the topic and words the server's sentence", () => {
|
||||
const html = render(heart);
|
||||
expect(html).toMatch(
|
||||
/href="\/topics\/heart"[^>]*data-slot="topic-tile-link"[^>]*>Heart<|data-slot="topic-tile-link"[^>]*href="\/topics\/heart"[^>]*>Heart</,
|
||||
);
|
||||
expect(html).toContain('data-code="outside"');
|
||||
expect(html).toContain("above your usual range");
|
||||
expect(html).toContain('data-tone="watch"');
|
||||
expect(html).toContain("2 of 4");
|
||||
expect(html).toMatch(/>59(\.0)?</);
|
||||
});
|
||||
|
||||
it("speaks German and leaks no raw key", () => {
|
||||
const html = render(heart, "de");
|
||||
expect(html).toContain(">Herz<");
|
||||
expect(html).toContain("über deinem üblichen Bereich");
|
||||
expect(html).not.toMatch(/topics\.[a-z]/);
|
||||
});
|
||||
|
||||
it("says a block headline in words, without a sparkline", () => {
|
||||
const html = render({
|
||||
...heart,
|
||||
key: "metabolism",
|
||||
headline: { kind: "labs", inRange: 11, total: 12 },
|
||||
summary: {
|
||||
code: "labs",
|
||||
signal: null,
|
||||
direction: null,
|
||||
count: 11,
|
||||
total: 12,
|
||||
},
|
||||
tone: "watch",
|
||||
sparkline: [],
|
||||
});
|
||||
expect(html).toContain("11/12");
|
||||
expect(html).toContain("11 of 12 lab values");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/**
|
||||
* The "Beta" mark beside a preview: the nav entry, the More tile, the page
|
||||
* title and the Settings switch. A status, so a `Badge`, in the one accent a
|
||||
* preview carries. Its text is part of the accessible name of whatever it
|
||||
* sits in, which is what a visible label should be.
|
||||
*/
|
||||
export function PreviewBadge({
|
||||
label,
|
||||
className,
|
||||
}: {
|
||||
label: string;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<Badge
|
||||
variant="outline"
|
||||
data-slot="preview-badge"
|
||||
className={cn(
|
||||
"border-primary/40 text-primary text-2xs px-1.5 py-0 tracking-wide uppercase",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
"use client";
|
||||
|
||||
import { FlaskConical } from "lucide-react";
|
||||
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
|
||||
/**
|
||||
* The line every topic page ends on: this is a preview, it may change, and
|
||||
* the single-metric pages are untouched. Neutral, with no link out.
|
||||
*/
|
||||
export function PreviewNote() {
|
||||
const { t } = useTranslations();
|
||||
return (
|
||||
<p
|
||||
className="text-muted-foreground flex items-start gap-2 text-xs"
|
||||
data-slot="topics-preview-note"
|
||||
>
|
||||
<FlaskConical className="mt-px size-3.5 shrink-0" aria-hidden="true" />
|
||||
<span>{t("topics.previewNote")}</span>
|
||||
</p>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { Brain, Droplets } from "lucide-react";
|
||||
|
||||
import { Card, CardContent, CardHeader } from "@/components/ui/card";
|
||||
import { InlineAction } from "@/components/ui/inline-action";
|
||||
import { ScorePill } from "@/components/ui/score-pill";
|
||||
import { TileHeader } from "@/components/insights/tile-header";
|
||||
import { useFormatters, useTranslations } from "@/lib/i18n/context";
|
||||
import type { AssessmentTrend } from "@/lib/mental-health/assessment-trend";
|
||||
import type { TopicCycleSummary } from "@/lib/topics/read";
|
||||
|
||||
import { INSTRUMENT_LABEL, TOPIC_EXTRA_HREF } from "./topic-meta";
|
||||
|
||||
const VERDICT_TONE = {
|
||||
improved: "success",
|
||||
worsened: "warning",
|
||||
within_threshold: "neutral",
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* The mind topic's screener block: each instrument's latest total and how it
|
||||
* moved since the first check-in, measured against the change studies treat
|
||||
* as meaningful (the check-in page's own trend).
|
||||
*/
|
||||
export function TopicScreenersCard({
|
||||
screeners,
|
||||
}: {
|
||||
screeners: AssessmentTrend[];
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
const fmt = useFormatters();
|
||||
return (
|
||||
<Card data-slot="topic-screeners">
|
||||
<CardHeader>
|
||||
<TileHeader
|
||||
icon={Brain}
|
||||
titleAs="h2"
|
||||
title={t("topics.screeners.title")}
|
||||
/>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-3">
|
||||
{screeners.length === 0 ? (
|
||||
<p className="text-sm" data-slot="topic-screeners-none">
|
||||
{t("topics.screeners.none")}
|
||||
</p>
|
||||
) : (
|
||||
<ul className="flex flex-col gap-3">
|
||||
{screeners.map((trend) => (
|
||||
<li
|
||||
key={trend.instrument}
|
||||
className="flex flex-wrap items-center justify-between gap-2"
|
||||
data-slot="topic-screener"
|
||||
data-instrument={trend.instrument}
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium">
|
||||
{INSTRUMENT_LABEL[trend.instrument] ?? trend.instrument}{" "}
|
||||
<span className="tabular-nums">
|
||||
{fmt.number(trend.latest.score, 0)}
|
||||
</span>
|
||||
</p>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("topics.screeners.checkIns", { count: trend.n })}
|
||||
</p>
|
||||
</div>
|
||||
{trend.sinceFirst ? (
|
||||
<ScorePill tone={VERDICT_TONE[trend.sinceFirst]}>
|
||||
{t(`topics.screeners.${trend.sinceFirst}`)}
|
||||
</ScorePill>
|
||||
) : (
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{t("topics.screeners.single")}
|
||||
</span>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
<InlineAction asChild tone="primary" size="xs">
|
||||
<Link href={TOPIC_EXTRA_HREF.screeners} className="self-start">
|
||||
{t("topics.screeners.open")}
|
||||
</Link>
|
||||
</InlineAction>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The cycle topic's block: today's cycle day and phase as the cycle page
|
||||
* decides them, and the lengths of the completed cycles on record.
|
||||
*/
|
||||
export function TopicCycleCard({ cycle }: { cycle: TopicCycleSummary }) {
|
||||
const { t } = useTranslations();
|
||||
const fmt = useFormatters();
|
||||
return (
|
||||
<Card data-slot="topic-cycle">
|
||||
<CardHeader>
|
||||
<TileHeader
|
||||
icon={Droplets}
|
||||
titleAs="h2"
|
||||
title={t("topics.cycle.title")}
|
||||
/>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-3">
|
||||
{cycle.today ? (
|
||||
<p className="text-sm font-medium">
|
||||
{cycle.today.phase
|
||||
? t("topics.cycle.todayWithPhase", {
|
||||
day: cycle.today.dayOfCycle,
|
||||
phase: t(`cycle.phase.${cycle.today.phase}`),
|
||||
})
|
||||
: t("topics.cycle.today", { day: cycle.today.dayOfCycle })}
|
||||
</p>
|
||||
) : null}
|
||||
{cycle.averageLength !== null ? (
|
||||
<p className="text-sm">
|
||||
{t("topics.cycle.average", {
|
||||
days: fmt.number(cycle.averageLength, 1),
|
||||
count: cycle.lengths.length,
|
||||
})}
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-sm" data-slot="topic-cycle-needs">
|
||||
{t("topics.cycle.needs", { count: cycle.periodsLogged })}
|
||||
</p>
|
||||
)}
|
||||
{cycle.lengths.length > 0 ? (
|
||||
<p className="text-muted-foreground text-xs tabular-nums">
|
||||
{t("topics.cycle.lengths", {
|
||||
lengths: cycle.lengths.map((l) => fmt.integer(l)).join(", "),
|
||||
})}
|
||||
</p>
|
||||
) : null}
|
||||
<InlineAction asChild tone="primary" size="xs">
|
||||
<Link href={TOPIC_EXTRA_HREF.cycle} className="self-start">
|
||||
{t("topics.cycle.open")}
|
||||
</Link>
|
||||
</InlineAction>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
|
||||
import { TrainingLoadCard } from "@/components/workouts/training-load-card";
|
||||
import { PageHeader } from "@/components/ui/page-header";
|
||||
import { QueryErrorCard } from "@/components/ui/query-error-card";
|
||||
import { Segmented } from "@/components/ui/segmented";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import {
|
||||
DEFAULT_TOPIC_RANGE,
|
||||
TOPIC_RANGES,
|
||||
type TopicKey,
|
||||
type TopicRange,
|
||||
} from "@/lib/topics/registry";
|
||||
|
||||
import { PreviewBadge } from "./preview-badge";
|
||||
import { PreviewNote } from "./preview-note";
|
||||
import { TopicCycleCard, TopicScreenersCard } from "./topic-blocks";
|
||||
import { TopicLabsCard } from "./topic-labs-card";
|
||||
import { TopicLinksCard, TopicMissingCard } from "./topic-links-card";
|
||||
import { topicDescriptionKey, topicTitleKey } from "./topic-meta";
|
||||
import { TopicRelationshipsCard } from "./topic-relationships-card";
|
||||
import { TopicSideBySideCard } from "./topic-side-by-side-card";
|
||||
import { TopicTodayCard } from "./topic-today-card";
|
||||
import { TopicTrendsCard } from "./topic-trends-card";
|
||||
import { useTopicDetail } from "./use-topics";
|
||||
|
||||
function DetailSkeleton() {
|
||||
return (
|
||||
<div className="space-y-6" aria-hidden="true">
|
||||
<Skeleton className="h-40 rounded-xl" />
|
||||
<Skeleton className="h-80 rounded-xl" />
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<Skeleton className="h-64 rounded-xl" />
|
||||
<Skeleton className="h-64 rounded-xl" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* `/topics/<topic>`: the topic's signals today, side by side over a chosen
|
||||
* window, how they move together, their trends, the topic's blocks, the
|
||||
* single-metric pages behind it and what it holds nothing for yet.
|
||||
*/
|
||||
export function TopicDetailView({ topic }: { topic: TopicKey }) {
|
||||
const { t } = useTranslations();
|
||||
const [range, setRange] = useState<TopicRange>(DEFAULT_TOPIC_RANGE);
|
||||
const query = useTopicDetail(topic, range, true);
|
||||
const detail = query.data?.key === topic ? query.data : undefined;
|
||||
|
||||
return (
|
||||
<div className="space-y-6" data-slot="topic-detail" data-topic={topic}>
|
||||
<PageHeader
|
||||
title={t(topicTitleKey(topic))}
|
||||
titleAccessory={<PreviewBadge label={t("nav.previewBadge")} />}
|
||||
description={t(topicDescriptionKey(topic))}
|
||||
backLink={{
|
||||
href: "/topics",
|
||||
label: t("topics.detail.back"),
|
||||
dataSlot: "topic-back",
|
||||
}}
|
||||
/>
|
||||
<Segmented
|
||||
options={TOPIC_RANGES.map((value) => ({
|
||||
value,
|
||||
label: t(`topics.range.${value}`),
|
||||
}))}
|
||||
value={range}
|
||||
onChange={setRange}
|
||||
label={t("topics.range.label")}
|
||||
slot="topic-range"
|
||||
size="sm"
|
||||
/>
|
||||
|
||||
{query.isError ? (
|
||||
<QueryErrorCard
|
||||
error={query.error}
|
||||
title={t("topics.loadError")}
|
||||
onRetry={() => void query.refetch()}
|
||||
/>
|
||||
) : !detail ? (
|
||||
<DetailSkeleton />
|
||||
) : (
|
||||
<div
|
||||
className="space-y-6"
|
||||
data-slot="topic-detail-body"
|
||||
data-status={detail.status}
|
||||
>
|
||||
<TopicTodayCard detail={detail} />
|
||||
<TopicSideBySideCard detail={detail} />
|
||||
{detail.relationships.length > 0 ||
|
||||
detail.trends.length > 0 ||
|
||||
detail.training?.status === "ok" ? (
|
||||
<div className="grid items-start gap-4 md:grid-cols-2">
|
||||
<TopicRelationshipsCard relationships={detail.relationships} />
|
||||
<div className="flex flex-col gap-4">
|
||||
<TopicTrendsCard trends={detail.trends} />
|
||||
<TrainingLoadCard trainingLoad={detail.training ?? undefined} />
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
{detail.labs ? <TopicLabsCard labs={detail.labs} /> : null}
|
||||
{detail.screeners ? (
|
||||
<TopicScreenersCard screeners={detail.screeners} />
|
||||
) : null}
|
||||
{detail.cycle ? <TopicCycleCard cycle={detail.cycle} /> : null}
|
||||
<div className="grid items-start gap-4 md:grid-cols-2">
|
||||
<TopicLinksCard detail={detail} />
|
||||
<TopicMissingCard detail={detail} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<PreviewNote />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { FlaskConical, TrendingUp } from "lucide-react";
|
||||
|
||||
import { labTrendLines } from "@/components/labs/lab-trend-lines";
|
||||
import { useLabNumber } from "@/components/labs/use-lab-format";
|
||||
import { Card, CardContent, CardHeader } from "@/components/ui/card";
|
||||
import { ScorePill } from "@/components/ui/score-pill";
|
||||
import { TileHeader } from "@/components/insights/tile-header";
|
||||
import { useFormatters, useTranslations } from "@/lib/i18n/context";
|
||||
import { formatReferenceRange } from "@/lib/labs/reference-range";
|
||||
import type { TopicLabMarker, TopicLabPanel } from "@/lib/topics/labs";
|
||||
|
||||
function MarkerTile({ marker }: { marker: TopicLabMarker }) {
|
||||
const { t } = useTranslations();
|
||||
const labNumber = useLabNumber();
|
||||
const range = formatReferenceRange(marker.low, marker.high, (v) =>
|
||||
labNumber(v),
|
||||
);
|
||||
const outside = marker.status === "above" || marker.status === "below";
|
||||
return (
|
||||
<Link
|
||||
href={`/labs/${marker.biomarkerId}`}
|
||||
data-slot="topic-lab-marker"
|
||||
data-status={marker.status}
|
||||
className="border-border hover:bg-accent/40 focus-visible:ring-ring/50 flex min-w-0 flex-col gap-1 rounded-lg border p-3 transition-colors focus-visible:ring-2 focus-visible:outline-none"
|
||||
>
|
||||
<span className="text-foreground truncate text-xs font-medium">
|
||||
{marker.name}
|
||||
</span>
|
||||
<span className="flex items-baseline gap-1">
|
||||
<span className="text-lg font-semibold tabular-nums">
|
||||
{labNumber(marker.latest)}
|
||||
</span>
|
||||
<span className="text-muted-foreground text-xs">{marker.unit}</span>
|
||||
</span>
|
||||
{range ? (
|
||||
<span className="text-muted-foreground text-xs">{range}</span>
|
||||
) : null}
|
||||
{outside ? (
|
||||
<ScorePill tone="warning" className="self-start">
|
||||
{t(`topics.labs.${marker.status}`)}
|
||||
</ScorePill>
|
||||
) : null}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The metabolism topic's lab block: each marker's newest reading against the
|
||||
* window it is judged by, then what the markers' trend lines say. A trend
|
||||
* needs three readings spanning a year; markers short of that are named in
|
||||
* one sentence instead of being given a line.
|
||||
*/
|
||||
export function TopicLabsCard({ labs }: { labs: TopicLabPanel }) {
|
||||
const { t } = useTranslations();
|
||||
const fmt = useFormatters();
|
||||
const trending = labs.markers.filter((m) => m.trend.status === "ok");
|
||||
const short = labs.markers.filter((m) => m.trend.status !== "ok");
|
||||
|
||||
return (
|
||||
<Card data-slot="topic-labs">
|
||||
<CardHeader>
|
||||
<TileHeader
|
||||
icon={FlaskConical}
|
||||
titleAs="h2"
|
||||
title={t("topics.labs.title")}
|
||||
right={
|
||||
labs.latestOn ? (
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{fmt.dateShortSmartCalendar(labs.latestOn)}
|
||||
</span>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-4">
|
||||
<div className="grid grid-cols-2 gap-2 md:grid-cols-4">
|
||||
{labs.markers.map((marker) => (
|
||||
<MarkerTile key={marker.biomarkerId} marker={marker} />
|
||||
))}
|
||||
</div>
|
||||
<section
|
||||
className="flex flex-col gap-2"
|
||||
aria-labelledby="topic-lab-trends-heading"
|
||||
>
|
||||
<h3
|
||||
id="topic-lab-trends-heading"
|
||||
className="text-foreground flex items-center gap-2 text-sm font-semibold"
|
||||
>
|
||||
<TrendingUp className="size-4" aria-hidden="true" />
|
||||
{t("topics.labs.trendsTitle")}
|
||||
</h3>
|
||||
{trending.map((marker) => {
|
||||
if (marker.trend.status !== "ok") return null;
|
||||
const lines = labTrendLines(marker.trend, marker.unit, t, fmt);
|
||||
return (
|
||||
<div
|
||||
key={marker.biomarkerId}
|
||||
data-slot="topic-lab-trend"
|
||||
data-projection={marker.trend.projection}
|
||||
>
|
||||
<p className="text-sm font-medium">{marker.name}</p>
|
||||
<p className="text-sm">{lines.slope}</p>
|
||||
{lines.projection ? (
|
||||
<p className="text-sm">{lines.projection}</p>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{short.length > 0 ? (
|
||||
<p
|
||||
className="text-muted-foreground text-xs"
|
||||
data-slot="topic-lab-trend-short"
|
||||
>
|
||||
{t("topics.labs.trendNeeds", {
|
||||
markers: short.map((m) => m.name).join(", "),
|
||||
})}
|
||||
</p>
|
||||
) : null}
|
||||
</section>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { ChevronRight, Info, Lightbulb } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader } from "@/components/ui/card";
|
||||
import { TileHeader } from "@/components/insights/tile-header";
|
||||
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import type { TopicDetail } from "@/lib/topics/build";
|
||||
|
||||
import { TOPIC_EXTRA_HREF, topicSourcesKey } from "./topic-meta";
|
||||
import { useSignalFormat } from "./use-signal-format";
|
||||
|
||||
interface PageLink {
|
||||
href: string;
|
||||
label: string;
|
||||
value: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The single-metric pages behind the topic, unchanged and one tap away: a
|
||||
* topic is an overview, never a replacement for any of them.
|
||||
*/
|
||||
export function TopicLinksCard({ detail }: { detail: TopicDetail }) {
|
||||
const { t } = useTranslations();
|
||||
const format = useSignalFormat();
|
||||
const links: PageLink[] = [];
|
||||
const seen = new Set<string>();
|
||||
const add = (link: PageLink) => {
|
||||
if (seen.has(link.href)) return;
|
||||
seen.add(link.href);
|
||||
links.push(link);
|
||||
};
|
||||
for (const signal of detail.signals) {
|
||||
if (!signal.href || !signal.latest) continue;
|
||||
add({
|
||||
href: signal.href,
|
||||
label: format.label(signal.key),
|
||||
value: format.value(signal.key, signal.latest.value),
|
||||
});
|
||||
}
|
||||
if (detail.labs) {
|
||||
add({ href: TOPIC_EXTRA_HREF.labs, label: t("nav.labs"), value: null });
|
||||
}
|
||||
if (detail.training?.status === "ok") {
|
||||
add({
|
||||
href: TOPIC_EXTRA_HREF.training,
|
||||
label: t("topics.links.workouts"),
|
||||
value: null,
|
||||
});
|
||||
}
|
||||
if (links.length === 0) return null;
|
||||
|
||||
return (
|
||||
<Card data-slot="topic-links">
|
||||
<CardHeader>
|
||||
<TileHeader
|
||||
icon={Lightbulb}
|
||||
titleAs="h2"
|
||||
title={t("topics.links.title")}
|
||||
/>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ul className="divide-border flex flex-col divide-y">
|
||||
{links.map((link) => (
|
||||
<li key={link.href}>
|
||||
<Link
|
||||
href={link.href}
|
||||
data-slot="topic-link"
|
||||
className="hover:bg-accent/40 focus-visible:ring-ring/50 flex min-h-11 items-center gap-3 rounded-md py-2 transition-colors focus-visible:ring-2 focus-visible:outline-none"
|
||||
>
|
||||
<span className="min-w-0 flex-1 text-sm font-medium">
|
||||
{link.label}
|
||||
</span>
|
||||
{link.value ? (
|
||||
<span className="text-muted-foreground text-xs tabular-nums">
|
||||
{link.value}
|
||||
</span>
|
||||
) : null}
|
||||
<ChevronRight
|
||||
className="text-muted-foreground size-4 shrink-0"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* What the topic could show but holds nothing for yet, and where such values
|
||||
* come from. Never a zero, never an empty chart.
|
||||
*/
|
||||
export function TopicMissingCard({ detail }: { detail: TopicDetail }) {
|
||||
const { t } = useTranslations();
|
||||
const format = useSignalFormat();
|
||||
const { inSharedRecord } = useRecordCapabilities();
|
||||
if (detail.missing.length === 0) return null;
|
||||
return (
|
||||
<Card
|
||||
data-slot="topic-missing"
|
||||
className="border-dashed shadow-none"
|
||||
density="compact"
|
||||
>
|
||||
<CardContent className="flex flex-col gap-2">
|
||||
<p className="flex items-start gap-2 text-sm font-medium">
|
||||
<Info className="mt-0.5 size-4 shrink-0" aria-hidden="true" />
|
||||
<span>
|
||||
{t("topics.missing.title", {
|
||||
signals: detail.missing.map((k) => format.label(k)).join(", "),
|
||||
})}
|
||||
</span>
|
||||
</p>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t(topicSourcesKey(detail.key))}
|
||||
</p>
|
||||
{inSharedRecord ? null : (
|
||||
<Button asChild variant="outline" size="sm" className="self-start">
|
||||
<Link href="/settings/integrations">
|
||||
{t("topics.missing.connect")}
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
/**
|
||||
* Client-side presentation of the topics: the glyph per topic, the message
|
||||
* keys of its name and description, and where each extra block's own page
|
||||
* lives. Kept apart from the server registry so the browser bundle carries
|
||||
* no read code; the keys themselves come from `@/lib/topics/registry`.
|
||||
*/
|
||||
import {
|
||||
Activity,
|
||||
Brain,
|
||||
Droplets,
|
||||
FlaskConical,
|
||||
Gauge,
|
||||
Heart,
|
||||
Moon,
|
||||
Scale,
|
||||
type LucideIcon,
|
||||
} from "lucide-react";
|
||||
|
||||
import type { TopicExtra, TopicKey } from "@/lib/topics/registry";
|
||||
|
||||
export const TOPIC_ICONS: Readonly<Record<TopicKey, LucideIcon>> = {
|
||||
heart: Heart,
|
||||
body: Scale,
|
||||
"blood-pressure": Gauge,
|
||||
sleep: Moon,
|
||||
activity: Activity,
|
||||
metabolism: FlaskConical,
|
||||
mind: Brain,
|
||||
cycle: Droplets,
|
||||
};
|
||||
|
||||
/** Message-key segment per topic (`topics.names.<id>.title`). */
|
||||
export const TOPIC_MESSAGE_ID: Readonly<Record<TopicKey, string>> = {
|
||||
heart: "heart",
|
||||
body: "body",
|
||||
"blood-pressure": "bloodPressure",
|
||||
sleep: "sleep",
|
||||
activity: "activity",
|
||||
metabolism: "metabolism",
|
||||
mind: "mind",
|
||||
cycle: "cycle",
|
||||
};
|
||||
|
||||
export function topicTitleKey(key: TopicKey): string {
|
||||
return `topics.names.${TOPIC_MESSAGE_ID[key]}.title`;
|
||||
}
|
||||
|
||||
export function topicDescriptionKey(key: TopicKey): string {
|
||||
return `topics.names.${TOPIC_MESSAGE_ID[key]}.description`;
|
||||
}
|
||||
|
||||
/** Where to record what an empty topic is missing (`topics.empty.<id>`). */
|
||||
export function topicSourcesKey(key: TopicKey): string {
|
||||
return `topics.sources.${TOPIC_MESSAGE_ID[key]}`;
|
||||
}
|
||||
|
||||
/** The page each extra block opens. */
|
||||
export const TOPIC_EXTRA_HREF: Readonly<Record<TopicExtra, string>> = {
|
||||
training: "/insights/workouts",
|
||||
labs: "/labs",
|
||||
screeners: "/mental-wellbeing",
|
||||
cycle: "/cycle",
|
||||
};
|
||||
|
||||
export function topicHref(key: TopicKey): string {
|
||||
return `/topics/${key}`;
|
||||
}
|
||||
|
||||
/** The screener names as the check-in page writes them. */
|
||||
export const INSTRUMENT_LABEL: Readonly<Record<string, string>> = {
|
||||
PHQ9: "PHQ-9",
|
||||
GAD7: "GAD-7",
|
||||
WHO5: "WHO-5",
|
||||
};
|
||||
@@ -0,0 +1,200 @@
|
||||
"use client";
|
||||
|
||||
import dynamic from "next/dynamic";
|
||||
import { Info, Link2 } from "lucide-react";
|
||||
|
||||
import { Card, CardContent, CardHeader } from "@/components/ui/card";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { TileHeader } from "@/components/insights/tile-header";
|
||||
import { useFormatters, useTranslations } from "@/lib/i18n/context";
|
||||
import type { TopicRelationship } from "@/lib/topics/stats";
|
||||
|
||||
import { useSignalAxis, useSignalFormat } from "./use-signal-format";
|
||||
|
||||
const ScatterCorrelationChart = dynamic(
|
||||
() =>
|
||||
import("@/components/charts/chart-runtime").then((mod) => ({
|
||||
default: mod.ScatterCorrelationChart,
|
||||
})),
|
||||
{ ssr: false, loading: () => <Skeleton className="h-44 w-full" /> },
|
||||
);
|
||||
|
||||
const STATUS_DOT = {
|
||||
linked: "bg-chart-1",
|
||||
no_clear_link: "bg-muted-foreground",
|
||||
insufficient: "bg-border",
|
||||
} as const;
|
||||
|
||||
/** The values' own span with a tenth of it on each side, so dots sit inside. */
|
||||
function paddedDomain(values: number[]): [number, number] {
|
||||
const min = Math.min(...values);
|
||||
const max = Math.max(...values);
|
||||
const pad = (max - min) * 0.1 || Math.abs(max) * 0.05 || 1;
|
||||
return [min - pad, max + pad];
|
||||
}
|
||||
|
||||
/** The one sentence a relationship reads as, from the server's figures. */
|
||||
function useRelationshipSentence() {
|
||||
const { t } = useTranslations();
|
||||
const fmt = useFormatters();
|
||||
const format = useSignalFormat();
|
||||
return (rel: TopicRelationship): { text: string; meta: string | null } => {
|
||||
const x = format.label(rel.x);
|
||||
const y = format.label(rel.y);
|
||||
if (rel.status === "insufficient") {
|
||||
return {
|
||||
text: t("topics.relationships.insufficient", {
|
||||
x,
|
||||
y,
|
||||
count: rel.pairedDays,
|
||||
required: rel.requiredDays,
|
||||
}),
|
||||
meta: null,
|
||||
};
|
||||
}
|
||||
if (rel.status === "no_clear_link") {
|
||||
return {
|
||||
text: t("topics.relationships.noClearLink", {
|
||||
x,
|
||||
y,
|
||||
count: rel.pairedDays,
|
||||
}),
|
||||
meta: null,
|
||||
};
|
||||
}
|
||||
if (rel.kind === "split" && rel.split) {
|
||||
const higher = rel.split.difference > 0;
|
||||
return {
|
||||
text: t(
|
||||
higher
|
||||
? "topics.relationships.splitHigher"
|
||||
: "topics.relationships.splitLower",
|
||||
{
|
||||
x,
|
||||
y,
|
||||
threshold:
|
||||
rel.x === "SLEEP_DURATION" && rel.split.threshold % 60 === 0
|
||||
? t("common.durationClockHours", {
|
||||
time: fmt.number(rel.split.threshold / 60, 0),
|
||||
})
|
||||
: format.value(rel.x, rel.split.threshold),
|
||||
amount: format.amount(rel.y, rel.split.difference),
|
||||
},
|
||||
),
|
||||
meta: t("topics.relationships.splitDays", {
|
||||
below: rel.split.belowDays,
|
||||
other: rel.split.otherDays,
|
||||
}),
|
||||
};
|
||||
}
|
||||
const higher = (rel.r ?? 0) > 0;
|
||||
const key = rel.lagDays === 1 ? "NextDay" : "SameDay";
|
||||
return {
|
||||
text: t(
|
||||
`topics.relationships.correlation${key}${higher ? "Higher" : "Lower"}`,
|
||||
{ x, y, r: fmt.number(rel.r ?? 0, 2) },
|
||||
),
|
||||
meta: t("topics.relationships.pairedDays", { count: rel.pairedDays }),
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* How the topic's signals move together: one statement per pair, each with
|
||||
* the number of days it rests on, and a scatter for the first pair that holds
|
||||
* a pattern. Fourteen paired days before anything is said; never a cause.
|
||||
*/
|
||||
export function TopicRelationshipsCard({
|
||||
relationships,
|
||||
}: {
|
||||
relationships: TopicRelationship[];
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
const format = useSignalFormat();
|
||||
const axis = useSignalAxis();
|
||||
const sentence = useRelationshipSentence();
|
||||
if (relationships.length === 0) return null;
|
||||
// A scatter only reads for a correlation: a split's x is two groups.
|
||||
const featured = relationships.find(
|
||||
(r) =>
|
||||
r.kind === "correlation" && r.status === "linked" && r.points.length > 0,
|
||||
);
|
||||
const scatter = featured
|
||||
? featured.points.map((p) => ({
|
||||
x: axis.toAxis(featured.x, p.x),
|
||||
y: axis.toAxis(featured.y, p.y),
|
||||
}))
|
||||
: [];
|
||||
|
||||
return (
|
||||
<Card data-slot="topic-relationships">
|
||||
<CardHeader>
|
||||
<TileHeader
|
||||
icon={Link2}
|
||||
titleAs="h2"
|
||||
title={t("topics.relationships.title")}
|
||||
/>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-3">
|
||||
{featured ? (
|
||||
<figure className="flex flex-col gap-1">
|
||||
<div aria-hidden="true">
|
||||
<ScatterCorrelationChart
|
||||
data={scatter}
|
||||
fill="var(--chart-1)"
|
||||
xAxis={{
|
||||
dataKey: "x",
|
||||
name: format.label(featured.x),
|
||||
domain: paddedDomain(scatter.map((p) => p.x)),
|
||||
tickFormatter: (v) => axis.tick(featured.x, v),
|
||||
}}
|
||||
yAxis={{
|
||||
dataKey: "y",
|
||||
name: format.label(featured.y),
|
||||
domain: paddedDomain(scatter.map((p) => p.y)),
|
||||
tickFormatter: (v) => axis.tick(featured.y, v),
|
||||
}}
|
||||
height={176}
|
||||
/>
|
||||
</div>
|
||||
<figcaption className="text-muted-foreground text-xs">
|
||||
{t("topics.relationships.scatterCaption", {
|
||||
x: format.label(featured.x),
|
||||
y: format.label(featured.y),
|
||||
})}
|
||||
</figcaption>
|
||||
</figure>
|
||||
) : null}
|
||||
<ul className="flex flex-col gap-3">
|
||||
{relationships.map((rel) => {
|
||||
const { text, meta } = sentence(rel);
|
||||
return (
|
||||
<li
|
||||
key={rel.id}
|
||||
className="flex items-start gap-2"
|
||||
data-slot="topic-relationship"
|
||||
data-relationship={rel.id}
|
||||
data-status={rel.status}
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={`mt-1.5 size-2 shrink-0 rounded-full ${STATUS_DOT[rel.status]}`}
|
||||
/>
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm">{text}</p>
|
||||
{meta ? (
|
||||
<p className="text-muted-foreground text-xs">{meta}</p>
|
||||
) : null}
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
<p className="text-muted-foreground flex items-start gap-2 text-xs">
|
||||
<Info className="mt-px size-3.5 shrink-0" aria-hidden="true" />
|
||||
<span>{t("topics.relationships.footnote")}</span>
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
"use client";
|
||||
|
||||
import dynamic from "next/dynamic";
|
||||
import { Layers } from "lucide-react";
|
||||
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Card, CardContent, CardHeader } from "@/components/ui/card";
|
||||
import { TileHeader } from "@/components/insights/tile-header";
|
||||
import { useFormatters, useTranslations } from "@/lib/i18n/context";
|
||||
import type { TopicDetail } from "@/lib/topics/build";
|
||||
import { daysBetweenDateKeys, shiftDateKey } from "@/lib/tz/format";
|
||||
|
||||
import { useSignalAxis, useSignalFormat } from "./use-signal-format";
|
||||
|
||||
// Recharts loads through the one shared chart chunk, never in this route's own.
|
||||
const TopicSideBySideChart = dynamic(
|
||||
() =>
|
||||
import("@/components/charts/chart-runtime").then((mod) => ({
|
||||
default: mod.TopicSideBySideChart,
|
||||
})),
|
||||
{ ssr: false, loading: () => <Skeleton className="h-72 w-full" /> },
|
||||
);
|
||||
|
||||
const SERIES_COLORS = ["var(--chart-1)", "var(--chart-2)", "var(--chart-3)"];
|
||||
|
||||
/**
|
||||
* The topic's signals on one time axis for the chosen window, each against
|
||||
* its usual range. Sleep draws in hours on the axis and reads as hours and
|
||||
* minutes in the tooltip; every other type in the person's display unit.
|
||||
*/
|
||||
export function TopicSideBySideCard({ detail }: { detail: TopicDetail }) {
|
||||
const { t } = useTranslations();
|
||||
const fmt = useFormatters();
|
||||
const format = useSignalFormat();
|
||||
const { toAxis, fromAxis, tick } = useSignalAxis();
|
||||
if (detail.series.length === 0) return null;
|
||||
|
||||
const days = daysBetweenDateKeys(detail.from, detail.to) + 1;
|
||||
|
||||
const series = detail.series.map((s, i) => ({
|
||||
key: s.key,
|
||||
label: format.label(s.key),
|
||||
color: SERIES_COLORS[i % SERIES_COLORS.length],
|
||||
points: s.points.map((p) => ({
|
||||
x: daysBetweenDateKeys(detail.from, p.day),
|
||||
y: toAxis(s.key, p.value),
|
||||
})),
|
||||
band: s.usual
|
||||
? { low: toAxis(s.key, s.usual.low), high: toAxis(s.key, s.usual.high) }
|
||||
: null,
|
||||
}));
|
||||
const names = series.map((s) => s.label).join(", ");
|
||||
|
||||
return (
|
||||
<Card data-slot="topic-side-by-side">
|
||||
<CardHeader>
|
||||
<TileHeader
|
||||
icon={Layers}
|
||||
titleAs="h2"
|
||||
title={t("topics.chart.title")}
|
||||
/>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-3">
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("topics.chart.legend")}
|
||||
</p>
|
||||
<div
|
||||
role="img"
|
||||
aria-label={t("topics.chart.label", { signals: names })}
|
||||
>
|
||||
<TopicSideBySideChart
|
||||
series={series}
|
||||
days={days}
|
||||
formatDay={(x) =>
|
||||
fmt.dateShortSmartCalendar(shiftDateKey(detail.from, x))
|
||||
}
|
||||
formatValue={(key, value) =>
|
||||
format.value(key, fromAxis(key, value))
|
||||
}
|
||||
formatTick={tick}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
CartesianGrid,
|
||||
Line,
|
||||
LineChart,
|
||||
ReferenceArea,
|
||||
ResponsiveContainer,
|
||||
Tooltip,
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from "recharts";
|
||||
|
||||
import { RichChartTooltip } from "@/components/charts/chart-tooltip";
|
||||
|
||||
export interface SideBySideSeries {
|
||||
key: string;
|
||||
label: string;
|
||||
/** A `var(--chart-N)` expression. */
|
||||
color: string;
|
||||
/** Day index from the window's first day, and the value in display units. */
|
||||
points: Array<{ x: number; y: number }>;
|
||||
/** The usual range in display units, shaded behind the line. */
|
||||
band: { low: number; high: number } | null;
|
||||
}
|
||||
|
||||
const AXIS_TICK = { fontSize: 11, fill: "var(--muted-foreground)" };
|
||||
const ROW_HEIGHT = 88;
|
||||
const AXIS_HEIGHT = 24;
|
||||
|
||||
/**
|
||||
* Up to three signals stacked on one time axis, each with its usual range
|
||||
* shaded, so a reader sees two lines move together before reading that they
|
||||
* do. Each row keeps its own scale; the axis, the grid and the band follow the
|
||||
* metric charts. Values arrive in display units, already converted.
|
||||
*/
|
||||
export function TopicSideBySideChart({
|
||||
series,
|
||||
days,
|
||||
formatDay,
|
||||
formatValue,
|
||||
formatTick,
|
||||
}: {
|
||||
series: SideBySideSeries[];
|
||||
/** Days in the window; the axis runs from 0 to `days - 1`. */
|
||||
days: number;
|
||||
formatDay: (x: number) => string;
|
||||
/** A value with its unit, for the tooltip. */
|
||||
formatValue: (key: string, value: number) => string;
|
||||
/** A bare axis number. */
|
||||
formatTick: (key: string, value: number) => string;
|
||||
}) {
|
||||
const ticks = [0, Math.round((days - 1) / 2), days - 1];
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
{series.map((s, index) => {
|
||||
const last = index === series.length - 1;
|
||||
return (
|
||||
<div key={s.key} data-slot="topic-chart-row" data-signal={s.key}>
|
||||
<p className="text-foreground flex items-center gap-2 text-xs font-medium">
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="inline-block h-0.5 w-3 rounded-full"
|
||||
style={{ backgroundColor: s.color }}
|
||||
/>
|
||||
{s.label}
|
||||
</p>
|
||||
<div style={{ height: ROW_HEIGHT + (last ? AXIS_HEIGHT : 0) }}>
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<LineChart
|
||||
data={s.points}
|
||||
margin={{ top: 6, right: 8, bottom: 0, left: 0 }}
|
||||
syncId="topic-side-by-side"
|
||||
>
|
||||
<CartesianGrid
|
||||
strokeDasharray="3 3"
|
||||
stroke="var(--border)"
|
||||
opacity={0.5}
|
||||
vertical={false}
|
||||
/>
|
||||
<XAxis
|
||||
type="number"
|
||||
dataKey="x"
|
||||
domain={[0, Math.max(1, days - 1)]}
|
||||
ticks={ticks}
|
||||
hide={!last}
|
||||
height={last ? AXIS_HEIGHT : 0}
|
||||
tick={AXIS_TICK}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tickFormatter={(value) => formatDay(Number(value))}
|
||||
/>
|
||||
<YAxis
|
||||
domain={["auto", "auto"]}
|
||||
width={44}
|
||||
tick={AXIS_TICK}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tickCount={3}
|
||||
tickFormatter={(value) =>
|
||||
typeof value === "number" ? formatTick(s.key, value) : ""
|
||||
}
|
||||
/>
|
||||
{s.band ? (
|
||||
<ReferenceArea
|
||||
y1={s.band.low}
|
||||
y2={s.band.high}
|
||||
fill="var(--success)"
|
||||
fillOpacity={0.12}
|
||||
strokeOpacity={0}
|
||||
ifOverflow="extendDomain"
|
||||
/>
|
||||
) : null}
|
||||
<Tooltip
|
||||
cursor={{ stroke: "var(--border)" }}
|
||||
content={({ active, payload }) => {
|
||||
const point = payload?.[0]?.payload as
|
||||
{ x: number; y: number } | undefined;
|
||||
if (!active || !point) return null;
|
||||
return (
|
||||
<RichChartTooltip
|
||||
active
|
||||
label={formatDay(point.x)}
|
||||
rows={[
|
||||
{
|
||||
name: s.label,
|
||||
value: formatValue(s.key, point.y),
|
||||
color: s.color,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="y"
|
||||
name={s.label}
|
||||
stroke={s.color}
|
||||
strokeWidth={2}
|
||||
dot={s.points.length <= 31 ? { r: 2 } : false}
|
||||
isAnimationActive={false}
|
||||
connectNulls
|
||||
/>
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
"use client";
|
||||
|
||||
import { useFormatters, useTranslations } from "@/lib/i18n/context";
|
||||
import type { TopicHeadline, TopicSummary } from "@/lib/topics/build";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
import { INSTRUMENT_LABEL } from "./topic-meta";
|
||||
import { useSignalFormat } from "./use-signal-format";
|
||||
|
||||
/**
|
||||
* The topic's one sentence, worded from the server's code. The rule that
|
||||
* picked the code lives in `src/lib/topics/build.ts`; this only says it.
|
||||
*/
|
||||
export function useSummarySentence(summary: TopicSummary): string {
|
||||
const { t } = useTranslations();
|
||||
const signals = useSignalFormat();
|
||||
switch (summary.code) {
|
||||
case "outside":
|
||||
return t(
|
||||
summary.direction === "below"
|
||||
? "topics.summary.outsideBelow"
|
||||
: "topics.summary.outsideAbove",
|
||||
{ signal: summary.signal ? signals.label(summary.signal) : "" },
|
||||
);
|
||||
case "labs":
|
||||
return t("topics.summary.labs", {
|
||||
count: summary.count ?? 0,
|
||||
total: summary.total ?? 0,
|
||||
});
|
||||
case "cycleDay":
|
||||
return t("topics.summary.cycleDay", { day: summary.count ?? 0 });
|
||||
case "steady":
|
||||
return summary.count === summary.total
|
||||
? t("topics.summary.steadyAll")
|
||||
: t("topics.summary.steadySome", {
|
||||
count: summary.count ?? 0,
|
||||
total: summary.total ?? 0,
|
||||
});
|
||||
case "learning":
|
||||
return t("topics.summary.learning");
|
||||
default:
|
||||
return t("topics.summary.empty");
|
||||
}
|
||||
}
|
||||
|
||||
export function TopicSummaryText({
|
||||
summary,
|
||||
className,
|
||||
}: {
|
||||
summary: TopicSummary;
|
||||
className?: string;
|
||||
}) {
|
||||
const sentence = useSummarySentence(summary);
|
||||
return (
|
||||
<p
|
||||
data-slot="topic-summary"
|
||||
data-code={summary.code}
|
||||
className={cn("text-foreground", className)}
|
||||
>
|
||||
{sentence}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
/** The headline as a value and a unit, for whichever kind the server sent. */
|
||||
export function useHeadline(
|
||||
headline: TopicHeadline | null,
|
||||
): { value: string; unit: string } | null {
|
||||
const { t } = useTranslations();
|
||||
const fmt = useFormatters();
|
||||
const signals = useSignalFormat();
|
||||
if (!headline) return null;
|
||||
switch (headline.kind) {
|
||||
case "signal":
|
||||
return {
|
||||
value: signals.number(headline.key, headline.value),
|
||||
unit: signals.unit(headline.key),
|
||||
};
|
||||
case "labs":
|
||||
return {
|
||||
value: t("topics.headline.labsValue", {
|
||||
count: headline.inRange,
|
||||
total: headline.total,
|
||||
}),
|
||||
unit: t("topics.headline.labsUnit"),
|
||||
};
|
||||
case "cycleDay":
|
||||
return {
|
||||
value: t("topics.headline.cycleDay", { day: headline.dayOfCycle }),
|
||||
unit: "",
|
||||
};
|
||||
case "screener":
|
||||
return {
|
||||
value: `${INSTRUMENT_LABEL[headline.instrument] ?? headline.instrument} ${fmt.number(headline.score, 0)}`,
|
||||
unit: "",
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
"use client";
|
||||
|
||||
import dynamic from "next/dynamic";
|
||||
import Link from "next/link";
|
||||
|
||||
import { Card, CardContent, CardHeader } from "@/components/ui/card";
|
||||
import { ScorePill } from "@/components/ui/score-pill";
|
||||
import { TileHeader } from "@/components/insights/tile-header";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import type { TopicOverviewEntry } from "@/lib/topics/build";
|
||||
|
||||
import { TOPIC_ICONS, topicHref, topicTitleKey } from "./topic-meta";
|
||||
import { TopicSummaryText, useHeadline } from "./topic-summary";
|
||||
|
||||
// The sparkline loads through the shared chart runtime, so the overview's own
|
||||
// chunk carries no recharts; the tile owns the fixed band it draws in.
|
||||
const DeltaSparkline = dynamic(
|
||||
() =>
|
||||
import("@/components/charts/chart-runtime").then((mod) => ({
|
||||
default: mod.DeltaSparkline,
|
||||
})),
|
||||
{ ssr: false, loading: () => null },
|
||||
);
|
||||
|
||||
const TONE_PILL = {
|
||||
steady: "success",
|
||||
watch: "warning",
|
||||
learning: "neutral",
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* One topic on the overview: its name (the link), the headline value, the
|
||||
* rule-based sentence, the last 30 days as a sparkline, and how many of the
|
||||
* topic's signals hold a value. The whole card is the target, through the
|
||||
* title link stretched over it, so the link's name is the visible title.
|
||||
*/
|
||||
export function TopicTile({ entry }: { entry: TopicOverviewEntry }) {
|
||||
const { t } = useTranslations();
|
||||
const headline = useHeadline(entry.headline);
|
||||
const title = t(topicTitleKey(entry.key));
|
||||
const points = entry.sparkline.map((v, i) => ({ i, v }));
|
||||
|
||||
return (
|
||||
<Card
|
||||
data-slot="topic-tile"
|
||||
data-topic={entry.key}
|
||||
data-tone={entry.tone}
|
||||
className="hover:bg-accent/40 relative gap-2 transition-colors md:gap-3"
|
||||
>
|
||||
<CardHeader>
|
||||
<TileHeader
|
||||
icon={TOPIC_ICONS[entry.key]}
|
||||
titleAs="h2"
|
||||
title={
|
||||
<Link
|
||||
href={topicHref(entry.key)}
|
||||
data-slot="topic-tile-link"
|
||||
className="focus-visible:ring-ring/50 rounded-sm after:absolute after:inset-0 after:rounded-xl focus-visible:ring-2 focus-visible:outline-none"
|
||||
>
|
||||
{title}
|
||||
</Link>
|
||||
}
|
||||
/>
|
||||
</CardHeader>
|
||||
<CardContent className="flex min-w-0 flex-1 flex-col gap-2">
|
||||
{headline ? (
|
||||
<p className="text-foreground flex items-baseline gap-1.5">
|
||||
<span className="text-2xl font-semibold tabular-nums">
|
||||
{headline.value}
|
||||
</span>
|
||||
{headline.unit ? (
|
||||
<span className="text-muted-foreground text-sm">
|
||||
{headline.unit}
|
||||
</span>
|
||||
) : null}
|
||||
</p>
|
||||
) : null}
|
||||
<TopicSummaryText summary={entry.summary} className="text-sm" />
|
||||
{points.length >= 2 ? (
|
||||
<div className="h-10" aria-hidden="true">
|
||||
<DeltaSparkline data={points} strokeVar="var(--chart-1)" />
|
||||
</div>
|
||||
) : null}
|
||||
<div className="mt-auto flex flex-wrap items-center justify-between gap-2">
|
||||
<ScorePill tone={TONE_PILL[entry.tone]}>
|
||||
{t(`topics.tone.${entry.tone}`)}
|
||||
</ScorePill>
|
||||
{entry.recorded > 0 ? (
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{t("topics.recorded", {
|
||||
count: entry.recorded,
|
||||
total: entry.total,
|
||||
})}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { ChevronRight, Sun } from "lucide-react";
|
||||
|
||||
import { Card, CardContent, CardHeader } from "@/components/ui/card";
|
||||
import { InlineAction } from "@/components/ui/inline-action";
|
||||
import { ScorePill } from "@/components/ui/score-pill";
|
||||
import { TileHeader } from "@/components/insights/tile-header";
|
||||
import { useFormatters, useTranslations } from "@/lib/i18n/context";
|
||||
import type { TopicDetail, TopicSignal } from "@/lib/topics/build";
|
||||
|
||||
import { TopicSummaryText } from "./topic-summary";
|
||||
import { useSignalFormat } from "./use-signal-format";
|
||||
|
||||
const STATE_TONE = {
|
||||
within: "success",
|
||||
above: "warning",
|
||||
below: "warning",
|
||||
learning: "neutral",
|
||||
} as const;
|
||||
|
||||
function SignalReadout({
|
||||
signal,
|
||||
todayKey,
|
||||
}: {
|
||||
signal: TopicSignal;
|
||||
todayKey: string;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
const fmt = useFormatters();
|
||||
const format = useSignalFormat();
|
||||
if (!signal.latest) return null;
|
||||
const label = format.label(signal.key);
|
||||
const unit = format.unit(signal.key);
|
||||
const usual = signal.usual;
|
||||
return (
|
||||
<div
|
||||
className="flex min-w-0 flex-col gap-1"
|
||||
data-slot="topic-signal"
|
||||
data-signal={signal.key}
|
||||
data-state={signal.state}
|
||||
>
|
||||
<dt className="text-muted-foreground text-xs">{label}</dt>
|
||||
<dd className="text-foreground flex items-baseline gap-1">
|
||||
<span className="text-xl font-semibold tabular-nums">
|
||||
{format.number(signal.key, signal.latest.value)}
|
||||
</span>
|
||||
{unit ? (
|
||||
<span className="text-muted-foreground text-xs">{unit}</span>
|
||||
) : null}
|
||||
</dd>
|
||||
<dd className="text-muted-foreground text-xs">
|
||||
{usual
|
||||
? t("topics.today.usual", {
|
||||
low: format.number(signal.key, usual.low),
|
||||
high: format.number(signal.key, usual.high),
|
||||
})
|
||||
: t("topics.today.learning")}
|
||||
</dd>
|
||||
{signal.latest.day !== todayKey ? (
|
||||
<dd className="text-muted-foreground text-xs">
|
||||
{t("topics.today.asOf", {
|
||||
date: fmt.dateShortSmartCalendar(signal.latest.day),
|
||||
})}
|
||||
</dd>
|
||||
) : null}
|
||||
<dd className="flex flex-wrap items-center gap-2">
|
||||
{signal.state !== "none" ? (
|
||||
<ScorePill tone={STATE_TONE[signal.state]}>
|
||||
{t(`topics.state.${signal.state}`)}
|
||||
</ScorePill>
|
||||
) : null}
|
||||
{signal.href ? (
|
||||
<InlineAction asChild tone="primary" size="xs">
|
||||
<Link href={signal.href}>
|
||||
{t("topics.today.details")}
|
||||
<span className="sr-only">: {label}</span>
|
||||
<ChevronRight className="size-3" aria-hidden="true" />
|
||||
</Link>
|
||||
</InlineAction>
|
||||
) : null}
|
||||
</dd>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The topic today: its one sentence, then each recorded signal with its
|
||||
* latest value, its usual range and a way into its own page.
|
||||
*/
|
||||
export function TopicTodayCard({ detail }: { detail: TopicDetail }) {
|
||||
const { t } = useTranslations();
|
||||
const recorded = detail.signals.filter((s) => s.latest !== null);
|
||||
return (
|
||||
<Card data-slot="topic-today">
|
||||
<CardHeader>
|
||||
<TileHeader icon={Sun} titleAs="h2" title={t("topics.today.title")} />
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-4">
|
||||
<TopicSummaryText
|
||||
summary={detail.summary}
|
||||
className="text-base font-medium"
|
||||
/>
|
||||
{recorded.length > 0 ? (
|
||||
<dl className="grid grid-cols-2 gap-x-3 gap-y-4 md:grid-cols-4">
|
||||
{recorded.map((signal) => (
|
||||
<SignalReadout
|
||||
key={signal.key}
|
||||
signal={signal}
|
||||
todayKey={detail.to}
|
||||
/>
|
||||
))}
|
||||
</dl>
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
"use client";
|
||||
|
||||
import dynamic from "next/dynamic";
|
||||
import { TrendingUp } from "lucide-react";
|
||||
|
||||
import { Card, CardContent, CardHeader } from "@/components/ui/card";
|
||||
import { TileHeader } from "@/components/insights/tile-header";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import type { TopicTrend } from "@/lib/topics/stats";
|
||||
|
||||
import { useSignalFormat } from "./use-signal-format";
|
||||
|
||||
const DeltaSparkline = dynamic(
|
||||
() =>
|
||||
import("@/components/charts/chart-runtime").then((mod) => ({
|
||||
default: mod.DeltaSparkline,
|
||||
})),
|
||||
{ ssr: false, loading: () => null },
|
||||
);
|
||||
|
||||
/**
|
||||
* Each recorded signal over the trailing 90 days: the change the fitted line
|
||||
* amounts to, beside its weekly means. Below two weeks of days a row says how
|
||||
* many it has rather than drawing a slope through a handful of points.
|
||||
*/
|
||||
export function TopicTrendsCard({ trends }: { trends: TopicTrend[] }) {
|
||||
const { t } = useTranslations();
|
||||
const format = useSignalFormat();
|
||||
if (trends.length === 0) return null;
|
||||
|
||||
return (
|
||||
<Card data-slot="topic-trends">
|
||||
<CardHeader>
|
||||
<TileHeader
|
||||
icon={TrendingUp}
|
||||
titleAs="h2"
|
||||
title={t("topics.trends.title")}
|
||||
/>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ul className="divide-border flex flex-col divide-y">
|
||||
{trends.map((trend) => {
|
||||
const points = trend.weekly.map((p, i) => ({ i, v: p.value }));
|
||||
return (
|
||||
<li
|
||||
key={trend.key}
|
||||
className="flex items-center gap-3 py-2.5 first:pt-0 last:pb-0"
|
||||
data-slot="topic-trend"
|
||||
data-signal={trend.key}
|
||||
data-status={trend.status}
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-medium">
|
||||
{format.label(trend.key)}
|
||||
</p>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{trend.status === "ok"
|
||||
? t("topics.trends.window")
|
||||
: t("topics.trends.insufficient", { count: trend.days })}
|
||||
</p>
|
||||
</div>
|
||||
{trend.status === "ok" && points.length >= 2 ? (
|
||||
<div className="h-8 w-20 shrink-0" aria-hidden="true">
|
||||
<DeltaSparkline data={points} strokeVar="var(--chart-2)" />
|
||||
</div>
|
||||
) : null}
|
||||
{trend.status === "ok" && trend.change !== null ? (
|
||||
<span className="shrink-0 text-sm font-semibold tabular-nums">
|
||||
{format.delta(trend.key, trend.change)}
|
||||
</span>
|
||||
) : null}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { Layers } from "lucide-react";
|
||||
|
||||
import { EmptyState } from "@/components/ui/empty-state";
|
||||
import { InlineAction } from "@/components/ui/inline-action";
|
||||
import { PageHeader } from "@/components/ui/page-header";
|
||||
import { QueryErrorCard } from "@/components/ui/query-error-card";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import type { TopicKey } from "@/lib/topics/registry";
|
||||
|
||||
import { PreviewBadge } from "./preview-badge";
|
||||
import { PreviewNote } from "./preview-note";
|
||||
import { topicTitleKey } from "./topic-meta";
|
||||
import { TopicTile } from "./topic-tile";
|
||||
import { useTopicOverview } from "./use-topics";
|
||||
|
||||
function useTopicList() {
|
||||
const { t } = useTranslations();
|
||||
return (keys: readonly TopicKey[]) =>
|
||||
keys.map((key) => t(topicTitleKey(key))).join(", ");
|
||||
}
|
||||
|
||||
/**
|
||||
* `/topics`: one tile per topic the record holds data for. Topics with
|
||||
* nothing recorded yet and topics whose module is off are named once each,
|
||||
* at the end, with where to change that; neither gets an empty tile.
|
||||
*/
|
||||
export function TopicsOverview() {
|
||||
const { t } = useTranslations();
|
||||
const list = useTopicList();
|
||||
const { inSharedRecord } = useRecordCapabilities();
|
||||
const query = useTopicOverview(true);
|
||||
const data = query.data;
|
||||
|
||||
return (
|
||||
<div className="space-y-6" data-slot="topics-overview">
|
||||
<PageHeader
|
||||
title={t("topics.overview.title")}
|
||||
titleAccessory={<PreviewBadge label={t("nav.previewBadge")} />}
|
||||
description={t("topics.overview.description")}
|
||||
/>
|
||||
|
||||
{query.isError ? (
|
||||
<QueryErrorCard
|
||||
error={query.error}
|
||||
title={t("topics.loadError")}
|
||||
onRetry={() => void query.refetch()}
|
||||
/>
|
||||
) : !data ? (
|
||||
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
|
||||
{Array.from({ length: 4 }, (_, i) => (
|
||||
<Skeleton key={i} className="h-56 rounded-xl" />
|
||||
))}
|
||||
</div>
|
||||
) : data.topics.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={<Layers className="size-6" aria-hidden="true" />}
|
||||
title={t("topics.overview.emptyTitle")}
|
||||
description={t("topics.overview.emptyDescription")}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
className="grid gap-4 sm:grid-cols-2 xl:grid-cols-4"
|
||||
data-slot="topics-grid"
|
||||
>
|
||||
{data.topics.map((entry) => (
|
||||
<TopicTile key={entry.key} entry={entry} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{data && data.empty.length > 0 ? (
|
||||
<p
|
||||
className="text-muted-foreground text-xs"
|
||||
data-slot="topics-empty-list"
|
||||
>
|
||||
{t("topics.overview.notRecorded", { topics: list(data.empty) })}
|
||||
</p>
|
||||
) : null}
|
||||
{data && data.hiddenByModule.length > 0 ? (
|
||||
<p
|
||||
className="text-muted-foreground text-xs"
|
||||
data-slot="topics-hidden-list"
|
||||
>
|
||||
{t("topics.overview.hiddenByModule", {
|
||||
topics: list(data.hiddenByModule),
|
||||
})}{" "}
|
||||
{inSharedRecord ? null : (
|
||||
<InlineAction asChild tone="muted" size="xs">
|
||||
<Link href="/settings/modules">
|
||||
{t("topics.overview.modulesLink")}
|
||||
</Link>
|
||||
</InlineAction>
|
||||
)}
|
||||
</p>
|
||||
) : null}
|
||||
<PreviewNote />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, type ReactNode } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
import { PageAuthGate } from "@/components/ui/page-auth-gate";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
|
||||
/**
|
||||
* The Topics area exists only for a person who switched the preview on.
|
||||
*
|
||||
* Until the account payload answers, the page shows the shell's loading
|
||||
* state; signed out, it goes to sign-in like every page; with the preview off
|
||||
* (an old bookmark, a link from someone else) it leaves for Insights, where
|
||||
* the person's pages are. The routes refuse on their own (404
|
||||
* `topics.preview_off`), so this is the friendly end, not the guard.
|
||||
*/
|
||||
export function TopicsPreviewGate({ children }: { children: ReactNode }) {
|
||||
const { user, isLoading, isAuthenticated } = useAuth();
|
||||
const router = useRouter();
|
||||
const enabled = user?.previewFeatures?.topics === true;
|
||||
|
||||
useEffect(() => {
|
||||
if (isLoading) return;
|
||||
if (!isAuthenticated) router.replace("/auth/login");
|
||||
else if (!enabled) router.replace("/insights");
|
||||
}, [isLoading, isAuthenticated, enabled, router]);
|
||||
|
||||
if (isLoading || !isAuthenticated || !enabled) return <PageAuthGate />;
|
||||
return <>{children}</>;
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* How a topic signal reads on screen: its name, a value in the person's own
|
||||
* units, and a signed change. Measurement types go through the same unit
|
||||
* display every metric page uses (`useUnitDisplay`), so a topic never shows a
|
||||
* number its metric page would write differently; sleep reads as hours and
|
||||
* minutes, the mood score as its 1–5 value.
|
||||
*/
|
||||
import { useMemo } from "react";
|
||||
|
||||
import { useUnitDisplay } from "@/hooks/use-unit-display";
|
||||
import { formatDurationMinutes } from "@/lib/i18n/duration";
|
||||
import { useFormatters, useTranslations } from "@/lib/i18n/context";
|
||||
import { MEASUREMENT_TYPE_LABEL_KEYS } from "@/lib/measurements/type-label-keys";
|
||||
import { rawDisplayFractionDigits } from "@/lib/measurements/display-transform";
|
||||
import { vitalDisplayDecimals } from "@/lib/measurements/vital-precision";
|
||||
import { MOOD_SIGNAL } from "@/lib/topics/registry";
|
||||
|
||||
/**
|
||||
* Whole numbers for counts (steps, minutes) and for the vitals every device
|
||||
* reads whole (pulse, blood pressure, HRV, SpO2); one decimal for weight and
|
||||
* temperature; a converted unit at its transform's precision. The same
|
||||
* answer the Today overview and the server-side texts give.
|
||||
*/
|
||||
const WHOLE_VITALS = new Set([
|
||||
"PULSE",
|
||||
"RESTING_HEART_RATE",
|
||||
"HEART_RATE_VARIABILITY",
|
||||
"HRV_RMSSD",
|
||||
"BLOOD_PRESSURE_SYS",
|
||||
"BLOOD_PRESSURE_DIA",
|
||||
"OXYGEN_SATURATION",
|
||||
"RESPIRATORY_RATE",
|
||||
"WALKING_HEART_RATE_AVERAGE",
|
||||
]);
|
||||
|
||||
function signalDigits(key: string, transformDecimals: number): number {
|
||||
if (rawDisplayFractionDigits(key) === 0) return 0;
|
||||
if (WHOLE_VITALS.has(key))
|
||||
return vitalDisplayDecimals(key, transformDecimals);
|
||||
return transformDecimals;
|
||||
}
|
||||
|
||||
export interface SignalFormat {
|
||||
label: (key: string) => string;
|
||||
/** The value with its unit, as one string. */
|
||||
value: (key: string, raw: number) => string;
|
||||
/** The number alone, in display units. */
|
||||
number: (key: string, raw: number) => string;
|
||||
unit: (key: string) => string;
|
||||
/** A signed change with its unit ("+1.2 kg", "−4 ms"). */
|
||||
delta: (key: string, raw: number) => string;
|
||||
/** The size of a change with its unit, unsigned ("1.2 kg"). */
|
||||
amount: (key: string, raw: number) => string;
|
||||
}
|
||||
|
||||
export function useSignalFormat(): SignalFormat {
|
||||
const { t } = useTranslations();
|
||||
const fmt = useFormatters();
|
||||
const units = useUnitDisplay();
|
||||
|
||||
return useMemo(() => {
|
||||
const label = (key: string) => {
|
||||
if (key === MOOD_SIGNAL) return t("topics.signals.mood");
|
||||
const labelKey =
|
||||
MEASUREMENT_TYPE_LABEL_KEYS[
|
||||
key as keyof typeof MEASUREMENT_TYPE_LABEL_KEYS
|
||||
];
|
||||
return labelKey ? t(labelKey) : key;
|
||||
};
|
||||
const number = (key: string, raw: number) => {
|
||||
if (key === MOOD_SIGNAL) return fmt.number(raw, 1);
|
||||
if (key === "SLEEP_DURATION") return formatDurationMinutes(raw, t);
|
||||
return fmt.number(
|
||||
units.toDisplay(key, raw),
|
||||
signalDigits(key, units.decimalsFor(key)),
|
||||
);
|
||||
};
|
||||
const unit = (key: string) => {
|
||||
if (key === MOOD_SIGNAL) return t("topics.units.moodScale");
|
||||
if (key === "SLEEP_DURATION") return "";
|
||||
return units.unitFor(key);
|
||||
};
|
||||
const value = (key: string, raw: number) => {
|
||||
const u = unit(key);
|
||||
return u ? `${number(key, raw)} ${u}` : number(key, raw);
|
||||
};
|
||||
const amount = (key: string, raw: number) => {
|
||||
const magnitude = Math.abs(raw);
|
||||
if (key === "SLEEP_DURATION") return formatDurationMinutes(magnitude, t);
|
||||
const shown =
|
||||
key === MOOD_SIGNAL
|
||||
? fmt.number(magnitude, 1)
|
||||
: fmt.number(
|
||||
Math.abs(units.toDisplayDelta(key, magnitude)),
|
||||
signalDigits(key, units.decimalsFor(key)),
|
||||
);
|
||||
const u = unit(key);
|
||||
return u ? `${shown} ${u}` : shown;
|
||||
};
|
||||
const delta = (key: string, raw: number) => {
|
||||
const sign = raw > 0 ? "+" : raw < 0 ? "−" : "±";
|
||||
return `${sign}${amount(key, raw)}`;
|
||||
};
|
||||
return { label, value, number, unit, delta, amount };
|
||||
}, [t, fmt, units]);
|
||||
}
|
||||
|
||||
export interface SignalAxis {
|
||||
/** A stored value in the unit a chart axis draws (sleep in hours). */
|
||||
toAxis: (key: string, raw: number) => number;
|
||||
/** Back from the axis unit to the stored value. */
|
||||
fromAxis: (key: string, value: number) => number;
|
||||
/** A bare axis tick. */
|
||||
tick: (key: string, value: number) => string;
|
||||
}
|
||||
|
||||
/** How a signal draws on a chart axis, in the person's own units. */
|
||||
export function useSignalAxis(): SignalAxis {
|
||||
const fmt = useFormatters();
|
||||
const units = useUnitDisplay();
|
||||
return useMemo(() => {
|
||||
const plain = (key: string) =>
|
||||
key === MOOD_SIGNAL || key === "SLEEP_DURATION";
|
||||
return {
|
||||
toAxis: (key, raw) =>
|
||||
key === "SLEEP_DURATION"
|
||||
? raw / 60
|
||||
: key === MOOD_SIGNAL
|
||||
? raw
|
||||
: units.toDisplay(key, raw),
|
||||
fromAxis: (key, value) =>
|
||||
key === "SLEEP_DURATION"
|
||||
? value * 60
|
||||
: key === MOOD_SIGNAL
|
||||
? value
|
||||
: units.fromDisplay(key, value),
|
||||
tick: (key, value) =>
|
||||
fmt.number(
|
||||
value,
|
||||
plain(key) ? 1 : signalDigits(key, units.decimalsFor(key)),
|
||||
),
|
||||
};
|
||||
}, [fmt, units]);
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* The two topic reads. Each answer is the server's, rendered as given; the
|
||||
* failed read hands its `ApiError` through untouched so the surface's
|
||||
* read-failure primitive decides what to offer.
|
||||
*/
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
|
||||
import { apiGet } from "@/lib/api/api-fetch";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import type { TopicDetail, TopicOverview } from "@/lib/topics/build";
|
||||
import type { TopicKey, TopicRange } from "@/lib/topics/registry";
|
||||
|
||||
export function useTopicOverview(enabled: boolean) {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.topicsOverview(),
|
||||
queryFn: () => apiGet<TopicOverview>("/api/topics"),
|
||||
enabled,
|
||||
});
|
||||
}
|
||||
|
||||
export function useTopicDetail(
|
||||
topic: TopicKey,
|
||||
range: TopicRange,
|
||||
enabled: boolean,
|
||||
) {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.topicDetail(topic, range),
|
||||
queryFn: () =>
|
||||
apiGet<TopicDetail>(
|
||||
`/api/topics/${encodeURIComponent(topic)}?range=${encodeURIComponent(range)}`,
|
||||
),
|
||||
enabled,
|
||||
placeholderData: (previous) => previous,
|
||||
});
|
||||
}
|
||||
@@ -17,7 +17,7 @@ describe("<ScorePill>", () => {
|
||||
|
||||
it("defaults to the neutral tone", () => {
|
||||
const html = renderToStaticMarkup(<ScorePill>Low</ScorePill>);
|
||||
expect(html).toContain("bg-secondary text-muted-foreground");
|
||||
expect(html).toContain("bg-secondary text-secondary-foreground");
|
||||
expect(html).toContain('data-tone="neutral"');
|
||||
});
|
||||
|
||||
|
||||
@@ -30,7 +30,10 @@ const scorePillVariants = cva(
|
||||
info: "bg-info/15 text-info",
|
||||
warning: "bg-warning/15 text-warning",
|
||||
destructive: "bg-destructive/15 text-destructive",
|
||||
neutral: "bg-secondary text-muted-foreground",
|
||||
// The secondary surface's own foreground: muted text on the secondary
|
||||
// fill measured 3.6:1 in the dark theme, under the AA floor a pill
|
||||
// label needs.
|
||||
neutral: "bg-secondary text-secondary-foreground",
|
||||
},
|
||||
numeric: {
|
||||
true: "font-semibold tabular-nums",
|
||||
|
||||
@@ -24,6 +24,10 @@ import { storeTimezone } from "@/lib/timezone-mirror";
|
||||
import type { ModuleKey } from "@/lib/modules/registry";
|
||||
import type { AiCapabilities } from "@/lib/ai/capabilities/types";
|
||||
import type { CoachReasoningState } from "@/lib/ai/reasoning/resolve";
|
||||
import {
|
||||
resolvePreviewFeatures,
|
||||
type PreviewFeatures,
|
||||
} from "@/lib/settings/preview-features";
|
||||
import type { ModuleAccessState } from "@/lib/sharing/module-disclosure";
|
||||
import type { OnboardingStateDto } from "@/lib/onboarding/needs";
|
||||
import type { TourProgress } from "@/lib/onboarding/tour-progress";
|
||||
@@ -254,6 +258,14 @@ export interface AuthUser {
|
||||
* server image that does not publish it.
|
||||
*/
|
||||
coachReasoning?: CoachReasoningState | null;
|
||||
/**
|
||||
* The person's own preview opt-ins, resolved on the server: every
|
||||
* flag present, off unless switched on in Settings > System > Preview
|
||||
* features. Read through `usePreviewFeature`. Optional so existing
|
||||
* fixtures stay valid; `fetchMe` coerces an absent block (an older server)
|
||||
* to every flag off.
|
||||
*/
|
||||
previewFeatures?: PreviewFeatures;
|
||||
/**
|
||||
* v1.36.0 — account sharing, resolved server-side. `accounts` is the
|
||||
* switcher's menu, `active` is the record this browser is inside (null when
|
||||
@@ -477,6 +489,8 @@ export async function fetchMe(): Promise<AuthUser> {
|
||||
data.coachReasoning && typeof data.coachReasoning === "object"
|
||||
? data.coachReasoning
|
||||
: null,
|
||||
// An older server publishes no opt-ins; every preview stays off.
|
||||
previewFeatures: resolvePreviewFeatures(data.previewFeatures),
|
||||
accountAccess,
|
||||
accountAccessStatus,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
"use client";
|
||||
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import type { PreviewFeatures } from "@/lib/settings/preview-features";
|
||||
|
||||
/**
|
||||
* The person's own preview opt-ins, off the cached account payload, or null
|
||||
* while it has not answered. Every reader treats null as "off": a preview
|
||||
* appears only once the server has said yes, never on a guess.
|
||||
*/
|
||||
export function usePreviewFeatures(): PreviewFeatures | null {
|
||||
const { user } = useAuth();
|
||||
return user?.previewFeatures ?? null;
|
||||
}
|
||||
@@ -51,6 +51,8 @@ const WHOLE_RECORD_ROUTE_FAMILIES = [
|
||||
"/achievements",
|
||||
// v1.42 — the timeline lays every section side by side.
|
||||
"/timeline",
|
||||
// So does a topic page (preview).
|
||||
"/topics",
|
||||
] as const;
|
||||
|
||||
interface RouteFamily {
|
||||
|
||||
@@ -452,6 +452,7 @@ export const ERROR_CODE_CATALOGUE: Readonly<Record<string, readonly string[]>> =
|
||||
"tokens.measurements.ceiling_reached",
|
||||
"tokens.workouts.ceiling_reached",
|
||||
],
|
||||
topics: ["topics.not_available", "topics.not_found", "topics.preview_off"],
|
||||
vaccination: [
|
||||
"vaccination.booster-invalid",
|
||||
"vaccination.booster-no-antigen",
|
||||
|
||||
@@ -44,6 +44,7 @@ import {
|
||||
} from "@/lib/validations/managed-profile-handover";
|
||||
import { onboardingStateResource } from "./onboarding";
|
||||
import { aiAccountBlock, moduleAccessMap } from "./profile";
|
||||
import { previewFeaturesSchema } from "./settings";
|
||||
import {
|
||||
dataEnvelope,
|
||||
errorEnvelope,
|
||||
@@ -562,6 +563,11 @@ const accountPayload = z
|
||||
ai: aiAccountBlock,
|
||||
// v1.41 — enumerated for the reason `ai` is: the settings decide on it.
|
||||
coachReasoning: coachReasoningBlock,
|
||||
// Enumerated because the navigation decides on it: the Topics
|
||||
// entry exists only while the person reading has switched it on. The
|
||||
// caller's own opt-in, never the record's, so it does not move under a
|
||||
// switch. The same object `GET /api/settings/preview-features` answers.
|
||||
previewFeatures: previewFeaturesSchema,
|
||||
recordSession: recordSessionState.nullable(),
|
||||
// Enumerated because a client DECIDES on it rather than displays it: the
|
||||
// medication surface hides the server-side reminder switch when
|
||||
|
||||
@@ -77,7 +77,7 @@ const biomarkerRow = z
|
||||
"A user-scoped catalog marker. `context` is the decrypted per-marker note (or null); `hasContext` flags its presence. `analyteKey` is the analyte the marker was recognised as: the catalog slug its name belongs to (e.g. `fasting-glucose`, `ldl`, `hdl`), or a key of the unit-conversion registry for an analyte with no catalog seed (e.g. `urea`, `uric-acid`), or null when its name matches none. It is derived from `name` and `unit`, never chosen: it is set on create and re-derived (or cleared) when an update changes either. A reading in another unit is converted only for a marker that has one. `hidden` drops the marker from the active catalog list and the lab-entry pickers without deleting it (its readings and unit/range survive). A `LabResult` linking this marker resolves its unit + reference bounds from here.",
|
||||
});
|
||||
|
||||
const labTrend = z
|
||||
export const biomarkerTrendSchema = z
|
||||
.union([
|
||||
z.object({
|
||||
status: z.literal("ok"),
|
||||
@@ -140,7 +140,9 @@ const labTrend = z
|
||||
|
||||
const biomarkerDetail = biomarkerRow
|
||||
.extend({
|
||||
trend: labTrend.optional().describe("v1.44 — absent on older servers."),
|
||||
trend: biomarkerTrendSchema
|
||||
.optional()
|
||||
.describe("v1.44 — absent on older servers."),
|
||||
})
|
||||
.meta({ id: "BiomarkerDetail" });
|
||||
|
||||
|
||||
@@ -56,6 +56,7 @@ import { cyclePaths } from "./cycle";
|
||||
import { dashboardWidgetPaths } from "./dashboard";
|
||||
import { dayPaths } from "./day";
|
||||
import { searchPaths } from "./search";
|
||||
import { topicPaths } from "./topics";
|
||||
import { biomarkerPaths } from "./biomarkers";
|
||||
import { inboundDocumentPaths } from "./documents";
|
||||
import { documentSourcePaths } from "./document-sources";
|
||||
@@ -204,6 +205,9 @@ const registeredPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
...lifeCircumstancesPaths,
|
||||
...dawarichPaths,
|
||||
...relationLinkPaths,
|
||||
// The topic pages, a preview each account switches on for itself
|
||||
// (appended, spread order is load-bearing).
|
||||
...topicPaths,
|
||||
// The environmental-context overview. Its own module because nothing else
|
||||
// owns the surface (appended, spread order is load-bearing).
|
||||
//
|
||||
|
||||
@@ -115,7 +115,7 @@ const changeVerdict = z
|
||||
.enum(["improved", "worsened", "within_threshold"])
|
||||
.nullable();
|
||||
|
||||
const assessmentTrend = z
|
||||
export const assessmentTrendSchema = z
|
||||
.object({
|
||||
instrument: z.enum(["PHQ9", "GAD7", "WHO5"]),
|
||||
n: z.number().int(),
|
||||
@@ -143,7 +143,7 @@ const listResponse = z
|
||||
.object({
|
||||
assessments: z.array(assessmentRow),
|
||||
trends: z
|
||||
.array(assessmentTrend)
|
||||
.array(assessmentTrendSchema)
|
||||
.optional()
|
||||
.describe(
|
||||
"v1.44 — one per PHQ-9 / GAD-7 / WHO-5 with at least one administration. Absent on older servers.",
|
||||
|
||||
@@ -72,12 +72,12 @@ const moduleListLayoutSchema = z
|
||||
"Per-user presentation of the labs, illness and checkups lists. Display-only; a client that does not render a list ignores it.",
|
||||
});
|
||||
|
||||
const previewFeaturesSchema = z
|
||||
export const previewFeaturesSchema = z
|
||||
.object({
|
||||
topics: z
|
||||
.boolean()
|
||||
.describe(
|
||||
"Topic overview pages above the single-metric Insights pages. Off by default.",
|
||||
"The Topics area (`/api/topics`): overview pages that bring related signals together above the single-metric Insights pages, which stay as they are. Off by default.",
|
||||
),
|
||||
})
|
||||
.meta({
|
||||
|
||||
@@ -0,0 +1,324 @@
|
||||
/**
|
||||
* OpenAPI route table for the topic pages (preview): `/api/topics` and
|
||||
* `/api/topics/{topic}`.
|
||||
*
|
||||
* Part of the OpenAPI route table; aggregated in `./index.ts`. The request
|
||||
* schemas are the routes' own (`@/lib/validations/topics`); the response
|
||||
* shapes mirror `src/lib/topics/build.ts` and reuse the published lab-trend,
|
||||
* training-load and screener-trend components rather than restating them.
|
||||
*/
|
||||
import type { ZodOpenApiObject } from "zod-openapi";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { TOPIC_KEYS, TOPIC_RANGES } from "@/lib/topics/registry";
|
||||
import {
|
||||
topicDetailQuerySchema,
|
||||
topicPathSchema,
|
||||
} from "@/lib/validations/topics";
|
||||
|
||||
import { biomarkerTrendSchema } from "./biomarkers";
|
||||
import { assessmentTrendSchema } from "./mental-health";
|
||||
import {
|
||||
dataEnvelope,
|
||||
errorEnvelope,
|
||||
measurementTypeEnum,
|
||||
recordRefusal,
|
||||
stdResponses,
|
||||
} from "./shared";
|
||||
import { trainingLoadSchema } from "./workouts";
|
||||
|
||||
const dayKey = z.string().describe("A local calendar day, `YYYY-MM-DD`.");
|
||||
|
||||
const topicKey = z.enum(TOPIC_KEYS).meta({
|
||||
id: "TopicKey",
|
||||
description: "A topic, as its URL slug.",
|
||||
});
|
||||
|
||||
const signalKey = z.union([measurementTypeEnum, z.literal("MOOD")]).meta({
|
||||
id: "TopicSignalKey",
|
||||
description:
|
||||
"A daily series a topic reads: a `MeasurementType`, or `MOOD` for the day's mean mood score (1 to 5). Values are in the type's stored unit (sleep in minutes asleep per night, keyed by the day the night ended); clients convert for display as they do for the metric pages.",
|
||||
});
|
||||
|
||||
const dayValue = z.object({ day: dayKey, value: z.number() });
|
||||
|
||||
const usualRange = z
|
||||
.object({
|
||||
low: z.number(),
|
||||
high: z.number(),
|
||||
center: z.number(),
|
||||
days: z.number().int().describe("Days the band was built from."),
|
||||
})
|
||||
.meta({
|
||||
id: "TopicUsualRange",
|
||||
description:
|
||||
"The personal usual range over the trailing 90 days: median ± 3 scaled MAD of the daily values, never narrower than the measurement's own wobble. Needs 7 days, else null.",
|
||||
});
|
||||
|
||||
const signal = z
|
||||
.object({
|
||||
key: signalKey,
|
||||
latest: dayValue.nullable(),
|
||||
usual: usualRange.nullable(),
|
||||
state: z
|
||||
.enum(["within", "above", "below", "learning", "none"])
|
||||
.describe(
|
||||
"`latest` against `usual`: `learning` while the range needs more days, `none` without a value.",
|
||||
),
|
||||
href: z
|
||||
.string()
|
||||
.nullable()
|
||||
.describe("The in-app single-metric page of this signal."),
|
||||
})
|
||||
.meta({ id: "TopicSignal" });
|
||||
|
||||
const summary = z
|
||||
.object({
|
||||
code: z.enum([
|
||||
"outside",
|
||||
"labs",
|
||||
"cycleDay",
|
||||
"steady",
|
||||
"learning",
|
||||
"empty",
|
||||
]),
|
||||
signal: signalKey.nullable(),
|
||||
direction: z.enum(["above", "below"]).nullable(),
|
||||
count: z.number().int().nullable(),
|
||||
total: z.number().int().nullable(),
|
||||
})
|
||||
.meta({
|
||||
id: "TopicSummary",
|
||||
description:
|
||||
"The topic's one sentence as a code with parameters, picked by a fixed rule (never model text): `outside` names the first signal outside its usual range and the direction; `labs` counts lab markers inside their window (`count` of `total`); `cycleDay` gives today's cycle day in `count`; `steady` counts signals inside their range (`count` of `total` recorded); `learning` while no range is established; `empty` without any data.",
|
||||
});
|
||||
|
||||
const headline = z
|
||||
.discriminatedUnion("kind", [
|
||||
z.object({
|
||||
kind: z.literal("signal"),
|
||||
key: signalKey,
|
||||
value: z.number(),
|
||||
day: dayKey,
|
||||
}),
|
||||
z.object({
|
||||
kind: z.literal("labs"),
|
||||
inRange: z.number().int(),
|
||||
total: z.number().int(),
|
||||
}),
|
||||
z.object({ kind: z.literal("cycleDay"), dayOfCycle: z.number().int() }),
|
||||
z.object({
|
||||
kind: z.literal("screener"),
|
||||
instrument: z.string(),
|
||||
score: z.number(),
|
||||
}),
|
||||
])
|
||||
.meta({ id: "TopicHeadline" });
|
||||
|
||||
const overviewEntry = z
|
||||
.object({
|
||||
key: topicKey,
|
||||
headline: headline.nullable(),
|
||||
summary,
|
||||
tone: z.enum(["steady", "watch", "learning"]),
|
||||
sparkline: z
|
||||
.array(z.number())
|
||||
.describe(
|
||||
"The headline signal's daily values over the last 30 days, oldest first; empty when the headline is a block.",
|
||||
),
|
||||
recorded: z.number().int(),
|
||||
total: z.number().int(),
|
||||
})
|
||||
.meta({ id: "TopicOverviewEntry" });
|
||||
|
||||
const overviewSchema = z
|
||||
.object({
|
||||
topics: z.array(overviewEntry),
|
||||
empty: z
|
||||
.array(topicKey)
|
||||
.describe("Offered topics with nothing recorded yet."),
|
||||
hiddenByModule: z
|
||||
.array(topicKey)
|
||||
.describe("Topics whose every module is switched off for the record."),
|
||||
})
|
||||
.meta({ id: "TopicOverview" });
|
||||
|
||||
const relationship = z
|
||||
.object({
|
||||
id: z.string(),
|
||||
x: signalKey,
|
||||
y: signalKey,
|
||||
lagDays: z
|
||||
.union([z.literal(0), z.literal(1)])
|
||||
.describe("`x` on day d is held against `y` on day d + `lagDays`."),
|
||||
kind: z.enum(["correlation", "split"]),
|
||||
pairedDays: z.number().int(),
|
||||
requiredDays: z
|
||||
.number()
|
||||
.int()
|
||||
.describe("Paired days needed before anything is said: 14."),
|
||||
status: z
|
||||
.enum(["insufficient", "no_clear_link", "linked"])
|
||||
.describe(
|
||||
"`linked` only at p < 0.05 (Pearson, or Welch for a split); a pattern, never a cause.",
|
||||
),
|
||||
r: z.number().nullable(),
|
||||
pValue: z.number().nullable(),
|
||||
split: z
|
||||
.object({
|
||||
threshold: z.number(),
|
||||
belowDays: z.number().int(),
|
||||
otherDays: z.number().int(),
|
||||
belowMean: z.number(),
|
||||
otherMean: z.number(),
|
||||
difference: z.number(),
|
||||
})
|
||||
.nullable()
|
||||
.describe(
|
||||
"For a split: the mean of `y` on days `x` sat below `threshold` against the other days.",
|
||||
),
|
||||
points: z
|
||||
.array(z.object({ x: z.number(), y: z.number() }))
|
||||
.describe(
|
||||
"Paired values for a scatter, at most 120; empty when insufficient.",
|
||||
),
|
||||
})
|
||||
.meta({ id: "TopicRelationship" });
|
||||
|
||||
const trend = z
|
||||
.object({
|
||||
key: signalKey,
|
||||
status: z.enum(["ok", "insufficient"]),
|
||||
days: z.number().int(),
|
||||
change: z
|
||||
.number()
|
||||
.nullable()
|
||||
.describe(
|
||||
"The least-squares change across the trailing 90 days, in the stored unit. Needs 14 days.",
|
||||
),
|
||||
weekly: z
|
||||
.array(dayValue)
|
||||
.describe("Weekly means, each keyed by its Monday."),
|
||||
})
|
||||
.meta({ id: "TopicTrend" });
|
||||
|
||||
const labMarker = z
|
||||
.object({
|
||||
biomarkerId: z.string(),
|
||||
name: z.string(),
|
||||
unit: z.string(),
|
||||
latest: z.number(),
|
||||
takenOn: dayKey,
|
||||
low: z.number().nullable(),
|
||||
high: z.number().nullable(),
|
||||
status: z.enum(["in-range", "below", "above", "unknown"]),
|
||||
readings: z.number().int(),
|
||||
trend: biomarkerTrendSchema,
|
||||
})
|
||||
.meta({ id: "TopicLabMarker" });
|
||||
|
||||
const labPanel = z
|
||||
.object({
|
||||
markers: z.array(labMarker).describe("At most 24, newest reading first."),
|
||||
inRange: z.number().int(),
|
||||
withRange: z.number().int(),
|
||||
latestOn: dayKey.nullable(),
|
||||
})
|
||||
.meta({ id: "TopicLabPanel" });
|
||||
|
||||
const cycleSummary = z
|
||||
.object({
|
||||
today: z
|
||||
.object({
|
||||
dayOfCycle: z.number().int(),
|
||||
phase: z.string().nullable(),
|
||||
})
|
||||
.nullable(),
|
||||
lengths: z.array(z.number().int()),
|
||||
averageLength: z.number().nullable(),
|
||||
periodsLogged: z.number().int(),
|
||||
})
|
||||
.meta({ id: "TopicCycleSummary" });
|
||||
|
||||
const detailSchema = z
|
||||
.object({
|
||||
key: topicKey,
|
||||
range: z.enum(TOPIC_RANGES),
|
||||
from: dayKey,
|
||||
to: dayKey,
|
||||
status: z.enum(["ready", "partial", "empty"]),
|
||||
summary,
|
||||
signals: z.array(signal),
|
||||
series: z.array(
|
||||
z.object({
|
||||
key: signalKey,
|
||||
usual: usualRange.nullable(),
|
||||
points: z.array(dayValue),
|
||||
}),
|
||||
),
|
||||
relationships: z.array(relationship),
|
||||
trends: z.array(trend),
|
||||
missing: z
|
||||
.array(signalKey)
|
||||
.describe("Signals the topic could show that hold no value yet."),
|
||||
labs: labPanel.nullable(),
|
||||
training: trainingLoadSchema.nullable(),
|
||||
screeners: z.array(assessmentTrendSchema).nullable(),
|
||||
cycle: cycleSummary.nullable(),
|
||||
})
|
||||
.meta({ id: "TopicDetail" });
|
||||
|
||||
const PREVIEW_OFF =
|
||||
"`topics.preview_off`: the person reading has not switched the topics preview on (Settings > System > Preview features). The preview is per account and off by default.";
|
||||
|
||||
export const topicPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
"/api/topics": {
|
||||
get: {
|
||||
tags: ["Insights"],
|
||||
summary: "Topic overview (preview)",
|
||||
description:
|
||||
"One entry per topic the record holds data for, with a headline value, a rule-based sentence and a 30-day sparkline, computed from the same daily values the metric pages draw. No model, no AI gate. Topics follow the record's module switches. Reads across sections, so a grant scoped to some of them is refused.",
|
||||
responses: {
|
||||
"200": {
|
||||
description: "The overview.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(overviewSchema, "TopicOverviewEnvelope"),
|
||||
},
|
||||
},
|
||||
},
|
||||
"404": {
|
||||
description: PREVIEW_OFF,
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
...stdResponses,
|
||||
...recordRefusal(),
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/topics/{topic}": {
|
||||
get: {
|
||||
tags: ["Insights"],
|
||||
summary: "One topic (preview)",
|
||||
description:
|
||||
"A topic's signals with their usual ranges, the side-by-side series for `range`, relationships between its signals (nothing is said below 14 paired days), 90-day trends, the signals it holds nothing for yet, and its blocks: training load, lab markers with their trend (three readings spanning a year), screener trends, today's cycle day. No model, no AI gate.",
|
||||
requestParams: { path: topicPathSchema, query: topicDetailQuerySchema },
|
||||
responses: {
|
||||
"200": {
|
||||
description: "The topic.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(detailSchema, "TopicDetailEnvelope"),
|
||||
},
|
||||
},
|
||||
},
|
||||
"404": {
|
||||
description: `${PREVIEW_OFF}\n\n\`topics.not_found\`: no topic by that name. \`topics.not_available\`: the record's module switches do not offer this topic.`,
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
...stdResponses,
|
||||
...recordRefusal(),
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -122,7 +122,7 @@ const trainingLoadPoint = z.object({
|
||||
),
|
||||
});
|
||||
|
||||
const trainingLoad = z
|
||||
export const trainingLoadSchema = z
|
||||
.union([
|
||||
z.object({
|
||||
status: z.literal("ok"),
|
||||
@@ -174,7 +174,7 @@ const workoutListResponse = z
|
||||
offset: z.number().int().nonnegative(),
|
||||
droppedDuplicates: z.number().int().nonnegative(),
|
||||
}),
|
||||
trainingLoad: trainingLoad
|
||||
trainingLoad: trainingLoadSchema
|
||||
.optional()
|
||||
.describe(
|
||||
"v1.44 — on the first page (`offset=0`) only, account-wide whatever the filter. Absent on later pages and older servers.",
|
||||
|
||||
@@ -49,6 +49,7 @@ import { vaccinationKeys } from "./vaccinations";
|
||||
import { workoutKeys } from "./workouts";
|
||||
import { activityKeys } from "./activity";
|
||||
import { provenanceKeys } from "./provenance";
|
||||
import { topicKeys } from "./topics";
|
||||
|
||||
export const queryKeys = {
|
||||
...authKeys,
|
||||
@@ -64,6 +65,7 @@ export const queryKeys = {
|
||||
...workoutKeys,
|
||||
...activityKeys,
|
||||
...provenanceKeys,
|
||||
...topicKeys,
|
||||
...settingsKeys,
|
||||
...cycleKeys,
|
||||
...measurementReminderKeys,
|
||||
@@ -165,6 +167,7 @@ export const measurementDependentKeys = [
|
||||
// write to this one evicts both roots.
|
||||
queryKeys.dayRoot(),
|
||||
queryKeys.timelineRoot(),
|
||||
queryKeys.topicsRoot(),
|
||||
];
|
||||
|
||||
/**
|
||||
@@ -199,6 +202,7 @@ export const moodDependentKeys = [
|
||||
// write to this one evicts both roots.
|
||||
queryKeys.dayRoot(),
|
||||
queryKeys.timelineRoot(),
|
||||
queryKeys.topicsRoot(),
|
||||
];
|
||||
|
||||
/**
|
||||
@@ -254,6 +258,7 @@ export const medicationDependentKeys = [
|
||||
// write to this one evicts both roots.
|
||||
queryKeys.dayRoot(),
|
||||
queryKeys.timelineRoot(),
|
||||
queryKeys.topicsRoot(),
|
||||
// v1.43 — a taken supplement dose adds to the day's micronutrients
|
||||
// (`source = SUPPLEMENT`), so a dose write evicts the nutrient reads.
|
||||
queryKeys.nutrientsRoot(),
|
||||
@@ -274,6 +279,7 @@ export const cycleDependentKeys = [
|
||||
// write to this one evicts both roots.
|
||||
queryKeys.dayRoot(),
|
||||
queryKeys.timelineRoot(),
|
||||
queryKeys.topicsRoot(),
|
||||
];
|
||||
|
||||
/**
|
||||
@@ -297,6 +303,7 @@ export const illnessDependentKeys = [
|
||||
// write to this one evicts both roots.
|
||||
queryKeys.dayRoot(),
|
||||
queryKeys.timelineRoot(),
|
||||
queryKeys.topicsRoot(),
|
||||
];
|
||||
|
||||
/**
|
||||
@@ -330,6 +337,7 @@ export const encounterDependentKeys = [
|
||||
// write to this one evicts both roots.
|
||||
queryKeys.dayRoot(),
|
||||
queryKeys.timelineRoot(),
|
||||
queryKeys.topicsRoot(),
|
||||
];
|
||||
|
||||
/**
|
||||
@@ -357,6 +365,7 @@ export const vaccinationDependentKeys = [
|
||||
// write to this one evicts both roots.
|
||||
queryKeys.dayRoot(),
|
||||
queryKeys.timelineRoot(),
|
||||
queryKeys.topicsRoot(),
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
@@ -44,6 +44,9 @@ export const settingsKeys = {
|
||||
*/
|
||||
moduleListLayout: () => ["settings", "module-list-layout"] as const,
|
||||
|
||||
/** The per-user preview-feature opt-ins behind `/api/settings/preview-features`. */
|
||||
previewFeatures: () => ["settings", "preview-features"] as const,
|
||||
|
||||
/**
|
||||
* v1.35.0 — which pillars count toward the Health Score, behind
|
||||
* `GET/PATCH /api/auth/me/health-score-config`. Its own cell rather than
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
/**
|
||||
* Query keys — the topic pages (preview).
|
||||
* Part of the centralized factory; aggregated in `./index.ts`.
|
||||
*/
|
||||
export const topicKeys = {
|
||||
/** Every topic read; a write to any record table the topics read evicts it. */
|
||||
topicsRoot: () => ["topics"] as const,
|
||||
topicsOverview: () => ["topics", "overview"] as const,
|
||||
topicDetail: (topic: string, range: string) =>
|
||||
["topics", "detail", topic, range] as const,
|
||||
};
|
||||
@@ -98,6 +98,7 @@ export const SETTINGS_DESTINATION_INVENTORY = {
|
||||
export: PERSONAL,
|
||||
advanced: PERSONAL,
|
||||
privacy: PERSONAL,
|
||||
preview: PERSONAL,
|
||||
about: PERSONAL,
|
||||
} as const satisfies Record<
|
||||
SettingsSectionSlug,
|
||||
|
||||
@@ -6,10 +6,11 @@
|
||||
* adds it beside what is there and never replaces an existing page.
|
||||
*
|
||||
* Flags:
|
||||
* - `topics`: topic overview pages above the single-metric Insights pages.
|
||||
* Stored, served by `/api/settings/preview-features` and carried by the
|
||||
* backup. The Settings > System switch is not listed until the topic pages
|
||||
* ship, because nothing reads the flag yet.
|
||||
* - `topics`: the Topics area (`/topics`, `/api/topics`), overview pages
|
||||
* that bring related signals together above the single-metric Insights
|
||||
* pages, which stay exactly as they are. Switched in Settings > System >
|
||||
* Preview features, published on `/api/auth/me` as `previewFeatures`, read
|
||||
* by the navigation and refused by the topic routes while off.
|
||||
*
|
||||
* Unknown keys in a stored blob are dropped on read, so a flag retired later
|
||||
* disappears cleanly and a malformed blob reads as everything off.
|
||||
|
||||
@@ -114,10 +114,10 @@ export const SETTINGS_SECTION_SLUGS = [
|
||||
// pieces into one coherent privacy pane. Sits next to `advanced` (the
|
||||
// destructive-action home) and before `about`.
|
||||
"privacy",
|
||||
// v1.44 — "Preview features" (per-user opt-ins, Settings > System) is not
|
||||
// listed until the first preview it switches, the topic pages, ships: a
|
||||
// switch that changes nothing is a promise the page cannot keep. The stored
|
||||
// flag, `/api/settings/preview-features` and its backup round-trip stay.
|
||||
// "Preview features": per-user opt-in to things finished enough to
|
||||
// try and not yet the default (the Topics area first). Sits in the System
|
||||
// group before About.
|
||||
"preview",
|
||||
"about",
|
||||
] as const;
|
||||
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { shiftDateKey } from "@/lib/tz/format";
|
||||
|
||||
import { buildDetail, buildOverview, summarise } from "../build";
|
||||
import type { TopicInputs } from "../read";
|
||||
import { TOPICS, type TopicSignalKey } from "../registry";
|
||||
import type { TopicVisibility } from "../visibility";
|
||||
|
||||
const TODAY = "2026-10-23";
|
||||
|
||||
function series(n: number, f: (i: number) => number) {
|
||||
const out = new Map<string, number>();
|
||||
for (let i = 0; i < n; i++) out.set(shiftDateKey(TODAY, -(n - 1 - i)), f(i));
|
||||
return out;
|
||||
}
|
||||
|
||||
function inputs(
|
||||
entries: Array<[TopicSignalKey, Map<string, number>]>,
|
||||
extra: Partial<TopicInputs> = {},
|
||||
): TopicInputs {
|
||||
return {
|
||||
series: new Map(entries),
|
||||
labs: null,
|
||||
training: null,
|
||||
screeners: null,
|
||||
cycle: null,
|
||||
...extra,
|
||||
};
|
||||
}
|
||||
|
||||
const everything: TopicVisibility = { modules: {}, sees: () => true };
|
||||
|
||||
describe("summarise", () => {
|
||||
it("names the first signal outside its usual range", () => {
|
||||
const data = inputs([
|
||||
["RESTING_HEART_RATE", series(30, (i) => (i === 29 ? 75 : 58 + (i % 2)))],
|
||||
]);
|
||||
const detail = buildDetail(TOPICS.heart, "30d", data, everything, TODAY);
|
||||
expect(detail.summary).toMatchObject({
|
||||
code: "outside",
|
||||
signal: "RESTING_HEART_RATE",
|
||||
direction: "above",
|
||||
});
|
||||
});
|
||||
|
||||
it("is still learning below a week, and empty without anything", () => {
|
||||
expect(
|
||||
summarise(
|
||||
[
|
||||
{
|
||||
key: "WEIGHT",
|
||||
latest: { day: TODAY, value: 80 },
|
||||
usual: null,
|
||||
state: "learning",
|
||||
href: null,
|
||||
},
|
||||
],
|
||||
inputs([]),
|
||||
).code,
|
||||
).toBe("learning");
|
||||
expect(summarise([], inputs([])).code).toBe("empty");
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildOverview", () => {
|
||||
it("gives a tile to a topic with data and names the empty and the hidden ones", () => {
|
||||
const data = inputs([["WEIGHT", series(20, () => 80)]]);
|
||||
const overview = buildOverview(
|
||||
["heart", "body"],
|
||||
["cycle"],
|
||||
data,
|
||||
everything,
|
||||
TODAY,
|
||||
);
|
||||
expect(overview.topics.map((t) => t.key)).toEqual(["body"]);
|
||||
expect(overview.empty).toEqual(["heart"]);
|
||||
expect(overview.hiddenByModule).toEqual(["cycle"]);
|
||||
const body = overview.topics[0];
|
||||
expect(body.headline).toMatchObject({ kind: "signal", key: "WEIGHT" });
|
||||
expect(body.sparkline).toHaveLength(20);
|
||||
expect(body.recorded).toBe(1);
|
||||
expect(body.total).toBe(TOPICS.body.signals.length);
|
||||
expect(body.tone).toBe("steady");
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildDetail", () => {
|
||||
it("lists what it could show but holds nothing for, and says it is partial", () => {
|
||||
const data = inputs([["RESTING_HEART_RATE", series(20, () => 58)]]);
|
||||
const detail = buildDetail(TOPICS.heart, "30d", data, everything, TODAY);
|
||||
expect(detail.status).toBe("partial");
|
||||
expect(detail.missing).toContain("HEART_RATE_VARIABILITY");
|
||||
expect(detail.series.map((s) => s.key)).toEqual(["RESTING_HEART_RATE"]);
|
||||
expect(detail.from).toBe(shiftDateKey(TODAY, -29));
|
||||
expect(detail.series[0].points).toHaveLength(20);
|
||||
});
|
||||
|
||||
it("never names a pair or a chart row whose module is off", () => {
|
||||
const sleepOff: TopicVisibility = {
|
||||
modules: { sleep: false },
|
||||
sees: () => true,
|
||||
};
|
||||
const data = inputs([
|
||||
["RESTING_HEART_RATE", series(30, () => 58)],
|
||||
["SLEEP_DURATION", series(30, () => 420)],
|
||||
]);
|
||||
const detail = buildDetail(TOPICS.heart, "30d", data, sleepOff, TODAY);
|
||||
expect(detail.series.map((s) => s.key)).not.toContain("SLEEP_DURATION");
|
||||
expect(
|
||||
detail.relationships.some(
|
||||
(r) => r.x === "SLEEP_DURATION" || r.y === "SLEEP_DURATION",
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("states a below-threshold pair with its count instead of a figure", () => {
|
||||
const data = inputs([
|
||||
["SLEEP_DURATION", series(10, () => 400)],
|
||||
["RESTING_HEART_RATE", series(10, () => 60)],
|
||||
]);
|
||||
const detail = buildDetail(TOPICS.heart, "30d", data, everything, TODAY);
|
||||
const pair = detail.relationships.find(
|
||||
(r) => r.id === "short-nights-resting-hr",
|
||||
);
|
||||
expect(pair).toMatchObject({ status: "insufficient", pairedDays: 10 });
|
||||
});
|
||||
|
||||
it("does not name a pair one side of which holds nothing", () => {
|
||||
const data = inputs([["ACTIVITY_STEPS", series(30, () => 8000)]]);
|
||||
const detail = buildDetail(
|
||||
TOPICS.metabolism,
|
||||
"30d",
|
||||
data,
|
||||
everything,
|
||||
TODAY,
|
||||
);
|
||||
expect(detail.relationships).toEqual([]);
|
||||
});
|
||||
|
||||
it("drops a block the grant does not open", () => {
|
||||
const noLabs: TopicVisibility = {
|
||||
modules: {},
|
||||
sees: (scope) => scope !== "labs",
|
||||
};
|
||||
const labs = {
|
||||
markers: [],
|
||||
inRange: 0,
|
||||
withRange: 0,
|
||||
latestOn: null,
|
||||
};
|
||||
const detail = buildDetail(
|
||||
TOPICS.metabolism,
|
||||
"30d",
|
||||
inputs([], { labs }),
|
||||
noLabs,
|
||||
TODAY,
|
||||
);
|
||||
expect(detail.labs).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,83 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { completedCycleLengths } from "../read";
|
||||
import { shapeLabPanel } from "../labs";
|
||||
|
||||
const NOW = new Date("2026-10-23T12:00:00Z");
|
||||
|
||||
function reading(biomarkerId: string, value: number, takenAt: string) {
|
||||
return {
|
||||
biomarkerId,
|
||||
value,
|
||||
takenAt: new Date(takenAt),
|
||||
sourceReferenceLow: null,
|
||||
sourceReferenceHigh: null,
|
||||
sourceReferenceText: null,
|
||||
sourceUnit: null,
|
||||
};
|
||||
}
|
||||
|
||||
describe("shapeLabPanel", () => {
|
||||
const markers = [
|
||||
{ id: "ldl", name: "LDL", unit: "mg/dL", lowerBound: 0, upperBound: 130 },
|
||||
{
|
||||
id: "vitd",
|
||||
name: "Vitamin D",
|
||||
unit: "ng/mL",
|
||||
lowerBound: 30,
|
||||
upperBound: 100,
|
||||
},
|
||||
{
|
||||
id: "none",
|
||||
name: "Unused",
|
||||
unit: "x",
|
||||
lowerBound: null,
|
||||
upperBound: null,
|
||||
},
|
||||
];
|
||||
|
||||
it("judges the newest reading and carries the marker's trend", () => {
|
||||
const panel = shapeLabPanel(
|
||||
markers,
|
||||
[
|
||||
reading("ldl", 98, "2026-10-01T08:00:00Z"),
|
||||
reading("ldl", 120, "2025-09-01T08:00:00Z"),
|
||||
reading("ldl", 140, "2024-08-01T08:00:00Z"),
|
||||
reading("vitd", 24, "2026-09-10T08:00:00Z"),
|
||||
],
|
||||
NOW,
|
||||
);
|
||||
expect(panel).not.toBeNull();
|
||||
expect(panel!.markers.map((m) => m.biomarkerId)).toEqual(["ldl", "vitd"]);
|
||||
const ldl = panel!.markers[0];
|
||||
expect(ldl).toMatchObject({ latest: 98, status: "in-range", readings: 3 });
|
||||
expect(ldl.trend.status).toBe("ok");
|
||||
const vitd = panel!.markers[1];
|
||||
expect(vitd.status).toBe("below");
|
||||
// One reading is not a trend; the panel says so rather than drawing one.
|
||||
expect(vitd.trend).toMatchObject({ status: "insufficient", n: 1 });
|
||||
expect(panel).toMatchObject({
|
||||
inRange: 1,
|
||||
withRange: 2,
|
||||
latestOn: "2026-10-01",
|
||||
});
|
||||
});
|
||||
|
||||
it("is absent without a single numeric reading", () => {
|
||||
expect(shapeLabPanel(markers, [], NOW)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("completedCycleLengths", () => {
|
||||
it("measures between logged starts and skips gaps in logging", () => {
|
||||
expect(
|
||||
completedCycleLengths([
|
||||
"2026-06-01",
|
||||
"2026-06-29",
|
||||
"2026-07-28",
|
||||
"2026-10-20",
|
||||
]),
|
||||
).toEqual([28, 29]);
|
||||
expect(completedCycleLengths(["2026-06-01"])).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,222 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { shiftDateKey } from "@/lib/tz/format";
|
||||
|
||||
import {
|
||||
RELATIONSHIP_MIN_PAIRED_DAYS,
|
||||
type TopicRelationshipDef,
|
||||
} from "../registry";
|
||||
import {
|
||||
computeRelationship,
|
||||
computeTrend,
|
||||
latestValue,
|
||||
pairDays,
|
||||
stateAgainstRange,
|
||||
usualRange,
|
||||
welchPValue,
|
||||
windowValues,
|
||||
} from "../stats";
|
||||
|
||||
const TODAY = "2026-10-23";
|
||||
|
||||
/** `n` consecutive days ending today, valued by `f(i)` (i = 0 oldest). */
|
||||
function series(n: number, f: (i: number) => number, end = TODAY) {
|
||||
const out = new Map<string, number>();
|
||||
for (let i = 0; i < n; i++) {
|
||||
out.set(shiftDateKey(end, -(n - 1 - i)), f(i));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
describe("windowValues / latestValue", () => {
|
||||
it("keeps the window's days in order and ignores a day after today", () => {
|
||||
const s = new Map([
|
||||
["2026-10-20", 3],
|
||||
["2026-10-18", 1],
|
||||
["2026-10-24", 9],
|
||||
]);
|
||||
expect(windowValues(s, "2026-10-18", TODAY).map((p) => p.day)).toEqual([
|
||||
"2026-10-18",
|
||||
"2026-10-20",
|
||||
]);
|
||||
expect(latestValue(s, TODAY)).toEqual({ day: "2026-10-20", value: 3 });
|
||||
expect(latestValue(undefined, TODAY)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("usualRange", () => {
|
||||
it("says nothing below a week of days", () => {
|
||||
expect(
|
||||
usualRange(
|
||||
"PULSE",
|
||||
series(6, () => 60),
|
||||
TODAY,
|
||||
),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("builds the personal band from a week or more", () => {
|
||||
const band = usualRange(
|
||||
"RESTING_HEART_RATE",
|
||||
series(30, (i) => 58 + (i % 3)),
|
||||
TODAY,
|
||||
);
|
||||
expect(band).not.toBeNull();
|
||||
expect(band!.days).toBe(30);
|
||||
expect(band!.low).toBeLessThanOrEqual(58);
|
||||
expect(band!.high).toBeGreaterThanOrEqual(60);
|
||||
});
|
||||
|
||||
it("reads only the trailing 90 days", () => {
|
||||
const old = series(30, () => 100, shiftDateKey(TODAY, -200));
|
||||
expect(usualRange("WEIGHT", old, TODAY)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("stateAgainstRange", () => {
|
||||
const band = { low: 55, high: 62, center: 58, days: 30 };
|
||||
it("places a value against the band, and says when it cannot", () => {
|
||||
expect(stateAgainstRange(null, band)).toBe("none");
|
||||
expect(stateAgainstRange({ day: TODAY, value: 60 }, null)).toBe("learning");
|
||||
expect(stateAgainstRange({ day: TODAY, value: 60 }, band)).toBe("within");
|
||||
expect(stateAgainstRange({ day: TODAY, value: 70 }, band)).toBe("above");
|
||||
expect(stateAgainstRange({ day: TODAY, value: 50 }, band)).toBe("below");
|
||||
});
|
||||
});
|
||||
|
||||
describe("computeTrend", () => {
|
||||
it("draws no slope through fewer than 14 days", () => {
|
||||
const trend = computeTrend(
|
||||
"WEIGHT",
|
||||
series(10, (i) => 80 - i),
|
||||
TODAY,
|
||||
);
|
||||
expect(trend.status).toBe("insufficient");
|
||||
expect(trend.change).toBeNull();
|
||||
expect(trend.days).toBe(10);
|
||||
});
|
||||
|
||||
it("reports the fitted change across the window", () => {
|
||||
// One kilogram down every ten days, for ninety days.
|
||||
const trend = computeTrend(
|
||||
"WEIGHT",
|
||||
series(90, (i) => 85 - i / 10),
|
||||
TODAY,
|
||||
);
|
||||
expect(trend.status).toBe("ok");
|
||||
expect(trend.change).toBeCloseTo(-8.9, 1);
|
||||
expect(trend.weekly.length).toBeGreaterThanOrEqual(13);
|
||||
expect(trend.weekly[0].value).toBeGreaterThan(trend.weekly.at(-1)!.value);
|
||||
});
|
||||
});
|
||||
|
||||
describe("pairDays", () => {
|
||||
it("holds x on day d against y on day d + lag, inside the window", () => {
|
||||
const x = series(5, (i) => i);
|
||||
const y = series(5, (i) => 10 + i);
|
||||
const sameDay = pairDays(x, y, 0, shiftDateKey(TODAY, -4), TODAY);
|
||||
expect(sameDay).toHaveLength(5);
|
||||
const nextDay = pairDays(x, y, 1, shiftDateKey(TODAY, -4), TODAY);
|
||||
expect(nextDay).toHaveLength(4);
|
||||
expect(nextDay[0]).toMatchObject({ x: 0, y: 11 });
|
||||
});
|
||||
});
|
||||
|
||||
describe("computeRelationship", () => {
|
||||
const corr: TopicRelationshipDef = {
|
||||
id: "a-b",
|
||||
x: "HEART_RATE_VARIABILITY",
|
||||
y: "RESTING_HEART_RATE",
|
||||
lagDays: 0,
|
||||
kind: "correlation",
|
||||
};
|
||||
const from = shiftDateKey(TODAY, -89);
|
||||
|
||||
it("says nothing but the count below 14 paired days", () => {
|
||||
const n = RELATIONSHIP_MIN_PAIRED_DAYS - 1;
|
||||
const rel = computeRelationship(
|
||||
corr,
|
||||
series(n, (i) => 50 + i),
|
||||
series(n, (i) => 70 - i),
|
||||
from,
|
||||
TODAY,
|
||||
);
|
||||
expect(rel.status).toBe("insufficient");
|
||||
expect(rel.pairedDays).toBe(n);
|
||||
expect(rel.requiredDays).toBe(14);
|
||||
expect(rel.r).toBeNull();
|
||||
expect(rel.points).toEqual([]);
|
||||
});
|
||||
|
||||
it("reports a clear negative link with its r and its days", () => {
|
||||
const rel = computeRelationship(
|
||||
corr,
|
||||
series(30, (i) => 50 + i),
|
||||
series(30, (i) => 70 - i + (i % 2) * 0.5),
|
||||
from,
|
||||
TODAY,
|
||||
);
|
||||
expect(rel.status).toBe("linked");
|
||||
expect(rel.r).toBeLessThan(-0.9);
|
||||
expect(rel.pairedDays).toBe(30);
|
||||
expect(rel.points).toHaveLength(30);
|
||||
});
|
||||
|
||||
it("calls an unrelated pair what it is", () => {
|
||||
const noise = [3, 9, 1, 7, 5, 2, 8, 4, 6, 0, 9, 2, 7, 1, 5, 8, 3, 6, 0, 4];
|
||||
const rel = computeRelationship(
|
||||
corr,
|
||||
series(20, (i) => i),
|
||||
series(20, (i) => noise[i]),
|
||||
from,
|
||||
TODAY,
|
||||
);
|
||||
expect(rel.status).toBe("no_clear_link");
|
||||
expect(rel.pairedDays).toBe(20);
|
||||
});
|
||||
|
||||
it("compares the days below a threshold with the others", () => {
|
||||
const split: TopicRelationshipDef = {
|
||||
id: "short-nights",
|
||||
x: "SLEEP_DURATION",
|
||||
y: "RESTING_HEART_RATE",
|
||||
lagDays: 0,
|
||||
kind: "split",
|
||||
threshold: 420,
|
||||
};
|
||||
const sleep = series(28, (i) => (i % 4 === 0 ? 360 : 480));
|
||||
const rhr = series(28, (i) => (i % 4 === 0 ? 63 : 58) + (i % 3) * 0.2);
|
||||
const rel = computeRelationship(split, sleep, rhr, from, TODAY);
|
||||
expect(rel.status).toBe("linked");
|
||||
expect(rel.split).toMatchObject({ belowDays: 7, otherDays: 21 });
|
||||
expect(rel.split!.difference).toBeGreaterThan(4);
|
||||
});
|
||||
|
||||
it("will not compare a split whose one side is too thin", () => {
|
||||
const split: TopicRelationshipDef = {
|
||||
id: "short-nights",
|
||||
x: "SLEEP_DURATION",
|
||||
y: "MOOD",
|
||||
lagDays: 0,
|
||||
kind: "split",
|
||||
threshold: 420,
|
||||
};
|
||||
const sleep = series(20, (i) => (i < 2 ? 360 : 480));
|
||||
const rel = computeRelationship(
|
||||
split,
|
||||
sleep,
|
||||
series(20, () => 3),
|
||||
from,
|
||||
TODAY,
|
||||
);
|
||||
expect(rel.status).toBe("insufficient");
|
||||
expect(rel.pairedDays).toBe(20);
|
||||
});
|
||||
});
|
||||
|
||||
describe("welchPValue", () => {
|
||||
it("is 1 for identical groups and small for well separated ones", () => {
|
||||
expect(welchPValue([1, 1, 1], [1, 1, 1])).toBe(1);
|
||||
expect(welchPValue([1, 2, 1, 2, 1], [9, 8, 9, 8, 9])).toBeLessThan(0.001);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,52 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { TOPICS } from "../registry";
|
||||
import {
|
||||
isSignalVisible,
|
||||
offeredTopics,
|
||||
signalsToRead,
|
||||
visibleExtras,
|
||||
} from "../visibility";
|
||||
|
||||
const sees = () => true;
|
||||
|
||||
describe("topic visibility", () => {
|
||||
it("offers every topic when nothing is switched off", () => {
|
||||
expect(offeredTopics({ modules: {}, sees })).toEqual([
|
||||
"heart",
|
||||
"body",
|
||||
"blood-pressure",
|
||||
"sleep",
|
||||
"activity",
|
||||
"metabolism",
|
||||
"mind",
|
||||
"cycle",
|
||||
]);
|
||||
});
|
||||
|
||||
it("follows the modules: cycle only with the cycle module, mind with either of its two", () => {
|
||||
const off = offeredTopics({
|
||||
modules: { cycle: false, mood: false, mentalHealth: false, sleep: false },
|
||||
sees,
|
||||
});
|
||||
expect(off).not.toContain("cycle");
|
||||
expect(off).not.toContain("mind");
|
||||
expect(off).not.toContain("sleep");
|
||||
expect(off).toContain("heart");
|
||||
expect(offeredTopics({ modules: { mood: false }, sees })).toContain("mind");
|
||||
});
|
||||
|
||||
it("never reads a signal or a block whose module is off or whose section is closed", () => {
|
||||
const ctx = {
|
||||
modules: { sleep: false, workouts: false },
|
||||
sees: (scope: string) => scope !== "mind",
|
||||
};
|
||||
expect(isSignalVisible("SLEEP_DURATION", ctx)).toBe(false);
|
||||
expect(isSignalVisible("MOOD", ctx)).toBe(false);
|
||||
expect(isSignalVisible("PULSE", ctx)).toBe(true);
|
||||
expect([...signalsToRead(TOPICS.heart, ctx)]).not.toContain(
|
||||
"SLEEP_DURATION",
|
||||
);
|
||||
expect([...visibleExtras(TOPICS.heart, ctx)]).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,386 @@
|
||||
/**
|
||||
* Assemble the topic overview and a topic page from what `read.ts` returned.
|
||||
* Pure: every input arrives as an argument, so a fixture covers every state.
|
||||
*
|
||||
* The summary sentence is a rule, never model text: the first signal outside
|
||||
* its usual range names itself; otherwise the lab panel or the cycle day
|
||||
* speaks for its topic; otherwise the count of signals inside their range;
|
||||
* otherwise "still learning". The client words the code; the server only
|
||||
* decides which one applies.
|
||||
*/
|
||||
import { insightsHrefForMetric } from "@/lib/insights/metric-deep-link";
|
||||
import type { AssessmentTrend } from "@/lib/mental-health/assessment-trend";
|
||||
import type { TrainingLoad } from "@/lib/insights/training-load";
|
||||
import { shiftDateKey } from "@/lib/tz/format";
|
||||
|
||||
import type { TopicLabPanel } from "./labs";
|
||||
import type { TopicCycleSummary, TopicInputs } from "./read";
|
||||
import {
|
||||
MOOD_SIGNAL,
|
||||
SPARKLINE_POINTS,
|
||||
TOPIC_BASE_WINDOW_DAYS,
|
||||
TOPIC_RANGE_DAYS,
|
||||
TOPICS,
|
||||
type TopicDefinition,
|
||||
type TopicKey,
|
||||
type TopicRange,
|
||||
type TopicSignalKey,
|
||||
} from "./registry";
|
||||
import {
|
||||
computeRelationship,
|
||||
computeTrend,
|
||||
latestValue,
|
||||
stateAgainstRange,
|
||||
usualRange,
|
||||
windowValues,
|
||||
type DayValue,
|
||||
type SignalState,
|
||||
type TopicRelationship,
|
||||
type TopicTrend,
|
||||
type UsualRange,
|
||||
} from "./stats";
|
||||
import {
|
||||
isExtraVisible,
|
||||
isSignalVisible,
|
||||
visibleSignals,
|
||||
type TopicVisibility,
|
||||
} from "./visibility";
|
||||
|
||||
export interface TopicSignal {
|
||||
key: TopicSignalKey;
|
||||
latest: DayValue | null;
|
||||
usual: UsualRange | null;
|
||||
state: SignalState;
|
||||
/** The single-metric page this signal opens, when it has one. */
|
||||
href: string | null;
|
||||
}
|
||||
|
||||
export type TopicSummaryCode =
|
||||
"outside" | "labs" | "cycleDay" | "steady" | "learning" | "empty";
|
||||
|
||||
export interface TopicSummary {
|
||||
code: TopicSummaryCode;
|
||||
signal: TopicSignalKey | null;
|
||||
direction: "above" | "below" | null;
|
||||
count: number | null;
|
||||
total: number | null;
|
||||
}
|
||||
|
||||
export type TopicHeadline =
|
||||
| { kind: "signal"; key: TopicSignalKey; value: number; day: string }
|
||||
| { kind: "labs"; inRange: number; total: number }
|
||||
| { kind: "cycleDay"; dayOfCycle: number }
|
||||
| { kind: "screener"; instrument: string; score: number };
|
||||
|
||||
export type TopicTone = "steady" | "watch" | "learning";
|
||||
|
||||
export interface TopicOverviewEntry {
|
||||
key: TopicKey;
|
||||
headline: TopicHeadline | null;
|
||||
summary: TopicSummary;
|
||||
tone: TopicTone;
|
||||
/** The headline signal's last values, oldest first (empty for a block headline). */
|
||||
sparkline: number[];
|
||||
/** Signals of the topic's list that hold a value. */
|
||||
recorded: number;
|
||||
/** Signals of the topic's list the record could hold. */
|
||||
total: number;
|
||||
}
|
||||
|
||||
export interface TopicOverview {
|
||||
topics: TopicOverviewEntry[];
|
||||
/** Offered topics with nothing recorded yet. */
|
||||
empty: TopicKey[];
|
||||
/** Topics whose every module is switched off. */
|
||||
hiddenByModule: TopicKey[];
|
||||
}
|
||||
|
||||
export interface TopicSeries {
|
||||
key: TopicSignalKey;
|
||||
usual: UsualRange | null;
|
||||
points: DayValue[];
|
||||
}
|
||||
|
||||
export interface TopicDetail {
|
||||
key: TopicKey;
|
||||
range: TopicRange;
|
||||
from: string;
|
||||
to: string;
|
||||
status: "ready" | "partial" | "empty";
|
||||
summary: TopicSummary;
|
||||
signals: TopicSignal[];
|
||||
series: TopicSeries[];
|
||||
relationships: TopicRelationship[];
|
||||
trends: TopicTrend[];
|
||||
/** Signals the topic could show that hold no value yet. */
|
||||
missing: TopicSignalKey[];
|
||||
labs: TopicLabPanel | null;
|
||||
training: TrainingLoad | null;
|
||||
screeners: AssessmentTrend[] | null;
|
||||
cycle: TopicCycleSummary | null;
|
||||
}
|
||||
|
||||
function signalHref(key: TopicSignalKey): string | null {
|
||||
if (key === MOOD_SIGNAL) return "/insights/mood";
|
||||
return insightsHrefForMetric(key);
|
||||
}
|
||||
|
||||
export function buildSignals(
|
||||
keys: readonly TopicSignalKey[],
|
||||
inputs: TopicInputs,
|
||||
todayKey: string,
|
||||
): TopicSignal[] {
|
||||
return keys.map((key) => {
|
||||
const series = inputs.series.get(key);
|
||||
const latest = latestValue(series, todayKey);
|
||||
const usual = usualRange(key, series, todayKey);
|
||||
return {
|
||||
key,
|
||||
latest,
|
||||
usual,
|
||||
state: stateAgainstRange(latest, usual),
|
||||
href: signalHref(key),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function hasBlockData(inputs: TopicInputs): boolean {
|
||||
return (
|
||||
inputs.labs !== null ||
|
||||
(inputs.screeners !== null && inputs.screeners.length > 0) ||
|
||||
inputs.cycle !== null ||
|
||||
(inputs.training !== null && inputs.training.status === "ok")
|
||||
);
|
||||
}
|
||||
|
||||
/** The rule that picks the topic's one sentence. */
|
||||
export function summarise(
|
||||
signals: readonly TopicSignal[],
|
||||
inputs: TopicInputs,
|
||||
): TopicSummary {
|
||||
const none: TopicSummary = {
|
||||
code: "empty",
|
||||
signal: null,
|
||||
direction: null,
|
||||
count: null,
|
||||
total: null,
|
||||
};
|
||||
const recorded = signals.filter((s) => s.latest !== null);
|
||||
if (recorded.length === 0 && !hasBlockData(inputs)) return none;
|
||||
|
||||
const outside = recorded.find(
|
||||
(s) => s.state === "above" || s.state === "below",
|
||||
);
|
||||
if (outside) {
|
||||
return {
|
||||
...none,
|
||||
code: "outside",
|
||||
signal: outside.key,
|
||||
direction: outside.state === "above" ? "above" : "below",
|
||||
};
|
||||
}
|
||||
if (inputs.labs && inputs.labs.withRange > 0) {
|
||||
return {
|
||||
...none,
|
||||
code: "labs",
|
||||
count: inputs.labs.inRange,
|
||||
total: inputs.labs.withRange,
|
||||
};
|
||||
}
|
||||
if (inputs.cycle?.today) {
|
||||
return { ...none, code: "cycleDay", count: inputs.cycle.today.dayOfCycle };
|
||||
}
|
||||
const within = recorded.filter((s) => s.state === "within");
|
||||
if (within.length > 0) {
|
||||
return {
|
||||
...none,
|
||||
code: "steady",
|
||||
count: within.length,
|
||||
total: recorded.length,
|
||||
};
|
||||
}
|
||||
return { ...none, code: "learning" };
|
||||
}
|
||||
|
||||
function toneOf(summary: TopicSummary, labs: TopicLabPanel | null): TopicTone {
|
||||
if (summary.code === "outside") return "watch";
|
||||
if (summary.code === "labs") {
|
||||
return labs && labs.inRange < labs.withRange ? "watch" : "steady";
|
||||
}
|
||||
if (summary.code === "steady" || summary.code === "cycleDay") return "steady";
|
||||
return "learning";
|
||||
}
|
||||
|
||||
function headlineOf(
|
||||
signals: readonly TopicSignal[],
|
||||
inputs: TopicInputs,
|
||||
): TopicHeadline | null {
|
||||
const first = signals.find((s) => s.latest !== null);
|
||||
if (first?.latest) {
|
||||
return {
|
||||
kind: "signal",
|
||||
key: first.key,
|
||||
value: first.latest.value,
|
||||
day: first.latest.day,
|
||||
};
|
||||
}
|
||||
if (inputs.labs) {
|
||||
return {
|
||||
kind: "labs",
|
||||
inRange: inputs.labs.inRange,
|
||||
total: inputs.labs.withRange,
|
||||
};
|
||||
}
|
||||
if (inputs.cycle?.today) {
|
||||
return { kind: "cycleDay", dayOfCycle: inputs.cycle.today.dayOfCycle };
|
||||
}
|
||||
const screener = inputs.screeners?.[0];
|
||||
if (screener) {
|
||||
return {
|
||||
kind: "screener",
|
||||
instrument: screener.instrument,
|
||||
score: screener.latest.score,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** The topic's slice of the shared read, with blocks it may not show removed. */
|
||||
export function inputsForTopic(
|
||||
def: TopicDefinition,
|
||||
inputs: TopicInputs,
|
||||
ctx: TopicVisibility,
|
||||
): TopicInputs {
|
||||
const allowed = (extra: "labs" | "training" | "screeners" | "cycle") =>
|
||||
def.extras.includes(extra) && isExtraVisible(extra, ctx);
|
||||
return {
|
||||
series: inputs.series,
|
||||
labs: allowed("labs") ? inputs.labs : null,
|
||||
training: allowed("training") ? inputs.training : null,
|
||||
screeners: allowed("screeners") ? inputs.screeners : null,
|
||||
cycle: allowed("cycle") ? inputs.cycle : null,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildOverviewEntry(
|
||||
def: TopicDefinition,
|
||||
inputs: TopicInputs,
|
||||
ctx: TopicVisibility,
|
||||
todayKey: string,
|
||||
): TopicOverviewEntry | null {
|
||||
const own = inputsForTopic(def, inputs, ctx);
|
||||
const signals = buildSignals(visibleSignals(def, ctx), own, todayKey);
|
||||
const summary = summarise(signals, own);
|
||||
if (summary.code === "empty") return null;
|
||||
const headline = headlineOf(signals, own);
|
||||
const sparkline =
|
||||
headline?.kind === "signal"
|
||||
? windowValues(
|
||||
own.series.get(headline.key),
|
||||
shiftDateKey(todayKey, -(SPARKLINE_POINTS - 1)),
|
||||
todayKey,
|
||||
).map((p) => p.value)
|
||||
: [];
|
||||
return {
|
||||
key: def.key,
|
||||
headline,
|
||||
summary,
|
||||
tone: toneOf(summary, own.labs),
|
||||
sparkline,
|
||||
recorded: signals.filter((s) => s.latest !== null).length,
|
||||
total: signals.length,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildOverview(
|
||||
offered: readonly TopicKey[],
|
||||
hiddenByModule: readonly TopicKey[],
|
||||
inputs: TopicInputs,
|
||||
ctx: TopicVisibility,
|
||||
todayKey: string,
|
||||
): TopicOverview {
|
||||
const topics: TopicOverviewEntry[] = [];
|
||||
const empty: TopicKey[] = [];
|
||||
for (const key of offered) {
|
||||
const entry = buildOverviewEntry(TOPICS[key], inputs, ctx, todayKey);
|
||||
if (entry) topics.push(entry);
|
||||
else empty.push(key);
|
||||
}
|
||||
return { topics, empty, hiddenByModule: [...hiddenByModule] };
|
||||
}
|
||||
|
||||
/** First day of the read a topic page needs for its range. */
|
||||
export function detailReadStart(range: TopicRange, todayKey: string): string {
|
||||
const days = Math.max(TOPIC_RANGE_DAYS[range], TOPIC_BASE_WINDOW_DAYS);
|
||||
return shiftDateKey(todayKey, -days);
|
||||
}
|
||||
|
||||
export function buildDetail(
|
||||
def: TopicDefinition,
|
||||
range: TopicRange,
|
||||
inputs: TopicInputs,
|
||||
ctx: TopicVisibility,
|
||||
todayKey: string,
|
||||
): TopicDetail {
|
||||
const own = inputsForTopic(def, inputs, ctx);
|
||||
const signalKeys = visibleSignals(def, ctx);
|
||||
const signals = buildSignals(signalKeys, own, todayKey);
|
||||
const summary = summarise(signals, own);
|
||||
const from = shiftDateKey(todayKey, -(TOPIC_RANGE_DAYS[range] - 1));
|
||||
const pairFrom = detailReadStart(range, todayKey);
|
||||
|
||||
const series: TopicSeries[] = [];
|
||||
for (const key of def.chart) {
|
||||
if (!isSignalVisible(key, ctx)) continue;
|
||||
const values = own.series.get(key);
|
||||
const points = windowValues(values, from, todayKey);
|
||||
if (points.length === 0) continue;
|
||||
series.push({ key, usual: usualRange(key, values, todayKey), points });
|
||||
}
|
||||
|
||||
// A pair shows when both sides may be shown and both hold values: that is
|
||||
// when "needs 14 paired days, you have n" tells the reader something. A
|
||||
// pair with a switched-off or never-recorded side is not named.
|
||||
const relationships = def.relationships
|
||||
.filter((rel) => isSignalVisible(rel.x, ctx) && isSignalVisible(rel.y, ctx))
|
||||
.filter((rel) => own.series.has(rel.x) && own.series.has(rel.y))
|
||||
.map((rel) =>
|
||||
computeRelationship(
|
||||
rel,
|
||||
own.series.get(rel.x),
|
||||
own.series.get(rel.y),
|
||||
pairFrom,
|
||||
todayKey,
|
||||
),
|
||||
);
|
||||
|
||||
const trends = signals
|
||||
.filter((s) => s.latest !== null)
|
||||
.map((s) => computeTrend(s.key, own.series.get(s.key), todayKey));
|
||||
|
||||
const missing = signals.filter((s) => s.latest === null).map((s) => s.key);
|
||||
const status =
|
||||
summary.code === "empty"
|
||||
? "empty"
|
||||
: missing.length > 0
|
||||
? "partial"
|
||||
: "ready";
|
||||
|
||||
return {
|
||||
key: def.key,
|
||||
range,
|
||||
from,
|
||||
to: todayKey,
|
||||
status,
|
||||
summary,
|
||||
signals,
|
||||
series,
|
||||
relationships,
|
||||
trends,
|
||||
missing,
|
||||
labs: own.labs,
|
||||
training: own.training,
|
||||
screeners: own.screeners,
|
||||
cycle: own.cycle,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
/**
|
||||
* The lab block of the metabolism topic: each marker's latest reading against
|
||||
* the reference window in force for it, and the marker's trend line.
|
||||
*
|
||||
* The window is the one the labs list judges the reading by
|
||||
* (`resolveEffectiveReferenceRange`: the range the report printed, else the
|
||||
* marker's catalog band); the trend is `computeLabTrend` over every numeric
|
||||
* reading of the marker against the marker's band, exactly as the marker page
|
||||
* computes it. A trend needs three readings spanning a year; below that it is
|
||||
* `insufficient` and the topic says what is missing.
|
||||
*/
|
||||
import { prisma } from "@/lib/db";
|
||||
import { computeLabTrend, type LabTrend } from "@/lib/labs/lab-trend";
|
||||
import {
|
||||
classifyReferenceRange,
|
||||
resolveEffectiveReferenceRange,
|
||||
type ReferenceRangeStatus,
|
||||
} from "@/lib/labs/reference-range";
|
||||
import { dateOnlyKey } from "@/lib/tz/date-only";
|
||||
|
||||
/** Markers the panel shows, the most recently measured first. */
|
||||
export const LAB_PANEL_MAX_MARKERS = 24;
|
||||
|
||||
/** Readings the panel reads; far above any real history, the cap falls on the oldest. */
|
||||
const LAB_READ_CAP = 4000;
|
||||
|
||||
export interface TopicLabMarker {
|
||||
biomarkerId: string;
|
||||
name: string;
|
||||
unit: string;
|
||||
latest: number;
|
||||
/** Calendar day of the newest reading. */
|
||||
takenOn: string;
|
||||
low: number | null;
|
||||
high: number | null;
|
||||
status: ReferenceRangeStatus;
|
||||
readings: number;
|
||||
trend: LabTrend;
|
||||
}
|
||||
|
||||
export interface TopicLabPanel {
|
||||
markers: TopicLabMarker[];
|
||||
/** Markers whose latest reading sits inside its window. */
|
||||
inRange: number;
|
||||
/** Markers with a window to sit inside. */
|
||||
withRange: number;
|
||||
/** Calendar day of the newest reading across the panel. */
|
||||
latestOn: string | null;
|
||||
}
|
||||
|
||||
interface MarkerRow {
|
||||
id: string;
|
||||
name: string;
|
||||
unit: string;
|
||||
lowerBound: number | null;
|
||||
upperBound: number | null;
|
||||
}
|
||||
|
||||
interface ReadingRow {
|
||||
biomarkerId: string | null;
|
||||
value: number | null;
|
||||
takenAt: Date;
|
||||
sourceReferenceLow: number | null;
|
||||
sourceReferenceHigh: number | null;
|
||||
sourceReferenceText: string | null;
|
||||
sourceUnit: string | null;
|
||||
}
|
||||
|
||||
/** Shape the panel from the rows. Pure; `readings` arrive newest first. */
|
||||
export function shapeLabPanel(
|
||||
markers: readonly MarkerRow[],
|
||||
readings: readonly ReadingRow[],
|
||||
now: Date,
|
||||
): TopicLabPanel | null {
|
||||
const byMarker = new Map<string, ReadingRow[]>();
|
||||
for (const row of readings) {
|
||||
if (row.biomarkerId === null || row.value === null) continue;
|
||||
const list = byMarker.get(row.biomarkerId) ?? [];
|
||||
list.push(row);
|
||||
byMarker.set(row.biomarkerId, list);
|
||||
}
|
||||
const shaped: TopicLabMarker[] = [];
|
||||
for (const marker of markers) {
|
||||
const rows = byMarker.get(marker.id);
|
||||
if (!rows || rows.length === 0) continue;
|
||||
const newest = rows.reduce((a, b) => (b.takenAt > a.takenAt ? b : a));
|
||||
const window = resolveEffectiveReferenceRange(
|
||||
marker.lowerBound,
|
||||
marker.upperBound,
|
||||
newest,
|
||||
);
|
||||
const latest = newest.value as number;
|
||||
shaped.push({
|
||||
biomarkerId: marker.id,
|
||||
name: marker.name,
|
||||
unit: marker.unit,
|
||||
latest,
|
||||
takenOn: dateOnlyKey(newest.takenAt),
|
||||
low: window.low,
|
||||
high: window.high,
|
||||
status: classifyReferenceRange(latest, window.low, window.high),
|
||||
readings: rows.length,
|
||||
trend: computeLabTrend(
|
||||
rows.map((r) => ({ takenAt: r.takenAt, value: r.value as number })),
|
||||
{ low: marker.lowerBound, high: marker.upperBound },
|
||||
now,
|
||||
),
|
||||
});
|
||||
}
|
||||
if (shaped.length === 0) return null;
|
||||
shaped.sort((a, b) =>
|
||||
a.takenOn === b.takenOn
|
||||
? a.name.localeCompare(b.name)
|
||||
: a.takenOn < b.takenOn
|
||||
? 1
|
||||
: -1,
|
||||
);
|
||||
const panel = shaped.slice(0, LAB_PANEL_MAX_MARKERS);
|
||||
const withWindow = panel.filter((m) => m.status !== "unknown");
|
||||
return {
|
||||
markers: panel,
|
||||
inRange: withWindow.filter((m) => m.status === "in-range").length,
|
||||
withRange: withWindow.length,
|
||||
latestOn: panel[0]?.takenOn ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
export async function readLabPanel(
|
||||
userId: string,
|
||||
now: Date,
|
||||
): Promise<TopicLabPanel | null> {
|
||||
const [markers, readings] = await Promise.all([
|
||||
prisma.biomarker.findMany({
|
||||
where: { userId, hidden: false },
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
unit: true,
|
||||
lowerBound: true,
|
||||
upperBound: true,
|
||||
},
|
||||
}),
|
||||
prisma.labResult.findMany({
|
||||
where: {
|
||||
userId,
|
||||
deletedAt: null,
|
||||
value: { not: null },
|
||||
biomarkerId: { not: null },
|
||||
},
|
||||
orderBy: { takenAt: "desc" },
|
||||
take: LAB_READ_CAP,
|
||||
select: {
|
||||
biomarkerId: true,
|
||||
value: true,
|
||||
takenAt: true,
|
||||
sourceReferenceLow: true,
|
||||
sourceReferenceHigh: true,
|
||||
sourceReferenceText: true,
|
||||
sourceUnit: true,
|
||||
},
|
||||
}),
|
||||
]);
|
||||
return shapeLabPanel(markers, readings, now);
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
/**
|
||||
* The reads behind the topic pages. Every one is a read the single-metric
|
||||
* pages already make, so a topic never shows a value its metric page would
|
||||
* not:
|
||||
*
|
||||
* - measurement days: `readLocalDailyStats`, the canonical-source local-day
|
||||
* value the charts draw (a cumulative type's day total, a pulse-like
|
||||
* type's mean of hourly means), in one query for every type a page needs;
|
||||
* - sleep: the night's time asleep as the sleep page reconstructs it
|
||||
* (`readNightCells`), keyed by the day the night ended;
|
||||
* - mood: the day's mean of its entries' 1–5 score;
|
||||
* - the extra blocks: the training load the workouts page shows, the lab
|
||||
* markers with the trend the marker page shows, the screener trends of the
|
||||
* check-in page, and today's place in the cycle as the cycle page decides
|
||||
* it.
|
||||
*
|
||||
* The caller decides what may be read (module switches, the grant's
|
||||
* sections); a signal or block it leaves out is never queried.
|
||||
*/
|
||||
import type { MeasurementType } from "@/generated/prisma/enums";
|
||||
import { readTodayCycle } from "@/lib/cycle/today-verdict";
|
||||
import { readLocalDailyStats } from "@/lib/day/daily-stats";
|
||||
import { prisma } from "@/lib/db";
|
||||
import {
|
||||
readTrainingLoad,
|
||||
type TrainingLoad,
|
||||
} from "@/lib/insights/training-load";
|
||||
import {
|
||||
computeAssessmentTrends,
|
||||
TREND_INSTRUMENTS,
|
||||
type AssessmentTrend,
|
||||
} from "@/lib/mental-health/assessment-trend";
|
||||
import { loadUserSourcePriority } from "@/lib/rollups/measurement-read";
|
||||
import { readNightCells } from "@/lib/timeline/series";
|
||||
import { daysBetweenDateKeys, shiftDateKey } from "@/lib/tz/format";
|
||||
import { startOfLocalDayKey } from "@/lib/tz/local-day";
|
||||
|
||||
import { readLabPanel, type TopicLabPanel } from "./labs";
|
||||
import { MOOD_SIGNAL, type TopicExtra, type TopicSignalKey } from "./registry";
|
||||
import type { DaySeries } from "./stats";
|
||||
|
||||
/** Screener administrations the trends read; the cap falls on the oldest. */
|
||||
const SCREENER_READ_CAP = 1500;
|
||||
|
||||
/** Cycle lengths the cycle block reports, newest kept. */
|
||||
const CYCLE_LENGTHS_SHOWN = 6;
|
||||
|
||||
/** A plausible cycle length; anything outside is a gap in logging. */
|
||||
const CYCLE_LENGTH_MIN_DAYS = 15;
|
||||
const CYCLE_LENGTH_MAX_DAYS = 60;
|
||||
|
||||
export interface TopicCycleSummary {
|
||||
/** Today's cycle day and phase, or null while the cycle is still learned. */
|
||||
today: { dayOfCycle: number; phase: string | null } | null;
|
||||
/** Lengths of the completed cycles, oldest first, at most six. */
|
||||
lengths: number[];
|
||||
/** Mean of `lengths`, or null with none. */
|
||||
averageLength: number | null;
|
||||
/** Logged period starts on record. */
|
||||
periodsLogged: number;
|
||||
}
|
||||
|
||||
export interface TopicReadRequest {
|
||||
userId: string;
|
||||
tz: string;
|
||||
todayKey: string;
|
||||
/** First local day to read (inclusive); the last is `todayKey`. */
|
||||
fromKey: string;
|
||||
signals: ReadonlySet<TopicSignalKey>;
|
||||
extras: ReadonlySet<TopicExtra>;
|
||||
now: Date;
|
||||
}
|
||||
|
||||
export interface TopicInputs {
|
||||
series: Map<TopicSignalKey, DaySeries>;
|
||||
labs: TopicLabPanel | null;
|
||||
training: TrainingLoad | null;
|
||||
screeners: AssessmentTrend[] | null;
|
||||
cycle: TopicCycleSummary | null;
|
||||
}
|
||||
|
||||
async function readMeasurementDays(
|
||||
req: TopicReadRequest,
|
||||
types: MeasurementType[],
|
||||
priorityJson: unknown,
|
||||
): Promise<Map<TopicSignalKey, DaySeries>> {
|
||||
const out = new Map<TopicSignalKey, DaySeries>();
|
||||
if (types.length === 0) return out;
|
||||
const stats = await readLocalDailyStats({
|
||||
userId: req.userId,
|
||||
types,
|
||||
from: startOfLocalDayKey(req.fromKey, req.tz),
|
||||
to: startOfLocalDayKey(shiftDateKey(req.todayKey, 1), req.tz),
|
||||
tz: req.tz,
|
||||
priorityJson,
|
||||
});
|
||||
for (const [type, days] of stats) {
|
||||
if (days.size > 0) out.set(type, days);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
async function readSleepNights(
|
||||
req: TopicReadRequest,
|
||||
priorityJson: unknown,
|
||||
): Promise<DaySeries | null> {
|
||||
const cells = await readNightCells({
|
||||
userId: req.userId,
|
||||
from: req.fromKey,
|
||||
to: req.todayKey,
|
||||
tz: req.tz,
|
||||
priorityJson,
|
||||
});
|
||||
if (cells.size === 0) return null;
|
||||
const out = new Map<string, number>();
|
||||
for (const [day, cell] of cells) out.set(day, cell.value);
|
||||
return out;
|
||||
}
|
||||
|
||||
async function readMoodDays(req: TopicReadRequest): Promise<DaySeries | null> {
|
||||
const rows = await prisma.moodEntry.groupBy({
|
||||
by: ["date"],
|
||||
where: {
|
||||
userId: req.userId,
|
||||
deletedAt: null,
|
||||
date: { gte: req.fromKey, lte: req.todayKey },
|
||||
},
|
||||
_avg: { score: true },
|
||||
});
|
||||
const out = new Map<string, number>();
|
||||
for (const row of rows) {
|
||||
if (row._avg.score !== null) out.set(row.date, row._avg.score);
|
||||
}
|
||||
return out.size > 0 ? out : null;
|
||||
}
|
||||
|
||||
async function readScreenerTrends(userId: string): Promise<AssessmentTrend[]> {
|
||||
const rows = await prisma.mentalHealthAssessment.findMany({
|
||||
where: { userId, deletedAt: null, instrument: { in: TREND_INSTRUMENTS } },
|
||||
orderBy: [{ takenAt: "desc" }, { id: "desc" }],
|
||||
take: SCREENER_READ_CAP,
|
||||
select: { id: true, instrument: true, totalScore: true, takenAt: true },
|
||||
});
|
||||
return computeAssessmentTrends(rows);
|
||||
}
|
||||
|
||||
/** Lengths between consecutive logged period starts, plausible ones only. */
|
||||
export function completedCycleLengths(starts: readonly string[]): number[] {
|
||||
const sorted = [...starts].sort();
|
||||
const lengths: number[] = [];
|
||||
for (let i = 1; i < sorted.length; i++) {
|
||||
const length = daysBetweenDateKeys(sorted[i - 1], sorted[i]);
|
||||
if (length >= CYCLE_LENGTH_MIN_DAYS && length <= CYCLE_LENGTH_MAX_DAYS) {
|
||||
lengths.push(length);
|
||||
}
|
||||
}
|
||||
return lengths.slice(-CYCLE_LENGTHS_SHOWN);
|
||||
}
|
||||
|
||||
async function readCycleSummary(
|
||||
req: TopicReadRequest,
|
||||
): Promise<TopicCycleSummary | null> {
|
||||
const [today, cycles] = await Promise.all([
|
||||
readTodayCycle(req.userId, req.tz, req.now),
|
||||
prisma.menstrualCycle.findMany({
|
||||
where: { userId: req.userId, deletedAt: null, isPredicted: false },
|
||||
orderBy: { startDate: "asc" },
|
||||
select: { startDate: true },
|
||||
}),
|
||||
]);
|
||||
const starts = cycles
|
||||
.map((c) => c.startDate)
|
||||
.filter((day) => day <= req.todayKey);
|
||||
if (starts.length === 0 && today === null) return null;
|
||||
const lengths = completedCycleLengths(starts);
|
||||
const averageLength =
|
||||
lengths.length > 0
|
||||
? Math.round((lengths.reduce((s, v) => s + v, 0) / lengths.length) * 10) /
|
||||
10
|
||||
: null;
|
||||
return {
|
||||
today: today ? { dayOfCycle: today.dayOfCycle, phase: today.phase } : null,
|
||||
lengths,
|
||||
averageLength,
|
||||
periodsLogged: starts.length,
|
||||
};
|
||||
}
|
||||
|
||||
/** Read everything one request needs, in parallel. */
|
||||
export async function readTopicInputs(
|
||||
req: TopicReadRequest,
|
||||
): Promise<TopicInputs> {
|
||||
const wantsSleep = req.signals.has("SLEEP_DURATION");
|
||||
const wantsMood = req.signals.has(MOOD_SIGNAL);
|
||||
const types = [...req.signals].filter(
|
||||
(key): key is MeasurementType =>
|
||||
key !== MOOD_SIGNAL && key !== "SLEEP_DURATION",
|
||||
);
|
||||
const needsPriority = types.length > 0 || wantsSleep;
|
||||
const priorityJson = needsPriority
|
||||
? await loadUserSourcePriority(req.userId)
|
||||
: null;
|
||||
|
||||
const [measurements, sleep, mood, labs, training, screeners, cycle] =
|
||||
await Promise.all([
|
||||
readMeasurementDays(req, types, priorityJson),
|
||||
wantsSleep ? readSleepNights(req, priorityJson) : Promise.resolve(null),
|
||||
wantsMood ? readMoodDays(req) : Promise.resolve(null),
|
||||
req.extras.has("labs")
|
||||
? readLabPanel(req.userId, req.now)
|
||||
: Promise.resolve(null),
|
||||
req.extras.has("training")
|
||||
? readTrainingLoad(req.userId, req.now)
|
||||
: Promise.resolve(null),
|
||||
req.extras.has("screeners")
|
||||
? readScreenerTrends(req.userId)
|
||||
: Promise.resolve(null),
|
||||
req.extras.has("cycle") ? readCycleSummary(req) : Promise.resolve(null),
|
||||
]);
|
||||
|
||||
const series = measurements;
|
||||
if (sleep) series.set("SLEEP_DURATION", sleep);
|
||||
if (mood) series.set(MOOD_SIGNAL, mood);
|
||||
return { series, labs, training, screeners, cycle };
|
||||
}
|
||||
@@ -0,0 +1,321 @@
|
||||
/**
|
||||
* The topic pages (preview): which signals each topic brings together, which
|
||||
* pairs it checks against each other and which extra blocks it carries.
|
||||
*
|
||||
* A topic is an overview layer above the single-metric Insights pages. It
|
||||
* never owns a number of its own: every value is the same daily value the
|
||||
* metric page draws, and every topic links into those pages. This file is the
|
||||
* one declaration; the server builds from it (`build.ts`) and the browser
|
||||
* imports only the keys and labels, so it carries constants and types only.
|
||||
*
|
||||
* Module ownership is not restated here. A measurement signal follows its
|
||||
* type's owner (`moduleForMeasurementType`), the mood signal follows `mood`,
|
||||
* and the extra blocks name their module in {@link TOPIC_EXTRA_MODULE}. A
|
||||
* topic is offered when at least one of its {@link TopicDefinition.modules}
|
||||
* is on (an empty list means it rides core signals and is always offered);
|
||||
* inside it, each signal and block shows only when its own owner is on.
|
||||
*/
|
||||
import type { MeasurementType } from "@/generated/prisma/enums";
|
||||
import type { ModuleKey } from "@/lib/modules/registry";
|
||||
|
||||
/** The topics, in the overview's reading order. Also the URL slugs. */
|
||||
export const TOPIC_KEYS = [
|
||||
"heart",
|
||||
"body",
|
||||
"blood-pressure",
|
||||
"sleep",
|
||||
"activity",
|
||||
"metabolism",
|
||||
"mind",
|
||||
"cycle",
|
||||
] as const;
|
||||
|
||||
export type TopicKey = (typeof TOPIC_KEYS)[number];
|
||||
|
||||
export function isTopicKey(value: unknown): value is TopicKey {
|
||||
return (
|
||||
typeof value === "string" &&
|
||||
(TOPIC_KEYS as readonly string[]).includes(value)
|
||||
);
|
||||
}
|
||||
|
||||
/** The mood score (1 to 5, the day's mean of its entries) as a signal. */
|
||||
export const MOOD_SIGNAL = "MOOD";
|
||||
|
||||
/** A daily series a topic reads: a measurement type or the mood score. */
|
||||
export type TopicSignalKey = MeasurementType | typeof MOOD_SIGNAL;
|
||||
|
||||
/** The extra blocks a topic may carry beside its signals. */
|
||||
export type TopicExtra = "training" | "labs" | "screeners" | "cycle";
|
||||
|
||||
/** The module each extra block follows. */
|
||||
export const TOPIC_EXTRA_MODULE: Readonly<Record<TopicExtra, ModuleKey>> = {
|
||||
training: "workouts",
|
||||
labs: "labs",
|
||||
screeners: "mentalHealth",
|
||||
cycle: "cycle",
|
||||
};
|
||||
|
||||
/**
|
||||
* One pair a topic checks. `x` on day d is held against `y` on day
|
||||
* d + `lagDays`. A sleep night is keyed by the day it ended, so "the night
|
||||
* before" and "that morning" share a key and need no lag.
|
||||
*
|
||||
* `correlation` reports Pearson's r; `split` compares the mean of `y` on the
|
||||
* days `x` sat below `threshold` (in `x`'s stored unit) with the other days.
|
||||
*/
|
||||
export interface TopicRelationshipDef {
|
||||
id: string;
|
||||
x: TopicSignalKey;
|
||||
y: TopicSignalKey;
|
||||
lagDays: 0 | 1;
|
||||
kind: "correlation" | "split";
|
||||
threshold?: number;
|
||||
}
|
||||
|
||||
export interface TopicDefinition {
|
||||
key: TopicKey;
|
||||
/** Any one of these on offers the topic; empty means core signals carry it. */
|
||||
modules: readonly ModuleKey[];
|
||||
/** Display order. The first one with data is the topic's headline. */
|
||||
signals: readonly TopicSignalKey[];
|
||||
/** The side-by-side chart, top to bottom (at most three). */
|
||||
chart: readonly TopicSignalKey[];
|
||||
relationships: readonly TopicRelationshipDef[];
|
||||
extras: readonly TopicExtra[];
|
||||
}
|
||||
|
||||
/** Seven hours, in the minutes `SLEEP_DURATION` is stored in. */
|
||||
const SHORT_NIGHT_MINUTES = 420;
|
||||
|
||||
export const TOPICS: Readonly<Record<TopicKey, TopicDefinition>> = {
|
||||
heart: {
|
||||
key: "heart",
|
||||
modules: [],
|
||||
signals: [
|
||||
"RESTING_HEART_RATE",
|
||||
"HEART_RATE_VARIABILITY",
|
||||
"VO2_MAX",
|
||||
"PULSE",
|
||||
],
|
||||
chart: ["RESTING_HEART_RATE", "HEART_RATE_VARIABILITY", "SLEEP_DURATION"],
|
||||
relationships: [
|
||||
{
|
||||
id: "short-nights-resting-hr",
|
||||
x: "SLEEP_DURATION",
|
||||
y: "RESTING_HEART_RATE",
|
||||
lagDays: 0,
|
||||
kind: "split",
|
||||
threshold: SHORT_NIGHT_MINUTES,
|
||||
},
|
||||
{
|
||||
id: "hrv-resting-hr",
|
||||
x: "HEART_RATE_VARIABILITY",
|
||||
y: "RESTING_HEART_RATE",
|
||||
lagDays: 0,
|
||||
kind: "correlation",
|
||||
},
|
||||
{
|
||||
id: "exercise-next-day-hrv",
|
||||
x: "EXERCISE_MINUTES",
|
||||
y: "HEART_RATE_VARIABILITY",
|
||||
lagDays: 1,
|
||||
kind: "correlation",
|
||||
},
|
||||
],
|
||||
extras: ["training"],
|
||||
},
|
||||
body: {
|
||||
key: "body",
|
||||
modules: [],
|
||||
signals: [
|
||||
"WEIGHT",
|
||||
"BODY_FAT",
|
||||
"BODY_MASS_INDEX",
|
||||
"MUSCLE_MASS",
|
||||
"WAIST_CIRCUMFERENCE",
|
||||
"VISCERAL_FAT",
|
||||
],
|
||||
chart: ["WEIGHT", "BODY_FAT", "MUSCLE_MASS"],
|
||||
relationships: [
|
||||
{
|
||||
id: "active-energy-next-day-weight",
|
||||
x: "ACTIVE_ENERGY_BURNED",
|
||||
y: "WEIGHT",
|
||||
lagDays: 1,
|
||||
kind: "correlation",
|
||||
},
|
||||
],
|
||||
extras: [],
|
||||
},
|
||||
"blood-pressure": {
|
||||
key: "blood-pressure",
|
||||
modules: [],
|
||||
signals: ["BLOOD_PRESSURE_SYS", "BLOOD_PRESSURE_DIA", "PULSE"],
|
||||
chart: ["BLOOD_PRESSURE_SYS", "BLOOD_PRESSURE_DIA", "PULSE"],
|
||||
relationships: [
|
||||
{
|
||||
id: "short-nights-systolic",
|
||||
x: "SLEEP_DURATION",
|
||||
y: "BLOOD_PRESSURE_SYS",
|
||||
lagDays: 0,
|
||||
kind: "split",
|
||||
threshold: SHORT_NIGHT_MINUTES,
|
||||
},
|
||||
{
|
||||
id: "steps-next-day-systolic",
|
||||
x: "ACTIVITY_STEPS",
|
||||
y: "BLOOD_PRESSURE_SYS",
|
||||
lagDays: 1,
|
||||
kind: "correlation",
|
||||
},
|
||||
],
|
||||
extras: [],
|
||||
},
|
||||
sleep: {
|
||||
key: "sleep",
|
||||
modules: ["sleep"],
|
||||
signals: [
|
||||
"SLEEP_DURATION",
|
||||
"RESPIRATORY_RATE",
|
||||
"OXYGEN_SATURATION",
|
||||
"BREATHING_DISTURBANCES",
|
||||
],
|
||||
chart: ["SLEEP_DURATION", "RESPIRATORY_RATE", "HEART_RATE_VARIABILITY"],
|
||||
relationships: [
|
||||
{
|
||||
id: "exercise-sleep",
|
||||
x: "EXERCISE_MINUTES",
|
||||
y: "SLEEP_DURATION",
|
||||
lagDays: 1,
|
||||
kind: "correlation",
|
||||
},
|
||||
{
|
||||
id: "sleep-mood",
|
||||
x: "SLEEP_DURATION",
|
||||
y: MOOD_SIGNAL,
|
||||
lagDays: 0,
|
||||
kind: "correlation",
|
||||
},
|
||||
],
|
||||
extras: [],
|
||||
},
|
||||
activity: {
|
||||
key: "activity",
|
||||
modules: [],
|
||||
signals: [
|
||||
"ACTIVITY_STEPS",
|
||||
"EXERCISE_MINUTES",
|
||||
"ACTIVE_ENERGY_BURNED",
|
||||
"WALKING_RUNNING_DISTANCE",
|
||||
"FLIGHTS_CLIMBED",
|
||||
],
|
||||
chart: ["ACTIVITY_STEPS", "EXERCISE_MINUTES", "ACTIVE_ENERGY_BURNED"],
|
||||
relationships: [
|
||||
{
|
||||
id: "steps-sleep",
|
||||
x: "ACTIVITY_STEPS",
|
||||
y: "SLEEP_DURATION",
|
||||
lagDays: 1,
|
||||
kind: "correlation",
|
||||
},
|
||||
{
|
||||
id: "exercise-next-day-resting-hr",
|
||||
x: "EXERCISE_MINUTES",
|
||||
y: "RESTING_HEART_RATE",
|
||||
lagDays: 1,
|
||||
kind: "correlation",
|
||||
},
|
||||
],
|
||||
extras: ["training"],
|
||||
},
|
||||
metabolism: {
|
||||
key: "metabolism",
|
||||
modules: ["glucose", "labs"],
|
||||
signals: ["BLOOD_GLUCOSE"],
|
||||
chart: ["BLOOD_GLUCOSE"],
|
||||
relationships: [
|
||||
{
|
||||
id: "steps-glucose",
|
||||
x: "ACTIVITY_STEPS",
|
||||
y: "BLOOD_GLUCOSE",
|
||||
lagDays: 0,
|
||||
kind: "correlation",
|
||||
},
|
||||
{
|
||||
id: "short-nights-glucose",
|
||||
x: "SLEEP_DURATION",
|
||||
y: "BLOOD_GLUCOSE",
|
||||
lagDays: 0,
|
||||
kind: "split",
|
||||
threshold: SHORT_NIGHT_MINUTES,
|
||||
},
|
||||
],
|
||||
extras: ["labs"],
|
||||
},
|
||||
mind: {
|
||||
key: "mind",
|
||||
modules: ["mood", "mentalHealth"],
|
||||
signals: [MOOD_SIGNAL, "PHQ9_SCORE", "GAD7_SCORE", "WHO5_SCORE"],
|
||||
chart: [MOOD_SIGNAL, "SLEEP_DURATION", "ACTIVITY_STEPS"],
|
||||
relationships: [
|
||||
{
|
||||
id: "short-nights-mood",
|
||||
x: "SLEEP_DURATION",
|
||||
y: MOOD_SIGNAL,
|
||||
lagDays: 0,
|
||||
kind: "split",
|
||||
threshold: SHORT_NIGHT_MINUTES,
|
||||
},
|
||||
{
|
||||
id: "steps-mood",
|
||||
x: "ACTIVITY_STEPS",
|
||||
y: MOOD_SIGNAL,
|
||||
lagDays: 0,
|
||||
kind: "correlation",
|
||||
},
|
||||
],
|
||||
extras: ["screeners"],
|
||||
},
|
||||
cycle: {
|
||||
key: "cycle",
|
||||
modules: ["cycle"],
|
||||
signals: ["WRIST_TEMPERATURE", "RESTING_HEART_RATE"],
|
||||
chart: ["WRIST_TEMPERATURE", "RESTING_HEART_RATE"],
|
||||
relationships: [],
|
||||
extras: ["cycle"],
|
||||
},
|
||||
};
|
||||
|
||||
/** The selectable windows of a topic page. */
|
||||
export const TOPIC_RANGES = ["7d", "30d", "90d", "1y"] as const;
|
||||
export type TopicRange = (typeof TOPIC_RANGES)[number];
|
||||
export const DEFAULT_TOPIC_RANGE: TopicRange = "30d";
|
||||
|
||||
export const TOPIC_RANGE_DAYS: Readonly<Record<TopicRange, number>> = {
|
||||
"7d": 7,
|
||||
"30d": 30,
|
||||
"90d": 90,
|
||||
"1y": 365,
|
||||
};
|
||||
|
||||
/** A relationship says nothing below this many paired days. */
|
||||
export const RELATIONSHIP_MIN_PAIRED_DAYS = 14;
|
||||
|
||||
/** Each side of a `split` needs this many days to be compared at all. */
|
||||
export const SPLIT_MIN_DAYS_PER_SIDE = 3;
|
||||
|
||||
/** The usual range needs this many days of the trailing window. */
|
||||
export const USUAL_RANGE_MIN_DAYS = 7;
|
||||
|
||||
/** The trailing window the usual range, the trends and the pairs read. */
|
||||
export const TOPIC_BASE_WINDOW_DAYS = 90;
|
||||
|
||||
/** A trend over the base window needs this many days with a value. */
|
||||
export const TREND_MIN_DAYS = 14;
|
||||
|
||||
/** Points a relationship ships for its scatter, newest kept. */
|
||||
export const RELATIONSHIP_MAX_POINTS = 120;
|
||||
|
||||
/** Points in an overview tile's sparkline. */
|
||||
export const SPARKLINE_POINTS = 30;
|
||||
@@ -0,0 +1,127 @@
|
||||
/**
|
||||
* The topic routes' one entry: who may see the topics, what the record may
|
||||
* show, and the reads and assembly behind both answers.
|
||||
*
|
||||
* Two people are in play and they are kept apart. The preview switch is the
|
||||
* VIEWER's (`actorId`): a preview is chrome of the person reading, so it is
|
||||
* their opt-in that decides whether the topics exist for them. Everything the
|
||||
* page shows is the RECORD's (`userId`): its module switches decide which
|
||||
* topics, signals and blocks are offered, and the sections the viewer's grant
|
||||
* opens decide which of them may be read at all.
|
||||
*/
|
||||
import { prisma } from "@/lib/db";
|
||||
import { resolveModuleMap } from "@/lib/modules/gate";
|
||||
import { resolvePreviewFeatures } from "@/lib/settings/preview-features";
|
||||
import { actingDomainVisibility } from "@/lib/sharing/acting-domains";
|
||||
import { shiftDateKey, userDayKey } from "@/lib/tz/format";
|
||||
import { resolveUserTimezone } from "@/lib/tz/resolver";
|
||||
|
||||
import {
|
||||
buildDetail,
|
||||
buildOverview,
|
||||
detailReadStart,
|
||||
type TopicDetail,
|
||||
type TopicOverview,
|
||||
} from "./build";
|
||||
import { readTopicInputs } from "./read";
|
||||
import {
|
||||
TOPIC_BASE_WINDOW_DAYS,
|
||||
TOPICS,
|
||||
TOPIC_KEYS,
|
||||
type TopicExtra,
|
||||
type TopicKey,
|
||||
type TopicRange,
|
||||
type TopicSignalKey,
|
||||
} from "./registry";
|
||||
import {
|
||||
isExtraVisible,
|
||||
isTopicOffered,
|
||||
offeredTopics,
|
||||
signalsToRead,
|
||||
visibleExtras,
|
||||
visibleSignals,
|
||||
type TopicVisibility,
|
||||
} from "./visibility";
|
||||
|
||||
/** Whether the viewer switched the topics preview on for themselves. */
|
||||
export async function hasTopicsPreview(actorId: string): Promise<boolean> {
|
||||
const row = await prisma.user.findUnique({
|
||||
where: { id: actorId },
|
||||
select: { previewFeaturesJson: true },
|
||||
});
|
||||
return resolvePreviewFeatures(row?.previewFeaturesJson).topics;
|
||||
}
|
||||
|
||||
async function visibilityFor(
|
||||
userId: string,
|
||||
grantId: string | null,
|
||||
): Promise<TopicVisibility> {
|
||||
const [modules, sees] = await Promise.all([
|
||||
resolveModuleMap(userId),
|
||||
actingDomainVisibility(prisma, grantId),
|
||||
]);
|
||||
return { modules, sees };
|
||||
}
|
||||
|
||||
async function today(userId: string, now: Date) {
|
||||
const tz = await resolveUserTimezone(userId);
|
||||
return { tz, todayKey: userDayKey(now, tz) };
|
||||
}
|
||||
|
||||
export async function loadTopicOverview(args: {
|
||||
userId: string;
|
||||
grantId: string | null;
|
||||
now: Date;
|
||||
}): Promise<TopicOverview> {
|
||||
const ctx = await visibilityFor(args.userId, args.grantId);
|
||||
const { tz, todayKey } = await today(args.userId, args.now);
|
||||
const offered = offeredTopics(ctx);
|
||||
const hidden = TOPIC_KEYS.filter((key) => !offered.includes(key));
|
||||
|
||||
// The overview needs each offered topic's own list and its blocks, not its
|
||||
// chart or pairs; the training load stays on the topic page.
|
||||
const signals = new Set<TopicSignalKey>();
|
||||
const extras = new Set<TopicExtra>();
|
||||
for (const key of offered) {
|
||||
const def = TOPICS[key];
|
||||
for (const signal of visibleSignals(def, ctx)) signals.add(signal);
|
||||
for (const extra of def.extras) {
|
||||
if (extra !== "training" && isExtraVisible(extra, ctx)) extras.add(extra);
|
||||
}
|
||||
}
|
||||
|
||||
const inputs = await readTopicInputs({
|
||||
userId: args.userId,
|
||||
tz,
|
||||
todayKey,
|
||||
fromKey: shiftDateKey(todayKey, -(TOPIC_BASE_WINDOW_DAYS - 1)),
|
||||
signals,
|
||||
extras,
|
||||
now: args.now,
|
||||
});
|
||||
return buildOverview(offered, hidden, inputs, ctx, todayKey);
|
||||
}
|
||||
|
||||
/** One topic page, or null when the record's switches do not offer it. */
|
||||
export async function loadTopicDetail(args: {
|
||||
userId: string;
|
||||
grantId: string | null;
|
||||
topic: TopicKey;
|
||||
range: TopicRange;
|
||||
now: Date;
|
||||
}): Promise<TopicDetail | null> {
|
||||
const ctx = await visibilityFor(args.userId, args.grantId);
|
||||
const def = TOPICS[args.topic];
|
||||
if (!isTopicOffered(def, ctx)) return null;
|
||||
const { tz, todayKey } = await today(args.userId, args.now);
|
||||
const inputs = await readTopicInputs({
|
||||
userId: args.userId,
|
||||
tz,
|
||||
todayKey,
|
||||
fromKey: detailReadStart(args.range, todayKey),
|
||||
signals: signalsToRead(def, ctx),
|
||||
extras: visibleExtras(def, ctx),
|
||||
now: args.now,
|
||||
});
|
||||
return buildDetail(def, args.range, inputs, ctx, todayKey);
|
||||
}
|
||||
@@ -0,0 +1,367 @@
|
||||
/**
|
||||
* The arithmetic of the topic pages, over day-keyed series. Pure: the reads
|
||||
* live in `read.ts`, the assembly in `build.ts`.
|
||||
*
|
||||
* Nothing here invents a figure. A usual range needs a week of days, a trend
|
||||
* two weeks, a relationship fourteen paired days; below that the answer says
|
||||
* how many days there are and how many it needs, and carries no number that
|
||||
* would read as a finding.
|
||||
*
|
||||
* - Usual range: the personal band the Insights tiles call "typical for
|
||||
* you", median ± 3 scaled MAD of the daily values (`buildBaselineBand`),
|
||||
* over the trailing {@link TOPIC_BASE_WINDOW_DAYS} days.
|
||||
* - Trend: the least-squares slope of the daily values over the same
|
||||
* window, reported as the change it amounts to across the window, beside
|
||||
* weekly means for the sparkline.
|
||||
* - Relationship: Pearson's r (two-sided p from the t statistic, the one
|
||||
* correlation the app reports) or, for a `split`, the difference of two
|
||||
* means with a Welch t test. A finding needs p < 0.05; otherwise it reads
|
||||
* as "no clear link over n days". Never a cause.
|
||||
*/
|
||||
import type { MeasurementType } from "@/generated/prisma/enums";
|
||||
import { buildBaselineBand } from "@/lib/insights/derived/baseline";
|
||||
import {
|
||||
MAX_P_VALUE,
|
||||
pearson,
|
||||
twoSidedPFromT,
|
||||
} from "@/lib/insights/correlations";
|
||||
import { linearFit } from "@/lib/stats/linear-fit";
|
||||
import { variance } from "@/lib/stats/spread";
|
||||
import { daysBetweenDateKeys, shiftDateKey } from "@/lib/tz/format";
|
||||
|
||||
import {
|
||||
MOOD_SIGNAL,
|
||||
RELATIONSHIP_MAX_POINTS,
|
||||
RELATIONSHIP_MIN_PAIRED_DAYS,
|
||||
SPLIT_MIN_DAYS_PER_SIDE,
|
||||
TOPIC_BASE_WINDOW_DAYS,
|
||||
TREND_MIN_DAYS,
|
||||
USUAL_RANGE_MIN_DAYS,
|
||||
type TopicRelationshipDef,
|
||||
type TopicSignalKey,
|
||||
} from "./registry";
|
||||
|
||||
/** One signal's values, keyed by local day (`YYYY-MM-DD`). */
|
||||
export type DaySeries = ReadonlyMap<string, number>;
|
||||
|
||||
export interface DayValue {
|
||||
day: string;
|
||||
value: number;
|
||||
}
|
||||
|
||||
export interface UsualRange {
|
||||
low: number;
|
||||
high: number;
|
||||
center: number;
|
||||
/** Days the band was built from. */
|
||||
days: number;
|
||||
}
|
||||
|
||||
export type SignalState = "within" | "above" | "below" | "learning" | "none";
|
||||
|
||||
export interface TopicTrend {
|
||||
key: TopicSignalKey;
|
||||
status: "ok" | "insufficient";
|
||||
/** Days with a value in the trend window. */
|
||||
days: number;
|
||||
/** The fitted change across the window, in the signal's stored unit. */
|
||||
change: number | null;
|
||||
/** Weekly means, oldest first; a week without a value is left out. */
|
||||
weekly: DayValue[];
|
||||
}
|
||||
|
||||
export interface SplitComparison {
|
||||
threshold: number;
|
||||
belowDays: number;
|
||||
otherDays: number;
|
||||
belowMean: number;
|
||||
otherMean: number;
|
||||
/** `belowMean − otherMean`, in `y`'s stored unit. */
|
||||
difference: number;
|
||||
}
|
||||
|
||||
export interface TopicRelationship {
|
||||
id: string;
|
||||
x: TopicSignalKey;
|
||||
y: TopicSignalKey;
|
||||
lagDays: 0 | 1;
|
||||
kind: "correlation" | "split";
|
||||
pairedDays: number;
|
||||
requiredDays: number;
|
||||
/**
|
||||
* `insufficient`: below the paired-day floor (or one side of a split is
|
||||
* too thin). `no_clear_link`: enough days, no difference a test can tell
|
||||
* from chance. `linked`: p < 0.05, reported as a pattern, never a cause.
|
||||
*/
|
||||
status: "insufficient" | "no_clear_link" | "linked";
|
||||
r: number | null;
|
||||
pValue: number | null;
|
||||
split: SplitComparison | null;
|
||||
/** Paired values for the scatter, newest kept; empty when insufficient. */
|
||||
points: Array<{ x: number; y: number }>;
|
||||
}
|
||||
|
||||
const round = (value: number, digits: number) => {
|
||||
const f = 10 ** digits;
|
||||
return Math.round(value * f) / f;
|
||||
};
|
||||
|
||||
/** The days of `series` in `[fromKey, toKey]`, oldest first. */
|
||||
export function windowValues(
|
||||
series: DaySeries | undefined,
|
||||
fromKey: string,
|
||||
toKey: string,
|
||||
): DayValue[] {
|
||||
if (!series) return [];
|
||||
const out: DayValue[] = [];
|
||||
for (const [day, value] of series) {
|
||||
if (day >= fromKey && day <= toKey && Number.isFinite(value)) {
|
||||
out.push({ day, value });
|
||||
}
|
||||
}
|
||||
return out.sort((a, b) => (a.day < b.day ? -1 : a.day > b.day ? 1 : 0));
|
||||
}
|
||||
|
||||
/** The newest day with a value on or before `todayKey`. */
|
||||
export function latestValue(
|
||||
series: DaySeries | undefined,
|
||||
todayKey: string,
|
||||
): DayValue | null {
|
||||
if (!series) return null;
|
||||
let best: DayValue | null = null;
|
||||
for (const [day, value] of series) {
|
||||
if (day > todayKey || !Number.isFinite(value)) continue;
|
||||
if (best === null || day > best.day) best = { day, value };
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
/** The first day of the trailing base window that ends on `todayKey`. */
|
||||
export function baseWindowStart(todayKey: string): string {
|
||||
return shiftDateKey(todayKey, -(TOPIC_BASE_WINDOW_DAYS - 1));
|
||||
}
|
||||
|
||||
/**
|
||||
* The personal usual range over the trailing base window, or null below
|
||||
* {@link USUAL_RANGE_MIN_DAYS} days. The mood score has no measurement type
|
||||
* and keeps the raw band edges.
|
||||
*/
|
||||
export function usualRange(
|
||||
key: TopicSignalKey,
|
||||
series: DaySeries | undefined,
|
||||
todayKey: string,
|
||||
): UsualRange | null {
|
||||
const values = windowValues(series, baseWindowStart(todayKey), todayKey).map(
|
||||
(p) => p.value,
|
||||
);
|
||||
if (values.length < USUAL_RANGE_MIN_DAYS) return null;
|
||||
const type = key === MOOD_SIGNAL ? null : (key as MeasurementType);
|
||||
const band = buildBaselineBand(values, type);
|
||||
if (!band) return null;
|
||||
return {
|
||||
low: round(band.low, 2),
|
||||
high: round(band.high, 2),
|
||||
center: round(band.center, 2),
|
||||
days: band.sampleDays,
|
||||
};
|
||||
}
|
||||
|
||||
/** Where a value sits against the usual range. */
|
||||
export function stateAgainstRange(
|
||||
latest: DayValue | null,
|
||||
range: UsualRange | null,
|
||||
): SignalState {
|
||||
if (latest === null) return "none";
|
||||
if (range === null) return "learning";
|
||||
if (latest.value > range.high) return "above";
|
||||
if (latest.value < range.low) return "below";
|
||||
return "within";
|
||||
}
|
||||
|
||||
/** The Monday-anchored start of the week a day falls in (ISO weeks). */
|
||||
function weekStart(day: string): string {
|
||||
const [y, m, d] = day.split("-").map(Number);
|
||||
const weekday = new Date(Date.UTC(y, m - 1, d)).getUTCDay();
|
||||
const sinceMonday = (weekday + 6) % 7;
|
||||
return shiftDateKey(day, -sinceMonday);
|
||||
}
|
||||
|
||||
/**
|
||||
* The slope of the daily values over the trailing base window, as the change
|
||||
* it amounts to across the window, beside weekly means.
|
||||
*/
|
||||
export function computeTrend(
|
||||
key: TopicSignalKey,
|
||||
series: DaySeries | undefined,
|
||||
todayKey: string,
|
||||
): TopicTrend {
|
||||
const fromKey = baseWindowStart(todayKey);
|
||||
const points = windowValues(series, fromKey, todayKey);
|
||||
const weeks = new Map<string, { sum: number; n: number }>();
|
||||
for (const p of points) {
|
||||
const week = weekStart(p.day);
|
||||
const cell = weeks.get(week) ?? { sum: 0, n: 0 };
|
||||
cell.sum += p.value;
|
||||
cell.n += 1;
|
||||
weeks.set(week, cell);
|
||||
}
|
||||
const weekly = [...weeks.entries()]
|
||||
.sort(([a], [b]) => (a < b ? -1 : 1))
|
||||
.map(([day, cell]) => ({ day, value: round(cell.sum / cell.n, 2) }));
|
||||
if (points.length < TREND_MIN_DAYS) {
|
||||
return {
|
||||
key,
|
||||
status: "insufficient",
|
||||
days: points.length,
|
||||
change: null,
|
||||
weekly,
|
||||
};
|
||||
}
|
||||
const fit = linearFit(
|
||||
points.map((p) => daysBetweenDateKeys(fromKey, p.day)),
|
||||
points.map((p) => p.value),
|
||||
);
|
||||
if (!fit) {
|
||||
return {
|
||||
key,
|
||||
status: "insufficient",
|
||||
days: points.length,
|
||||
change: null,
|
||||
weekly,
|
||||
};
|
||||
}
|
||||
return {
|
||||
key,
|
||||
status: "ok",
|
||||
days: points.length,
|
||||
change: round(fit.slope * (TOPIC_BASE_WINDOW_DAYS - 1), 2),
|
||||
weekly,
|
||||
};
|
||||
}
|
||||
|
||||
/** `x` on day d beside `y` on day d + lag, both inside the window. */
|
||||
export function pairDays(
|
||||
x: DaySeries | undefined,
|
||||
y: DaySeries | undefined,
|
||||
lagDays: number,
|
||||
fromKey: string,
|
||||
toKey: string,
|
||||
): Array<{ day: string; x: number; y: number }> {
|
||||
if (!x || !y) return [];
|
||||
const out: Array<{ day: string; x: number; y: number }> = [];
|
||||
for (const { day, value } of windowValues(x, fromKey, toKey)) {
|
||||
const target = shiftDateKey(day, lagDays);
|
||||
if (target > toKey) continue;
|
||||
const yValue = y.get(target);
|
||||
if (yValue === undefined || !Number.isFinite(yValue)) continue;
|
||||
out.push({ day, x: value, y: yValue });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function mean(values: readonly number[]): number {
|
||||
return values.reduce((s, v) => s + v, 0) / values.length;
|
||||
}
|
||||
|
||||
/** Welch's two-sided p for a difference of two means. */
|
||||
export function welchPValue(
|
||||
a: readonly number[],
|
||||
b: readonly number[],
|
||||
): number {
|
||||
const ma = mean(a);
|
||||
const mb = mean(b);
|
||||
const va = (variance(a, "sample") ?? 0) / a.length;
|
||||
const vb = (variance(b, "sample") ?? 0) / b.length;
|
||||
const se = Math.sqrt(va + vb);
|
||||
if (se === 0) return ma === mb ? 1 : 0;
|
||||
const t = Math.abs(ma - mb) / se;
|
||||
const df =
|
||||
(va + vb) ** 2 /
|
||||
((va * va) / Math.max(1, a.length - 1) +
|
||||
(vb * vb) / Math.max(1, b.length - 1));
|
||||
return twoSidedPFromT(t, df);
|
||||
}
|
||||
|
||||
function compareSplit(
|
||||
pairs: ReadonlyArray<{ x: number; y: number }>,
|
||||
threshold: number,
|
||||
): { split: SplitComparison; pValue: number } | null {
|
||||
const below = pairs.filter((p) => p.x < threshold).map((p) => p.y);
|
||||
const other = pairs.filter((p) => p.x >= threshold).map((p) => p.y);
|
||||
if (below.length < SPLIT_MIN_DAYS_PER_SIDE) return null;
|
||||
if (other.length < SPLIT_MIN_DAYS_PER_SIDE) return null;
|
||||
const belowMean = mean(below);
|
||||
const otherMean = mean(other);
|
||||
return {
|
||||
split: {
|
||||
threshold,
|
||||
belowDays: below.length,
|
||||
otherDays: other.length,
|
||||
belowMean: round(belowMean, 2),
|
||||
otherMean: round(otherMean, 2),
|
||||
difference: round(belowMean - otherMean, 2),
|
||||
},
|
||||
pValue: welchPValue(below, other),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* One relationship over `[fromKey, toKey]`. Below the paired-day floor it
|
||||
* reports the count and nothing else.
|
||||
*/
|
||||
export function computeRelationship(
|
||||
def: TopicRelationshipDef,
|
||||
x: DaySeries | undefined,
|
||||
y: DaySeries | undefined,
|
||||
fromKey: string,
|
||||
toKey: string,
|
||||
): TopicRelationship {
|
||||
const pairs = pairDays(x, y, def.lagDays, fromKey, toKey);
|
||||
const base: TopicRelationship = {
|
||||
id: def.id,
|
||||
x: def.x,
|
||||
y: def.y,
|
||||
lagDays: def.lagDays,
|
||||
kind: def.kind,
|
||||
pairedDays: pairs.length,
|
||||
requiredDays: RELATIONSHIP_MIN_PAIRED_DAYS,
|
||||
status: "insufficient",
|
||||
r: null,
|
||||
pValue: null,
|
||||
split: null,
|
||||
points: [],
|
||||
};
|
||||
if (pairs.length < RELATIONSHIP_MIN_PAIRED_DAYS) return base;
|
||||
const points = pairs
|
||||
.slice(-RELATIONSHIP_MAX_POINTS)
|
||||
.map((p) => ({ x: round(p.x, 2), y: round(p.y, 2) }));
|
||||
|
||||
if (def.kind === "split") {
|
||||
const compared = compareSplit(pairs, def.threshold ?? 0);
|
||||
if (!compared) return base;
|
||||
return {
|
||||
...base,
|
||||
status: compared.pValue < MAX_P_VALUE ? "linked" : "no_clear_link",
|
||||
pValue: round(compared.pValue, 4),
|
||||
split: compared.split,
|
||||
points,
|
||||
};
|
||||
}
|
||||
|
||||
const result = pearson({
|
||||
xs: pairs.map((p) => p.x),
|
||||
ys: pairs.map((p) => p.y),
|
||||
minPairs: RELATIONSHIP_MIN_PAIRED_DAYS,
|
||||
});
|
||||
if (result.status !== "ok") {
|
||||
// A flat series (no variance) is enough days and no link to report.
|
||||
return { ...base, status: "no_clear_link", points };
|
||||
}
|
||||
return {
|
||||
...base,
|
||||
status: result.pValue < MAX_P_VALUE ? "linked" : "no_clear_link",
|
||||
r: round(result.r, 2),
|
||||
pValue: round(result.pValue, 4),
|
||||
points,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
/**
|
||||
* What a topic may show for the record on screen: the record's module
|
||||
* switches and the sections the viewer's grant opens.
|
||||
*
|
||||
* Both are asked of every signal and every block, not once per topic, so a
|
||||
* switched-off module disappears from every topic it would otherwise appear
|
||||
* in (sleep from the heart chart, mood from the sleep pairs) and a section the
|
||||
* grant does not open is never read.
|
||||
*/
|
||||
import type { MeasurementType } from "@/generated/prisma/enums";
|
||||
import { moduleForMeasurementType } from "@/lib/modules/measurement-scope";
|
||||
import type { ModuleKey } from "@/lib/modules/registry";
|
||||
import type { SurfaceModuleMap } from "@/lib/modules/surface";
|
||||
import type { ShareScope } from "@/lib/sharing/scope";
|
||||
|
||||
import {
|
||||
MOOD_SIGNAL,
|
||||
TOPIC_EXTRA_MODULE,
|
||||
TOPICS,
|
||||
TOPIC_KEYS,
|
||||
type TopicDefinition,
|
||||
type TopicExtra,
|
||||
type TopicKey,
|
||||
type TopicSignalKey,
|
||||
} from "./registry";
|
||||
|
||||
export interface TopicVisibility {
|
||||
/** The record's resolved module map; only an explicit `false` hides. */
|
||||
modules: SurfaceModuleMap;
|
||||
/** Whether the viewer's grant opens a section (always true on one's own). */
|
||||
sees: (scope: ShareScope) => boolean;
|
||||
}
|
||||
|
||||
const EXTRA_SECTION: Readonly<Record<TopicExtra, ShareScope>> = {
|
||||
training: "measurements",
|
||||
labs: "labs",
|
||||
screeners: "mind",
|
||||
cycle: "cycle",
|
||||
};
|
||||
|
||||
function moduleOn(key: ModuleKey | null, modules: SurfaceModuleMap): boolean {
|
||||
return key === null || modules[key] !== false;
|
||||
}
|
||||
|
||||
export function signalModule(key: TopicSignalKey): ModuleKey | null {
|
||||
if (key === MOOD_SIGNAL) return "mood";
|
||||
return moduleForMeasurementType(key as MeasurementType);
|
||||
}
|
||||
|
||||
export function isSignalVisible(
|
||||
key: TopicSignalKey,
|
||||
ctx: TopicVisibility,
|
||||
): boolean {
|
||||
const section: ShareScope = key === MOOD_SIGNAL ? "mind" : "measurements";
|
||||
return moduleOn(signalModule(key), ctx.modules) && ctx.sees(section);
|
||||
}
|
||||
|
||||
export function isExtraVisible(
|
||||
extra: TopicExtra,
|
||||
ctx: TopicVisibility,
|
||||
): boolean {
|
||||
return (
|
||||
moduleOn(TOPIC_EXTRA_MODULE[extra], ctx.modules) &&
|
||||
ctx.sees(EXTRA_SECTION[extra])
|
||||
);
|
||||
}
|
||||
|
||||
/** Whether the topic is offered at all under the record's module switches. */
|
||||
export function isTopicOffered(
|
||||
def: TopicDefinition,
|
||||
ctx: TopicVisibility,
|
||||
): boolean {
|
||||
if (def.modules.length === 0) return true;
|
||||
return def.modules.some((key) => ctx.modules[key] !== false);
|
||||
}
|
||||
|
||||
export function offeredTopics(ctx: TopicVisibility): TopicKey[] {
|
||||
return TOPIC_KEYS.filter((key) => isTopicOffered(TOPICS[key], ctx));
|
||||
}
|
||||
|
||||
/** The signals a topic's own list shows, in display order. */
|
||||
export function visibleSignals(
|
||||
def: TopicDefinition,
|
||||
ctx: TopicVisibility,
|
||||
): TopicSignalKey[] {
|
||||
return def.signals.filter((key) => isSignalVisible(key, ctx));
|
||||
}
|
||||
|
||||
/** Every signal a topic page reads: its list, its chart and its pairs. */
|
||||
export function signalsToRead(
|
||||
def: TopicDefinition,
|
||||
ctx: TopicVisibility,
|
||||
): Set<TopicSignalKey> {
|
||||
const keys = new Set<TopicSignalKey>([...def.signals, ...def.chart]);
|
||||
for (const rel of def.relationships) {
|
||||
keys.add(rel.x);
|
||||
keys.add(rel.y);
|
||||
}
|
||||
return new Set([...keys].filter((key) => isSignalVisible(key, ctx)));
|
||||
}
|
||||
|
||||
export function visibleExtras(
|
||||
def: TopicDefinition,
|
||||
ctx: TopicVisibility,
|
||||
): Set<TopicExtra> {
|
||||
return new Set(def.extras.filter((extra) => isExtraVisible(extra, ctx)));
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* Request schemas of the topic routes (preview): the topic in the path and
|
||||
* the window of a topic page. Shared by the routes and the OpenAPI registry.
|
||||
*/
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import {
|
||||
DEFAULT_TOPIC_RANGE,
|
||||
TOPIC_KEYS,
|
||||
TOPIC_RANGES,
|
||||
} from "@/lib/topics/registry";
|
||||
|
||||
export const topicKeySchema = z
|
||||
.enum(TOPIC_KEYS)
|
||||
.describe("The topic, as its URL slug.");
|
||||
|
||||
export const topicPathSchema = z.object({ topic: topicKeySchema });
|
||||
|
||||
export const topicDetailQuerySchema = z.object({
|
||||
range: z
|
||||
.enum(TOPIC_RANGES)
|
||||
.default(DEFAULT_TOPIC_RANGE)
|
||||
.describe(
|
||||
"The window the side-by-side series cover. The usual ranges, the trends and the relationships always read at least the trailing 90 days.",
|
||||
),
|
||||
});
|
||||
|
||||
export type TopicDetailQuery = z.infer<typeof topicDetailQuerySchema>;
|
||||
Loaded 100 of 102 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user