Merge branch 'v143/ios' into release/v1.43.0

This commit is contained in:
Marc-André Bombeck committed 2026-10-16 00:28:40 +02:00
commit 3fc37c9183
78 files changed
+5928 -489

No files matched your search

+269 -187
View File
@@ -447,17 +447,36 @@ paths:
tags:
- Cycle
summary: Soft-delete a cycle day-log (v1.15.0)
description: Sets `deletedAt` + bumps `syncVersion`; surfaces as a tombstone on the next `/api/sync/changes` page. 204.
Idempotent.
description: "Sets `deletedAt` + bumps `syncVersion`; surfaces as a tombstone on the next `/api/sync/changes` page. A
day that opened a cycle takes that cycle with it, which gives back any start that cycle had folded in. 204, or
200 with `CycleDeleteOutcome` under `Prefer: return=representation`. Idempotent."
parameters:
- in: path
name: id
schema:
type: string
required: true
- name: Prefer
in: header
required: false
schema:
type: string
example: return=representation
description: "v1.43 — send `return=representation` (RFC 7240) to get 200 with what the delete changed
(`CycleDeleteOutcome`) and a `Preference-Applied: return=representation` header. Without it the route
answers 204 with no body, as before."
responses:
"200":
description: "v1.43 — with `Prefer: return=representation`: what the delete changed."
description: "v1.43 — with `Prefer: return=representation`: the removed cycle (null when the delete removed none, e.g. a
day that opened no cycle or a repeated delete) and the starts the removal gave back."
headers:
Preference-Applied:
required: true
description: "The route honoured `Prefer: return=representation`."
schema:
type: string
const: return=representation
description: "The route honoured `Prefer: return=representation`."
content:
application/json:
schema:
@@ -618,16 +637,35 @@ paths:
tags:
- Cycle
summary: Soft-delete a menstrual cycle (v1.15.0)
description: Sets `deletedAt` + bumps `syncVersion`; tombstones on the next sync page. 204. Idempotent.
description: "Sets `deletedAt` + bumps `syncVersion`; tombstones on the next sync page. Any start this cycle had folded
in stands again. 204, or 200 with `CycleDeleteOutcome` under `Prefer: return=representation`. Idempotent."
parameters:
- in: path
name: id
schema:
type: string
required: true
- name: Prefer
in: header
required: false
schema:
type: string
example: return=representation
description: "v1.43 — send `return=representation` (RFC 7240) to get 200 with what the delete changed
(`CycleDeleteOutcome`) and a `Preference-Applied: return=representation` header. Without it the route
answers 204 with no body, as before."
responses:
"200":
description: "v1.43 — with `Prefer: return=representation`: what the delete changed."
description: "v1.43 — with `Prefer: return=representation`: the removed cycle (null when the delete removed none, e.g. a
day that opened no cycle or a repeated delete) and the starts the removal gave back."
headers:
Preference-Applied:
required: true
description: "The route honoured `Prefer: return=representation`."
schema:
type: string
const: return=representation
description: "The route honoured `Prefer: return=representation`."
content:
application/json:
schema:
@@ -4739,59 +4777,6 @@ paths:
"403": *a13
"422": *a4
"429": *a5
/api/personal-records:
get:
tags:
- Awards
summary: List detected personal bests
description: >-
The record's personal bests, newest achievement first. Read-only — rows come from the detection worker and no
endpoint creates or edits one.
Both query parameters are validated and a bad value is a 422. They were forgiving until this release, and in one
direction that was worse than merely quiet: dropping an unrecognised `metricType` WIDENED the read to every
metric, so a typo returned more rows than the caller asked for. The `limit` ceiling refuses rather than clamps
for the same reason.
Delegable on a `measurements` grant.
parameters:
- name: metricType
in: query
required: false
schema:
type: string
description: A `MeasurementType` to filter to. A value outside the enum is refused with 422 — it is not dropped, because
dropping it would widen the read rather than narrow it.
- name: limit
in: query
required: false
schema:
type: integer
minimum: 1
maximum: 500
default: 100
description: Page cap, default 100, ceiling 500. A non-integer, a zero, a negative value or anything above 500 is
refused with 422 rather than corrected — serving a different number of rows than were asked for is another
difference nobody is told about.
responses:
"200":
description: The record's personal bests, newest first.
content:
application/json:
schema:
$ref: "#/components/schemas/ListPersonalRecordsEnvelope"
"401": *a1
"403": *a11
"422":
description: "`metricType` or `limit` carried an unacceptable value. `meta.errorCode` =
`personal_records.invalid_query`, with the offending path under `details.issues`."
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorEnvelope"
"429": *a5
/api/workouts:
get:
tags:
@@ -22360,6 +22345,59 @@ paths:
anyOf:
- $ref: "#/components/schemas/HealthAdminResponse"
- $ref: "#/components/schemas/HealthPublicResponse"
/api/personal-records:
get:
tags:
- Awards
summary: List detected personal bests
description: >-
The record's personal bests, newest achievement first. Read-only — rows come from the detection worker and no
endpoint creates or edits one.
Both query parameters are validated and a bad value is a 422. They were forgiving until this release, and in one
direction that was worse than merely quiet: dropping an unrecognised `metricType` WIDENED the read to every
metric, so a typo returned more rows than the caller asked for. The `limit` ceiling refuses rather than clamps
for the same reason.
Delegable on a `measurements` grant.
parameters:
- name: metricType
in: query
required: false
schema:
type: string
description: A `MeasurementType` to filter to. A value outside the enum is refused with 422 — it is not dropped, because
dropping it would widen the read rather than narrow it.
- name: limit
in: query
required: false
schema:
type: integer
minimum: 1
maximum: 500
default: 100
description: Page cap, default 100, ceiling 500. A non-integer, a zero, a negative value or anything above 500 is
refused with 422 rather than corrected — serving a different number of rows than were asked for is another
difference nobody is told about.
responses:
"200":
description: The record's personal bests, newest first.
content:
application/json:
schema:
$ref: "#/components/schemas/ListPersonalRecordsEnvelope"
"401": *a1
"403": *a11
"422":
description: "`metricType` or `limit` carried an unacceptable value. `meta.errorCode` =
`personal_records.invalid_query`, with the offending path under `details.issues`."
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorEnvelope"
"429": *a5
/api/gamification/achievements:
get:
tags:
@@ -26438,19 +26476,24 @@ components:
$ref: "#/components/schemas/AppleHealthBatchEntry"
syncTrigger:
description: 'Diagnostic-only. Names what triggered this sync (foreground app open, background refresh, a push wake, or
a user-initiated `manual` "Sync all" run, since v1.42). Recorded on the ingest wide event and kept on the
account as `lastSyncTrigger` — plus `lastBackgroundSyncAt` for a `background` or `push` trigger — both
readable from `GET /api/integrations/healthkit`. Since v1.42 it is also recorded per measurement type the
batch carried (Apple Health rows only), served as `lastReceivedAt` / `lastTrigger` / `lastNewSampleAt` on
each `metricFreshness` entry of that read, plus `lastBackgroundAt` (since v1.42.4) for a `background`
trigger. It does not affect dedup, attribution, or how any sample is stored. Optional and
backward-compatible: pre-#66 clients omit it.'
a user-initiated `manual` "Sync all" run, since v1.42). `backfill` (v1.43, ios #77; send it only when `GET
/api/auth/me` reports `features.syncTriggerBackfill: true`, an older server refuses the whole batch with
422) names a history catch-up: it moves only `syncProgress` and the per-type `lastBackfillAt` in `GET
/api/integrations/healthkit`, never `lastReceivedAt`, `lastTrigger`, `lastBackgroundAt`, `lastSyncTrigger`,
`lastBackgroundSyncAt` or the sync checkpoint. Recorded on the ingest wide event and kept on the account as
`lastSyncTrigger` — plus `lastBackgroundSyncAt` for a `background` or `push` trigger — both readable from
`GET /api/integrations/healthkit`. Since v1.42 it is also recorded per measurement type the batch carried
(Apple Health rows only), served as `lastReceivedAt` / `lastTrigger` / `lastNewSampleAt` on each
`metricFreshness` entry of that read, plus `lastBackgroundAt` (since v1.42.4) for a `background` trigger. It
does not affect dedup, attribution, or how any sample is stored. Optional and backward-compatible: pre-#66
clients omit it.'
type: string
enum:
- foreground
- background
- push
- manual
- backfill
required:
- entries
description: Apple Health batch ingest. ≤500 entries per call; idempotent via `Idempotency-Key`.
@@ -26515,7 +26558,7 @@ components:
minLength: 1
maxLength: 120
description: >-
Dedup key for the `(userId, type, source, externalId)` composite index. Three shapes:
Dedup key for the `(userId, type, source, externalId)` composite index. Four shapes:
• `HKSample.uuid` — a per-sample reading. First-write-wins: a re-post returns `duplicate` and the stored row
is immutable.
@@ -26530,6 +26573,17 @@ components:
returns `updated`, so iOS uploads ~144 rows/day instead of one per raw HR sample. A row that targets this
prefix with a malformed bucket-start suffix is `skipped` with reason `malformed_hr_bucket_id`.
• `stats:<HKQuantityTypeIdentifier>:<YYYY-MM-DD>T<HH>` — v1.43 (ios #77), only when `GET /api/auth/me`
reports `features.cumulativeHourBuckets: true`: one local hour's SUM of steps, active energy, walking and
running distance or flights climbed, the date and the zero-padded hour being the local hour in the account's
zone (`timezone` on `/api/auth/me`). `value` is the hour's sum in the unit of the day form, `startDate` the
first instant of that local hour, `endDate` one hour later. Such a row is never a measurement and never
changes the day total, which stays the day form: it feeds the intraday profile behind the same-time baseline
(a day with at least four posted hours is built from them). A re-post overwrites (`updated`). Skipped with
`hour_bucket_misaligned` when `startDate` is not the start of that local hour,
`hour_bucket_unsupported_type` for any other identifier or a `MANUAL` / bridge entry, `hour_bucket_too_old`
when the day is more than 400 days back.
The id MUST be stable across app restarts. An object description or memory address (`<Class: 0x…>`,
`0x12ab34cd`) is a new value on every launch, so it can never match its own earlier row; such an entry is
`skipped` with reason `unstable_external_id` while the rest of the batch still lands.
@@ -27033,17 +27087,20 @@ components:
items:
$ref: "#/components/schemas/BulkMedicationIntakeEntry"
syncTrigger:
description: 'Diagnostic-only, the same field `POST /api/measurements/batch` takes (since v1.42.4): what woke the client
for this sync. When the batch carries entries with `source: "APPLE_HEALTH"` that land (`inserted`, `updated`
or `duplicate`), it is recorded on the `MEDICATION_DOSE` entry of `metricFreshness` in `GET
description: "Diagnostic-only, the same field `POST /api/measurements/batch` takes (since v1.42.4): what woke the client
for this sync. `backfill` (v1.43, only when `features.syncTriggerBackfill` is `true`) marks a history
catch-up, which moves only the `MEDICATION_DOSE` entry's `lastBackfillAt` and leaves its live stamps and the
sync checkpoint alone. When the batch carries entries with `source: \"APPLE_HEALTH\"` that land (`inserted`,
`updated` or `duplicate`), it is recorded on the `MEDICATION_DOSE` entry of `metricFreshness` in `GET
/api/integrations/healthkit` as `lastTrigger`, and a `background` value also dates its `lastBackgroundAt`.
It changes no dose row. Optional; omitted records a null trigger.'
It changes no dose row. Optional; omitted records a null trigger."
type: string
enum:
- foreground
- background
- push
- manual
- backfill
required:
- entries
description: iOS SyncMode bulk intake backfill. 1–500 entries per call; idempotent via `Idempotency-Key` and per-entry
@@ -36140,7 +36197,8 @@ components:
- type: object
properties:
restored:
description: v1.43 — starts given back because this edit cleared the flow on a start day.
description: v1.43 — starts given back because this edit cleared the flow on a start day; empty when it gave back none.
Absent on older servers.
type: array
items:
$ref: "#/components/schemas/CycleRestoredStart"
@@ -36296,7 +36354,8 @@ components:
- $ref: "#/components/schemas/CycleDayLogDTO"
- type: "null"
absorbed:
description: 'v1.43 — on `action: "start"`: a later start within the period length folded into this one, or null.'
description: 'v1.43 — a later start within the period length that this start folded in (it stops being a start of its
own), or null. Present on every `action: "start"` answer; null on `end`.'
anyOf:
- $ref: "#/components/schemas/CycleRestoredStart"
- type: "null"
@@ -40106,7 +40165,6 @@ components:
- type: "null"
unit:
type: string
minLength: 1
referenceLow:
anyOf:
- type: number
@@ -40189,7 +40247,6 @@ components:
minLength: 1
unit:
type: string
minLength: 1
lowerBound:
anyOf:
- type: number
@@ -41599,6 +41656,24 @@ components:
type: integer
minimum: -9007199254740991
maximum: 9007199254740991
hourlyIncrements:
default: []
type: array
items:
type: number
basis:
default: PER_SAMPLE
type: string
enum:
- PER_SAMPLE
- HOUR_BUCKETS
bucketCount:
default: null
anyOf:
- type: integer
minimum: 0
maximum: 9007199254740991
- type: "null"
timezone:
type: string
minLength: 1
@@ -41614,6 +41689,9 @@ components:
- hourlyCumulative
- dayTotal
- sampleCount
- hourlyIncrements
- basis
- bucketCount
- timezone
additionalProperties: {}
healthScoreRecords:
@@ -46778,7 +46856,9 @@ components:
reason:
description: "Why a row was `skipped` or a `duplicate`. `folded_window` (since v1.42): a raw sample of a dense or mean
type whose local day lies before the fold boundary and whose hour or day a live `stats:` row of the same
source already covers. Nothing to retry; treat it like any duplicate."
source already covers. Nothing to retry; treat it like any duplicate. `hour_bucket_misaligned`,
`hour_bucket_unsupported_type`, `hour_bucket_too_old` (since v1.43): an hourly
`stats:<HK>:<YYYY-MM-DD>T<HH>` row the server refused, see `externalId`; permanent for the row as sent."
type: string
convertedValue:
description: "Only on a `value_out_of_range` skip (since v1.39.1): the entry's value after conversion into the stored
@@ -47388,115 +47468,6 @@ components:
description: "Server-authoritative sleep-rhythm read: the outstanding sleep-debt balance over the rolling window + MCTQ
chronotype (MSF/MSFsc band, social jetlag). Free vs work nights default to weekend = free in the user's timezone
(no work calendar). A view over existing per-stage SLEEP_DURATION rows — no schema, no new type."
ListPersonalRecordsEnvelope:
type: object
properties:
data:
type: array
items:
$ref: "#/components/schemas/PersonalRecord"
error:
type: "null"
meta:
type: object
properties:
requestId:
type: string
additionalProperties: false
required:
- data
- error
additionalProperties: false
PersonalRecord:
type: object
properties:
id:
type: string
userId:
type: string
description: The RECORD's user id — under a delegated read this is the owner, not the caller.
metricType:
$ref: "#/components/schemas/MeasurementType"
metricSlot:
anyOf:
- type: string
- type: "null"
description: Per-sport bucket for workout-driven bests (e.g. "running_5km_time"). Null for a plain measurement-driven
best, where the metric type alone is the dimension.
direction:
type: string
enum:
- MAX
- MIN
description: Which way is better. MAX for steps / VO2 max / HRV / distance; MIN for resting heart rate, body fat, audio
exposure.
value:
type: number
unit:
type: string
achievedAt:
type: string
format: date-time
pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$
sourceMeasurementId:
anyOf:
- type: string
- type: "null"
description: The measurement that achieved the record. Nulled rather than cascaded when that measurement is later
deleted, so the historical fact survives the reading behind it.
source:
$ref: "#/components/schemas/MeasurementSource"
externalId:
anyOf:
- type: string
- type: "null"
createdAt:
type: string
format: date-time
pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$
benchmark:
description: v1.43 — an age- and sex-aware comparison band where a cited norm exists, in the record's unit; null
otherwise.
anyOf:
- $ref: "#/components/schemas/RecordBenchmark"
- type: "null"
required:
- id
- userId
- metricType
- metricSlot
- direction
- value
- unit
- achievedAt
- sourceMeasurementId
- source
- externalId
- createdAt
additionalProperties: false
description: One detected personal best. Rows are written by the detection worker, never by a client — there is no write
endpoint on this resource.
RecordBenchmark:
type: object
properties:
low:
type: number
high:
type: number
unit:
type: string
source:
type: string
sourceLabel:
type: string
required:
- low
- high
- unit
- source
- sourceLabel
additionalProperties: false
description: An age- and sex-aware comparison band from a cited norm, in the record's unit.
ListWorkoutsResponse:
type: object
properties:
@@ -63897,9 +63868,11 @@ components:
`STRESS_SCORE` / `STRAIN_SCORE` return the `WellnessScoreValue` schema — field-by-field in
`components.schemas`, including the `series`, `daysInWindow` and `asOf` a client would otherwise have to
discover by inspecting a live payload. `VITALS_BASELINE` returns { type, center, low, high, spread,
sampleDays, k, series }, where `series` is the trailing per-day means for the inline sparkline. The
remaining metrics' value objects are not modelled here yet; their shapes are TypeScript interfaces beside
their engines under `src/lib/insights/derived/`.
sampleDays, k, series, direction }, where `series` is the trailing per-day means for the inline sparkline
and `direction` (v1.43, a `TrendVerdict`) says which way a change of the vital counts for this person, with
`center` placed against their own target or a profile default. The remaining metrics' value objects are not
modelled here yet; their shapes are TypeScript interfaces beside their engines under
`src/lib/insights/derived/`.
coverage:
$ref: "#/components/schemas/DerivedCoverage"
confidence:
@@ -82404,6 +82377,115 @@ components:
additionalProperties: false
description: "What an unauthenticated (or non-admin) caller sees: the aggregate verdict and nothing else. No component
detail, so an uptime probe cannot be used to fingerprint which part of a self-hosted instance is unwell."
ListPersonalRecordsEnvelope:
type: object
properties:
data:
type: array
items:
$ref: "#/components/schemas/PersonalRecord"
error:
type: "null"
meta:
type: object
properties:
requestId:
type: string
additionalProperties: false
required:
- data
- error
additionalProperties: false
PersonalRecord:
type: object
properties:
id:
type: string
userId:
type: string
description: The RECORD's user id — under a delegated read this is the owner, not the caller.
metricType:
$ref: "#/components/schemas/MeasurementType"
metricSlot:
anyOf:
- type: string
- type: "null"
description: Per-sport bucket for workout-driven bests (e.g. "running_5km_time"). Null for a plain measurement-driven
best, where the metric type alone is the dimension.
direction:
type: string
enum:
- MAX
- MIN
description: Which way is better. MAX for steps / VO2 max / HRV / distance; MIN for resting heart rate, body fat, audio
exposure.
value:
type: number
unit:
type: string
achievedAt:
type: string
format: date-time
pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$
sourceMeasurementId:
anyOf:
- type: string
- type: "null"
description: The measurement that achieved the record. Nulled rather than cascaded when that measurement is later
deleted, so the historical fact survives the reading behind it.
source:
$ref: "#/components/schemas/MeasurementSource"
externalId:
anyOf:
- type: string
- type: "null"
createdAt:
type: string
format: date-time
pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$
benchmark:
description: v1.43 — an age- and sex-aware comparison band where a cited norm exists, in the record's unit; null
otherwise.
anyOf:
- $ref: "#/components/schemas/RecordBenchmark"
- type: "null"
required:
- id
- userId
- metricType
- metricSlot
- direction
- value
- unit
- achievedAt
- sourceMeasurementId
- source
- externalId
- createdAt
additionalProperties: false
description: One detected personal best. Rows are written by the detection worker, never by a client — there is no write
endpoint on this resource.
RecordBenchmark:
type: object
properties:
low:
type: number
high:
type: number
unit:
type: string
source:
type: string
sourceLabel:
type: string
required:
- low
- high
- unit
- source
- sourceLabel
additionalProperties: false
description: An age- and sex-aware comparison band from a cited norm, in the record's unit.
AchievementsEnvelope:
type: object
properties:
+30 -3
View File
@@ -2723,7 +2723,8 @@
"assessmentPreparing": "Einschätzung wird vorbereitet…",
"personalRecord": {
"badge": "PR",
"tooltip": "Persönlicher Rekord: {value} {unit} am {date}"
"tooltip": "Persönlicher Rekord: {value} {unit} am {date}",
"benchmark": "Typisch in dem Alter: {low} bis {high} {unit} ({source})"
},
"loadError": "Auswertungen konnten nicht geladen werden",
"heroPersonalBaseline": "Basierend auf deinen letzten 90 Tagen",
@@ -4277,6 +4278,21 @@
"learning": "Lernphase: {entries} von {threshold} Tagen erfasst. Ein Vergleich erscheint, sobald genug davon zusammengekommen sind.",
"noPattern": "In den bisher erfassten {entries} Tagen ist noch nichts gleichmäßig genug, um einen Tag damit zu vergleichen. Wenn du auch den Kontext eines Tages festhältst, wird mehr davon vergleichbar.",
"featureValue": "{field}: {value}"
},
"patterns": {
"title": "Muster in deiner Stimmung",
"basedOn": "Grundlage: {subject} und {comparison} Tage. Ein Muster, keine Ursache.",
"previousDayHigher": "Nach einem Tag unter deinem Üblichen ist der nächste Tag meist besser ({subject} gegenüber {comparison} nach einem Tag darüber).",
"previousDayLower": "Ein Tag unter deinem Üblichen wirkt meist in den nächsten Tag hinein ({subject} gegenüber {comparison} nach einem Tag darüber).",
"weekendHigher": "Am Wochenende ist deine Stimmung meist besser ({subject} gegenüber {comparison} unter der Woche).",
"weekendLower": "Am Wochenende ist deine Stimmung meist schlechter ({subject} gegenüber {comparison} unter der Woche).",
"bestWeekday": "{weekday} ist meist dein bester Tag ({subject} gegenüber {comparison} an den übrigen Tagen).",
"notesHigher": "An Tagen mit Notiz ist deine Stimmung meist besser ({subject} gegenüber {comparison} ohne).",
"notesLower": "An Tagen mit Notiz ist deine Stimmung meist schlechter ({subject} gegenüber {comparison} ohne).",
"delayedTagHigher": "Am Tag nach „{tag}“ ist deine Stimmung meist besser ({subject} gegenüber {comparison} sonst).",
"delayedTagLower": "Am Tag nach „{tag}“ ist deine Stimmung meist schlechter ({subject} gegenüber {comparison} sonst).",
"multipleEntriesHigher": "An Tagen mit mehreren Einträgen ist deine Stimmung meist besser ({subject} gegenüber {comparison} an Tagen mit einem).",
"multipleEntriesLower": "An Tagen mit mehreren Einträgen ist deine Stimmung meist schlechter ({subject} gegenüber {comparison} an Tagen mit einem)."
}
},
"derived": {
@@ -7223,7 +7239,8 @@
"lastReceived": "Zuletzt empfangen {relative}",
"lastNewValue": "Letzter neuer Wert {relative}",
"lastBackground": "Zuletzt im Hintergrund {relative}",
"never": "Noch nicht empfangen"
"never": "Noch nicht empfangen",
"lastBackfill": "Verlauf nachgeladen {relative}"
}
},
"garminInfo": {
@@ -9325,7 +9342,9 @@
"periodStartSaved": "Periodenbeginn gespeichert",
"periodEndSaved": "Periodenende gespeichert",
"startedPeriodOnDate": "Periode hat an diesem Tag begonnen",
"endedPeriodOnDate": "Periode an diesem Tag beendet"
"endedPeriodOnDate": "Periode an diesem Tag beendet",
"startAbsorbed": "Periodenbeginn gespeichert. Dein Beginn am {absorbed} wurde mit dem {date} zusammengeführt.",
"startRestored": "Gespeichert. Der Beginn am {dates} ist wieder da."
},
"cycleReminders": {
"periodSoonTitle": "Periode steht wahrscheinlich bevor",
@@ -12943,5 +12962,13 @@
"lastUpdated": "Zuletzt aktualisiert: {date}",
"project": "Projekt",
"credits": "Danksagungen"
},
"series": {
"reference": {
"targetUpper": "Dein Ziel, Obergrenze",
"targetLower": "Dein Ziel, Untergrenze",
"clinicalUpper": "Klinische Obergrenze",
"clinicalLower": "Klinische Untergrenze"
}
}
}
+30 -3
View File
@@ -2723,7 +2723,8 @@
"bmiEmptyAction": "Open account settings",
"personalRecord": {
"badge": "PR",
"tooltip": "Personal record: {value} {unit} on {date}"
"tooltip": "Personal record: {value} {unit} on {date}",
"benchmark": "Typical at that age: {low} to {high} {unit} ({source})"
},
"loadError": "Failed to load insights",
"heroPersonalBaseline": "Based on your last 90 days",
@@ -4277,6 +4278,21 @@
"learning": "Learning phase: {entries} of {threshold} days recorded. A comparison appears once there are enough of them.",
"noPattern": "Nothing in the {entries} days recorded so far is steady enough to compare a day against. When you also capture a day's context, more of it becomes comparable.",
"featureValue": "{field}: {value}"
},
"patterns": {
"title": "Patterns in your mood",
"basedOn": "Based on {subject} and {comparison} days. A pattern, not a cause.",
"previousDayHigher": "After a below-usual day, the next day tends to be better ({subject} against {comparison} after an above-usual day).",
"previousDayLower": "A below-usual day tends to carry over into the next one ({subject} against {comparison} after an above-usual day).",
"weekendHigher": "Your mood tends to be higher at the weekend ({subject} against {comparison} on weekdays).",
"weekendLower": "Your mood tends to be lower at the weekend ({subject} against {comparison} on weekdays).",
"bestWeekday": "{weekday} tends to be your best day ({subject} against {comparison} on the other days).",
"notesHigher": "On days with a note your mood tends to be higher ({subject} against {comparison} without one).",
"notesLower": "On days with a note your mood tends to be lower ({subject} against {comparison} without one).",
"delayedTagHigher": "The day after “{tag}”, your mood tends to be higher ({subject} against {comparison} otherwise).",
"delayedTagLower": "The day after “{tag}”, your mood tends to be lower ({subject} against {comparison} otherwise).",
"multipleEntriesHigher": "On days you log more than once, your mood tends to be higher ({subject} against {comparison} on single-entry days).",
"multipleEntriesLower": "On days you log more than once, your mood tends to be lower ({subject} against {comparison} on single-entry days)."
}
},
"derived": {
@@ -7223,7 +7239,8 @@
"lastReceived": "Last received {relative}",
"lastNewValue": "Last new value {relative}",
"lastBackground": "Last in background {relative}",
"never": "Not received yet"
"never": "Not received yet",
"lastBackfill": "History caught up {relative}"
}
},
"garminInfo": {
@@ -9325,7 +9342,9 @@
"periodStartSaved": "Period start saved",
"periodEndSaved": "End of period saved",
"startedPeriodOnDate": "My period started this day",
"endedPeriodOnDate": "My period ended this day"
"endedPeriodOnDate": "My period ended this day",
"startAbsorbed": "Period start saved. Your start on {absorbed} was merged into {date}.",
"startRestored": "Saved. The start on {dates} is back."
},
"cycleReminders": {
"periodSoonTitle": "Period likely soon",
@@ -12943,5 +12962,13 @@
"lastUpdated": "Last updated: {date}",
"project": "Project",
"credits": "Credits"
},
"series": {
"reference": {
"targetUpper": "Your target, upper limit",
"targetLower": "Your target, lower limit",
"clinicalUpper": "Clinical upper limit",
"clinicalLower": "Clinical lower limit"
}
}
}
+30 -3
View File
@@ -2723,7 +2723,8 @@
"bmiEmptyAction": "Abrir ajustes de cuenta",
"personalRecord": {
"badge": "PR",
"tooltip": "Récord personal: {value} {unit} el {date}"
"tooltip": "Récord personal: {value} {unit} el {date}",
"benchmark": "Lo típico a esa edad: de {low} a {high} {unit} ({source})"
},
"loadError": "No se han podido cargar los análisis",
"heroPersonalBaseline": "Basado en tus últimos 90 días",
@@ -4277,6 +4278,21 @@
"learning": "Fase de aprendizaje: {entries} de {threshold} días registrados. La comparación aparece cuando haya suficientes.",
"noPattern": "En los {entries} días registrados hasta ahora no hay nada lo bastante constante para comparar un día. Cuando anotas también el contexto del día, se puede comparar una parte mayor.",
"featureValue": "{field}: {value}"
},
"patterns": {
"title": "Patrones en tu estado de ánimo",
"basedOn": "Basado en {subject} y {comparison} días. Un patrón, no una causa.",
"previousDayHigher": "Después de un día por debajo de lo habitual, el siguiente suele ser mejor ({subject} frente a {comparison} tras un día por encima).",
"previousDayLower": "Un día por debajo de lo habitual suele arrastrarse al siguiente ({subject} frente a {comparison} tras un día por encima).",
"weekendHigher": "Tu estado de ánimo suele ser mejor el fin de semana ({subject} frente a {comparison} entre semana).",
"weekendLower": "Tu estado de ánimo suele ser peor el fin de semana ({subject} frente a {comparison} entre semana).",
"bestWeekday": "{weekday} suele ser tu mejor día ({subject} frente a {comparison} el resto de días).",
"notesHigher": "Los días con una nota tu estado de ánimo suele ser mejor ({subject} frente a {comparison} sin nota).",
"notesLower": "Los días con una nota tu estado de ánimo suele ser peor ({subject} frente a {comparison} sin nota).",
"delayedTagHigher": "El día después de «{tag}», tu estado de ánimo suele ser mejor ({subject} frente a {comparison} en otros casos).",
"delayedTagLower": "El día después de «{tag}», tu estado de ánimo suele ser peor ({subject} frente a {comparison} en otros casos).",
"multipleEntriesHigher": "Los días que registras más de una vez, tu estado de ánimo suele ser mejor ({subject} frente a {comparison} con un solo registro).",
"multipleEntriesLower": "Los días que registras más de una vez, tu estado de ánimo suele ser peor ({subject} frente a {comparison} con un solo registro)."
}
},
"derived": {
@@ -7223,7 +7239,8 @@
"lastReceived": "Última recepción {relative}",
"lastNewValue": "Último valor nuevo {relative}",
"lastBackground": "Última vez en segundo plano {relative}",
"never": "Aún no recibido"
"never": "Aún no recibido",
"lastBackfill": "Historial recuperado {relative}"
}
},
"garminInfo": {
@@ -9325,7 +9342,9 @@
"periodStartSaved": "Inicio del periodo guardado",
"periodEndSaved": "Fin del periodo guardado",
"startedPeriodOnDate": "Mi periodo empezó este día",
"endedPeriodOnDate": "Mi periodo terminó este día"
"endedPeriodOnDate": "Mi periodo terminó este día",
"startAbsorbed": "Inicio del periodo guardado. Tu inicio del {absorbed} se ha unido al del {date}.",
"startRestored": "Guardado. El inicio del {dates} vuelve a estar."
},
"cycleReminders": {
"periodSoonTitle": "El periodo llegará pronto",
@@ -12943,5 +12962,13 @@
"lastUpdated": "Última actualización: {date}",
"project": "Proyecto",
"credits": "Créditos"
},
"series": {
"reference": {
"targetUpper": "Tu objetivo, límite superior",
"targetLower": "Tu objetivo, límite inferior",
"clinicalUpper": "Límite clínico superior",
"clinicalLower": "Límite clínico inferior"
}
}
}
+30 -3
View File
@@ -2723,7 +2723,8 @@
"bmiEmptyAction": "Ouvrir les paramètres du compte",
"personalRecord": {
"badge": "PR",
"tooltip": "Record personnel : {value} {unit} le {date}"
"tooltip": "Record personnel : {value} {unit} le {date}",
"benchmark": "Valeur typique à cet âge : de {low} à {high} {unit} ({source})"
},
"loadError": "Impossible de charger les analyses",
"heroPersonalBaseline": "Basé sur tes 90 derniers jours",
@@ -4277,6 +4278,21 @@
"learning": "Phase d'apprentissage : {entries} journées sur {threshold} enregistrées. La comparaison apparaît une fois qu'il y en a assez.",
"noPattern": "Dans les {entries} journées enregistrées jusqu'ici, rien n'est assez régulier pour comparer une journée. Quand tu notes aussi le contexte d'une journée, tu en rends davantage comparable.",
"featureValue": "{field} : {value}"
},
"patterns": {
"title": "Tendances de votre humeur",
"basedOn": "Sur la base de {subject} et {comparison} jours. Une tendance, pas une cause.",
"previousDayHigher": "Après une journée en dessous de l’habitude, le lendemain est souvent meilleur ({subject} contre {comparison} après une journée au-dessus).",
"previousDayLower": "Une journée en dessous de l’habitude a tendance à se prolonger le lendemain ({subject} contre {comparison} après une journée au-dessus).",
"weekendHigher": "Votre humeur a tendance à être meilleure le week-end ({subject} contre {comparison} en semaine).",
"weekendLower": "Votre humeur a tendance à être plus basse le week-end ({subject} contre {comparison} en semaine).",
"bestWeekday": "{weekday} est souvent votre meilleur jour ({subject} contre {comparison} les autres jours).",
"notesHigher": "Les jours avec une note, votre humeur a tendance à être meilleure ({subject} contre {comparison} sans note).",
"notesLower": "Les jours avec une note, votre humeur a tendance à être plus basse ({subject} contre {comparison} sans note).",
"delayedTagHigher": "Le lendemain de « {tag} », votre humeur a tendance à être meilleure ({subject} contre {comparison} sinon).",
"delayedTagLower": "Le lendemain de « {tag} », votre humeur a tendance à être plus basse ({subject} contre {comparison} sinon).",
"multipleEntriesHigher": "Les jours où vous notez plusieurs fois, votre humeur a tendance à être meilleure ({subject} contre {comparison} avec une seule saisie).",
"multipleEntriesLower": "Les jours où vous notez plusieurs fois, votre humeur a tendance à être plus basse ({subject} contre {comparison} avec une seule saisie)."
}
},
"derived": {
@@ -7223,7 +7239,8 @@
"lastReceived": "Dernière réception {relative}",
"lastNewValue": "Dernière nouvelle valeur {relative}",
"lastBackground": "Dernière fois en arrière-plan {relative}",
"never": "Pas encore reçu"
"never": "Pas encore reçu",
"lastBackfill": "Historique rattrapé {relative}"
}
},
"garminInfo": {
@@ -9325,7 +9342,9 @@
"periodStartSaved": "Début des règles enregistré",
"periodEndSaved": "Fin des règles enregistrée",
"startedPeriodOnDate": "Mes règles ont commencé ce jour-là",
"endedPeriodOnDate": "Mes règles se sont terminées ce jour-là"
"endedPeriodOnDate": "Mes règles se sont terminées ce jour-là",
"startAbsorbed": "Début des règles enregistré. Votre début du {absorbed} a été fusionné avec celui du {date}.",
"startRestored": "Enregistré. Le début du {dates} est de retour."
},
"cycleReminders": {
"periodSoonTitle": "Règles probablement bientôt",
@@ -12943,5 +12962,13 @@
"lastUpdated": "Dernière mise à jour : {date}",
"project": "Projet",
"credits": "Crédits"
},
"series": {
"reference": {
"targetUpper": "Votre objectif, limite haute",
"targetLower": "Votre objectif, limite basse",
"clinicalUpper": "Limite clinique haute",
"clinicalLower": "Limite clinique basse"
}
}
}
+30 -3
View File
@@ -2723,7 +2723,8 @@
"bmiEmptyAction": "Apri impostazioni account",
"personalRecord": {
"badge": "PR",
"tooltip": "Record personale: {value} {unit} il {date}"
"tooltip": "Record personale: {value} {unit} il {date}",
"benchmark": "Tipico a quell’età: da {low} a {high} {unit} ({source})"
},
"loadError": "Impossibile caricare le analisi",
"heroPersonalBaseline": "Basato sugli ultimi 90 giorni",
@@ -4277,6 +4278,21 @@
"learning": "Fase di apprendimento: {entries} giornate su {threshold} registrate. Il confronto compare quando ce ne sono abbastanza.",
"noPattern": "Nelle {entries} giornate registrate finora non c'è nulla di abbastanza costante per confrontare una giornata. Quando registri anche il contesto della giornata, ne rendi confrontabile una parte maggiore.",
"featureValue": "{field}: {value}"
},
"patterns": {
"title": "Schemi nel tuo umore",
"basedOn": "Basato su {subject} e {comparison} giorni. Uno schema, non una causa.",
"previousDayHigher": "Dopo un giorno sotto la tua media, il giorno seguente tende a essere migliore ({subject} contro {comparison} dopo un giorno sopra).",
"previousDayLower": "Un giorno sotto la tua media tende a trascinarsi in quello seguente ({subject} contro {comparison} dopo un giorno sopra).",
"weekendHigher": "Il tuo umore tende a essere migliore nel fine settimana ({subject} contro {comparison} nei giorni feriali).",
"weekendLower": "Il tuo umore tende a essere peggiore nel fine settimana ({subject} contro {comparison} nei giorni feriali).",
"bestWeekday": "{weekday} tende a essere il tuo giorno migliore ({subject} contro {comparison} negli altri giorni).",
"notesHigher": "Nei giorni con una nota il tuo umore tende a essere migliore ({subject} contro {comparison} senza).",
"notesLower": "Nei giorni con una nota il tuo umore tende a essere peggiore ({subject} contro {comparison} senza).",
"delayedTagHigher": "Il giorno dopo «{tag}», il tuo umore tende a essere migliore ({subject} contro {comparison} negli altri casi).",
"delayedTagLower": "Il giorno dopo «{tag}», il tuo umore tende a essere peggiore ({subject} contro {comparison} negli altri casi).",
"multipleEntriesHigher": "Nei giorni in cui registri più volte, il tuo umore tende a essere migliore ({subject} contro {comparison} con una sola voce).",
"multipleEntriesLower": "Nei giorni in cui registri più volte, il tuo umore tende a essere peggiore ({subject} contro {comparison} con una sola voce)."
}
},
"derived": {
@@ -7223,7 +7239,8 @@
"lastReceived": "Ultima ricezione {relative}",
"lastNewValue": "Ultimo nuovo valore {relative}",
"lastBackground": "Ultima volta in background {relative}",
"never": "Non ancora ricevuto"
"never": "Non ancora ricevuto",
"lastBackfill": "Storico recuperato {relative}"
}
},
"garminInfo": {
@@ -9325,7 +9342,9 @@
"periodStartSaved": "Inizio delle mestruazioni salvato",
"periodEndSaved": "Fine delle mestruazioni salvata",
"startedPeriodOnDate": "Le mestruazioni sono iniziate questo giorno",
"endedPeriodOnDate": "Le mestruazioni sono finite questo giorno"
"endedPeriodOnDate": "Le mestruazioni sono finite questo giorno",
"startAbsorbed": "Inizio delle mestruazioni salvato. Il tuo inizio del {absorbed} è stato unito a quello del {date}.",
"startRestored": "Salvato. L’inizio del {dates} è di nuovo presente."
},
"cycleReminders": {
"periodSoonTitle": "Ciclo probabilmente in arrivo",
@@ -12943,5 +12962,13 @@
"lastUpdated": "Ultimo aggiornamento: {date}",
"project": "Progetto",
"credits": "Riconoscimenti"
},
"series": {
"reference": {
"targetUpper": "Il tuo obiettivo, limite superiore",
"targetLower": "Il tuo obiettivo, limite inferiore",
"clinicalUpper": "Limite clinico superiore",
"clinicalLower": "Limite clinico inferiore"
}
}
}
+30 -3
View File
@@ -2723,7 +2723,8 @@
"bmiEmptyAction": "계정 설정 열기",
"personalRecord": {
"badge": "신기록",
"tooltip": "개인 최고 기록: {date} {value}{unit}"
"tooltip": "개인 최고 기록: {date} {value}{unit}",
"benchmark": "그 나이의 일반적인 범위: {low}~{high} {unit} ({source})"
},
"loadError": "인사이트를 불러오지 못했어요",
"heroPersonalBaseline": "최근 90일 기준",
@@ -4277,6 +4278,21 @@
"learning": "학습 중이에요: {threshold}일 중 {entries}일 기록했어요. 충분히 모이면 비교가 나타나요.",
"noPattern": "지금까지 기록한 {entries}일 중에는 하루를 견줄 만큼 일정한 패턴이 없어요. 하루의 맥락까지 함께 남기면 비교할 수 있는 게 늘어나요.",
"featureValue": "{field}: {value}"
},
"patterns": {
"title": "기분 패턴",
"basedOn": "{subject}일과 {comparison}일 기준. 원인이 아닌 패턴이에요.",
"previousDayHigher": "평소보다 낮은 날 다음 날은 대체로 더 나아요 (평소보다 높은 날 다음의 {comparison} 대비 {subject}).",
"previousDayLower": "평소보다 낮은 날은 다음 날까지 이어지는 경향이 있어요 (평소보다 높은 날 다음의 {comparison} 대비 {subject}).",
"weekendHigher": "주말에는 기분이 대체로 더 좋아요 (평일 {comparison} 대비 {subject}).",
"weekendLower": "주말에는 기분이 대체로 더 낮아요 (평일 {comparison} 대비 {subject}).",
"bestWeekday": "{weekday}이 대체로 가장 좋은 날이에요 (다른 날 {comparison} 대비 {subject}).",
"notesHigher": "메모를 남긴 날에는 기분이 대체로 더 좋아요 (메모 없는 날 {comparison} 대비 {subject}).",
"notesLower": "메모를 남긴 날에는 기분이 대체로 더 낮아요 (메모 없는 날 {comparison} 대비 {subject}).",
"delayedTagHigher": "'{tag}' 다음 날에는 기분이 대체로 더 좋아요 (그 외 {comparison} 대비 {subject}).",
"delayedTagLower": "'{tag}' 다음 날에는 기분이 대체로 더 낮아요 (그 외 {comparison} 대비 {subject}).",
"multipleEntriesHigher": "하루에 여러 번 기록한 날에는 기분이 대체로 더 좋아요 (한 번 기록한 날 {comparison} 대비 {subject}).",
"multipleEntriesLower": "하루에 여러 번 기록한 날에는 기분이 대체로 더 낮아요 (한 번 기록한 날 {comparison} 대비 {subject})."
}
},
"derived": {
@@ -7223,7 +7239,8 @@
"lastReceived": "마지막 수신 {relative}",
"lastNewValue": "마지막 새 값 {relative}",
"lastBackground": "마지막 백그라운드 수신 {relative}",
"never": "아직 수신되지 않음"
"never": "아직 수신되지 않음",
"lastBackfill": "기록 보충 {relative}"
}
},
"garminInfo": {
@@ -9325,7 +9342,9 @@
"periodStartSaved": "생리 시작을 저장했어요",
"periodEndSaved": "생리 종료를 저장했어요",
"startedPeriodOnDate": "이 날 생리를 시작했어요",
"endedPeriodOnDate": "이 날 생리가 끝났어요"
"endedPeriodOnDate": "이 날 생리가 끝났어요",
"startAbsorbed": "생리 시작을 저장했어요. {absorbed}의 시작은 {date}로 합쳐졌어요.",
"startRestored": "저장했어요. {dates}의 시작이 다시 표시돼요."
},
"cycleReminders": {
"periodSoonTitle": "생리가 곧 시작될 것 같아요",
@@ -12943,5 +12962,13 @@
"lastUpdated": "마지막 업데이트: {date}",
"project": "프로젝트",
"credits": "크레딧"
},
"series": {
"reference": {
"targetUpper": "내 목표 상한",
"targetLower": "내 목표 하한",
"clinicalUpper": "임상 기준 상한",
"clinicalLower": "임상 기준 하한"
}
}
}
+30 -3
View File
@@ -2723,7 +2723,8 @@
"bmiEmptyAction": "Otwórz ustawienia konta",
"personalRecord": {
"badge": "PR",
"tooltip": "Rekord osobisty: {value} {unit} dnia {date}"
"tooltip": "Rekord osobisty: {value} {unit} dnia {date}",
"benchmark": "Typowo w tym wieku: od {low} do {high} {unit} ({source})"
},
"loadError": "Nie udało się załadować analiz",
"heroPersonalBaseline": "Na podstawie ostatnich 90 dni",
@@ -4277,6 +4278,21 @@
"learning": "Faza uczenia: zapisano {entries} z {threshold} dni. Porównanie pojawi się, gdy będzie ich dość.",
"noPattern": "W zapisanych dotąd {entries} dniach nic nie jest na tyle regularne, by porównać z nimi dzień. Gdy zapisujesz też kontekst dnia, więcej da się porównać.",
"featureValue": "{field}: {value}"
},
"patterns": {
"title": "Wzorce w twoim nastroju",
"basedOn": "Na podstawie {subject} i {comparison} dni. Wzorzec, nie przyczyna.",
"previousDayHigher": "Po dniu poniżej twojej normy następny dzień jest zwykle lepszy ({subject} wobec {comparison} po dniu powyżej normy).",
"previousDayLower": "Dzień poniżej twojej normy zwykle przenosi się na następny ({subject} wobec {comparison} po dniu powyżej normy).",
"weekendHigher": "W weekendy twój nastrój jest zwykle lepszy ({subject} wobec {comparison} w dni robocze).",
"weekendLower": "W weekendy twój nastrój jest zwykle gorszy ({subject} wobec {comparison} w dni robocze).",
"bestWeekday": "{weekday} to zwykle twój najlepszy dzień ({subject} wobec {comparison} w pozostałe dni).",
"notesHigher": "W dni z notatką twój nastrój jest zwykle lepszy ({subject} wobec {comparison} bez notatki).",
"notesLower": "W dni z notatką twój nastrój jest zwykle gorszy ({subject} wobec {comparison} bez notatki).",
"delayedTagHigher": "Dzień po „{tag}” twój nastrój jest zwykle lepszy ({subject} wobec {comparison} w pozostałe dni).",
"delayedTagLower": "Dzień po „{tag}” twój nastrój jest zwykle gorszy ({subject} wobec {comparison} w pozostałe dni).",
"multipleEntriesHigher": "W dni z kilkoma wpisami twój nastrój jest zwykle lepszy ({subject} wobec {comparison} przy jednym wpisie).",
"multipleEntriesLower": "W dni z kilkoma wpisami twój nastrój jest zwykle gorszy ({subject} wobec {comparison} przy jednym wpisie)."
}
},
"derived": {
@@ -7223,7 +7239,8 @@
"lastReceived": "Ostatnio odebrano {relative}",
"lastNewValue": "Ostatnia nowa wartość {relative}",
"lastBackground": "Ostatnio w tle {relative}",
"never": "Jeszcze nie odebrano"
"never": "Jeszcze nie odebrano",
"lastBackfill": "Historia uzupełniona {relative}"
}
},
"garminInfo": {
@@ -9325,7 +9342,9 @@
"periodStartSaved": "Początek miesiączki zapisany",
"periodEndSaved": "Koniec miesiączki zapisany",
"startedPeriodOnDate": "Miesiączka zaczęła się tego dnia",
"endedPeriodOnDate": "Miesiączka skończyła się tego dnia"
"endedPeriodOnDate": "Miesiączka skończyła się tego dnia",
"startAbsorbed": "Początek miesiączki zapisany. Twój początek z {absorbed} został połączony z {date}.",
"startRestored": "Zapisano. Początek z {dates} jest z powrotem."
},
"cycleReminders": {
"periodSoonTitle": "Miesiączka prawdopodobnie wkrótce",
@@ -12943,5 +12962,13 @@
"lastUpdated": "Ostatnia aktualizacja: {date}",
"project": "Projekt",
"credits": "Podziękowania"
},
"series": {
"reference": {
"targetUpper": "Twój cel, górna granica",
"targetLower": "Twój cel, dolna granica",
"clinicalUpper": "Kliniczna górna granica",
"clinicalLower": "Kliniczna dolna granica"
}
}
}
+15 -9
View File
@@ -2334,16 +2334,18 @@ model IntradayCumulativeProfile {
/// two rows is not a shape; the baseline reader uses this to refuse such
/// a day rather than let it drag the median.
sampleCount Int @map("sample_count")
/// @pending-consumer(#v143-B9) v1.43 (#77) — per-hour increments posted as
/// `stats:<HK>:<YYYY-MM-DD>T<HH>` rows, 24 slots in canonical units. Empty
/// for a profile folded from per-sample rows.
/// v1.43 (#77) — per-hour increments posted as
/// `stats:<HK>:<YYYY-MM-DD>T<HH>` rows (`intraday-hour-buckets.ts`), 24
/// slots in canonical units, -1 for an hour not posted. Empty for a day
/// the phone never posted hours for.
hourlyIncrements Float[] @map("hourly_increments")
/// @pending-consumer(#v143-B9) What `hourlyCumulative` was built from:
/// `PER_SAMPLE` (the fold of raw rows) or `HOUR_BUCKETS` (the posted hour
/// rows). CHECK in migration 0408.
/// What `hourlyCumulative` was built from: `PER_SAMPLE` (the fold of raw
/// rows) or `HOUR_BUCKETS` (the posted hours, once a day holds four).
/// CHECK in migration 0408. Read by the profile reader and the drain's
/// merge, which leaves a bucket-built day alone.
basis String @default("PER_SAMPLE")
/// @pending-consumer(#v143-B9) How many hour buckets backed a
/// `HOUR_BUCKETS` profile; NULL for `PER_SAMPLE`.
/// How many hours the phone posted for the day; NULL when it posted none.
/// Below four the day keeps the per-sample fold (or no curve yet).
bucketCount Int? @map("bucket_count")
/// The IANA zone the day was bucketed in. A user who later moves zones
/// leaves behind profiles cut on the old clock; carrying the zone lets
@@ -4044,7 +4046,11 @@ model HealthKitTypeSync {
/// A `MeasurementType` name for rows the measurement batch writes, or
/// `MEDICATION_DOSE` for Apple Health doses the intake bulk route
/// receives. Text, not the enum, since v1.42.4 (migration 0401) because a
/// dose is not a measurement type.
/// dose is not a measurement type. Since v1.43 (ios #77) a
/// `backfill:<type>` row records when a `syncTrigger: "backfill"` batch
/// last carried the type (its `lastReceivedAt`, every other column NULL),
/// so a catch-up leaves the live row of the type untouched; live readers
/// skip the prefix (`healthkit-type-sync.ts`).
type String
/// Last batch that carried this type.
lastReceivedAt DateTime @map("last_received_at")
@@ -0,0 +1,356 @@
/**
* Client code holds no threshold or polarity table of its own (v1.43, ios
* #116 items 3 and 4).
*
* Which way a change counts and where a chart's reference lines sit depend on
* the person: their own target, their age, their sex, their glucose unit. The
* server resolves both (`src/lib/targets/trend-verdict.ts`,
* `src/lib/measurements/series-reference-lines.ts`) and publishes them on the
* dashboard snapshot (`tiles.directions`), the vitals baseline (`direction`),
* the summary and target reads and the series read. A client that keeps its
* own copy answers for an imaginary average person: a rising blood pressure
* painted as a setback for someone below their target, a 140 mmHg line on a
* chart whose person has a band of 115 to 125.
*
* Four shapes are refused in client code (`src/components`, `src/hooks`, and
* every `"use client"` module under `src/app` outside the API):
*
* 1. a `directionSentiment` given as a string literal: the tile must read
* the resolved verdict;
* 2. a `ReferenceLine` / `ReferenceArea` drawn at a number literal: a line
* has to come from data the server resolved;
* 3. a polarity table, i.e. a map or set of metric names to `up-good` /
* `up-bad`, or a `…Sentiment(type)` function deciding one;
* 4. an import of the server's resolvers into the browser (a type import
* is fine).
*
* The guard cannot pass by reading nothing: the walk carries a floor, every
* rule's matcher is proven against a sample of the shape it exists to catch,
* every allowlist entry must still match with its exact count, and the rules
* must see at least one real match across the tree.
*
* Its limit: it reads the source, not the running page. A polarity computed
* from a variable that happens to hold a literal is not seen; review catches
* that.
*/
import { readFileSync } from "node:fs";
import { join } from "node:path";
import ts from "typescript";
import { describe, expect, it } from "vitest";
import { stripComments, walkSourceFiles } from "./helpers/source-files";
const ROOT = process.cwd();
interface ClientFile {
rel: string;
source: string;
}
function isClientModule(rel: string, source: string): boolean {
if (rel.startsWith("src/components/") || rel.startsWith("src/hooks/")) {
return true;
}
return /^\s*["']use client["']/.test(stripComments(source).trimStart());
}
function clientFiles(): ClientFile[] {
const roots: Array<{ dir: string; floor: number }> = [
{ dir: "src/components", floor: 900 },
{ dir: "src/hooks", floor: 50 },
{ dir: "src/app", floor: 900 },
];
const out: ClientFile[] = [];
for (const { dir, floor } of roots) {
for (const file of walkSourceFiles(join(ROOT, dir), { floor })) {
const rel = `${dir}/${file}`;
if (rel.startsWith("src/app/api/")) continue;
if (rel.includes("/__tests__/") || /\.test\.tsx?$/.test(rel)) continue;
const source = readFileSync(join(ROOT, rel), "utf8");
if (!isClientModule(rel, source)) continue;
out.push({ rel, source });
}
}
return out;
}
function parse(name: string, source: string): ts.SourceFile {
return ts.createSourceFile(
name,
source,
ts.ScriptTarget.Latest,
true,
name.endsWith("x") ? ts.ScriptKind.TSX : ts.ScriptKind.TS,
);
}
/** The literal text a JSX attribute initialiser carries, if it is one. */
function literalOf(init: ts.JsxAttributeValue | undefined): string | null {
if (!init) return null;
if (ts.isStringLiteral(init)) return init.text;
if (ts.isJsxExpression(init) && init.expression) {
const e = init.expression;
if (ts.isStringLiteral(e) || ts.isNoSubstitutionTemplateLiteral(e)) {
return e.text;
}
if (ts.isNumericLiteral(e)) return e.text;
if (
ts.isPrefixUnaryExpression(e) &&
e.operator === ts.SyntaxKind.MinusToken &&
ts.isNumericLiteral(e.operand)
) {
return `-${e.operand.text}`;
}
}
return null;
}
function jsxAttributes(
file: ts.SourceFile,
visit: (
tag: string,
name: string,
init: ts.JsxAttributeValue | undefined,
node: ts.Node,
) => void,
): void {
const walk = (node: ts.Node) => {
if (ts.isJsxSelfClosingElement(node) || ts.isJsxOpeningElement(node)) {
const tag = node.tagName.getText(file);
for (const attr of node.attributes.properties) {
if (!ts.isJsxAttribute(attr)) continue;
visit(tag, attr.name.getText(file), attr.initializer, attr);
}
}
ts.forEachChild(node, walk);
};
walk(file);
}
function lineOf(file: ts.SourceFile, node: ts.Node): number {
return file.getLineAndCharacterOfPosition(node.getStart(file)).line + 1;
}
/** Rule 1 — `directionSentiment` given as a literal. */
function literalDirections(name: string, source: string): string[] {
const file = parse(name, source);
const hits: string[] = [];
jsxAttributes(file, (tag, attr, init, node) => {
if (attr !== "directionSentiment") return;
const literal = literalOf(init);
if (literal !== null) {
hits.push(
`${name}:${lineOf(file, node)}: <${tag} directionSentiment="${literal}">`,
);
}
});
return hits;
}
const LINE_TAGS = new Set(["ReferenceLine", "ReferenceArea"]);
const LINE_ATTRS = new Set(["y", "y1", "y2"]);
/** Rule 2 — a chart reference drawn at a number literal. */
function literalLines(name: string, source: string): string[] {
const file = parse(name, source);
const hits: string[] = [];
jsxAttributes(file, (tag, attr, init, node) => {
if (!LINE_TAGS.has(tag) || !LINE_ATTRS.has(attr)) return;
const literal = literalOf(init);
if (literal !== null && /^-?\d/.test(literal)) {
hits.push(`${name}:${lineOf(file, node)}: <${tag} ${attr}={${literal}}>`);
}
});
return hits;
}
/** Rule 3 — a polarity table: line-shaped, over comment-stripped code. */
const POLARITY_PATTERNS: RegExp[] = [
// `RESTING_HEART_RATE: "up-bad"` / `["HRV", "up-good"]`
/\b[A-Z][A-Z0-9_]{2,}["']?\s*[:,]\s*["']up-(good|bad)["']/,
// `const UP_BAD_VITALS = new Set([...])`
/\b(UP_GOOD|UP_BAD)_[A-Z0-9_]+\s*=/,
// `function vitalSentiment(type: string)`
/function\s+\w*Sentiment\s*\(\s*(type|metric|kind)\b/,
];
const POLARITY_SAMPLES = [
'const POLARITY = { RESTING_HEART_RATE: "up-bad" };',
'new Map([["HEART_RATE_VARIABILITY", "up-good"]])',
"const UP_BAD_VITALS = new Set(['RESTING_HEART_RATE']);",
"function vitalSentiment(type: string): TrendDirectionSentiment {",
];
function polarityTables(name: string, source: string): string[] {
const hits: string[] = [];
stripComments(source)
.split("\n")
.forEach((line, index) => {
if (POLARITY_PATTERNS.some((p) => p.test(line))) {
hits.push(`${name}:${index + 1}: ${line.trim()}`);
}
});
return hits;
}
/** Rule 4 — the server's resolvers, imported into the browser. */
const SERVER_RESOLVERS = [
"@/lib/targets/trend-verdict",
"@/lib/measurements/series-reference-lines",
"@/lib/measurements/series-change",
"@/lib/personal-records/record-benchmark",
"@/lib/insights/mood-pattern-cards",
];
function resolverImports(name: string, source: string): string[] {
const file = parse(name, source);
const hits: string[] = [];
for (const statement of file.statements) {
if (!ts.isImportDeclaration(statement)) continue;
const from = (statement.moduleSpecifier as ts.StringLiteral).text;
if (!SERVER_RESOLVERS.includes(from)) continue;
if (statement.importClause?.isTypeOnly) continue;
hits.push(`${name}:${lineOf(file, statement)}: import from "${from}"`);
}
return hits;
}
interface Rule {
name: string;
find: (name: string, source: string) => string[];
/** Sources that must produce at least one hit, proving the matcher. */
samples: Array<{ name: string; source: string }>;
/** File → exact hit count it may have, with the reason. */
allow: Record<string, { count: number; reason: string }>;
}
const RULES: Rule[] = [
{
name: "a literal directionSentiment",
find: literalDirections,
samples: [
{
name: "sample.tsx",
source: 'const a = <TrendCard directionSentiment="up-bad" />;',
},
{
name: "sample.tsx",
source: 'const a = <TrendCard directionSentiment={"up-good"} />;',
},
],
allow: {
"src/components/insights/derived/vitals-dashboard.tsx": {
count: 6,
reason:
"the six derived re-frame tiles (same-time steps, six-minute walk, fitness age, vascular age, HRV balance, BMI) colour a figure whose good direction is its definition (a longer walk, a younger vascular age, no judgement on BMI), not a person's target; the per-vital baseline tile reads the server's `direction`",
},
},
},
{
name: "a reference line at a literal",
find: literalLines,
samples: [
{
name: "sample.tsx",
source: 'const a = <ReferenceLine y={140} stroke="red" />;',
},
{
name: "sample.tsx",
source: "const a = <ReferenceArea y1={70} y2={180} />;",
},
],
allow: {
"src/components/charts/medication-compliance-chart.tsx": {
count: 2,
reason:
"the opt-in adherence overlay: an 80 % minimum and the 100 % goal are properties of a share of doses, the same for every person, not a clinical threshold the person's profile moves",
},
"src/components/insights/mood/mood-time-of-day-chart.tsx": {
count: 1,
reason:
"the 3.5 line is the green edge of the 1 to 5 mood scale itself (`MOOD_GREEN_MIN`), the same for every person",
},
"src/components/insights/mood/mood-weekday-chart.tsx": {
count: 1,
reason:
"the 3.5 line is the green edge of the 1 to 5 mood scale itself (`MOOD_GREEN_MIN`), the same for every person",
},
},
},
{
name: "a polarity table",
find: polarityTables,
samples: POLARITY_SAMPLES.map((source) => ({ name: "sample.ts", source })),
allow: {},
},
{
name: "a server resolver imported into the browser",
find: resolverImports,
samples: [
{
name: "sample.ts",
source:
'import { resolveTrendVerdict } from "@/lib/targets/trend-verdict";',
},
],
allow: {},
},
];
const FILES = clientFiles();
function hitsFor(rule: Rule): Map<string, string[]> {
const out = new Map<string, string[]>();
for (const { rel, source } of FILES) {
const hits = rule.find(rel, source);
if (hits.length > 0) out.set(rel, hits);
}
return out;
}
describe("client threshold literal guard", () => {
it("walks a client tree of the size it expects", () => {
expect(FILES.length).toBeGreaterThan(700);
});
for (const rule of RULES) {
describe(rule.name, () => {
it("still catches the shape it exists to catch", () => {
for (const sample of rule.samples) {
expect(
rule.find(sample.name, sample.source).length,
`no hit on: ${sample.source}`,
).toBeGreaterThan(0);
}
});
it("finds none outside its allowlist, and the allowlist exactly", () => {
const hits = hitsFor(rule);
const offenders = [...hits.entries()]
.filter(([rel]) => !(rel in rule.allow))
.flatMap(([, lines]) => lines);
expect(
offenders,
"client code decides a threshold or a polarity itself. Read the " +
"server's resolved field instead (`tiles.directions`, `direction`, " +
"`referenceLines`), or allowlist the file with its reason.",
).toEqual([]);
for (const [rel, { count }] of Object.entries(rule.allow)) {
expect(
hits.get(rel)?.length ?? 0,
`${rel} is allowlisted for exactly ${count} hits`,
).toBe(count);
}
});
});
}
it("reads a non-empty set of matches across the tree", () => {
const total = RULES.reduce(
(sum, rule) =>
sum + [...hitsFor(rule).values()].reduce((n, l) => n + l.length, 0),
0,
);
expect(total).toBeGreaterThan(0);
});
});
@@ -125,6 +125,10 @@ const TS_READERS: Record<string, Entry> = {
"lib/analytics/correlations-fast-path.ts": {
exempt: "mood mean; pulse enters as the resting series, never a day mean",
},
"app/api/dashboard/summary/route.ts": {
exempt:
"places a card's seven-day sparkline mean against its band for the trend verdict; each sparkline point is already one rollup day value, so the mean weighs every day once",
},
"lib/dashboard/snapshot.ts": {
exempt: "summarises water-intake days and glucose contexts",
},
@@ -289,3 +289,134 @@ describe("resolved-field consumer guard", () => {
expect(total).toBeGreaterThanOrEqual(6);
});
});
/**
* v1.43 (ios #116 items 3, 4, 5, 6, 9; #77) — the fields the server resolves
* for the native client reach the web as well, so the two clients render the
* same answer. Each field names the web module that reads it, by a pattern
* over that module's code. A field the web has no screen for names the web
* twin it is resolved alongside (the same resolver, published where the web
* reads it) and why; the twin's reader is checked the same way.
*
* The rebuild side of the same rule (a client deciding a polarity or drawing
* a threshold of its own) is `client-threshold-literal-guard.test.ts`.
*/
interface WebReader {
file: string;
pattern: RegExp;
}
interface ResolvedFieldReader {
field: string;
readers: WebReader[];
/** Set when the web reads a twin rather than this route's field. */
twin?: string;
}
const V143_FIELDS: ResolvedFieldReader[] = [
{
field: "GET /api/dashboard/snapshot tiles.directions",
readers: [
{ file: "src/app/page-client.tsx", pattern: /tiles\.directions/ },
],
},
{
field:
"GET /api/dashboard/summary DashboardMetricCard.direction / trendSentiment",
twin: "the summary route is the native client's dashboard; the web dashboard reads the same `resolveTrendVerdict` answer from the snapshot's `tiles.directions`",
readers: [{ file: "src/app/page-client.tsx", pattern: /directionOf\(/ }],
},
{
field: "GET /api/insights/targets TargetItem.direction / trendSentiment",
twin: "the web target panels show the band, not a change; the change they would colour is the dashboard tile's, read from `tiles.directions`",
readers: [
{ file: "src/app/page-client.tsx", pattern: /tileDirections\?\.\[key\]/ },
],
},
{
field: "GET /api/insights/derived VITALS_BASELINE value.direction",
readers: [
{
file: "src/components/insights/derived/vitals-dashboard.tsx",
pattern: /v\.direction\?\.direction/,
},
],
},
{
field: "GET /api/measurements/series direction / change / referenceLines",
twin: "the web charts read the series-batch and draw the person's bands from the snapshot's `targetBands`; the series read is the native chart source",
readers: [{ file: "src/app/page-client.tsx", pattern: /targetBands/ }],
},
{
field: "GET /api/personal-records benchmark",
readers: [
{
file: "src/components/insights/personal-record-badge.tsx",
pattern: /fresh\.benchmark/,
},
],
},
{
field: "GET /api/mood/insights patterns",
readers: [
{
file: "src/components/insights/mood/mood-insights-breakdowns.tsx",
pattern: /data\.patterns/,
},
{
file: "src/components/insights/mood/mood-patterns.tsx",
pattern: /pattern\.values\.subject/,
},
],
},
{
field: "POST /api/cycle/period absorbed",
readers: [
{
file: "src/components/cycle/use-cycle.ts",
pattern: /data\?\.absorbed/,
},
],
},
{
field: "DELETE /api/cycle/day-logs/{id} (Prefer) and PATCH restored",
readers: [
{
file: "src/components/cycle/use-cycle.ts",
pattern: /Prefer: "return=representation"/,
},
{
file: "src/components/cycle/use-cycle.ts",
pattern: /data\?\.restored/,
},
],
},
{
field: "GET /api/integrations/healthkit metricFreshness[].lastBackfillAt",
readers: [
{
file: "src/components/settings/integrations/apple-health-card.tsx",
pattern: /entry\.lastBackfillAt/,
},
],
},
];
describe("v1.43 resolved fields reach the web", () => {
for (const entry of V143_FIELDS) {
it(`${entry.field} has a web reader${entry.twin ? " through its twin" : ""}`, () => {
expect(entry.readers.length).toBeGreaterThan(0);
if (entry.twin !== undefined)
expect(entry.twin.length).toBeGreaterThan(40);
for (const reader of entry.readers) {
const code = stripComments(
readFileSync(join(ROOT, reader.file), "utf8"),
);
expect(
reader.pattern.test(code),
`${reader.file} no longer reads ${entry.field} (${reader.pattern})`,
).toBe(true);
}
});
}
});
+2 -2
View File
@@ -87,8 +87,8 @@ const SERVER_FEATURES = {
trackIntake: true,
// v1.43 — each turns true in the change that makes its route or request
// form work; until then a client must not send it (contract §0.6).
cumulativeHourBuckets: false,
syncTriggerBackfill: false,
cumulativeHourBuckets: true,
syncTriggerBackfill: true,
biomarkerMerge: true,
lifeCircumstances: false,
} as const;
+10 -2
View File
@@ -2,6 +2,9 @@
* `DELETE /api/cycle/cycles/{id}` — soft-delete a cycle
* (ios-contract §2.F): set `deletedAt` + bump `syncVersion`, emit a
* tombstone on the next sync page. 204. Idempotent. Owner-scoped + gated.
* With `Prefer: return=representation` (v1.43, RFC 7240) the answer is 200
* with what the delete changed — the removed cycle and the starts it gave
* back — so a client can say "the start on 17 Sep is back".
*
* The delete also hands the span back to the preceding cycle. Opening a cycle
* closes the one before it, so removing it without re-deriving that boundary
@@ -18,6 +21,7 @@ import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import { overwriteDetails } from "@/lib/sharing/audit-details";
import { apiError, getClientIp } from "@/lib/api-response";
import { cycleDeleteOutcome } from "@/lib/cycle/delete-outcome";
import { requireCycleEnabled } from "@/lib/cycle/gate";
import { removeCycleStartedOn } from "@/lib/cycle/cycle-boundaries";
@@ -44,7 +48,7 @@ export const DELETE = apiHandler(
}
// Already a tombstone: the delete is done, and nothing is left to move.
if (existing.deletedAt !== null) {
return new Response(null, { status: 204 });
return cycleDeleteOutcome(request, null, []);
}
// Tombstone + re-anchor as one unit: a neighbour re-derived against a
@@ -99,6 +103,10 @@ export const DELETE = apiHandler(
meta: { reanchored_prior: reanchored !== null },
});
return new Response(null, { status: 204 });
return cycleDeleteOutcome(
request,
removed?.cycleId ?? null,
removed?.restored ?? [],
);
},
);
+13 -3
View File
@@ -3,7 +3,8 @@
* (ios-contract §2.A).
* `DELETE /api/cycle/day-logs/{id}` — soft-delete (ios-contract §2.F):
* set `deletedAt` + bump `syncVersion`, emit a tombstone on the next
* sync page. 204. Idempotent.
* sync page. 204. Idempotent. With `Prefer: return=representation` 200
* with the removed cycle and the starts given back (v1.43).
*
* Both are gated (`cycle.disabled` 403) and owner-scoped (a row owned by
* another user 404s).
@@ -34,6 +35,7 @@ import { getOrCreateCycleProfile } from "@/lib/cycle/profile";
import { replaceSymptomLinks } from "@/lib/cycle/day-log-write";
import { cycleDayLogPatchSchema } from "@/lib/validations/cycle";
import { toCycleDayLogDTO, dayLogSymptomInclude } from "@/lib/cycle/dto";
import { cycleDeleteOutcome } from "@/lib/cycle/delete-outcome";
type RouteParams = { params: Promise<{ id: string }> };
@@ -176,11 +178,13 @@ export const PATCH = apiHandler(
row,
);
let removedCycleId: string | null = null;
let restored: Array<{ cycleId: string; startDate: string }> = [];
if (!openedCycleId && !stillBleeds(row)) {
const removed = await prisma.$transaction((db) =>
removeCycleStartedOn(db, user.id, existing.date),
);
removedCycleId = removed?.cycleId ?? null;
restored = removed?.restored ?? [];
}
await auditLog("cycle.day-log.update", {
@@ -221,7 +225,9 @@ export const PATCH = apiHandler(
},
});
return apiSuccess(toCycleDayLogDTO(row));
// v1.43 (ios #116 item 9) — the starts this edit gave back by clearing
// the flow on a start day; empty when it removed none.
return apiSuccess({ ...toCycleDayLogDTO(row), restored });
},
);
@@ -303,7 +309,11 @@ export const DELETE = apiHandler(
},
});
return new Response(null, { status: 204 });
return cycleDeleteOutcome(
request,
removed?.cycleId ?? null,
removed?.restored ?? [],
);
},
);
+13 -2
View File
@@ -7,7 +7,8 @@
* and writes the boundary `CycleDayLog(flow=MEDIUM)`. `action:"end"`
* stamps the current cycle's `periodEndDate` and writes the boundary
* day-log. Convenience over §2.A; returns
* `{ cycle: MenstrualCycleDTO, dayLog: CycleDayLogDTO }`.
* `{ cycle: MenstrualCycleDTO, dayLog: CycleDayLogDTO, absorbed }`, where
* `absorbed` names a later start this one folded in (v1.43, null otherwise).
*/
import { NextRequest } from "next/server";
@@ -221,12 +222,22 @@ async function postPeriod(request: NextRequest): Promise<Response> {
return apiSuccess({
cycle: toMenstrualCycleDTO(cycleRow),
dayLog: toCycleDayLogDTO(dayLogRow),
// v1.43 (ios #116 item 9) — a later start inside the same period that
// this start folded in, so the client can say which date stopped being a
// start. Null when nothing was folded; present on every `start`.
absorbed:
txResult.moved.absorbedCycleId && txResult.moved.absorbedStartDate
? {
cycleId: txResult.moved.absorbedCycleId,
startDate: txResult.moved.absorbedStartDate,
}
: null,
});
}
/** `end` with no preceding cycle is a no-op the client should not hit. */
function apiSuccessNoCycle(): Response {
return apiSuccess({ cycle: null, dayLog: null });
return apiSuccess({ cycle: null, dayLog: null, absorbed: null });
}
/**
@@ -367,6 +367,18 @@ describe("GET /api/dashboard/summary", () => {
expect(bp?.allTimeCount).toBe(28);
expect(bp?.lastSeenAt).toBe(sixtyDaysAgo.toISOString());
expect(bp?.sparkline).toEqual([130, 132]);
// v1.43 (ios #116 item 3) — a 40-year-old's age band is 120–129: the
// sparkline's mean sits above it, so the rise is a setback.
const judged = bp as unknown as {
direction: unknown;
trendSentiment: string;
};
expect(judged.direction).toEqual({
direction: "up-bad",
targetPosition: "above",
basis: "profile_default",
});
expect(judged.trendSentiment).toBe("negative");
});
it("does NOT emit the BP tile when the account has zero readings ever (REG-11)", async () => {
+89 -1
View File
@@ -61,6 +61,13 @@ import type { MeasurementType } from "@/generated/prisma/client";
import { measurementTypeEnum } from "@/lib/validations/measurement";
import { CUMULATIVE_HK_TYPES } from "@/lib/measurements/apple-health-mapping";
import { getServerTranslator } from "@/lib/i18n/server-translator";
import {
resolveTrendVerdict,
trendSentimentOfTrend,
type TrendSentiment,
type TrendVerdict,
type TrendVerdictContext,
} from "@/lib/targets/trend-verdict";
import { type Locale } from "@/lib/i18n/config";
import { resolveJobLocale } from "@/lib/i18n/job-locale";
import { userDayKey, DEFAULT_TIMEZONE } from "@/lib/tz/resolver";
@@ -185,6 +192,69 @@ interface MetricCard {
* at all.
*/
lastSeenAt: string | null;
/**
* v1.43 (ios #116 item 3) — which way a change of this metric counts for
* the person, against their own target or a profile default
* (`src/lib/targets/trend-verdict.ts`).
*/
direction?: TrendVerdict | null;
/** v1.43 — `direction` applied to this card's `trend`. */
trendSentiment?: TrendSentiment;
}
/**
* The verdict key behind each card: the measurement type it reads, or the
* derived key for the two cards that are not one.
*/
const VERDICT_KEY: Record<MetricKind, string> = {
weight: "WEIGHT",
bloodPressure: "BLOOD_PRESSURE_SYS",
pulse: "PULSE",
bodyFat: "BODY_FAT",
glucose: "BLOOD_GLUCOSE",
sleep: "SLEEP_DURATION",
steps: "ACTIVITY_STEPS",
totalBodyWater: "TOTAL_BODY_WATER",
boneMass: "BONE_MASS",
oxygenSaturation: "OXYGEN_SATURATION",
mood: "MOOD",
bmi: "BMI",
};
/**
* Lay the verdict over each card. The reference placed against the band is
* the mean of the card's own seven-day sparkline, else its latest value, the
* seven-day average the web tiles place. Sleep cards are in hours and the
* verdict reads minutes.
*/
function withVerdicts(
cards: MetricCard[],
ctx: Omit<TrendVerdictContext, "referenceValue">,
): MetricCard[] {
const referenceOf = (card: MetricCard | undefined): number | null => {
if (!card) return null;
const spark = card.sparkline.filter((v) => Number.isFinite(v));
return spark.length > 0
? spark.reduce((sum, v) => sum + v, 0) / spark.length
: card.latestValue;
};
// BMI is judged by the weight it is derived from (the target is a weight).
const weightReference = referenceOf(cards.find((c) => c.kind === "weight"));
return cards.map((card) => {
const reference = card.kind === "bmi" ? weightReference : referenceOf(card);
const direction = resolveTrendVerdict(VERDICT_KEY[card.kind], {
...ctx,
referenceValue:
reference !== null && card.kind === "sleep"
? reference * 60
: reference,
});
return {
...card,
direction,
trendSentiment: trendSentimentOfTrend(card.trend, direction),
};
});
}
/**
@@ -945,6 +1015,17 @@ async function buildDashboardSummary(
const t = getServerTranslator(ctx.locale).t;
// v1.43 — the record's profile and own targets, for the card verdicts.
const verdictProfile = await prisma.user.findUnique({
where: { id: userId },
select: {
dateOfBirth: true,
gender: true,
heightCm: true,
thresholdsJson: true,
},
});
annotate({ meta: timings });
return {
@@ -962,7 +1043,14 @@ async function buildDashboardSummary(
takenToday,
},
highlightInsight: null,
metrics,
metrics: withVerdicts(metrics, {
profile: {
dateOfBirth: verdictProfile?.dateOfBirth ?? null,
gender: verdictProfile?.gender ?? null,
heightCm: verdictProfile?.heightCm ?? null,
},
thresholdsJson: verdictProfile?.thresholdsJson ?? null,
}),
// v1.17.0 — sleep-debt + chronotype, server-authoritative. Same values
// the Sleep page's `/api/sleep/rhythm` read returns.
sleepRhythm,
@@ -533,6 +533,12 @@ describe("GET /api/insights/targets — mood-rollup tier swap", () => {
color: "var(--success)",
},
source: "WHO BMI",
direction: {
direction: "up-bad",
targetPosition: null,
basis: "metric_default",
},
trendSentiment: "neutral",
...fourDaysInRange,
},
{
@@ -548,6 +554,12 @@ describe("GET /api/insights/targets — mood-rollup tier swap", () => {
color: "var(--info)",
},
source: "ESH 2023",
direction: {
direction: "hold",
targetPosition: "inside",
basis: "profile_default",
},
trendSentiment: "positive",
...fourDaysInRange,
},
{
@@ -563,6 +575,12 @@ describe("GET /api/insights/targets — mood-rollup tier swap", () => {
color: "var(--success)",
},
source: "ESH 2023",
direction: {
direction: "up-good",
targetPosition: null,
basis: "metric_default",
},
trendSentiment: "neutral",
...fourDaysInRange,
},
{
@@ -578,6 +596,12 @@ describe("GET /api/insights/targets — mood-rollup tier swap", () => {
color: "var(--warning)",
},
source: "CDC/NCHS",
direction: {
direction: "up-good",
targetPosition: "below",
basis: "profile_default",
},
trendSentiment: "neutral",
daysInRange7d: 0,
daysLogged7d: 4,
daysInRange30d: 0,
@@ -600,6 +624,12 @@ describe("GET /api/insights/targets — mood-rollup tier swap", () => {
color: "var(--success)",
},
source: "AASM/SRS",
direction: {
direction: "up-good",
targetPosition: null,
basis: "metric_default",
},
trendSentiment: "neutral",
...fourDaysInRange,
},
{
@@ -615,6 +645,12 @@ describe("GET /api/insights/targets — mood-rollup tier swap", () => {
color: "var(--success)",
},
source: "WHO",
direction: {
direction: "up-bad",
targetPosition: null,
basis: "metric_default",
},
trendSentiment: "neutral",
...fourDaysInRange,
},
{
@@ -630,6 +666,12 @@ describe("GET /api/insights/targets — mood-rollup tier swap", () => {
color: "var(--warning)",
},
source: "ACE",
direction: {
direction: "hold",
targetPosition: "inside",
basis: "profile_default",
},
trendSentiment: "positive",
...fourDaysInRange,
},
{
@@ -645,6 +687,12 @@ describe("GET /api/insights/targets — mood-rollup tier swap", () => {
color: "var(--success)",
},
source: "Saint-Maurice JAMA 2020",
direction: {
direction: "up-good",
targetPosition: null,
basis: "metric_default",
},
trendSentiment: "neutral",
...fourDaysInRange,
},
{
@@ -657,6 +705,12 @@ describe("GET /api/insights/targets — mood-rollup tier swap", () => {
range: { min: 90, max: 100 },
classification: null,
source: "7-day",
direction: {
direction: "up-good",
targetPosition: null,
basis: "metric_default",
},
trendSentiment: "neutral",
details: {
medications: [
{
@@ -688,6 +742,12 @@ describe("GET /api/insights/targets — mood-rollup tier swap", () => {
color: "var(--success)",
},
source: "Mood entries",
direction: {
direction: "up-good",
targetPosition: null,
basis: "metric_default",
},
trendSentiment: "neutral",
...fiveDaysInRange,
},
{
@@ -703,6 +763,12 @@ describe("GET /api/insights/targets — mood-rollup tier swap", () => {
color: "var(--success)",
},
source: "Mood entries",
direction: {
direction: "neutral",
targetPosition: null,
basis: "metric_default",
},
trendSentiment: "neutral",
...fiveDaysInRange,
},
{
@@ -718,6 +784,12 @@ describe("GET /api/insights/targets — mood-rollup tier swap", () => {
color: "var(--success)",
},
source: "ADA 2024 / DDG",
direction: {
direction: "neutral",
targetPosition: null,
basis: "metric_default",
},
trendSentiment: "neutral",
daysInRange7d: 3,
daysLogged7d: 3,
daysInRange30d: 3,
@@ -533,10 +533,72 @@ describe("GET /api/integrations/healthkit — sync health", () => {
lastTrigger: "foreground",
lastNewSampleAt: null,
lastBackgroundAt: backgroundAt.toISOString(),
lastBackfillAt: null,
});
expect(byType.PULSE).toMatchObject({ lastBackgroundAt: null });
});
// v1.43 (ios #77) — a backfill's own ledger row reads as `lastBackfillAt`
// of its type and moves none of the live facts.
it("serves lastBackfillAt from the backfill row, apart from the live arrival", async () => {
vi.mocked(prisma.user.findUnique).mockResolvedValue({
healthKitConfigJson: null,
healthKitLastSyncedAt: ago(60 * 60 * 1000),
} as never);
vi.mocked(listLiveMeasurementTypes).mockResolvedValue([
"PULSE",
"ACTIVITY_STEPS",
]);
mockProbes([
{ type: "PULSE", last_seen: ago(60 * 60 * 1000) },
{ type: "ACTIVITY_STEPS", last_seen: ago(60 * 60 * 1000) },
]);
const receivedAt = ago(5 * 60 * 60 * 1000);
const backfillAt = ago(60 * 1000);
vi.mocked(prisma.healthKitTypeSync.findMany).mockResolvedValue([
{
type: "PULSE",
lastReceivedAt: receivedAt,
lastTrigger: "foreground",
lastNewSampleAt: receivedAt,
lastBackgroundAt: null,
},
{
type: "backfill:PULSE",
lastReceivedAt: backfillAt,
lastTrigger: null,
lastNewSampleAt: null,
lastBackgroundAt: null,
},
{
type: "backfill:ACTIVITY_STEPS",
lastReceivedAt: backfillAt,
lastTrigger: null,
lastNewSampleAt: null,
lastBackgroundAt: null,
},
] as never);
const body = await read();
const byType = Object.fromEntries(
body.metricFreshness.map((entry) => [entry.type, entry]),
);
expect(byType.PULSE).toMatchObject({
lastReceivedAt: receivedAt.toISOString(),
lastTrigger: "foreground",
lastBackfillAt: backfillAt.toISOString(),
});
// A type that only ever arrived through a backfill has no live arrival.
expect(byType.ACTIVITY_STEPS).toMatchObject({
lastReceivedAt: null,
lastTrigger: null,
lastBackfillAt: backfillAt.toISOString(),
});
expect(body.metricFreshness.map((e) => e.type)).not.toContain(
"backfill:PULSE",
);
});
it("leaves the dose entry out when there is no Apple Health dose", async () => {
vi.mocked(prisma.user.findUnique).mockResolvedValue({
healthKitConfigJson: null,
+200 -26
View File
@@ -73,10 +73,19 @@ import {
findFoldedWindowDuplicates,
} from "@/lib/measurements/folded-window";
import {
HEALTHKIT_SYNC_TRIGGERS,
BACKFILL_SYNC_TRIGGER,
HEALTHKIT_REQUEST_SYNC_TRIGGERS,
recordHealthKitTypeArrivals,
type HealthKitTypeArrival,
} from "@/lib/integrations/healthkit-type-sync";
import {
classifyHourBucket,
HOUR_BUCKET_SKIP_REASONS,
parseHourBucketId,
writeHourBuckets,
type HourBucketWrite,
} from "@/lib/measurements/intraday-hour-buckets";
import { resolveStoredTimezone } from "@/lib/tz/resolver";
import { isPlausibleEntryInstant } from "@/lib/validations/entry-instant";
import {
classifyExternalId,
@@ -199,7 +208,9 @@ const batchEntrySchema = z.object({
// pre-#66 caller stays byte-for-byte unchanged.
// v1.42 (#1173) — `manual` names a user-initiated "Sync all" run, as opposed to
// the app simply being open (`foreground`).
const syncTriggerEnum = z.enum(HEALTHKIT_SYNC_TRIGGERS);
// v1.43 (ios #77) — `backfill` names a history catch-up. It moves only the
// per-type `lastBackfillAt`, never the live sync stamps (see below).
const syncTriggerEnum = z.enum(HEALTHKIT_REQUEST_SYNC_TRIGGERS);
const batchPayloadSchema = z.object({
entries: z.array(batchEntrySchema).min(1),
@@ -380,6 +391,11 @@ async function postBatch(request: NextRequest): Promise<Response> {
const prepared: Prepared[] = [];
const results: EntryResult[] = new Array(entries.length);
const unstableShapes: UnstableExternalIdShape[] = [];
// v1.43 — hourly sums for the intraday profile, and the zone they are cut in
// (the account's, the one `/api/auth/me` publishes as `timezone`).
const hourWrites: HourBucketWrite[] = [];
let accountTz: string | undefined;
const batchNow = new Date();
for (let index = 0; index < entries.length; index++) {
const entry = entries[index];
@@ -402,6 +418,75 @@ async function postBatch(request: NextRequest): Promise<Response> {
continue;
}
// v1.43 (ios #77) — an hourly sum of a summed type
// (`stats:<HK>:<YYYY-MM-DD>T<HH>`) is never a measurement: it lands in the
// intraday profile behind the same-time baseline, and the day's total
// stays the day row. Validated here, written after the measurements.
const hourId = parseHourBucketId(entry.externalId);
if (hourId) {
const skip = (reason: string) => {
results[index] = { index, status: "skipped", reason };
};
// The profile has no source dimension and is the phone's: a bridge or a
// hand-entered row cannot post hours into it.
if (scoped || (entry.source ?? "APPLE_HEALTH") !== "APPLE_HEALTH") {
skip(HOUR_BUCKET_SKIP_REASONS.unsupportedType);
continue;
}
accountTz ??= await resolveStoredTimezone(user.timezone);
const startDate = new Date(entry.startDate);
const verdict = classifyHourBucket(
hourId,
startDate,
accountTz,
batchNow,
);
if (!verdict.ok) {
skip(verdict.reason);
continue;
}
if (!isPlausibleEntryInstant(startDate)) {
skip("implausible_timestamp");
continue;
}
const hourMapped = mapAppleHealthEntry({
hkIdentifier: entry.hkIdentifier,
value: entry.value,
unit: entry.unit,
startDate: entry.startDate,
endDate: entry.endDate,
});
if (!hourMapped || hourMapped.type !== verdict.type) {
// The entry's identifier must be the one its id names.
skip(
hourMapped
? HOUR_BUCKET_SKIP_REASONS.unsupportedType
: "unmappable_identifier",
);
continue;
}
if (
hourMapped.value < 0 ||
validateMeasurementRange(hourMapped.type, hourMapped.value) !== null
) {
results[index] = outOfRangeResult(
index,
hourMapped.type,
hourMapped.value,
);
continue;
}
hourWrites.push({
index,
type: verdict.type,
hkIdentifier: hourId.hkIdentifier,
dateKey: hourId.dateKey,
hour: hourId.hour,
value: hourMapped.value,
});
continue;
}
const mapped = mapAppleHealthEntry({
hkIdentifier: entry.hkIdentifier,
value: entry.value,
@@ -898,15 +983,79 @@ async function postBatch(request: NextRequest): Promise<Response> {
}
}
// v1.43 — the hours, after the measurements. The last entry for one slot
// wins, as for the day rows; an earlier one in the same batch is a
// superseded duplicate.
let hourInsertedCount = 0;
let hourUpdatedCount = 0;
const hourLanded: HourBucketWrite[] = [];
if (hourWrites.length > 0 && accountTz) {
const lastBySlot = new Map<string, HourBucketWrite>();
for (const w of hourWrites) {
lastBySlot.set(`${w.type}::${w.dateKey}::${w.hour}`, w);
}
for (const w of hourWrites) {
if (lastBySlot.get(`${w.type}::${w.dateKey}::${w.hour}`) !== w) {
results[w.index] = {
index: w.index,
status: "duplicate",
reason: "superseded_in_batch",
};
duplicateCount++;
}
}
const toWrite = [...lastBySlot.values()];
try {
const statuses = await writeHourBuckets(
prisma,
user.id,
accountTz,
toWrite,
);
for (const w of toWrite) {
const status = statuses.get(w.index) ?? "inserted";
results[w.index] = { index: w.index, status };
if (status === "inserted") hourInsertedCount++;
else hourUpdatedCount++;
hourLanded.push(w);
}
} catch (err) {
for (const w of toWrite) {
results[w.index] = {
index: w.index,
status: "failed",
reason: "write_failed",
};
failedCount++;
}
annotate({
action: { name: "measurement.batch.hour-bucket.failed" },
meta: { error: err instanceof Error ? err.message : String(err) },
});
}
annotate({
action: { name: "measurement.batch.hour-bucket" },
meta: {
attempted: hourWrites.length,
inserted: hourInsertedCount,
updated: hourUpdatedCount,
processed: entries.length,
},
});
}
const healthKitSyncSucceeded =
failedCount === 0 &&
prepared.some((p) => {
if ((p.entry.source ?? "APPLE_HEALTH") !== "APPLE_HEALTH") return false;
const status = results[p.index]?.status;
return (
status === "inserted" || status === "updated" || status === "duplicate"
);
});
(hourLanded.length > 0 ||
prepared.some((p) => {
if ((p.entry.source ?? "APPLE_HEALTH") !== "APPLE_HEALTH") return false;
const status = results[p.index]?.status;
return (
status === "inserted" ||
status === "updated" ||
status === "duplicate"
);
}));
// This batch route is the native ingestion boundary, so it is also where the
// SyncMode checkpoint (`User.lastSyncedAt`, the same column
@@ -938,6 +1087,11 @@ async function postBatch(request: NextRequest): Promise<Response> {
// uses to size its undelivered window, so letting a scale advance it can make
// the phone skip a window of its own samples — a silent data loss whose cause
// would be nowhere near the code that caused it.
//
// v1.43 (ios #77) — a `backfill` batch is a catch-up, not a sync. It records
// its types under `lastBackfillAt` only and moves none of the live stamps:
// thirty days of history must not read as thirty healthy daily syncs.
const backfill = syncTrigger === BACKFILL_SYNC_TRIGGER;
if (failedCount === 0 && !scoped) {
const syncedAt = new Date();
const deliveredWithoutTheApp =
@@ -962,6 +1116,13 @@ async function postBatch(request: NextRequest): Promise<Response> {
if (status !== "duplicate") entry.newSamples += 1;
arrivals.set(type, entry);
}
// The hours the phone posted are an arrival of their type as well.
for (const w of hourLanded) {
const entry = arrivals.get(w.type) ?? { accepted: 0, newSamples: 0 };
entry.accepted += 1;
entry.newSamples += 1;
arrivals.set(w.type, entry);
}
if (arrivals.size > 0) {
await recordHealthKitTypeArrivals(prisma, {
userId: user.id,
@@ -970,21 +1131,23 @@ async function postBatch(request: NextRequest): Promise<Response> {
at: syncedAt,
});
}
await prisma.user.update({
where: { id: user.id },
data: {
lastSyncedAt: syncedAt,
...(healthKitSyncSucceeded
? {
healthKitLastSyncedAt: syncedAt,
healthKitLastSyncTrigger: syncTrigger ?? null,
...(deliveredWithoutTheApp
? { healthKitLastBackgroundSyncAt: syncedAt }
: {}),
}
: {}),
},
});
if (!backfill) {
await prisma.user.update({
where: { id: user.id },
data: {
lastSyncedAt: syncedAt,
...(healthKitSyncSucceeded
? {
healthKitLastSyncedAt: syncedAt,
healthKitLastSyncTrigger: syncTrigger ?? null,
...(deliveredWithoutTheApp
? { healthKitLastBackgroundSyncAt: syncedAt }
: {}),
}
: {}),
},
});
}
}
const freshlyInsertedRows = insertedPrepared.map((p) => ({
@@ -1256,10 +1419,21 @@ async function postBatch(request: NextRequest): Promise<Response> {
}
}
// v1.43 — the hours are not measurements, so they touched none of the
// measurement consumers above; only the caches that read the intraday
// profile (the same-time baseline) are marked stale.
if (
hourInsertedCount + hourUpdatedCount > 0 &&
insertedCount === 0 &&
updatedCount === 0
) {
invalidateUserMeasurements(user.id);
}
const response = apiSuccess({
processed: entries.length,
inserted: insertedCount,
updated: updatedCount,
inserted: insertedCount + hourInsertedCount,
updated: updatedCount + hourUpdatedCount,
duplicates: duplicateCount,
failed: failedCount,
skipped,
@@ -536,6 +536,117 @@ describe("GET /api/measurements/series", () => {
);
});
describe("GET /api/measurements/series — resolved judgements (v1.43, ios #116)", () => {
function bpDays(values: Array<[string, number]>) {
vi.mocked(prisma.measurement.findMany).mockImplementation(((
args: unknown,
) => {
const a = args as { where: { type: string } };
if (a.where.type !== "BLOOD_PRESSURE_SYS") return Promise.resolve([]);
return Promise.resolve(
values.map(([day, value], i) => ({
id: `s${i}`,
value,
measuredAt: new Date(`${day}T08:00:00.000Z`),
})),
);
}) as never);
}
it("judges the window's change against the person's own target and draws its lines", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.user.findUnique).mockResolvedValue({
glucoseUnit: "mg/dL",
dateOfBirth: null,
gender: null,
heightCm: null,
locale: "en",
thresholdsJson: { BLOOD_PRESSURE_SYS: { min: 115, max: 125 } },
} as never);
bpDays([
["2026-05-01", 128],
["2026-05-02", 130],
["2026-05-03", 134],
]);
const res = await GET(req("kind=bloodPressure&days=30"));
const body = (await res.json()) as {
data: {
direction: unknown;
change: unknown;
referenceLines: Array<Record<string, unknown>>;
};
};
expect(body.data.direction).toEqual({
direction: "up-bad",
targetPosition: "above",
basis: "personal_target",
});
expect(body.data.change).toEqual({
from: 128,
to: 134,
delta: 6,
sentiment: "negative",
});
expect(
body.data.referenceLines.map((l) => [
l.appliesTo,
l.kind,
l.value,
l.source,
]),
).toEqual([
["value", "lower", 115, "personal"],
["value", "upper", 125, "personal"],
["secondary", "upper", 90, "clinical"],
]);
expect(body.data.referenceLines[0].label).toBe("Your target, lower limit");
});
it("calls the same rise progress for someone below their target", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.user.findUnique).mockResolvedValue({
glucoseUnit: "mg/dL",
dateOfBirth: null,
gender: null,
heightCm: null,
locale: "en",
thresholdsJson: { BLOOD_PRESSURE_SYS: { min: 115, max: 125 } },
} as never);
bpDays([
["2026-05-01", 100],
["2026-05-03", 106],
]);
const res = await GET(req("kind=bloodPressure&days=30"));
const body = (await res.json()) as {
data: { change: { sentiment: string } };
};
expect(body.data.change.sentiment).toBe("positive");
});
it("has no change under two days and the clinical glucose lines in mmol/L", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.user.findUnique).mockResolvedValue({
glucoseUnit: "mmol/L",
locale: "de",
thresholdsJson: null,
} as never);
vi.mocked(prisma.measurement.findMany).mockResolvedValue([
{ id: "g1", value: 100, measuredAt: new Date("2026-05-01T08:00:00Z") },
] as never);
const res = await GET(req("kind=glucose&days=30"));
const body = (await res.json()) as {
data: {
change: unknown;
direction: { direction: string };
referenceLines: Array<{ value: number; label: string }>;
};
};
expect(body.data.change).toBeNull();
expect(body.data.referenceLines.map((l) => l.value)).toEqual([3.9, 10]);
expect(body.data.referenceLines[0].label).toBe("Klinische Untergrenze");
});
});
describe("GET /api/measurements/series — dense-kind day-bucketing (v1.28.25)", () => {
it("day-buckets glucose in SQL beyond the 90-day raw window", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
+68 -1
View File
@@ -7,6 +7,12 @@
*
* The `kind=bloodPressure` case pairs systolic + diastolic by
* `measuredAt` so iOS can render the dual-line chart with one fetch.
*
* v1.43 (ios #116 items 3 and 4) — the answer also carries what a client
* would otherwise decide itself: `direction` (which way a change of this
* metric counts for this person), `change` (first and last day value of the
* window, judged by that direction) and `referenceLines` (the person's own
* target lines, else the clinical ones, in the unit of the points).
*/
import { NextRequest } from "next/server";
import { z } from "zod/v4";
@@ -37,6 +43,15 @@ import { seriesRowsFrom } from "@/lib/measurements/series-canonical";
import { buildSourceRankCase } from "@/lib/analytics/source-rank-sql";
import { resolveUserTimezone, userDayKey } from "@/lib/tz/resolver";
import { convertGlucose, resolveGlucoseUnit } from "@/lib/glucose";
import { resolveTrendVerdict } from "@/lib/targets/trend-verdict";
import {
computeSeriesChange,
seriesDayValues,
seriesReferenceValue,
} from "@/lib/measurements/series-change";
import { resolveSeriesReferenceLines } from "@/lib/measurements/series-reference-lines";
import { getServerTranslator } from "@/lib/i18n/server-translator";
import { resolveJobLocale } from "@/lib/i18n/job-locale";
/**
* v1.11.4 — sleep is read row-per-stage (not from the day rollup), so its
@@ -199,7 +214,7 @@ function stdDev(values: number[]): number {
}
export const GET = apiHandler(async (request: NextRequest) => {
const { user } = await requireRecordAuth("read", "measurements");
const { user, actor } = await requireRecordAuth("read", "measurements");
const rawQuery = Object.fromEntries(request.nextUrl.searchParams);
const parsed = querySchema.safeParse(rawQuery);
@@ -814,6 +829,55 @@ export const GET = apiHandler(async (request: NextRequest) => {
const summary = dataPoints.length > 0 ? summarize(dataPoints) : null;
const values = dataPoints.map((p) => p.value);
// v1.43 — the resolved judgements. The profile and targets are the
// RECORD's (the chart is theirs); the labels are in the language of the
// account reading it.
const [record, reader, seriesTz] = await Promise.all([
prisma.user.findUnique({
where: { id: user.id },
select: {
dateOfBirth: true,
gender: true,
heightCm: true,
thresholdsJson: true,
glucoseUnit: true,
locale: true,
},
}),
actor.id === user.id
? null
: prisma.user.findUnique({
where: { id: actor.id },
select: { locale: true },
}),
resolveUserTimezone(user.id),
]);
const rangeProfile = {
dateOfBirth: record?.dateOfBirth ?? null,
gender: record?.gender ?? null,
heightCm: record?.heightCm ?? null,
};
const verdictType = KIND_TO_TYPE[kind];
const dayValues = seriesDayValues(verdictType, points, seriesTz);
const reference = seriesReferenceValue(dayValues);
const direction = resolveTrendVerdict(verdictType, {
profile: rangeProfile,
thresholdsJson: record?.thresholdsJson ?? null,
// The verdict reads stored units: sleep is served in hours here and
// stored in minutes. Glucose has no band of its own on this chart.
referenceValue:
reference !== null && kind === "sleep" ? reference * 60 : reference,
});
const translator = getServerTranslator(
await resolveJobLocale((reader ?? record)?.locale ?? null),
);
const referenceLines = resolveSeriesReferenceLines(kind, {
profile: rangeProfile,
thresholdsJson: record?.thresholdsJson ?? null,
glucoseUnit: resolveGlucoseUnit(record?.glucoseUnit ?? null),
t: (key) => translator.t(key),
});
annotate({
action: { name: "measurements.series" },
meta: { kind, days, count: points.length },
@@ -823,6 +887,9 @@ export const GET = apiHandler(async (request: NextRequest) => {
kind,
unit,
points,
direction,
change: computeSeriesChange(dayValues, direction),
referenceLines,
stats:
statsOverride ??
(summary
@@ -80,7 +80,9 @@ const USER = {
};
/** A replayed Apple dose: the cheapest batch that still counts as an arrival. */
async function postReplayedDose(): Promise<Response> {
async function postReplayedDose(
syncTrigger: string = "background",
): Promise<Response> {
vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValueOnce([
{ id: "row-existing", externalId: "hk-dose-1" },
] as never);
@@ -89,7 +91,7 @@ async function postReplayedDose(): Promise<Response> {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
syncTrigger: "background",
syncTrigger,
entries: [
{
medicationId: "med-apple",
@@ -134,4 +136,33 @@ describe("the dose arrival record and the credential that sent the batch", () =>
expect(res.status).toBe(200);
expect(recordHealthKitTypeArrivals).not.toHaveBeenCalled();
});
// v1.43 (ios #77) — a history catch-up records the dose arrival under
// `lastBackfillAt` only, and does not move the sync checkpoint.
it("passes a backfill through and leaves the checkpoint where it was", async () => {
vi.mocked(requireAuth).mockResolvedValue({
session: { id: "s" },
user: USER,
} as never);
const res = await postReplayedDose("backfill");
expect(res.status).toBe(200);
expect(recordHealthKitTypeArrivals).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({ trigger: "backfill" }),
);
expect(prisma.user.update).not.toHaveBeenCalled();
});
it("moves the checkpoint for a live sync", async () => {
vi.mocked(requireAuth).mockResolvedValue({
session: { id: "s" },
user: USER,
} as never);
await postReplayedDose("background");
expect(prisma.user.update).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({ lastSyncedAt: expect.any(Date) }),
}),
);
});
});
+9 -4
View File
@@ -58,7 +58,8 @@ import { withIdempotency } from "@/lib/idempotency";
import { checkRateLimit } from "@/lib/rate-limit";
import { markSyncCheckpoint } from "@/lib/sync/checkpoint";
import {
HEALTHKIT_SYNC_TRIGGERS,
BACKFILL_SYNC_TRIGGER,
HEALTHKIT_REQUEST_SYNC_TRIGGERS,
MEDICATION_DOSE_ARRIVAL_TYPE,
recordHealthKitTypeArrivals,
} from "@/lib/integrations/healthkit-type-sync";
@@ -176,7 +177,9 @@ const bulkPayloadSchema = z.object({
// v1.42.4 — what woke the phone for this sync, the same diagnostic-only
// field `POST /api/measurements/batch` takes. It changes no row; it only
// feeds the `MEDICATION_DOSE` arrival record below.
syncTrigger: z.enum(HEALTHKIT_SYNC_TRIGGERS).optional(),
// v1.43 (ios #77) — `backfill` for a history catch-up: it moves only the
// dose entry's `lastBackfillAt`, never the live stamps or the checkpoint.
syncTrigger: z.enum(HEALTHKIT_REQUEST_SYNC_TRIGGERS).optional(),
});
// v1.8.2 — `updated` joins the per-entry status vocabulary: a write that
@@ -1038,8 +1041,10 @@ async function postBulk(request: NextRequest): Promise<Response> {
// The SyncMode checkpoint belongs to the push that moved the data, not to
// the `/api/sync/state` handshake that reports it. This endpoint is one of
// the three domains that handshake summarises, so an intake drain advances
// it.
await markSyncCheckpoint(user.id);
// it. A `backfill` catch-up does not: it is history, not a sync.
if (syncTrigger !== BACKFILL_SYNC_TRIGGER) {
await markSyncCheckpoint(user.id);
}
return apiSuccess({
processed: entries.length,
@@ -41,6 +41,22 @@ const AGGREGATE = {
tags: [],
stability: year,
stabilityByWindow: { 30: month, 90: null, 180: year, 365: year },
patternsByWindow: {
30: [],
90: [],
180: [],
365: [
{
id: "weekend",
kind: "weekend",
strength: "strong",
direction: "higher",
values: { subject: 4.2, comparison: 3.1, delta: 1.1 },
sampleSize: { subject: 100, comparison: 250 },
qValue: 0.001,
},
],
},
};
const call = (query = "") =>
@@ -78,6 +94,23 @@ describe("GET /api/mood/insights — stability per period", () => {
expect(body.data.stabilityWindowDays).toBe(90);
});
it("serves the patterns of the same window, without the cache's internals", async () => {
const year = await (await call()).json();
expect(year.data.patterns).toEqual([
{
id: "weekend",
kind: "weekend",
strength: "strong",
direction: "higher",
values: { subject: 4.2, comparison: 3.1, delta: 1.1 },
sampleSize: { subject: 100, comparison: 250 },
},
]);
expect(year.data).not.toHaveProperty("patternsByWindow");
const month = await (await call("?days=30")).json();
expect(month.data.patterns).toEqual([]);
});
it("refuses a window it does not offer", async () => {
const res = await call("?days=45");
expect(res.status).toBe(422);
+10 -2
View File
@@ -12,6 +12,8 @@ import {
type MoodStabilityWindow,
} from "@/lib/insights/mood-aggregates";
import { requireModuleEnabled } from "@/lib/modules/gate";
import { getServerTranslator } from "@/lib/i18n/server-translator";
import { resolveJobLocale } from "@/lib/i18n/job-locale";
export const dynamic = "force-dynamic";
@@ -33,7 +35,8 @@ export const dynamic = "force-dynamic";
*/
/**
* `?days=N` cuts `stability` to the trailing N days, so a period picker shows
* the steadiness of the period it shows. Without it the score covers the
* the steadiness of the period it shows. Since v1.43 it cuts `patterns` the
* same way. Without it the score covers the
* whole year, as it always has; `stabilityWindowDays` names the window the
* served score covers either way.
*/
@@ -75,5 +78,10 @@ export const GET = apiHandler(async (request: Request) => {
},
});
return apiSuccess(moodInsightsWire(result, stabilityWindowDays));
// v1.43 — `patterns` follows the same `?days` window, and a catalogue
// tag in a pattern is named in the reader's language.
const translator = getServerTranslator(await resolveJobLocale(user.locale));
return apiSuccess(
moodInsightsWire(result, stabilityWindowDays, (key) => translator.t(key)),
);
});
@@ -6,6 +6,8 @@ vi.mock("@/lib/db", () => ({
personalRecord: {
findMany: vi.fn(),
},
user: { findUnique: vi.fn() },
measurement: { findFirst: vi.fn() },
},
}));
@@ -186,4 +188,77 @@ describe("GET /api/personal-records", () => {
expect(body.data[0].value).toBe(47.2);
expect(body.data[0].metricType).toBe("VO2_MAX");
});
// v1.43 (ios #116 item 5) — every stored field stays, and the band for the
// age the record was set at rides beside it.
it("projects every stored field and adds the benchmark", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
const createdAt = new Date("2026-05-02T00:00:00.000Z");
vi.mocked(prisma.personalRecord.findMany).mockResolvedValue([
{
id: "pr-1",
userId: "user-1",
metricType: "VO2_MAX",
metricSlot: null,
direction: "MAX",
value: 47.2,
unit: "mL/(kg·min)",
achievedAt: new Date("2026-05-01T07:00:00.000Z"),
sourceMeasurementId: "m-1",
source: "APPLE_HEALTH",
externalId: "x-1",
createdAt,
},
{
id: "pr-2",
userId: "user-1",
metricType: "ACTIVITY_STEPS",
metricSlot: null,
direction: "MAX",
value: 21000,
unit: "steps",
achievedAt: new Date("2026-04-01T07:00:00.000Z"),
sourceMeasurementId: null,
source: "APPLE_HEALTH",
externalId: null,
createdAt,
},
] as never);
vi.mocked(prisma.user.findUnique).mockResolvedValue({
dateOfBirth: new Date("1981-03-10T00:00:00.000Z"),
gender: "MALE",
heightCm: 180,
} as never);
const res = await GET(req("http://localhost/api/personal-records"));
const body = (await res.json()) as { data: Array<Record<string, unknown>> };
expect(Object.keys(body.data[0]).sort()).toEqual(
[
"achievedAt",
"benchmark",
"createdAt",
"direction",
"externalId",
"id",
"metricSlot",
"metricType",
"source",
"sourceMeasurementId",
"unit",
"userId",
"value",
].sort(),
);
// 45 when the record was set: between the 40s and 50s male FRIEND bands.
expect(body.data[0].benchmark).toMatchObject({
unit: "mL/(kg·min)",
source: "friend_2015",
});
const band = body.data[0].benchmark as { low: number; high: number };
expect(band.low).toBeGreaterThan(31);
expect(band.high).toBeLessThan(49);
// A step record has no cited norm.
expect(body.data[1].benchmark).toBeNull();
// No six-minute walk on the page, so no weight read.
expect(prisma.measurement.findFirst).not.toHaveBeenCalled();
});
});
+50 -1
View File
@@ -21,6 +21,11 @@
*
* Response envelope (matches the project-wide `apiSuccess` contract):
* { data: PersonalRecord[], error: null }
*
* v1.43 (ios #116 item 5) — the route projects each row instead of passing the
* Prisma result through. Every stored field stays on the wire, and each row
* gains `benchmark`: the cited age- and sex-aware band for the age the record
* was set at (`resolveRecordBenchmark`), or null.
*/
import { prisma } from "@/lib/db";
import type { NextRequest } from "next/server";
@@ -28,6 +33,11 @@ import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { apiSuccess, returnAllZodIssues } from "@/lib/api-response";
import { listPersonalRecordsSchema } from "@/lib/validations/measurement";
import {
mayCarryBenchmark,
resolveRecordBenchmark,
} from "@/lib/personal-records/record-benchmark";
import { toProfileSex } from "@/lib/profile/sex";
export const dynamic = "force-dynamic";
@@ -61,5 +71,44 @@ export const GET = apiHandler(async (request: NextRequest) => {
take: limit,
});
return apiSuccess(records);
const wantsBand = records.some((r) => mayCarryBenchmark(r.metricType));
const profile = wantsBand
? await prisma.user.findUnique({
where: { id: user.id },
select: { dateOfBirth: true, gender: true, heightCm: true },
})
: null;
// Only the six-minute-walk equation needs a weight.
const weight =
profile && records.some((r) => r.metricType === "SIX_MINUTE_WALK_DISTANCE")
? await prisma.measurement.findFirst({
where: { userId: user.id, type: "WEIGHT", deletedAt: null },
orderBy: { measuredAt: "desc" },
select: { value: true },
})
: null;
const benchmarkProfile = {
dateOfBirth: profile?.dateOfBirth ?? null,
sex: toProfileSex(profile?.gender) ?? undefined,
heightCm: profile?.heightCm ?? null,
weightKg: weight?.value ?? null,
};
return apiSuccess(
records.map((r) => ({
id: r.id,
userId: r.userId,
metricType: r.metricType,
metricSlot: r.metricSlot,
direction: r.direction,
value: r.value,
unit: r.unit,
achievedAt: r.achievedAt,
sourceMeasurementId: r.sourceMeasurementId,
source: r.source,
externalId: r.externalId,
createdAt: r.createdAt,
benchmark: profile ? resolveRecordBenchmark(r, benchmarkProfile) : null,
})),
);
});
+10 -1
View File
@@ -4,6 +4,8 @@ import {
QueryClient,
} from "@tanstack/react-query";
import { getServerTranslator } from "@/lib/i18n/server-translator";
import { resolveJobLocale } from "@/lib/i18n/job-locale";
import { getUnswitchedSession } from "@/lib/auth/acting-carrier";
import { buildMoodDailySeries } from "@/lib/analytics/mood-series";
import {
@@ -91,10 +93,17 @@ export default async function InsightsMoodPage() {
),
]);
const queryClient = new QueryClient();
const translator = getServerTranslator(
await resolveJobLocale(user.locale),
);
if (aggregates) {
queryClient.setQueryData(
queryKeys.moodInsights(),
JSON.parse(JSON.stringify(moodInsightsWire(aggregates, 365))),
JSON.parse(
JSON.stringify(
moodInsightsWire(aggregates, 365, (key) => translator.t(key)),
),
),
);
}
if (series) {
+29 -16
View File
@@ -169,6 +169,7 @@ import {
resolveWeightTrend,
weightTrendReferenceKg,
} from "@/lib/targets/weight-trend";
import type { TrendDirectionSentiment } from "@/lib/insights/trend-sentiment";
import { toProfileSex } from "@/lib/profile/sex";
import { apiGet } from "@/lib/api/api-fetch";
@@ -785,6 +786,15 @@ export default function DashboardPageClient({
clientBandProfile.weightTargetOverride,
weightTrendReferenceKg(w),
);
// v1.43 — every other tile's direction comes resolved from the server
// (`tiles.directions`, against the record's own targets and profile). The
// snapshot-disabled path has no resolved answer and renders neutral rather
// than guessing a polarity.
const tileDirections = snapshotEnabled
? snapshotQuery.data?.tiles.directions
: undefined;
const directionOf = (key: string): TrendDirectionSentiment =>
tileDirections?.[key]?.direction ?? "neutral";
const bpTargets = bands.bpTargets;
const weightRange = bands.weightRange;
// The range hint tooltip prints its band bounds in the display unit, so scale
@@ -1093,7 +1103,7 @@ export default function DashboardPageClient({
slope30={sys?.slope30 ?? null}
trend7Delta={summaryToTrend7Delta(sys)}
icon={Heart}
directionSentiment="up-bad"
directionSentiment={directionOf("BLOOD_PRESSURE_SYS")}
compareBaseline={compareBaseline}
compareDelta={tileCompareDelta(sys)}
staleDays={tileStaleDays("BLOOD_PRESSURE_SYS")}
@@ -1135,7 +1145,7 @@ export default function DashboardPageClient({
slope30={dia?.slope30 ?? null}
trend7Delta={summaryToTrend7Delta(dia)}
icon={Heart}
directionSentiment="up-bad"
directionSentiment={directionOf("BLOOD_PRESSURE_DIA")}
compareBaseline={compareBaseline}
compareDelta={tileCompareDelta(dia)}
staleDays={tileStaleDays("BLOOD_PRESSURE_DIA")}
@@ -1177,6 +1187,9 @@ export default function DashboardPageClient({
slope30={pulseTileSummary?.slope30 ?? null}
trend7Delta={summaryToTrend7Delta(pulseTileSummary)}
icon={TrendingUp}
directionSentiment={directionOf(
hasRestingHr ? "RESTING_HEART_RATE" : "PULSE",
)}
compareBaseline={compareBaseline}
compareDelta={tileCompareDelta(pulseTileSummary)}
staleDays={tileStaleDays(
@@ -1201,7 +1214,7 @@ export default function DashboardPageClient({
slope30={bf?.slope30 ?? null}
trend7Delta={summaryToTrend7Delta(bf)}
icon={Percent}
directionSentiment="up-bad"
directionSentiment={directionOf("BODY_FAT")}
compareBaseline={compareBaseline}
compareDelta={tileCompareDelta(bf)}
staleDays={tileStaleDays("BODY_FAT")}
@@ -1224,7 +1237,7 @@ export default function DashboardPageClient({
slope30={moodSummary?.slope30 ?? null}
trend7Delta={summaryToTrend7Delta(moodSummary)}
icon={Smile}
directionSentiment="up-good"
directionSentiment={directionOf("MOOD")}
compareBaseline={compareBaseline}
compareDelta={tileCompareDelta(moodSummary)}
/>
@@ -1246,7 +1259,7 @@ export default function DashboardPageClient({
slope30={sleepSummaryHours?.slope30 ?? null}
trend7Delta={summaryToTrend7Delta(sleepSummaryHours)}
icon={Moon}
directionSentiment="up-good"
directionSentiment={directionOf("SLEEP_DURATION")}
compareBaseline={compareBaseline}
compareDelta={tileCompareDelta(sleepSummaryHours)}
staleDays={tileStaleDays("SLEEP_DURATION")}
@@ -1282,7 +1295,7 @@ export default function DashboardPageClient({
slope30={stepsSummary?.slope30 ?? null}
trend7Delta={summaryToTrend7Delta(stepsSummary)}
icon={Footprints}
directionSentiment="up-good"
directionSentiment={directionOf("ACTIVITY_STEPS")}
compareBaseline={compareBaseline}
compareDelta={tileCompareDelta(stepsSummary)}
staleDays={tileStaleDays("ACTIVITY_STEPS")}
@@ -1312,7 +1325,7 @@ export default function DashboardPageClient({
slope30={vo2Summary?.slope30 ?? null}
trend7Delta={summaryToTrend7Delta(vo2Summary)}
icon={Gauge}
directionSentiment="up-good"
directionSentiment={directionOf("VO2_MAX")}
compareBaseline={compareBaseline}
compareDelta={tileCompareDelta(vo2Summary)}
staleDays={tileStaleDays("VO2_MAX")}
@@ -1354,7 +1367,7 @@ export default function DashboardPageClient({
slope30={hrvSummary?.slope30 ?? null}
trend7Delta={summaryToTrend7Delta(hrvSummary)}
icon={HeartPulse}
directionSentiment="up-good"
directionSentiment={directionOf(hrvType)}
compareBaseline={compareBaseline}
compareDelta={tileCompareDelta(hrvSummary)}
// The type that actually backs the tile — reading freshness
@@ -1380,7 +1393,7 @@ export default function DashboardPageClient({
slope30={spo2Summary?.slope30 ?? null}
trend7Delta={summaryToTrend7Delta(spo2Summary)}
icon={Droplets}
directionSentiment="up-good"
directionSentiment={directionOf("OXYGEN_SATURATION")}
compareBaseline={compareBaseline}
compareDelta={tileCompareDelta(spo2Summary)}
staleDays={tileStaleDays("OXYGEN_SATURATION")}
@@ -1403,7 +1416,7 @@ export default function DashboardPageClient({
slope30={respRateSummary?.slope30 ?? null}
trend7Delta={summaryToTrend7Delta(respRateSummary)}
icon={Wind}
directionSentiment="neutral"
directionSentiment={directionOf("RESPIRATORY_RATE")}
compareBaseline={compareBaseline}
compareDelta={tileCompareDelta(respRateSummary)}
staleDays={tileStaleDays("RESPIRATORY_RATE")}
@@ -1429,7 +1442,7 @@ export default function DashboardPageClient({
summaryToTrend7Delta(wristTempSummary),
)}
icon={Thermometer}
directionSentiment="neutral"
directionSentiment={directionOf("WRIST_TEMPERATURE")}
compareBaseline={compareBaseline}
compareDelta={tdd(
"WRIST_TEMPERATURE",
@@ -1461,7 +1474,7 @@ export default function DashboardPageClient({
summaryToTrend7Delta(muscleMassSummary),
)}
icon={Dumbbell}
directionSentiment="up-good"
directionSentiment={directionOf("MUSCLE_MASS")}
compareBaseline={compareBaseline}
compareDelta={tdd(
"MUSCLE_MASS",
@@ -1490,7 +1503,7 @@ export default function DashboardPageClient({
summaryToTrend7Delta(bodyWaterSummary),
)}
icon={Droplet}
directionSentiment="neutral"
directionSentiment={directionOf("TOTAL_BODY_WATER")}
compareBaseline={compareBaseline}
compareDelta={tdd(
"TOTAL_BODY_WATER",
@@ -1519,7 +1532,7 @@ export default function DashboardPageClient({
summaryToTrend7Delta(boneMassSummary),
)}
icon={Bone}
directionSentiment="neutral"
directionSentiment={directionOf("BONE_MASS")}
compareBaseline={compareBaseline}
compareDelta={tdd(
"BONE_MASS",
@@ -1548,7 +1561,7 @@ export default function DashboardPageClient({
slope30={waterIntakeSummary?.slope30 ?? null}
trend7Delta={summaryToTrend7Delta(waterIntakeSummary)}
icon={GlassWater}
directionSentiment="neutral"
directionSentiment={directionOf("NUTRIENT_WATER")}
compareBaseline={compareBaseline}
compareDelta={tileCompareDelta(waterIntakeSummary)}
staleDays={tileStaleDays("NUTRIENT_WATER")}
@@ -1639,7 +1652,7 @@ export default function DashboardPageClient({
slope30={null}
trend7Delta={bpSufficient ? bpTrendDelta : null}
icon={Target}
directionSentiment="up-good"
directionSentiment={directionOf("BLOOD_PRESSURE_IN_TARGET")}
compareBaseline={compareBaseline}
compareDelta={bpSufficient ? bpCompareDelta : null}
/>
+59 -9
View File
@@ -23,6 +23,7 @@ import {
import { useTranslations } from "@/lib/i18n/context";
import { randomId } from "@/lib/random-id";
import { toastWrittenOutcome } from "@/components/outcome/outcome-toast";
import { useCalendarDate } from "@/hooks/use-calendar-date";
import {
cycleDependentKeys,
@@ -318,10 +319,33 @@ export interface CycleDayLogPatch {
note?: string | null;
}
/**
* A period start the server folded in or gave back (v1.43). The server names
* it; the toast only says it, so the person learns which date moved without
* comparing the calendar before and after.
*/
export interface CycleStartRef {
cycleId: string;
startDate: string;
}
/** The toast after a write that may have given starts back. */
function useRestoredStartsMessage() {
const { t } = useTranslations();
const formatDay = useCalendarDate();
return (fallback: string, restored: readonly CycleStartRef[] | undefined) =>
restored && restored.length > 0
? t("cycle.startRestored", {
dates: restored.map((r) => formatDay(r.startDate)).join(", "),
})
: fallback;
}
/** Edit an existing day-log by id (PATCH — accepts explicit null to clear). */
export function usePatchDayLog() {
const qc = useQueryClient();
const { t } = useTranslations();
const restoredMessage = useRestoredStartsMessage();
return useMutation({
mutationFn: async ({
id,
@@ -330,11 +354,17 @@ export function usePatchDayLog() {
id: string;
patch: CycleDayLogPatch;
}) => {
return apiPatch<CycleDayLogDTO>(`/api/cycle/day-logs/${id}`, patch);
return apiPatch<CycleDayLogDTO & { restored?: CycleStartRef[] }>(
`/api/cycle/day-logs/${id}`,
patch,
);
},
onSuccess: () => {
onSuccess: (data) => {
void invalidateKeys(qc, cycleDependentKeys);
toastWrittenOutcome("success", t("cycle.saveSuccess"));
toastWrittenOutcome(
"success",
restoredMessage(t("cycle.saveSuccess"), data?.restored),
);
},
});
}
@@ -342,17 +372,26 @@ export function usePatchDayLog() {
export function useStartPeriod() {
const qc = useQueryClient();
const { t } = useTranslations();
const formatDay = useCalendarDate();
return useMutation({
mutationFn: async (date: string) => {
return apiPost<unknown>("/api/cycle/period", {
return apiPost<{ absorbed?: CycleStartRef | null }>("/api/cycle/period", {
action: "start",
date,
loggedAt: new Date().toISOString(),
});
},
onSuccess: () => {
onSuccess: (data, date) => {
void invalidateKeys(qc, cycleDependentKeys);
toastWrittenOutcome("success", t("cycle.periodStartSaved"));
toastWrittenOutcome(
"success",
data?.absorbed
? t("cycle.startAbsorbed", {
absorbed: formatDay(data.absorbed.startDate),
date: formatDay(date),
})
: t("cycle.periodStartSaved"),
);
},
// v1.16.4 — period boundaries had no failure signal at all (the sheet
// simply stayed open); a toast names the rejection.
@@ -386,13 +425,24 @@ export function useEndPeriod() {
export function useDeleteDayLog() {
const qc = useQueryClient();
const { t } = useTranslations();
const restoredMessage = useRestoredStartsMessage();
return useMutation({
mutationFn: async (id: string) => {
await apiDelete(`/api/cycle/day-logs/${id}`);
// Ask for what the delete changed: removing a start day can give back a
// start it had folded in, and the toast names it. A server that does
// not know the preference answers 204, read as nothing given back.
return apiDelete<{ restored?: CycleStartRef[] } | null>(
`/api/cycle/day-logs/${id}`,
undefined,
{ headers: { Prefer: "return=representation" } },
);
},
onSuccess: () => {
onSuccess: (data) => {
void invalidateKeys(qc, cycleDependentKeys);
toastWrittenOutcome("success", t("cycle.deleteSuccess"));
toastWrittenOutcome(
"success",
restoredMessage(t("cycle.deleteSuccess"), data?.restored),
);
},
onError: (failure) =>
showErrorToast(t("cycle.deleteError"), { cause: failure }),
@@ -28,7 +28,6 @@ import {
} from "./use-dashboard-derived";
import { SAME_TIME_BASELINE_MIN_HISTORY_DAYS } from "@/lib/insights/derived/registry";
import { resolveTileLayout, type InsightsLayout } from "@/lib/insights-layout";
import type { TrendDirectionSentiment } from "@/lib/insights/trend-sentiment";
// Type-only — the compute payloads never drag the server graph into the bundle.
import type { VitalsBaselineValue } from "@/lib/insights/derived/baseline";
import type { FitnessAgeValue } from "@/lib/insights/derived/fitness-age";
@@ -64,26 +63,6 @@ import type { DerivedProvenance } from "@/lib/insights/derived/types";
// moved to `use-dashboard-derived.ts` so the page can own the one shared batch
// and feed it to both the wellness strip and this grid. Imported above.
/**
* Up-is-bad for the vitals where a rise is unfavourable. Weight is not one of
* them: which way it should move depends on the person's own target (#1006,
* `src/lib/targets/weight-trend.ts`), and this grid has no target in hand, so
* it stays neutral rather than assuming a fall is good.
*/
const UP_BAD_VITALS = new Set([
"RESTING_HEART_RATE",
"BODY_TEMPERATURE",
"BLOOD_GLUCOSE",
]);
/** Up-is-good for the vitals where a rise is favourable. */
const UP_GOOD_VITALS = new Set(["HEART_RATE_VARIABILITY", "OXYGEN_SATURATION"]);
function vitalSentiment(type: string): TrendDirectionSentiment {
if (UP_BAD_VITALS.has(type)) return "up-bad";
if (UP_GOOD_VITALS.has(type)) return "up-good";
return "neutral";
}
interface DashboardProps {
/**
* The shared overview derived batch (owned by the page so the wellness
@@ -306,7 +285,10 @@ function BaselineTile({
icon={Icon}
series={displaySeries}
framing={framing}
directionSentiment={vitalSentiment(type)}
// v1.43 — resolved on the server against the person's own target
// or profile default; an older server sends none and the tile
// stays neutral rather than assuming a polarity.
directionSentiment={v.direction?.direction ?? "neutral"}
provenance={
// v1.29.1 muted this on VITALS_BASELINE tiles because the always-on
// "typical range = median ± MAD" caption repeated across every vital
@@ -30,6 +30,7 @@ import {
Gauge,
Grid3x3,
Link2,
Repeat,
Tag,
Tags,
TrendingUp,
@@ -110,6 +111,7 @@ import { MoodStabilityTile } from "./mood-stability-tile";
import { MoodTagInfluence } from "./mood-tag-influence";
import { MoodTagMetricCrosstab } from "./mood-tag-metric-crosstab";
import { MoodFactorMetricCrosstab } from "./mood-factor-metric-crosstab";
import { MoodPatterns } from "./mood-patterns";
export function MoodInsightsBreakdowns({
data,
@@ -151,6 +153,7 @@ export function MoodInsightsBreakdowns({
const factorCrosstabRows = data.factorCrosstab ?? [];
const hasFactorCrosstab = factorCrosstabRows.length > 0;
const dimensionSummaries = data.dimensions ?? [];
const patterns = data.patterns ?? [];
const hasDimensions = dimensionSummaries.some((d) => d.present);
return (
@@ -184,6 +187,14 @@ export function MoodInsightsBreakdowns({
renders nothing when both halves are empty. */}
<MoodWhatStandsOut narratives={narratives} />
{/* v1.43 — the patterns the server found and gated; the card stays
away when none passed. */}
{patterns.length > 0 && (
<SectionCard title={t("insights.mood.patterns.title")} icon={Repeat}>
<MoodPatterns patterns={patterns} />
</SectionCard>
)}
{/* v1.37 — the five level-A dimensions. Renders only once at least one
of them carries a value; an account that has never opened the detail
section sees the page it saw before. */}
@@ -30,6 +30,7 @@ import type { MoodBetterDayFactor } from "./mood-better-days";
import type { MoodTagMetricCrosstabRow } from "./mood-tag-metric-crosstab";
import type { MoodFactorMetricCrosstabRow } from "./mood-factor-metric-crosstab";
import type { ContextComparisonRow } from "@/lib/insights/mood-context-crosstab";
import type { MoodPatternRow } from "./mood-patterns";
export interface MoodInsightsResponse {
summary: {
@@ -65,6 +66,11 @@ export interface MoodInsightsResponse {
// rollout; the live endpoint always populates it.
factorCrosstab?: MoodFactorMetricCrosstabRow[];
narratives: MoodNarrativeItem[];
/**
* v1.43 — the gated patterns the server found over the requested window.
* Optional to tolerate a payload from before it existed.
*/
patterns?: MoodPatternRow[];
correlations: {
sleep: MoodMetricCorrelationData;
steps: MoodMetricCorrelationData;
@@ -0,0 +1,111 @@
"use client";
/**
* v1.43 (ios #116 item 6) — the mood patterns the server found.
*
* Nothing here decides whether a pattern exists: every row is one the server
* published after its gate (eight days a side, Welch, false discovery
* control), computed on the mood day. This component only phrases each one.
* Rows render in the order delivered; a kind this build does not know is
* skipped rather than guessed at.
*/
import { useFormatters, useTranslations } from "@/lib/i18n/context";
export interface MoodPatternRow {
id: string;
kind: string;
strength: "weak" | "moderate" | "strong";
direction: "higher" | "lower" | "none";
values: {
subject: number;
comparison: number;
delta: number;
weekday?: number;
tagKey?: string;
tagLabel?: string;
};
sampleSize: { subject: number; comparison: number };
}
const WEEKDAY_KEYS = [
"charts.weekdaysFull.mon",
"charts.weekdaysFull.tue",
"charts.weekdaysFull.wed",
"charts.weekdaysFull.thu",
"charts.weekdaysFull.fri",
"charts.weekdaysFull.sat",
"charts.weekdaysFull.sun",
] as const;
/** The sentence key per kind and direction; `none` cannot pass the gate. */
const SENTENCE_KEYS: Record<string, { higher: string; lower: string | null }> =
{
previous_day: {
higher: "insights.mood.patterns.previousDayHigher",
lower: "insights.mood.patterns.previousDayLower",
},
weekend: {
higher: "insights.mood.patterns.weekendHigher",
lower: "insights.mood.patterns.weekendLower",
},
best_weekday: {
higher: "insights.mood.patterns.bestWeekday",
lower: null,
},
notes: {
higher: "insights.mood.patterns.notesHigher",
lower: "insights.mood.patterns.notesLower",
},
delayed_tag: {
higher: "insights.mood.patterns.delayedTagHigher",
lower: "insights.mood.patterns.delayedTagLower",
},
multiple_entries: {
higher: "insights.mood.patterns.multipleEntriesHigher",
lower: "insights.mood.patterns.multipleEntriesLower",
},
};
export function MoodPatterns({ patterns }: { patterns: MoodPatternRow[] }) {
const { t } = useTranslations();
const fmt = useFormatters();
const rows = patterns.flatMap((pattern) => {
const keys = SENTENCE_KEYS[pattern.kind];
if (!keys || pattern.direction === "none") return [];
const key = pattern.direction === "higher" ? keys.higher : keys.lower;
if (!key) return [];
const weekdayKey =
pattern.values.weekday !== undefined
? WEEKDAY_KEYS[pattern.values.weekday]
: undefined;
return [
{
id: pattern.id,
sentence: t(key, {
subject: fmt.number(pattern.values.subject, 1),
comparison: fmt.number(pattern.values.comparison, 1),
weekday: weekdayKey ? t(weekdayKey) : "",
tag: pattern.values.tagLabel ?? pattern.values.tagKey ?? "",
}),
basedOn: t("insights.mood.patterns.basedOn", {
subject: pattern.sampleSize.subject,
comparison: pattern.sampleSize.comparison,
}),
},
];
});
if (rows.length === 0) return null;
return (
<ul className="space-y-3" data-slot="mood-patterns">
{rows.map((row) => (
<li key={row.id} data-pattern-id={row.id} className="space-y-1">
<p className="text-sm">{row.sentence}</p>
<p className="text-muted-foreground text-xs">{row.basedOn}</p>
</li>
))}
</ul>
);
}
@@ -41,6 +41,17 @@ interface PersonalRecordRow {
value: number;
unit: string;
achievedAt: string;
/**
* v1.43 — the cited band for the age the record was set at, resolved on
* the server; null (or absent on an older server) when none applies.
*/
benchmark?: {
low: number;
high: number;
unit: string;
source: string;
sourceLabel: string;
} | null;
}
interface PersonalRecordBadgeProps {
@@ -146,6 +157,19 @@ export function PersonalRecordBadge({
data-slot="insights-pr-badge-tooltip"
>
<span className="text-xs">{tooltipText}</span>
{fresh.benchmark ? (
<span
className="text-muted-foreground block text-xs"
data-slot="insights-pr-badge-benchmark"
>
{t("insights.personalRecord.benchmark", {
low: fmt.number(fresh.benchmark.low, 1),
high: fmt.number(fresh.benchmark.high, 1),
unit: fresh.benchmark.unit,
source: fresh.benchmark.sourceLabel,
})}
</span>
) : null}
</TooltipContent>
</Tooltip>
);
@@ -474,6 +474,31 @@ describe("<AppleHealthCard> — per-type arrivals (#1173)", () => {
expect(out).not.toContain("Last in background");
});
// v1.43 (ios #77) — a history catch-up is named apart, and a type that
// only ever arrived through one still reads as not received live.
it("names the last backfill apart from the live arrival", () => {
const live = html({
...base,
lastReceivedAt: hoursAgo(1),
lastTrigger: "foreground",
lastNewSampleAt: hoursAgo(1),
lastBackfillAt: hoursAgo(3),
});
expect(live).toContain('data-state="received"');
expect(live).toContain("History caught up 3 h ago");
const backfillOnly = html({
...base,
lastReceivedAt: null,
lastTrigger: null,
lastNewSampleAt: null,
lastBackfillAt: hoursAgo(2),
});
expect(backfillOnly).toContain('data-state="never"');
expect(backfillOnly).toContain(
"Not received yet, History caught up 2 h ago",
);
});
it("names the medication dose row in the freshness list", () => {
statusPayload = {
lastSyncedAt: hoursAgo(1),
@@ -148,16 +148,25 @@ export function ArrivalDetail({
now?: Date;
}) {
if (entry.lastReceivedAt === undefined) return null;
if (entry.lastReceivedAt === null) {
return (
<span data-testid="apple-health-arrival" data-state="never">
{t("settings.appleHealth.freshness.never")}
</span>
);
}
const reference = (now ?? new Date()).getTime();
const relative = (iso: string) =>
formatRelative(Math.max(0, reference - new Date(iso).getTime()), t);
// v1.43 (ios #77) — a history catch-up is named apart from the live
// arrivals, and never counts as one.
const backfill = entry.lastBackfillAt
? t("settings.appleHealth.freshness.lastBackfill", {
relative: relative(entry.lastBackfillAt),
})
: null;
if (entry.lastReceivedAt === null) {
return (
<span data-testid="apple-health-arrival" data-state="never">
{[t("settings.appleHealth.freshness.never"), backfill]
.filter((part): part is string => part !== null)
.join(", ")}
</span>
);
}
const trigger = entry.lastTrigger ?? null;
const parts = [
t("settings.appleHealth.freshness.lastReceived", {
@@ -189,6 +198,7 @@ export function ArrivalDetail({
}),
);
}
if (backfill) parts.push(backfill);
return (
<span
data-testid="apple-health-arrival"
+6 -1
View File
@@ -332,7 +332,12 @@ export function resolveWeightTargetOverride(
return { min, max };
}
function resolveThresholdOverride(
/**
* Narrow one metric's own target out of a raw `User.thresholdsJson`, the same
* way `resolveWeightTargetOverride` does for weight. `null` for anything that
* is not a well-formed, ordered pair of finite numbers.
*/
export function resolveThresholdOverride(
thresholdsJson: unknown,
metric: ThresholdMetric,
): { min: number; max: number } | null {
+6
View File
@@ -366,6 +366,8 @@ export async function removeCycleStartedOn(
cycleId: string;
reanchored: ReanchoredCycle | null;
restoredCycleIds: string[];
/** The same starts with their dates, for the answer to the client. */
restored: Array<{ cycleId: string; startDate: string }>;
} | null> {
const opened = await db.menstrualCycle.findFirst({
where: { userId, deletedAt: null, startDate: date },
@@ -432,6 +434,10 @@ export async function removeCycleStartedOn(
cycleId: opened.id,
reanchored,
restoredCycleIds: restored.map((row) => row.id),
restored: restored.map((row) => ({
cycleId: row.id,
startDate: row.startDate,
})),
};
}
+30
View File
@@ -0,0 +1,30 @@
/**
* The answer to a delete that can take a period start with it (v1.43, ios
* #116 item 9).
*
* Without a preference the delete answers 204, exactly as before, which is
* what every client predating this relies on. With `Prefer:
* return=representation` (RFC 7240) it answers 200 with the cycle it removed
* and the starts that removal gave back — a start folded into the removed one
* stands again — and says so in `Preference-Applied`.
*/
import { apiSuccess } from "@/lib/api-response";
import { hasPreference, RETURN_REPRESENTATION } from "@/lib/http/prefer";
export interface CycleRestoredStart {
cycleId: string;
startDate: string;
}
export function cycleDeleteOutcome(
request: Request,
removedCycleId: string | null,
restored: readonly CycleRestoredStart[],
): Response {
if (!hasPreference(request, RETURN_REPRESENTATION)) {
return new Response(null, { status: 204 });
}
const response = apiSuccess({ removedCycleId, restored: [...restored] });
response.headers.set("Preference-Applied", RETURN_REPRESENTATION);
return response;
}
+21
View File
@@ -64,6 +64,10 @@ import {
weightTrendReferenceKg,
type WeightTrendJudgement,
} from "@/lib/targets/weight-trend";
import {
resolveSummaryVerdicts,
type TrendVerdict,
} from "@/lib/targets/trend-verdict";
import {
probeRollupCoverage,
typesWithReadingsInFoldWindow,
@@ -452,6 +456,15 @@ export interface DashboardSnapshot {
* builder always sets it.
*/
weightTrend?: WeightTrendJudgement;
/**
* v1.43 (ios #116 item 3) — per measurement type (and `MOOD`,
* `BLOOD_PRESSURE_IN_TARGET`), which way a change counts for this
* person, against their own target or a profile default where one
* applies (`src/lib/targets/trend-verdict.ts`). Every tile colours its
* arrow and deltas from this; `weightTrend` stays for older clients.
* Optional on the type so older cached snapshots stay valid.
*/
directions?: Record<string, TrendVerdict>;
};
/** Thick phase — null on a rollup-coverage miss. */
extras: DashboardSnapshotExtras | null;
@@ -1496,6 +1509,14 @@ export async function buildDashboardSnapshot(
weightTargetOverride,
weightTrendReferenceKg(slim.summaries.WEIGHT),
),
directions: resolveSummaryVerdicts(slim.summaries, {
profile: {
dateOfBirth: user.dateOfBirth,
gender: user.gender,
heightCm: user.heightCm ?? null,
},
thresholdsJson: user.thresholdsJson,
}),
},
extras: extrasResult?.extras ?? null,
medsToday,
+2 -8
View File
@@ -4,6 +4,7 @@
* response the synchronous route has always given.
*/
import { apiError, apiSuccess } from "@/lib/api-response";
import { hasPreference } from "@/lib/http/prefer";
import { AiUnavailableError } from "@/lib/ai/capabilities/refusal";
import type { AiRunFailure } from "./store";
@@ -20,14 +21,7 @@ import {
* is what a client that has not adopted runs relies on.
*/
export function prefersRespondAsync(request: Request): boolean {
const header = request.headers.get("prefer");
if (!header) return false;
return header
.split(",")
.some(
(preference) =>
preference.split(";")[0]?.trim().toLowerCase() === "respond-async",
);
return hasPreference(request, "respond-async");
}
/** The 202 for a queued run, with the poll address and the applied preference. */
@@ -34,6 +34,9 @@ function sourceRow() {
hourlyCumulative: CURVE,
dayTotal: CURVE[23],
sampleCount: 96,
hourlyIncrements: [] as number[],
basis: "PER_SAMPLE",
bucketCount: null,
timezone: "Europe/Berlin",
createdAt: new Date("2026-07-19T02:00:00.000Z"),
updatedAt: new Date("2026-07-19T02:00:00.000Z"),
@@ -57,6 +60,9 @@ function entry(
hourlyCumulative: CURVE,
dayTotal: CURVE[23],
sampleCount: 96,
hourlyIncrements: [],
basis: "PER_SAMPLE",
bucketCount: null,
timezone: "Europe/Berlin",
...overrides,
};
@@ -164,4 +170,62 @@ describe("intraday profile backup", () => {
).rejects.toThrow(/23 hourly slots, not 24/);
expect(created, "a row every reader drops is worse than none").toEqual([]);
});
// v1.43 (ios #77) — the posted hours travel with the day.
it("carries the posted hours, the basis and the hour count both ways", async () => {
const hours = Array.from({ length: 24 }, (_, h) => (h < 8 ? -1 : 100));
const built = await buildIntradayProfileBackupSection(
{
intradayCumulativeProfile: {
findMany: vi.fn().mockResolvedValue([
{
...sourceRow(),
hourlyIncrements: hours,
basis: "HOUR_BUCKETS",
bucketCount: 16,
},
]),
},
} as never,
"user-A",
);
expect(built.intradayProfiles[0]).toMatchObject({
hourlyIncrements: hours,
basis: "HOUR_BUCKETS",
bucketCount: 16,
});
const created: Array<Record<string, unknown>> = [];
await restoreIntradayProfileData(tx(created), "user-B", {
intradayProfiles: [built.intradayProfiles[0]],
});
expect(created[0]).toMatchObject({
hourlyIncrements: hours,
basis: "HOUR_BUCKETS",
bucketCount: 16,
});
});
it("restores a day that so far holds only a few posted hours", async () => {
const hours = Array.from({ length: 24 }, (_, h) => (h === 9 ? 120 : -1));
const created: Array<Record<string, unknown>> = [];
await restoreIntradayProfileData(tx(created), "user-B", {
intradayProfiles: [
entry({
hourlyCumulative: [],
dayTotal: 0,
sampleCount: 0,
hourlyIncrements: hours,
bucketCount: 1,
}),
],
});
expect(created[0]).toMatchObject({ hourlyCumulative: [], bucketCount: 1 });
await expect(
restoreIntradayProfileData(tx([]), "user-B", {
intradayProfiles: [entry({ hourlyIncrements: hours.slice(0, 3) })],
}),
).rejects.toThrow(/3 posted-hour slots/);
});
});
+36 -1
View File
@@ -59,6 +59,18 @@ export interface IntradayProfileBackupEntry {
hourlyCumulative: number[];
dayTotal: number;
sampleCount: number;
/**
* v1.43 (ios #77) — the hours the phone posted for the day, 24 slots with
* -1 for an hour not posted, or empty for a day folded from single samples
* only. A day with fewer than four posted hours keeps its posted hours here
* while its curve is still the fold's, or empty when the drain never saw
* the day.
*/
hourlyIncrements: number[];
/** What `hourlyCumulative` was built from. */
basis: "PER_SAMPLE" | "HOUR_BUCKETS";
/** Posted hours behind the day; null when none were posted. */
bucketCount: number | null;
/**
* The IANA zone the day was cut on. Carried because the reader compares it
* against the account's current zone and drops the mismatched days; a
@@ -113,6 +125,9 @@ export async function buildIntradayProfileBackupSection(
hourlyCumulative: row.hourlyCumulative,
dayTotal: row.dayTotal,
sampleCount: row.sampleCount,
hourlyIncrements: row.hourlyIncrements,
basis: row.basis === "HOUR_BUCKETS" ? "HOUR_BUCKETS" : "PER_SAMPLE",
bucketCount: row.bucketCount,
timezone: row.timezone,
})),
};
@@ -180,7 +195,24 @@ export async function restoreIntradayProfileData(
}
seen.add(key);
if (profile.hourlyCumulative.length !== PROFILE_BACKUP_HOURS) {
const increments = profile.hourlyIncrements ?? [];
if (increments.length !== 0 && increments.length !== PROFILE_BACKUP_HOURS) {
throw new Error(
`Intraday profile for ${profile.type} on ${profile.dateKey} carries ` +
`${increments.length} posted-hour slots, not ${PROFILE_BACKUP_HOURS} or none.`,
);
}
// A day that so far holds only a few posted hours has no curve yet; the
// reader skips it until it does, and the hours are what it is restored
// for. Every other row must carry a full day.
const hoursOnly =
profile.hourlyCumulative.length === 0 &&
increments.length === PROFILE_BACKUP_HOURS &&
(profile.basis ?? "PER_SAMPLE") === "PER_SAMPLE";
if (
!hoursOnly &&
profile.hourlyCumulative.length !== PROFILE_BACKUP_HOURS
) {
throw new Error(
`Intraday profile for ${profile.type} on ${profile.dateKey} carries ` +
`${profile.hourlyCumulative.length} hourly slots, not ${PROFILE_BACKUP_HOURS}. ` +
@@ -200,6 +232,9 @@ export async function restoreIntradayProfileData(
hourlyCumulative: profile.hourlyCumulative,
dayTotal: profile.dayTotal,
sampleCount: profile.sampleCount,
hourlyIncrements: profile.hourlyIncrements ?? [],
basis: profile.basis ?? "PER_SAMPLE",
bucketCount: profile.bucketCount ?? null,
timezone: profile.timezone,
...(profile.createdAt
? { createdAt: new Date(profile.createdAt) }
+39
View File
@@ -0,0 +1,39 @@
import { describe, expect, it } from "vitest";
import { hasPreference, RETURN_REPRESENTATION } from "../prefer";
function req(prefer?: string): Request {
return new Request("http://localhost/x", {
headers: prefer === undefined ? {} : { prefer },
});
}
describe("hasPreference", () => {
it("is false without the header", () => {
expect(hasPreference(req(), RETURN_REPRESENTATION)).toBe(false);
});
it("finds a valued preference among several, with parameters and case", () => {
expect(
hasPreference(
req("respond-async, Return=Representation; foo=bar"),
RETURN_REPRESENTATION,
),
).toBe(true);
expect(
hasPreference(req("return = representation"), "return=representation"),
).toBe(true);
});
it("does not take a different value of the same preference", () => {
expect(hasPreference(req("return=minimal"), RETURN_REPRESENTATION)).toBe(
false,
);
});
it("reads a bare token", () => {
expect(hasPreference(req("respond-async; wait=10"), "respond-async")).toBe(
true,
);
});
});
+22
View File
@@ -0,0 +1,22 @@
/**
* RFC 7240 `Prefer` header reading. A preference is the token before its
* first `;` (parameters follow it), several preferences are comma-separated,
* and a token without `=` stands alone (`respond-async`) while one with `=`
* carries a value (`return=representation`). Compared case-insensitively.
*/
export function hasPreference(request: Request, preference: string): boolean {
const header = request.headers.get("prefer");
if (!header) return false;
const wanted = preference.toLowerCase();
return header.split(",").some(
(entry) =>
entry
.split(";")[0]
?.trim()
.toLowerCase()
.replace(/\s*=\s*/, "=") === wanted,
);
}
/** `Prefer: return=representation`: answer a write with what it changed. */
export const RETURN_REPRESENTATION = "return=representation";
@@ -0,0 +1,232 @@
import { describe, expect, it } from "vitest";
import type { MoodAggregateEntry } from "@/lib/insights/mood-aggregates";
import { shiftDateKey } from "@/lib/tz/format";
import {
computeMoodPatterns,
moodPatternsWire,
PATTERN_MIN_DAYS_PER_SIDE,
weekdayOfKey,
} from "../mood-pattern-cards";
const TODAY = "2026-10-11"; // a Sunday
/** A deterministic pseudo-random stream (mulberry32). */
function rng(seed: number): () => number {
let a = seed;
return () => {
a |= 0;
a = (a + 0x6d2b79f5) | 0;
let t = Math.imul(a ^ (a >>> 15), 1 | a);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
function entry(
date: string,
score: number,
extra: Partial<MoodAggregateEntry> = {},
): MoodAggregateEntry {
return {
date,
score,
tags: null,
moodLoggedAt: new Date(`${date}T18:00:00.000Z`),
...extra,
};
}
function days(n: number, score: (date: string, i: number) => number) {
return Array.from({ length: n }, (_, i) => {
const date = shiftDateKey(TODAY, -(n - 1 - i));
return entry(date, score(date, i));
});
}
function run(entries: MoodAggregateEntry[], noteDays = new Set<string>()) {
return computeMoodPatterns({
entries,
noteDays,
todayKey: TODAY,
windowDays: 365,
});
}
describe("computeMoodPatterns", () => {
it("finds a clear weekend effect and names it", () => {
const r = rng(7);
const entries = days(120, (date) =>
Math.min(
5,
Math.max(1, (weekdayOfKey(date) >= 5 ? 4.2 : 3) + (r() - 0.5)),
),
);
const patterns = run(entries);
const weekend = patterns.find((p) => p.kind === "weekend");
expect(weekend).toBeDefined();
expect(weekend).toMatchObject({
id: "weekend",
direction: "higher",
strength: "strong",
});
expect(weekend!.sampleSize.subject).toBeGreaterThanOrEqual(
PATTERN_MIN_DAYS_PER_SIDE,
);
expect(weekend!.values.subject).toBeGreaterThan(weekend!.values.comparison);
// Saturday or Sunday is the best weekday, and only one is published.
const best = patterns.filter((p) => p.kind === "best_weekday");
expect(best.length).toBeLessThanOrEqual(1);
if (best[0]) expect(best[0].values.weekday).toBeGreaterThanOrEqual(5);
});
it("finds nothing in noise", () => {
for (const seed of [1, 2, 3, 4, 5]) {
const r = rng(seed);
const entries = days(180, () => 1 + Math.round(r() * 4));
expect(run(entries), `seed ${seed}`).toEqual([]);
}
});
it("holds every comparison to eight days a side", () => {
// Twelve days, all with a strong weekend split: too few weekend days.
const r = rng(16);
const entries = days(
12,
(date) => (weekdayOfKey(date) >= 5 ? 4.6 : 1.4) + (r() - 0.5) * 0.4,
);
expect(run(entries).find((p) => p.kind === "weekend")).toBeUndefined();
});
it("compares days with a note against days without", () => {
const r = rng(11);
const entries = days(
90,
(_, i) => (i % 3 === 0 ? 2 : 4) + (r() - 0.5) * 0.4,
);
const noteDays = new Set(
entries.filter((_, i) => i % 3 === 0).map((e) => e.date),
);
const notes = run(entries, noteDays).find((p) => p.kind === "notes");
expect(notes).toMatchObject({ id: "notes", direction: "lower" });
});
it("compares days with several entries against single-entry days", () => {
const r = rng(12);
const base = days(
90,
(_, i) => (i % 2 === 0 ? 4.5 : 3) + (r() - 0.5) * 0.4,
);
const extra = base
.filter((_, i) => i % 2 === 0)
.map((e) => entry(e.date, e.score));
const multi = run([...base, ...extra]).find(
(p) => p.kind === "multiple_entries",
);
expect(multi).toMatchObject({ direction: "higher" });
});
it("reads a tag's association with the NEXT day", () => {
const r = rng(13);
const tag = {
key: "alcohol",
categoryKey: "health",
labelKey: "mood.tag.alcohol",
icon: null,
};
const tagged = new Set<number>();
const entries = days(120, (_, i) => {
// The day after a tagged day is low.
return tagged.has(i - 1) ? 2 + r() * 0.4 : 4 + r() * 0.4;
});
entries.forEach((e, i) => {
if (i % 4 === 0) {
tagged.add(i);
e.structuredTags = [tag];
}
});
// Recompute the scores now that the tag days are known.
for (let i = 0; i < entries.length; i += 1) {
entries[i].score = tagged.has(i - 1) ? 2 + r() * 0.4 : 4 + r() * 0.4;
}
const delayed = run(entries).find((p) => p.kind === "delayed_tag");
expect(delayed).toMatchObject({
id: "delayed_tag:alcohol",
direction: "lower",
values: { tagKey: "alcohol" },
});
});
it("reads the day after a below-median day", () => {
const r = rng(14);
const entries: MoodAggregateEntry[] = [];
let prevLow = false;
for (let i = 0; i < 150; i += 1) {
const date = shiftDateKey(TODAY, -(149 - i));
// Persistent mood: a low day is followed by another low-ish day.
const low: boolean = r() < (prevLow ? 0.85 : 0.15);
entries.push(entry(date, low ? 2 + r() * 0.5 : 4 + r() * 0.5));
prevLow = low;
}
const previous = run(entries).find((p) => p.kind === "previous_day");
expect(previous).toMatchObject({ id: "previous_day", direction: "lower" });
});
it("only counts days inside the window", () => {
const r = rng(15);
const entries = days(
200,
(date) => (weekdayOfKey(date) >= 5 ? 4.5 : 2.5) + (r() - 0.5) * 0.4,
);
const recent = computeMoodPatterns({
entries,
noteDays: new Set(),
todayKey: TODAY,
windowDays: 30,
}).find((p) => p.kind === "weekend");
expect(recent).toBeDefined();
expect(recent!.sampleSize.subject).toBeGreaterThanOrEqual(8);
expect(recent!.sampleSize.subject + recent!.sampleSize.comparison).toBe(30);
});
});
describe("moodPatternsWire", () => {
it("names a catalogue tag in the reader's language and a custom one verbatim, and drops q", () => {
const wire = moodPatternsWire(
[
{
id: "delayed_tag:alcohol",
kind: "delayed_tag",
strength: "moderate",
direction: "lower",
values: {
subject: 2.1,
comparison: 4,
delta: -1.9,
tagKey: "alcohol",
},
sampleSize: { subject: 20, comparison: 80 },
tag: { labelKey: "mood.tag.alcohol", label: null },
qValue: 0.01,
},
{
id: "delayed_tag:custom:x",
kind: "delayed_tag",
strength: "weak",
direction: "higher",
values: { subject: 4, comparison: 3, delta: 1, tagKey: "custom:x" },
sampleSize: { subject: 9, comparison: 50 },
tag: { labelKey: "custom:x", label: "Choir" },
qValue: 0.08,
},
],
(key) => `t:${key}`,
);
expect(wire[0].values.tagLabel).toBe("t:mood.tag.alcohol");
expect(wire[1].values.tagLabel).toBe("Choir");
expect(wire[0]).not.toHaveProperty("qValue");
expect(wire[0]).not.toHaveProperty("tag");
expect(moodPatternsWire(undefined, (k) => k)).toEqual([]);
});
});
+43 -3
View File
@@ -28,6 +28,10 @@
* statistics helpers below are exported so the unit test can assert the
* composed-bucket baseline matches the raw-DAY baseline within tolerance.
*/
import {
resolveTrendVerdict,
type TrendVerdict,
} from "@/lib/targets/trend-verdict";
import type { MeasurementType, PrismaClient } from "@/generated/prisma/client";
import { readDayAggregates } from "@/lib/measurements/day-aggregates";
import { getAgeFromDateOfBirth } from "@/lib/analytics/pulse-targets";
@@ -91,6 +95,14 @@ export interface VitalsBaselineValue {
* band is already computed from (no extra read).
*/
series: number[];
/**
* v1.43 (ios #116 item 3) — which way a change of this vital counts for
* the person, with the band's centre placed against their own target or a
* profile default (`src/lib/targets/trend-verdict.ts`). The tile colours
* its delta from this instead of a fixed polarity. Absent when the caller
* passed no profile facts to judge with.
*/
direction?: TrendVerdict;
}
/** Caller-supplied profile (read once per request, never re-fetched here). */
@@ -109,6 +121,13 @@ export interface BaselineProfile {
* metric (weight ÷ height²); `null` when the profile has no height.
*/
heightCm?: number | null;
/**
* v1.43 — the date of birth and the person's own targets, which the trend
* verdict on `VITALS_BASELINE` is judged with. Optional: a caller that does
* not read them gets no verdict rather than a wrong one.
*/
dateOfBirth?: Date | null;
thresholdsJson?: unknown;
}
/**
@@ -127,13 +146,20 @@ export async function loadBaselineProfile(
): Promise<BaselineProfile> {
const user = await prisma.user.findUnique({
where: { id: userId },
select: { dateOfBirth: true, gender: true, heightCm: true },
select: {
dateOfBirth: true,
gender: true,
heightCm: true,
thresholdsJson: true,
},
});
const sex = toProfileSex(user?.gender);
return {
ageYears: getAgeFromDateOfBirth(user?.dateOfBirth ?? null),
sex,
heightCm: user?.heightCm ?? null,
dateOfBirth: user?.dateOfBirth ?? null,
thresholdsJson: user?.thresholdsJson ?? null,
};
}
@@ -311,7 +337,7 @@ export async function readDayMeanSeries(
*/
export async function computeVitalsBaseline(
userId: string,
_profile: BaselineProfile,
profile: BaselineProfile,
opts: VitalsBaselineOpts,
): Promise<Derived<VitalsBaselineValue>> {
const windowDays = opts.windowDays ?? DEFAULT_WINDOW_DAYS;
@@ -403,8 +429,22 @@ export async function computeVitalsBaseline(
// means the band is built from, capped to the recent window.
const series = points.slice(-SPARKLINE_MAX_POINTS).map((p) => p.mean);
// Judged only when the caller read the facts it is judged with; the
// nightly engines that never show a delta pass a bare profile.
const direction =
profile.dateOfBirth !== undefined
? resolveTrendVerdict(type, {
profile: {
dateOfBirth: profile.dateOfBirth,
gender: profile.sex,
heightCm: profile.heightCm ?? null,
},
thresholdsJson: profile.thresholdsJson ?? null,
referenceValue: band.center,
})
: undefined;
return buildOk<VitalsBaselineValue>({
value: { type, ...band, series },
value: { type, ...band, series, ...(direction ? { direction } : {}) },
coverage: cov,
confidence,
provenance: { inputs: [String(type)], source, windowDays, computedAt },
+49 -2
View File
@@ -18,6 +18,11 @@
* importing from here unchanged.
*/
import {
computeMoodPatterns,
moodPatternsWire,
type MoodPatternFinding,
} from "@/lib/insights/mood-pattern-cards";
import { prisma } from "@/lib/db";
import {
significantPearsonCorrelation,
@@ -512,6 +517,13 @@ export interface MoodAggregates {
* `stability` and does not publish this map.
*/
stabilityByWindow: Record<MoodStabilityWindow, MoodStability | null>;
/**
* v1.43 (ios #116 item 6) — the gated mood patterns per selectable window
* (`mood-pattern-cards.ts`). Cached with the aggregate like the stability
* map; the route serves the chosen window as `patterns`. Optional so an
* aggregate cached before it existed still reads (as none found).
*/
patternsByWindow?: Record<MoodStabilityWindow, MoodPatternFinding[]>;
tags: TagSummaryRow[];
/**
* v1.38 — how the day's pleasantness sat on days carrying each context
@@ -579,6 +591,11 @@ export function computeMoodAggregates(args: {
* calendar day. Defaults to Berlin for legacy / test callers.
*/
tz?: string;
/**
* v1.43 — the mood days that carry a note, for the `notes` pattern. Read
* as a separate projection so the note text itself is never loaded here.
*/
noteDays?: ReadonlySet<string>;
}): MoodAggregates {
const { entries, measurements, now } = args;
const userPriorityJson = args.userPriorityJson ?? null;
@@ -633,6 +650,18 @@ export function computeMoodAggregates(args: {
),
]),
) as Record<MoodStabilityWindow, MoodStability | null>;
const todayKey = dayOffsetToDayKey(now, 0, tz);
const patternsByWindow = Object.fromEntries(
MOOD_STABILITY_WINDOWS.map((days) => [
days,
computeMoodPatterns({
entries,
noteDays: args.noteDays ?? new Set<string>(),
todayKey,
windowDays: days,
}),
]),
) as Record<MoodStabilityWindow, MoodPatternFinding[]>;
const tags = computeTagSummary(entries, now);
const structuredTags = computeStructuredTagSummary(entries, now);
const tagInfluence = computeTagInfluence(entries, now);
@@ -715,6 +744,7 @@ export function computeMoodAggregates(args: {
timeOfDay,
stability,
stabilityByWindow,
patternsByWindow,
tags,
structuredTags,
tagInfluence,
@@ -921,9 +951,20 @@ export async function fetchMoodAggregates(
});
}
const [userPriorityJson, tz] = await Promise.all([
// v1.43 — which mood days carry a note, without reading a single note.
const [userPriorityJson, tz, noteRows] = await Promise.all([
loadUserSourcePriority(userId),
resolveUserTimezone(userId),
prisma.moodEntry.findMany({
where: {
userId,
deletedAt: null,
moodLoggedAt: { gte: windowCutoff },
OR: [{ noteEncrypted: { not: null } }, { note: { not: null } }],
},
select: { date: true },
distinct: ["date"],
}),
]);
const aggregate = computeMoodAggregates({
@@ -932,6 +973,7 @@ export async function fetchMoodAggregates(
now,
userPriorityJson,
tz,
noteDays: new Set(noteRows.map((row) => row.date)),
});
const tagEvidence = aggregate.tagMetricCrosstab.map((row) => ({
@@ -995,8 +1037,10 @@ export async function fetchMoodAggregates(
export function moodInsightsWire(
result: MoodAggregates,
stabilityWindowDays: MoodStabilityWindow,
/** Names a pattern's tag in the reader's language (catalogue tags). */
t: (key: string) => string,
) {
const { stabilityByWindow, ...wire } = result;
const { stabilityByWindow, patternsByWindow, ...wire } = result;
return {
...wire,
stability:
@@ -1004,5 +1048,8 @@ export function moodInsightsWire(
? result.stability
: (stabilityByWindow?.[stabilityWindowDays] ?? null),
stabilityWindowDays,
// v1.43 — the patterns of the same window; an aggregate cached before
// they existed reads as none found until its next rebuild.
patterns: moodPatternsWire(patternsByWindow?.[stabilityWindowDays], t),
};
}
+352
View File
@@ -0,0 +1,352 @@
/**
* Mood patterns the server finds and publishes (v1.43, ios #116 item 6).
*
* The native client used to compute six pattern cards itself (the day after
* a low day, weekends, the best weekday, days with a note, delayed tag
* effects, several entries a day) on the device calendar and without any
* statistical gate, and removed them. They are computed here instead, on the
* account's mood day (`MoodEntry.date`, anchored to the entry's own zone),
* and only the ones that pass the gate are published:
*
* - at least `PATTERN_MIN_DAYS_PER_SIDE` days on each side of a comparison;
* - a Welch two-sample t-test on the two groups of daily means (the same
* test the tag influence board uses);
* - Benjamini-Hochberg over every comparison tested in the window, kept at
* q ≤ `PATTERN_FDR_Q` (the false discovery rate the mood crosstabs use).
*
* An empty list means nothing passed, not that something failed. Every
* comparison is a daily-mean comparison, so a day with five entries weighs
* as much as a day with one. Observational only: the copy a client renders
* says "tends to", never "because".
*
* Pure; the caller hands over the entries and which days carry a note.
*/
import { welchTTest } from "@/lib/insights/correlations";
import { benjaminiHochberg } from "@/lib/insights/correlation-discovery";
import type {
MoodAggregateEntry,
StructuredTagRef,
} from "@/lib/insights/mood-aggregates";
import { round } from "@/lib/insights/status-shared";
import { shiftDateKey } from "@/lib/tz/format";
export const PATTERN_MIN_DAYS_PER_SIDE = 8;
export const PATTERN_FDR_Q = 0.1;
export type MoodPatternKind =
| "previous_day"
| "weekend"
| "best_weekday"
| "notes"
| "delayed_tag"
| "multiple_entries";
/** A pattern as the aggregate caches it: the tag's label is resolved at the wire. */
export interface MoodPatternFinding {
id: string;
kind: MoodPatternKind;
strength: "weak" | "moderate" | "strong";
direction: "higher" | "lower" | "none";
values: {
subject: number;
comparison: number;
delta: number;
weekday?: number;
tagKey?: string;
};
sampleSize: { subject: number; comparison: number };
/** For `delayed_tag`: how to name the tag (custom label first). */
tag?: { labelKey: string; label: string | null };
/** BH-adjusted q, kept for ordering and tests; not on the wire. */
qValue: number;
}
/** The wire shape (`MoodPattern` in OpenAPI). */
export interface MoodPatternWire {
id: string;
kind: MoodPatternKind;
strength: "weak" | "moderate" | "strong";
direction: "higher" | "lower" | "none";
values: {
subject: number;
comparison: number;
delta: number;
weekday?: number;
tagKey?: string;
tagLabel?: string;
};
sampleSize: { subject: number; comparison: number };
}
interface MoodDay {
date: string;
mean: number;
entries: number;
hasNote: boolean;
tags: Map<string, StructuredTagRef>;
}
interface Candidate {
id: string;
kind: MoodPatternKind;
subject: number[];
comparison: number[];
weekday?: number;
tag?: StructuredTagRef;
}
/** 0 = Monday … 6 = Sunday, read off the day key itself. */
export function weekdayOfKey(dateKey: string): number {
const d = new Date(`${dateKey}T12:00:00Z`);
return (d.getUTCDay() + 6) % 7;
}
function collapseDays(
entries: readonly MoodAggregateEntry[],
noteDays: ReadonlySet<string>,
): Map<string, MoodDay> {
const days = new Map<
string,
{ sum: number; count: number; tags: Map<string, StructuredTagRef> }
>();
for (const entry of entries) {
const day = days.get(entry.date) ?? {
sum: 0,
count: 0,
tags: new Map<string, StructuredTagRef>(),
};
day.sum += entry.score;
day.count += 1;
for (const ref of entry.structuredTags ?? []) day.tags.set(ref.key, ref);
days.set(entry.date, day);
}
const out = new Map<string, MoodDay>();
for (const [date, day] of days) {
out.set(date, {
date,
mean: day.sum / day.count,
entries: day.count,
hasNote: noteDays.has(date),
tags: day.tags,
});
}
return out;
}
function median(values: readonly number[]): number {
const sorted = [...values].sort((a, b) => a - b);
const mid = Math.floor(sorted.length / 2);
return sorted.length % 2 === 1
? sorted[mid]
: (sorted[mid - 1] + sorted[mid]) / 2;
}
function mean(values: readonly number[]): number {
return values.reduce((s, v) => s + v, 0) / values.length;
}
/** Cohen's d on the pooled SD, banded at the conventional 0.5 / 0.8. */
function strengthOf(
subject: readonly number[],
comparison: readonly number[],
): MoodPatternFinding["strength"] {
const ms = mean(subject);
const mc = mean(comparison);
const vs =
subject.reduce((s, v) => s + (v - ms) ** 2, 0) / (subject.length - 1);
const vc =
comparison.reduce((s, v) => s + (v - mc) ** 2, 0) / (comparison.length - 1);
const pooled =
((subject.length - 1) * vs + (comparison.length - 1) * vc) /
(subject.length + comparison.length - 2);
const d = pooled > 0 ? Math.abs(ms - mc) / Math.sqrt(pooled) : 0;
if (d >= 0.8) return "strong";
if (d >= 0.5) return "moderate";
return "weak";
}
function candidates(days: Map<string, MoodDay>): Candidate[] {
const list = [...days.values()].sort((a, b) =>
a.date < b.date ? -1 : a.date > b.date ? 1 : 0,
);
const out: Candidate[] = [];
// The day after a day below the person's own median, against the day after
// one above it. Days on the median itself belong to neither side.
const med = list.length > 0 ? median(list.map((d) => d.mean)) : 0;
const afterLow: number[] = [];
const afterHigh: number[] = [];
for (const day of list) {
const prev = days.get(shiftDateKey(day.date, -1));
if (!prev) continue;
if (prev.mean < med) afterLow.push(day.mean);
else if (prev.mean > med) afterHigh.push(day.mean);
}
out.push({
id: "previous_day",
kind: "previous_day",
subject: afterLow,
comparison: afterHigh,
});
// Saturday and Sunday against Monday to Friday.
out.push({
id: "weekend",
kind: "weekend",
subject: list.filter((d) => weekdayOfKey(d.date) >= 5).map((d) => d.mean),
comparison: list.filter((d) => weekdayOfKey(d.date) < 5).map((d) => d.mean),
});
// Each weekday against the rest. All seven are tested so the selection of
// the best one is paid for in the false discovery control; only the best
// can be published.
for (let weekday = 0; weekday < 7; weekday += 1) {
out.push({
id: `weekday:${weekday}`,
kind: "best_weekday",
weekday,
subject: list
.filter((d) => weekdayOfKey(d.date) === weekday)
.map((d) => d.mean),
comparison: list
.filter((d) => weekdayOfKey(d.date) !== weekday)
.map((d) => d.mean),
});
}
out.push({
id: "notes",
kind: "notes",
subject: list.filter((d) => d.hasNote).map((d) => d.mean),
comparison: list.filter((d) => !d.hasNote).map((d) => d.mean),
});
out.push({
id: "multiple_entries",
kind: "multiple_entries",
subject: list.filter((d) => d.entries >= 2).map((d) => d.mean),
comparison: list.filter((d) => d.entries === 1).map((d) => d.mean),
});
// A tag's association with the NEXT day's mood: the day after a tagged day
// against the day after an untagged one. Only consecutive logged days count.
const tagRefs = new Map<string, StructuredTagRef>();
for (const day of list) {
for (const [key, ref] of day.tags)
if (!tagRefs.has(key)) tagRefs.set(key, ref);
}
for (const [key, ref] of tagRefs) {
const afterTag: number[] = [];
const afterOther: number[] = [];
for (const day of list) {
const prev = days.get(shiftDateKey(day.date, -1));
if (!prev) continue;
if (prev.tags.has(key)) afterTag.push(day.mean);
else afterOther.push(day.mean);
}
out.push({
id: `delayed_tag:${key}`,
kind: "delayed_tag",
tag: ref,
subject: afterTag,
comparison: afterOther,
});
}
return out;
}
/**
* The gated patterns over the entries whose mood day falls inside the
* trailing `windowDays` ending at `todayKey`.
*/
export function computeMoodPatterns(args: {
entries: readonly MoodAggregateEntry[];
noteDays: ReadonlySet<string>;
todayKey: string;
windowDays: number;
}): MoodPatternFinding[] {
const fromKey = shiftDateKey(args.todayKey, -(args.windowDays - 1));
const inWindow = args.entries.filter(
(e) => e.date >= fromKey && e.date <= args.todayKey,
);
const days = collapseDays(inWindow, args.noteDays);
const tested: Array<{ c: Candidate; p: number }> = [];
for (const c of candidates(days)) {
if (
c.subject.length < PATTERN_MIN_DAYS_PER_SIDE ||
c.comparison.length < PATTERN_MIN_DAYS_PER_SIDE
) {
continue;
}
const welch = welchTTest(c.subject, c.comparison);
// No spread on either side: nothing a test can stand behind.
if (welch.status !== "ok") continue;
tested.push({ c, p: welch.pValue });
}
if (tested.length === 0) return [];
const q = benjaminiHochberg(tested.map((t) => t.p));
const passed: MoodPatternFinding[] = [];
tested.forEach(({ c }, i) => {
if (q[i] > PATTERN_FDR_Q) return;
const subject = mean(c.subject);
const comparison = mean(c.comparison);
const delta = subject - comparison;
passed.push({
id: c.kind === "best_weekday" ? "best_weekday" : c.id,
kind: c.kind,
strength: strengthOf(c.subject, c.comparison),
direction: delta > 0 ? "higher" : delta < 0 ? "lower" : "none",
values: {
subject: round(subject, 2),
comparison: round(comparison, 2),
delta: round(delta, 2),
...(c.weekday !== undefined ? { weekday: c.weekday } : {}),
...(c.tag ? { tagKey: c.tag.key } : {}),
},
sampleSize: {
subject: c.subject.length,
comparison: c.comparison.length,
},
...(c.tag
? { tag: { labelKey: c.tag.labelKey, label: c.tag.label ?? null } }
: {}),
qValue: q[i],
});
});
// One best weekday at most: the highest one that passed and is above the
// rest. A weekday that is only significantly LOWER is not a best weekday.
const weekdays = passed
.filter((p) => p.kind === "best_weekday" && p.direction === "higher")
.sort((a, b) => b.values.subject - a.values.subject);
const kept = passed.filter((p) => p.kind !== "best_weekday");
if (weekdays.length > 0) kept.push(weekdays[0]);
const STRENGTH_RANK = { strong: 0, moderate: 1, weak: 2 } as const;
return kept.sort(
(a, b) =>
STRENGTH_RANK[a.strength] - STRENGTH_RANK[b.strength] ||
Math.abs(b.values.delta) - Math.abs(a.values.delta) ||
a.id.localeCompare(b.id),
);
}
/** The wire form: the tag named in the reader's language, no internal fields. */
export function moodPatternsWire(
findings: readonly MoodPatternFinding[] | undefined,
t: (key: string) => string,
): MoodPatternWire[] {
return (findings ?? []).map((f) => ({
id: f.id,
kind: f.kind,
strength: f.strength,
direction: f.direction,
values: {
...f.values,
...(f.tag ? { tagLabel: f.tag.label ?? t(f.tag.labelKey) } : {}),
},
sampleSize: f.sampleSize,
}));
}
@@ -15,6 +15,7 @@
*
* Read-only. A connection that does not exist resolves to null.
*/
import { BACKFILL_LEDGER_PREFIX } from "./healthkit-type-sync";
import { prisma } from "@/lib/db";
import type { MeasurementSource } from "@/generated/prisma/client";
import type { IntegrationKey } from "./status";
@@ -194,7 +195,12 @@ export async function loadConnectionFacts(
: null,
wanted.has("apple-health")
? prisma.healthKitTypeSync.findMany({
where: { userId },
// A backfill's own ledger row is a catch-up, not a live arrival,
// and must not make the connection look alive (ios #77).
where: {
userId,
NOT: { type: { startsWith: BACKFILL_LEDGER_PREFIX } },
},
select: { type: true, lastReceivedAt: true, lastNewSampleAt: true },
orderBy: { lastReceivedAt: "desc" },
})
+87 -12
View File
@@ -25,7 +25,10 @@ import {
type MetricFreshnessEntry,
} from "./sync-verdict";
/** The `syncTrigger` values the Apple Health ingest routes accept. */
/**
* The `syncTrigger` values that describe a live sync: the ones a batch can
* leave behind as a type's `lastTrigger`.
*/
export const HEALTHKIT_SYNC_TRIGGERS = [
"foreground",
"background",
@@ -35,8 +38,39 @@ export const HEALTHKIT_SYNC_TRIGGERS = [
export type HealthKitSyncTrigger = (typeof HEALTHKIT_SYNC_TRIGGERS)[number];
/**
* v1.43 (ios #77) — a history catch-up (the 30-day baseline backfill, a
* "sync everything" walk). Accepted on the two ingest routes when the server
* says so (`features.syncTriggerBackfill`). It is not a live sync, so it is
* never stored as `lastTrigger` and moves none of the live stamps; it moves
* only `lastBackfillAt`.
*/
export const BACKFILL_SYNC_TRIGGER = "backfill" as const;
/** Every `syncTrigger` value the Apple Health ingest routes accept. */
export const HEALTHKIT_REQUEST_SYNC_TRIGGERS = [
...HEALTHKIT_SYNC_TRIGGERS,
BACKFILL_SYNC_TRIGGER,
] as const;
export type HealthKitRequestSyncTrigger =
(typeof HEALTHKIT_REQUEST_SYNC_TRIGGERS)[number];
const TRIGGERS: ReadonlySet<string> = new Set<string>(HEALTHKIT_SYNC_TRIGGERS);
/**
* A backfill arrival is kept as its own ledger row, keyed `backfill:<type>`,
* beside the live row of the same type. Its `last_received_at` is the
* backfill's time; it carries no trigger and no sample stamps. Keeping it in
* a row of its own is what lets a catch-up leave every live column of the type
* exactly as it was, and every live reader skips the prefix.
*/
export const BACKFILL_LEDGER_PREFIX = "backfill:";
export function isBackfillLedgerKey(type: string): boolean {
return type.startsWith(BACKFILL_LEDGER_PREFIX);
}
/**
* The ledger key for Apple Health medication doses, which are not a
* `MeasurementType`. Also the `type` of their `metricFreshness` entry.
@@ -72,7 +106,7 @@ export async function recordHealthKitTypeArrivals(
input: {
userId: string;
counts: ReadonlyMap<HealthKitArrivalType, HealthKitTypeArrival>;
trigger: HealthKitSyncTrigger | null;
trigger: HealthKitRequestSyncTrigger | null;
at?: Date;
},
): Promise<void> {
@@ -88,6 +122,27 @@ export async function recordHealthKitTypeArrivals(
// serialised in the process's local zone and these are zone-less UTC
// columns (same reasoning as the access-token sunset in refresh-token.ts).
const at = (input.at ?? new Date()).toISOString();
if (input.trigger === BACKFILL_SYNC_TRIGGER) {
// v1.43 — a catch-up moves only its own row; the live row of each type
// keeps its arrival, trigger and sample stamps untouched.
const keys = types.map((type) => `${BACKFILL_LEDGER_PREFIX}${type}`);
try {
await tx.$executeRaw`
INSERT INTO "healthkit_type_syncs"
("user_id", "type", "last_received_at")
SELECT ${input.userId}, u.t, ${at}::timestamptz AT TIME ZONE 'UTC'
FROM unnest(${keys}::text[]) AS u(t)
ON CONFLICT ("user_id", "type") DO UPDATE SET
"last_received_at" = EXCLUDED."last_received_at"
`;
} catch (error) {
logCaught("healthkit.type_sync.record_failed", error, {
types: types.length,
backfill: true,
});
}
return;
}
const background = input.trigger === "background";
try {
await tx.$executeRaw`
@@ -120,10 +175,13 @@ export async function recordHealthKitTypeArrivals(
/** One type's arrival facts, as the Apple Health status read serves them. */
export interface HealthKitTypeArrivalRecord {
lastReceivedAt: string;
/** Null for a type that so far arrived only through a backfill. */
lastReceivedAt: string | null;
lastTrigger: HealthKitSyncTrigger | null;
lastNewSampleAt: string | null;
lastBackgroundAt: string | null;
/** v1.43 — when a `backfill` batch last carried the type; null when none has. */
lastBackfillAt: string | null;
}
/**
@@ -145,16 +203,30 @@ export async function getHealthKitTypeArrivals(
},
});
const out = new Map<string, HealthKitTypeArrivalRecord>();
const empty = (): HealthKitTypeArrivalRecord => ({
lastReceivedAt: null,
lastTrigger: null,
lastNewSampleAt: null,
lastBackgroundAt: null,
lastBackfillAt: null,
});
for (const row of rows) {
out.set(row.type, {
lastReceivedAt: row.lastReceivedAt.toISOString(),
lastTrigger:
row.lastTrigger && TRIGGERS.has(row.lastTrigger)
? (row.lastTrigger as HealthKitSyncTrigger)
: null,
lastNewSampleAt: row.lastNewSampleAt?.toISOString() ?? null,
lastBackgroundAt: row.lastBackgroundAt?.toISOString() ?? null,
});
if (isBackfillLedgerKey(row.type)) {
const type = row.type.slice(BACKFILL_LEDGER_PREFIX.length);
const record = out.get(type) ?? empty();
record.lastBackfillAt = row.lastReceivedAt.toISOString();
out.set(type, record);
continue;
}
const record = out.get(row.type) ?? empty();
record.lastReceivedAt = row.lastReceivedAt.toISOString();
record.lastTrigger =
row.lastTrigger && TRIGGERS.has(row.lastTrigger)
? (row.lastTrigger as HealthKitSyncTrigger)
: null;
record.lastNewSampleAt = row.lastNewSampleAt?.toISOString() ?? null;
record.lastBackgroundAt = row.lastBackgroundAt?.toISOString() ?? null;
out.set(row.type, record);
}
return out;
}
@@ -195,6 +267,8 @@ export interface HealthKitFreshnessEntry extends MetricFreshnessEntry {
lastNewSampleAt?: string | null;
/** When a `background` sync last carried it; a later foreground one keeps it. */
lastBackgroundAt?: string | null;
/** v1.43 — when a `backfill` batch last carried it. */
lastBackfillAt?: string | null;
}
/**
@@ -220,6 +294,7 @@ export function withHealthKitArrivals(
lastTrigger: arrival?.lastTrigger ?? null,
lastNewSampleAt: arrival?.lastNewSampleAt ?? null,
lastBackgroundAt: arrival?.lastBackgroundAt ?? null,
lastBackfillAt: arrival?.lastBackfillAt ?? null,
};
});
}
@@ -0,0 +1,160 @@
import { describe, expect, it } from "vitest";
import {
classifyHourBucket,
cumulativeFromIncrements,
HOUR_NOT_POSTED,
isStartOfLocalHour,
parseHourBucketId,
postedHours,
} from "../intraday-hour-buckets";
const BERLIN = "Europe/Berlin";
const NOW = new Date("2026-10-15T12:00:00.000Z");
describe("parseHourBucketId", () => {
it("reads the hour form", () => {
expect(
parseHourBucketId(
"stats:HKQuantityTypeIdentifierStepCount:2026-10-14T09",
),
).toEqual({
hkIdentifier: "HKQuantityTypeIdentifierStepCount",
dateKey: "2026-10-14",
hour: 9,
});
});
it.each([
"stats:HKQuantityTypeIdentifierStepCount:2026-10-14",
"stats:HKQuantityTypeIdentifierHeartRate:2026-10-14T09",
"stats:HKQuantityTypeIdentifierHeartRate:2026-10-14T09:10:00.000Z",
"stats:HKQuantityTypeIdentifierStepCount:2026-10-14T24",
"stats:HKQuantityTypeIdentifierStepCount:2026-02-30T09",
"stats:HKQuantityTypeIdentifierStepCount:2026-10-14T9",
"uuid-1234",
])("leaves %s to its own path", (id) => {
expect(parseHourBucketId(id)).toBeNull();
});
});
describe("isStartOfLocalHour", () => {
const id = { hkIdentifier: "x", dateKey: "2026-10-14", hour: 9 };
it("accepts the first instant of the local hour only", () => {
// 09:00 in Berlin (CEST, UTC+2) is 07:00Z.
expect(
isStartOfLocalHour(new Date("2026-10-14T07:00:00.000Z"), id, BERLIN),
).toBe(true);
expect(
isStartOfLocalHour(new Date("2026-10-14T09:00:00.000Z"), id, BERLIN),
).toBe(false);
expect(
isStartOfLocalHour(new Date("2026-10-14T07:00:00.001Z"), id, BERLIN),
).toBe(false);
expect(
isStartOfLocalHour(new Date("2026-10-14T07:30:00.000Z"), id, BERLIN),
).toBe(false);
});
it("accepts either start of a repeated autumn hour and no start of a skipped spring hour", () => {
const repeated = { hkIdentifier: "x", dateKey: "2026-10-25", hour: 2 };
expect(
isStartOfLocalHour(
new Date("2026-10-25T00:00:00.000Z"),
repeated,
BERLIN,
),
).toBe(true);
expect(
isStartOfLocalHour(
new Date("2026-10-25T01:00:00.000Z"),
repeated,
BERLIN,
),
).toBe(true);
const skipped = { hkIdentifier: "x", dateKey: "2026-03-29", hour: 2 };
expect(
isStartOfLocalHour(new Date("2026-03-29T01:00:00.000Z"), skipped, BERLIN),
).toBe(false);
});
});
describe("classifyHourBucket", () => {
const steps = (dateKey: string, hour: number) => ({
hkIdentifier: "HKQuantityTypeIdentifierStepCount",
dateKey,
hour,
});
it("maps the four summed identifiers", () => {
expect(
classifyHourBucket(
steps("2026-10-14", 9),
new Date("2026-10-14T07:00:00Z"),
BERLIN,
NOW,
),
).toMatchObject({ ok: true, type: "ACTIVITY_STEPS" });
});
it("refuses another identifier", () => {
expect(
classifyHourBucket(
{
hkIdentifier: "HKQuantityTypeIdentifierBodyMass",
dateKey: "2026-10-14",
hour: 9,
},
new Date("2026-10-14T07:00:00Z"),
BERLIN,
NOW,
),
).toEqual({ ok: false, reason: "hour_bucket_unsupported_type" });
});
it("refuses a start that is not the local hour's start", () => {
// 09:00Z is 11:00 in Berlin, not 09:00.
expect(
classifyHourBucket(
steps("2026-10-14", 9),
new Date("2026-10-14T09:00:00Z"),
BERLIN,
NOW,
),
).toEqual({ ok: false, reason: "hour_bucket_misaligned" });
});
it("refuses a day more than 400 days back", () => {
expect(
classifyHourBucket(
steps("2025-09-01", 9),
new Date("2025-09-01T07:00:00Z"),
BERLIN,
NOW,
),
).toEqual({ ok: false, reason: "hour_bucket_too_old" });
expect(
classifyHourBucket(
steps("2025-09-11", 9),
new Date("2025-09-11T07:00:00Z"),
BERLIN,
NOW,
),
).toMatchObject({ ok: true });
});
});
describe("cumulativeFromIncrements", () => {
it("adds only posted hours and keeps the running total flat across gaps", () => {
const increments = new Array<number>(24).fill(HOUR_NOT_POSTED);
increments[8] = 100;
increments[9] = 0;
increments[12] = 50;
const cumulative = cumulativeFromIncrements(increments);
expect(cumulative[7]).toBe(0);
expect(cumulative[8]).toBe(100);
expect(cumulative[11]).toBe(100);
expect(cumulative[23]).toBe(150);
expect(postedHours(increments)).toBe(3);
});
});
@@ -0,0 +1,67 @@
import { describe, expect, it } from "vitest";
import { resolveTrendVerdict } from "@/lib/targets/trend-verdict";
import {
computeSeriesChange,
seriesDayValues,
seriesReferenceValue,
} from "../series-change";
const TZ = "Europe/Berlin";
const upBad = resolveTrendVerdict("RESTING_HEART_RATE", {
profile: { dateOfBirth: null, gender: null, heightCm: null },
thresholdsJson: null,
referenceValue: null,
});
describe("seriesDayValues", () => {
it("sums a summed type per local day and averages the rest", () => {
const points = [
{ at: "2026-09-01T06:00:00.000Z", value: 1000 },
{ at: "2026-09-01T20:00:00.000Z", value: 2000 },
// 23:30 UTC is already the next day in Berlin.
{ at: "2026-09-01T23:30:00.000Z", value: 500 },
];
expect(seriesDayValues("ACTIVITY_STEPS", points, TZ)).toEqual([
{ day: "2026-09-01", value: 3000 },
{ day: "2026-09-02", value: 500 },
]);
expect(seriesDayValues("WEIGHT", points, TZ)[0]).toEqual({
day: "2026-09-01",
value: 1500,
});
});
});
describe("computeSeriesChange", () => {
it("is null under two days", () => {
expect(
computeSeriesChange([{ day: "2026-09-01", value: 60 }], upBad),
).toBeNull();
});
it("compares the last day with the first and judges the change", () => {
expect(
computeSeriesChange(
[
{ day: "2026-09-01", value: 60 },
{ day: "2026-09-02", value: 70 },
{ day: "2026-09-03", value: 64.123 },
],
upBad,
),
).toEqual({ from: 60, to: 64.12, delta: 4.12, sentiment: "negative" });
});
});
describe("seriesReferenceValue", () => {
it("averages the last seven days", () => {
const days = Array.from({ length: 10 }, (_, i) => ({
day: `2026-09-${String(i + 1).padStart(2, "0")}`,
value: i + 1,
}));
expect(seriesReferenceValue(days)).toBe(7);
expect(seriesReferenceValue([])).toBeNull();
});
});
@@ -0,0 +1,117 @@
import { describe, expect, it } from "vitest";
import { getServerTranslator } from "@/lib/i18n/server-translator";
import { locales as LOCALES } from "@/lib/i18n/config";
import { resolveSeriesReferenceLines } from "../series-reference-lines";
const en = getServerTranslator("en");
const NO_PROFILE = { dateOfBirth: null, gender: null, heightCm: null };
const MAN_41 = {
dateOfBirth: new Date("1985-01-01T00:00:00.000Z"),
gender: "MALE",
heightCm: 180,
};
function lines(
kind: Parameters<typeof resolveSeriesReferenceLines>[0],
opts: {
profile?: typeof NO_PROFILE | typeof MAN_41;
thresholdsJson?: unknown;
glucoseUnit?: "mg/dL" | "mmol/L";
} = {},
) {
return resolveSeriesReferenceLines(kind, {
profile: opts.profile ?? NO_PROFILE,
thresholdsJson: opts.thresholdsJson ?? null,
glucoseUnit: opts.glucoseUnit ?? "mg/dL",
t: (key) => en.t(key),
});
}
describe("resolveSeriesReferenceLines", () => {
it("draws glucose 70 / 180 mg/dL, converted for an mmol/L account", () => {
expect(lines("glucose").map((l) => [l.kind, l.value])).toEqual([
["lower", 70],
["upper", 180],
]);
expect(
lines("glucose", { glucoseUnit: "mmol/L" }).map((l) => [l.kind, l.value]),
).toEqual([
["lower", 3.9],
["upper", 10],
]);
});
it("draws the ESH 140 / 90 lines without a profile or a target", () => {
expect(
lines("bloodPressure").map((l) => [
l.appliesTo,
l.kind,
l.value,
l.source,
]),
).toEqual([
["value", "upper", 140, "clinical"],
["secondary", "upper", 90, "clinical"],
]);
});
it("replaces the clinical edge with the person's band", () => {
const own = lines("bloodPressure", {
thresholdsJson: { BLOOD_PRESSURE_SYS: { min: 115, max: 125 } },
});
expect(own.map((l) => [l.appliesTo, l.kind, l.value, l.source])).toEqual([
["value", "lower", 115, "personal"],
["value", "upper", 125, "personal"],
["secondary", "upper", 90, "clinical"],
]);
// An age turns the diastolic side into the profile band too.
const aged = lines("bloodPressure", { profile: MAN_41 });
expect(aged.every((l) => l.source === "personal")).toBe(true);
expect(aged.map((l) => l.value)).toEqual([120, 129, 70, 79]);
});
it("draws the SpO2 floor, or the person's band instead", () => {
expect(lines("oxygenSaturation")).toMatchObject([
{ value: 92, kind: "lower", source: "clinical" },
]);
expect(
lines("oxygenSaturation", {
thresholdsJson: { OXYGEN_SATURATION: { min: 90, max: 96 } },
}).map((l) => l.value),
).toEqual([90, 96]);
});
it("draws the resting band on the resting chart only", () => {
expect(lines("restingHeartRate", { profile: MAN_41 })).toHaveLength(2);
expect(lines("pulse", { profile: MAN_41 })).toEqual([]);
});
it("is empty when there is nothing to draw", () => {
expect(lines("heartRateVariability")).toEqual([]);
expect(lines("steps")).toEqual([]);
expect(lines("weight", { profile: MAN_41 })).toEqual([]);
});
it("names every label key in every bundle, and labels in the account language", () => {
const keys = new Set([
...lines("bloodPressure").map((l) => l.labelKey),
...lines("bloodPressure", { profile: MAN_41 }).map((l) => l.labelKey),
...lines("glucose").map((l) => l.labelKey),
]);
expect(keys.size).toBe(4);
for (const locale of LOCALES) {
const t = getServerTranslator(locale);
for (const key of keys) expect(t.t(key)).not.toBe(key);
}
const de = getServerTranslator("de");
const german = resolveSeriesReferenceLines("glucose", {
profile: NO_PROFILE,
thresholdsJson: null,
glucoseUnit: "mg/dL",
t: (key) => de.t(key),
});
expect(german[0].label).toBe("Klinische Untergrenze");
});
});
@@ -110,6 +110,14 @@ export interface CumulativeDayFold {
export interface CumulativeDayProfile extends CumulativeDayFold {
/** Whether this came off the table or was folded live from raw rows. */
origin: "stored" | "live";
/**
* v1.43 (ios #77) — what the running totals were built from: the fold of
* single samples, or the hours the phone posted (`intraday-hour-buckets`).
* `sampleCount` is the number of posted hours for the latter.
*/
basis: "PER_SAMPLE" | "HOUR_BUCKETS";
/** Posted hours behind the day; null for a fold of single samples. */
bucketCount: number | null;
}
/**
@@ -207,9 +215,26 @@ export async function mergeIntradayCumulativeProfile(
const existing = await tx.intradayCumulativeProfile.findUnique({
where: { userId_type_dateKey: key },
select: { hourlyCumulative: true, sampleCount: true, timezone: true },
select: {
hourlyCumulative: true,
sampleCount: true,
timezone: true,
basis: true,
},
});
// v1.43 (ios #77) — a day built from the hours the phone posted keeps that
// shape: the posted hours already account for every sample, so adding a
// fold of the same samples would count the day twice. The fold is dropped,
// exactly as the samples it came from are about to be.
if (
existing !== null &&
existing.basis === "HOUR_BUCKETS" &&
existing.timezone === increment.timezone
) {
return;
}
const reusable =
existing !== null &&
existing.timezone === increment.timezone &&
@@ -227,6 +252,11 @@ export async function mergeIntradayCumulativeProfile(
dayTotal: hourlyCumulative[PROFILE_HOURS - 1],
sampleCount,
timezone: increment.timezone,
// A profile cut on another clock is replaced whole, its posted hours
// included (`basis` stays the default for a day under the threshold).
...(existing !== null && existing.timezone !== increment.timezone
? { hourlyIncrements: [], bucketCount: null, basis: "PER_SAMPLE" }
: {}),
};
await tx.intradayCumulativeProfile.upsert({
@@ -300,6 +330,8 @@ export async function readCumulativeDayProfiles(args: {
hourlyCumulative: true,
dayTotal: true,
sampleCount: true,
basis: true,
bucketCount: true,
},
});
@@ -316,6 +348,8 @@ export async function readCumulativeDayProfiles(args: {
dayTotal: row.dayTotal,
sampleCount: row.sampleCount,
origin: "stored",
basis: row.basis === "HOUR_BUCKETS" ? "HOUR_BUCKETS" : "PER_SAMPLE",
bucketCount: row.bucketCount,
});
}
@@ -326,7 +360,14 @@ export async function readCumulativeDayProfiles(args: {
if (dateKey < startDateKey) break;
if (byDay.has(dateKey)) continue;
const folded = await foldLiveDay(db, userId, type, dateKey, timezone);
if (folded) byDay.set(dateKey, { ...folded, origin: "live" });
if (folded) {
byDay.set(dateKey, {
...folded,
origin: "live",
basis: "PER_SAMPLE",
bucketCount: null,
});
}
}
return [...byDay.values()].sort((a, b) => (a.dateKey < b.dateKey ? 1 : -1));
@@ -0,0 +1,271 @@
/**
* Hour buckets for the summed intraday types (v1.43, ios #77).
*
* The phone can hand over a day of steps, active energy, walking distance or
* flights as twenty-four hourly sums (`stats:<HK>:<YYYY-MM-DD>T<HH>`, the
* local hour in the account's zone) instead of the minute-level samples the
* nightly drain folds. That matters for history: a thirty-day backfill of
* hourly sums makes the same-time baseline usable on day one, where the
* drain only ever sees samples that are still on the phone's recent window.
*
* Such a row is NEVER a measurement. The day's total stays the
* `stats:<HK>:<YYYY-MM-DD>` row the phone keeps posting; an hourly row stored
* as a measurement would be summed into that day a second time. Each hour
* lands as one slot of `IntradayCumulativeProfile.hourlyIncrements` instead,
* and once a day holds `HOUR_BUCKET_MIN_FOR_PROFILE` hours its running totals
* (`hourlyCumulative`) are rebuilt from the posted hours and the profile's
* `basis` reads `HOUR_BUCKETS`. Below that a day keeps the per-sample fold,
* if it has one.
*
* An hour not posted holds `HOUR_NOT_POSTED` (-1) in its slot; every value a
* posted hour can carry is zero or more, so the sentinel cannot collide with
* a reading, and `bucketCount` is the number of slots at zero or above.
*/
import type {
MeasurementType,
Prisma,
PrismaClient,
} from "@/generated/prisma/client";
import { shiftDateKey, userDayKey } from "@/lib/tz/format";
import { wallClockInTz } from "@/lib/tz/wall-clock";
import {
CUMULATIVE_PROFILE_RETENTION_DAYS,
PROFILE_HOURS,
} from "./intraday-cumulative-profile";
/** The HealthKit identifiers the hour form is defined for, and their types. */
export const HOUR_BUCKET_TYPES: Readonly<Record<string, MeasurementType>> = {
HKQuantityTypeIdentifierStepCount: "ACTIVITY_STEPS",
HKQuantityTypeIdentifierActiveEnergyBurned: "ACTIVE_ENERGY_BURNED",
HKQuantityTypeIdentifierDistanceWalkingRunning: "WALKING_RUNNING_DISTANCE",
HKQuantityTypeIdentifierFlightsClimbed: "FLIGHTS_CLIMBED",
};
/** Hours a day needs before its profile is built from buckets. */
export const HOUR_BUCKET_MIN_FOR_PROFILE = 4;
/** The oldest hour accepted: the profile's own retention bound. */
export const HOUR_BUCKET_MAX_AGE_DAYS = CUMULATIVE_PROFILE_RETENTION_DAYS;
/** The slot value of an hour the phone has not posted. */
export const HOUR_NOT_POSTED = -1;
export const HOUR_BUCKET_SKIP_REASONS = {
misaligned: "hour_bucket_misaligned",
unsupportedType: "hour_bucket_unsupported_type",
tooOld: "hour_bucket_too_old",
} as const;
export type HourBucketSkipReason =
(typeof HOUR_BUCKET_SKIP_REASONS)[keyof typeof HOUR_BUCKET_SKIP_REASONS];
/**
* The heart-rate identifier keeps its own bucket contract (ten-minute ISO
* buckets, `malformed_hr_bucket_id`), so the hour form is not claimed for it.
*/
const HEART_RATE_HK = "HKQuantityTypeIdentifierHeartRate";
const HOUR_FORM = /^stats:([A-Za-z0-9]+):(\d{4}-\d{2}-\d{2})T(\d{2})$/;
export interface HourBucketId {
hkIdentifier: string;
dateKey: string;
hour: number;
}
/**
* Parse `stats:<HK>:<YYYY-MM-DD>T<HH>`. Null for anything else, the day form
* and the heart-rate buckets included, so every other external id keeps the
* path it had.
*/
export function parseHourBucketId(externalId: string): HourBucketId | null {
const match = HOUR_FORM.exec(externalId);
if (!match) return null;
const [, hkIdentifier, dateKey, hh] = match;
if (hkIdentifier === HEART_RATE_HK) return null;
const hour = Number(hh);
if (!Number.isInteger(hour) || hour < 0 || hour >= PROFILE_HOURS) return null;
const [y, m, d] = dateKey.split("-").map(Number);
const probe = new Date(Date.UTC(y, m - 1, d));
if (
probe.getUTCFullYear() !== y ||
probe.getUTCMonth() !== m - 1 ||
probe.getUTCDate() !== d
) {
return null;
}
return { hkIdentifier, dateKey, hour };
}
/**
* Whether `startDate` is the first instant of that local hour in the
* account's zone. Read through the zone rather than computed from it, so a
* repeated autumn hour accepts either of its two starts and a spring hour
* that does not exist accepts none.
*/
export function isStartOfLocalHour(
startDate: Date,
id: HourBucketId,
timezone: string,
): boolean {
if (startDate.getUTCMilliseconds() !== 0) return false;
const wall = wallClockInTz(startDate, timezone);
const key = `${String(wall.year).padStart(4, "0")}-${String(wall.month).padStart(2, "0")}-${String(wall.day).padStart(2, "0")}`;
return (
key === id.dateKey &&
wall.hour === id.hour &&
wall.minute === 0 &&
wall.second === 0
);
}
export type HourBucketVerdict =
| { ok: true; type: MeasurementType; id: HourBucketId }
| { ok: false; reason: HourBucketSkipReason };
/** Decide whether one parsed hour row may be stored, and as which type. */
export function classifyHourBucket(
id: HourBucketId,
startDate: Date,
timezone: string,
now: Date,
): HourBucketVerdict {
const type = HOUR_BUCKET_TYPES[id.hkIdentifier];
if (!type)
return { ok: false, reason: HOUR_BUCKET_SKIP_REASONS.unsupportedType };
const oldest = shiftDateKey(
userDayKey(now, timezone),
-HOUR_BUCKET_MAX_AGE_DAYS,
);
if (id.dateKey < oldest) {
return { ok: false, reason: HOUR_BUCKET_SKIP_REASONS.tooOld };
}
if (!isStartOfLocalHour(startDate, id, timezone)) {
return { ok: false, reason: HOUR_BUCKET_SKIP_REASONS.misaligned };
}
return { ok: true, type, id };
}
/** Running totals from posted hours; an hour not posted adds nothing. */
export function cumulativeFromIncrements(
increments: readonly number[],
): number[] {
const out = new Array<number>(PROFILE_HOURS);
let running = 0;
for (let h = 0; h < PROFILE_HOURS; h += 1) {
const v = increments[h];
if (v !== undefined && v >= 0) running += v;
out[h] = running;
}
return out;
}
export function postedHours(increments: readonly number[]): number {
return increments.filter((v) => v >= 0).length;
}
/** One accepted hour, as the batch hands it over. */
export interface HourBucketWrite {
/** The entry's index in the batch, for its per-entry status. */
index: number;
type: MeasurementType;
hkIdentifier: string;
dateKey: string;
hour: number;
/** The hour's sum in the canonical unit of `type`. */
value: number;
}
/**
* Store a batch's hours, one transaction per (type, day). Returns the
* per-entry status: `inserted` for the first value of a slot, `updated` when
* it replaced one. A later entry for the same slot in one batch wins, and the
* earlier ones read `updated` too, since the slot already held a value when
* they were applied in order.
*
* Serialised against the nightly drain's merge for the same day by the
* drain's own advisory lock (keyed on the day's `stats:` id), so a bucket
* write and a per-sample fold never interleave.
*/
export async function writeHourBuckets(
db: PrismaClient,
userId: string,
timezone: string,
writes: readonly HourBucketWrite[],
): Promise<Map<number, "inserted" | "updated">> {
const statuses = new Map<number, "inserted" | "updated">();
const groups = new Map<string, HourBucketWrite[]>();
for (const w of writes) {
const key = `${w.type}::${w.dateKey}`;
const list = groups.get(key);
if (list) list.push(w);
else groups.set(key, [w]);
}
for (const group of groups.values()) {
const { type, dateKey, hkIdentifier } = group[0];
await db.$transaction(async (tx) => {
await tx.$queryRaw`
SELECT 1 AS locked
FROM pg_advisory_xact_lock(hashtextextended('drain-cumulative:' || ${userId} || ':' || ${`stats:${hkIdentifier}:${dateKey}`}, 0))
`;
const key = { userId, type, dateKey };
const existing = await tx.intradayCumulativeProfile.findUnique({
where: { userId_type_dateKey: key },
select: {
hourlyIncrements: true,
hourlyCumulative: true,
dayTotal: true,
sampleCount: true,
timezone: true,
basis: true,
},
});
// Hours cut on another clock mean other hours: start the day over.
const sameZone = existing !== null && existing.timezone === timezone;
const increments =
sameZone && existing.hourlyIncrements.length === PROFILE_HOURS
? [...existing.hourlyIncrements]
: new Array<number>(PROFILE_HOURS).fill(HOUR_NOT_POSTED);
for (const w of group) {
statuses.set(w.index, increments[w.hour] >= 0 ? "updated" : "inserted");
increments[w.hour] = w.value;
}
const bucketCount = postedHours(increments);
const fromBuckets = bucketCount >= HOUR_BUCKET_MIN_FOR_PROFILE;
const cumulative = cumulativeFromIncrements(increments);
// Below the threshold the day keeps whatever the per-sample fold left
// (nothing, for a day the drain never saw): the reader skips a profile
// without 24 running totals.
const keepFold = sameZone && !fromBuckets;
const data = {
hourlyIncrements: increments,
bucketCount,
basis: fromBuckets ? "HOUR_BUCKETS" : "PER_SAMPLE",
timezone,
hourlyCumulative: fromBuckets
? cumulative
: keepFold
? existing.hourlyCumulative
: [],
dayTotal: fromBuckets
? cumulative[PROFILE_HOURS - 1]
: keepFold
? existing.dayTotal
: 0,
sampleCount: fromBuckets
? bucketCount
: keepFold
? existing.sampleCount
: 0,
} satisfies Prisma.IntradayCumulativeProfileUncheckedUpdateInput;
await tx.intradayCumulativeProfile.upsert({
where: { userId_type_dateKey: key },
create: { ...key, ...data },
update: data,
});
});
}
return statuses;
}
+88
View File
@@ -0,0 +1,88 @@
/**
* The first-to-last change of a chart window, by day (v1.43, ios #116
* item 3).
*
* A chart's change is read off day values, not off single points: the first
* reading of a window can be an outlier minute of a dense series, and a day
* of steps is its total, not its last sample. Each local day of the window is
* reduced with the type's own day statistic (the total for a summed type, the
* mean of the hours' means for pulse, the mean otherwise), and the change is
* the last day against the first. Under two days there is no change to show.
*
* Pure: the series route hands over the points it already serves, in the
* unit it serves them in.
*/
import { dayValue } from "@/lib/measurements/day-mean";
import { isCumulativeDaySumType } from "@/lib/measurements/cumulative-day-sum";
import {
trendSentimentOf,
type TrendSentiment,
type TrendVerdict,
} from "@/lib/targets/trend-verdict";
import { userDayKey } from "@/lib/tz/format";
export interface SeriesChange {
from: number;
to: number;
delta: number;
sentiment: TrendSentiment;
}
export interface SeriesDayValue {
day: string;
value: number;
}
const round2 = (n: number) => Math.round(n * 100) / 100;
/** One value per local day, oldest first. */
export function seriesDayValues(
type: string,
points: ReadonlyArray<{ at: string; value: number }>,
timezone: string,
): SeriesDayValue[] {
const byDay = new Map<string, Array<{ value: number; measuredAt: Date }>>();
for (const point of points) {
const measuredAt = new Date(point.at);
if (!Number.isFinite(point.value) || Number.isNaN(measuredAt.getTime())) {
continue;
}
const day = userDayKey(measuredAt, timezone);
const list = byDay.get(day);
if (list) list.push({ value: point.value, measuredAt });
else byDay.set(day, [{ value: point.value, measuredAt }]);
}
const summed = isCumulativeDaySumType(type);
const out: SeriesDayValue[] = [];
for (const [day, rows] of byDay) {
const value = summed
? rows.reduce((sum, r) => sum + r.value, 0)
: dayValue(type, rows, timezone);
if (value !== null) out.push({ day, value });
}
return out.sort((a, b) => (a.day < b.day ? -1 : a.day > b.day ? 1 : 0));
}
export function computeSeriesChange(
days: readonly SeriesDayValue[],
verdict: TrendVerdict | null,
): SeriesChange | null {
if (days.length < 2) return null;
const from = round2(days[0].value);
const to = round2(days[days.length - 1].value);
const delta = round2(to - from);
return { from, to, delta, sentiment: trendSentimentOf(delta, verdict) };
}
/**
* The value a window's verdict places against the band: the mean of its last
* seven day values (the dashboard places the seven-day average the same way),
* so one day near a band edge does not flip the colour.
*/
export function seriesReferenceValue(
days: readonly SeriesDayValue[],
): number | null {
if (days.length === 0) return null;
const recent = days.slice(-7);
return recent.reduce((sum, d) => sum + d.value, 0) / recent.length;
}
@@ -0,0 +1,178 @@
/**
* Horizontal reference lines for a measurement chart (v1.43, ios #116 item 4).
*
* The native client drew fixed threshold lines (blood pressure 140/60, pulse
* 100/60, glucose 70/180 mg/dL, fever 37.5, SpO2 95) that knew neither the
* series' unit nor the person's targets, and removed them. The server knows
* both, so it resolves the lines here and `GET /api/measurements/series`
* serves them in the unit of its own points.
*
* Two sources, one rule: a personal line (the person's own target from
* `/targets`, else the profile default the target cards use: the age-based
* blood pressure band, the age- and sex-based resting pulse band, the
* sex-based body fat band) replaces the clinical line for the same edge.
* The clinical lines are the ones a guideline names:
*
* - blood pressure 140 / 90 mmHg, the ESH 2023 hypertension threshold;
* - glucose 70 and 180 mg/dL, the CGM time-in-range band (ATTD consensus,
* Battelino et al. 2019), converted for an mmol/L account;
* - SpO2 lower edge 92 %, the red-flag floor in `clinical-floors.ts`.
*
* Without either, the list is empty: nothing to draw is an answer, not an
* error.
*/
import type {
ThresholdMetric,
UserProfileForRange,
} from "@/lib/analytics/effective-range";
import { TIME_IN_RANGE_MGDL } from "@/lib/analytics/glucose-metrics";
import { SPO2_RED_FLAG_PCT } from "@/lib/clinical-floors";
import { convertGlucose, type GlucoseUnit } from "@/lib/glucose";
import { REFERENCE_RANGES } from "@/lib/reference-ranges";
import { resolvePersonalBand } from "@/lib/targets/trend-verdict";
export type SeriesKind =
| "weight"
| "bloodPressure"
| "pulse"
| "bodyFat"
| "glucose"
| "sleep"
| "steps"
| "totalBodyWater"
| "boneMass"
| "oxygenSaturation"
| "restingHeartRate"
| "heartRateVariability"
| "vo2Max";
export interface SeriesReferenceLine {
value: number;
kind: "lower" | "upper";
source: "personal" | "clinical";
/** `secondary` is the diastolic line of a blood pressure chart. */
appliesTo: "value" | "secondary";
labelKey: string;
label: string;
}
export interface SeriesReferenceContext {
/** The profile of the record on screen. */
profile: UserProfileForRange;
thresholdsJson: unknown;
glucoseUnit: GlucoseUnit;
/** Server translator in the account's language. */
t: (key: string) => string;
}
/** The ESH 2023 hypertension threshold, systolic from the shipped bands. */
const ESH_HYPERTENSION_SYS =
REFERENCE_RANGES.BLOOD_PRESSURE.bands.find((b) => b.label === "Hypertension")
?.low ?? 140;
/** Its diastolic counterpart (ESH 2023 grade 1 starts at 140 / 90). */
const ESH_HYPERTENSION_DIA = 90;
/** Which `/targets` threshold a kind's personal lines come from. */
const PERSONAL_METRIC: Partial<Record<SeriesKind, ThresholdMetric>> = {
weight: "WEIGHT",
bodyFat: "BODY_FAT",
sleep: "SLEEP_DURATION",
steps: "ACTIVITY_STEPS",
totalBodyWater: "TOTAL_BODY_WATER",
boneMass: "BONE_MASS",
oxygenSaturation: "OXYGEN_SATURATION",
// A resting band on the resting chart only: raw heart rate includes every
// workout, where the resting band says nothing.
restingHeartRate: "PULSE",
};
function personalBand(
metric: ThresholdMetric,
ctx: SeriesReferenceContext,
): { min: number; max: number } | null {
return (
resolvePersonalBand(metric, ctx.profile, ctx.thresholdsJson)?.band ?? null
);
}
function line(
ctx: SeriesReferenceContext,
value: number,
kind: "lower" | "upper",
source: "personal" | "clinical",
appliesTo: "value" | "secondary",
): SeriesReferenceLine {
const labelKey =
source === "personal"
? kind === "upper"
? "series.reference.targetUpper"
: "series.reference.targetLower"
: kind === "upper"
? "series.reference.clinicalUpper"
: "series.reference.clinicalLower";
return { value, kind, source, appliesTo, labelKey, label: ctx.t(labelKey) };
}
/** Both edges of a personal band. */
function bandLines(
ctx: SeriesReferenceContext,
band: { min: number; max: number },
appliesTo: "value" | "secondary",
): SeriesReferenceLine[] {
return [
line(ctx, band.min, "lower", "personal", appliesTo),
line(ctx, band.max, "upper", "personal", appliesTo),
];
}
export function resolveSeriesReferenceLines(
kind: SeriesKind,
ctx: SeriesReferenceContext,
): SeriesReferenceLine[] {
switch (kind) {
case "bloodPressure": {
const sys = personalBand("BLOOD_PRESSURE_SYS", ctx);
const dia = personalBand("BLOOD_PRESSURE_DIA", ctx);
return [
...(sys
? bandLines(ctx, sys, "value")
: [line(ctx, ESH_HYPERTENSION_SYS, "upper", "clinical", "value")]),
...(dia
? bandLines(ctx, dia, "secondary")
: [
line(ctx, ESH_HYPERTENSION_DIA, "upper", "clinical", "secondary"),
]),
];
}
case "glucose":
// The series mixes every meal context, so no single context's target
// is the person's line here; the time-in-range band is.
return [
line(
ctx,
convertGlucose(TIME_IN_RANGE_MGDL.low, ctx.glucoseUnit),
"lower",
"clinical",
"value",
),
line(
ctx,
convertGlucose(TIME_IN_RANGE_MGDL.high, ctx.glucoseUnit),
"upper",
"clinical",
"value",
),
];
case "oxygenSaturation": {
const own = personalBand("OXYGEN_SATURATION", ctx);
if (own) return bandLines(ctx, own, "value");
return [line(ctx, SPO2_RED_FLAG_PCT, "lower", "clinical", "value")];
}
default: {
const metric = PERSONAL_METRIC[kind];
if (!metric) return [];
const band = personalBand(metric, ctx);
return band ? bandLines(ctx, band, "value") : [];
}
}
}
+36 -18
View File
@@ -333,6 +333,34 @@ const cycleDeleteOutcomeEnvelope = dataEnvelope(
"CycleDeleteOutcomeEnvelope",
);
/**
* `Prefer: return=representation` on the two deletes that can remove a start.
* A factory, so each operation carries its own copy: a shared object becomes a
* YAML anchor and renumbers every other anchor in the document.
*/
const preferReturnRepresentationParameter = () => ({
name: "Prefer",
in: "header" as const,
required: false,
schema: { type: "string" as const, example: "return=representation" },
description:
"v1.43 — send `return=representation` (RFC 7240) to get 200 with what the delete changed (`CycleDeleteOutcome`) and a `Preference-Applied: return=representation` header. Without it the route answers 204 with no body, as before.",
});
/** The 200 a delete answers with when the request preferred a representation. */
const cycleDeleteOutcomeResponse = () => ({
description:
"v1.43 — with `Prefer: return=representation`: the removed cycle (null when the delete removed none, e.g. a day that opened no cycle or a repeated delete) and the starts the removal gave back.",
content: {
"application/json": { schema: cycleDeleteOutcomeEnvelope },
},
headers: z.object({
"Preference-Applied": z.literal("return=representation").meta({
description: "The route honoured `Prefer: return=representation`.",
}),
}),
});
const cyclePeriodResponse = z.object({
cycle: menstrualCycleDto.nullable(),
dayLog: cycleDayLogDto.nullable(),
@@ -340,7 +368,7 @@ const cyclePeriodResponse = z.object({
.nullable()
.optional()
.describe(
'v1.43 — on `action: "start"`: a later start within the period length folded into this one, or null.',
'v1.43 — a later start within the period length that this start folded in (it stops being a start of its own), or null. Present on every `action: "start"` answer; null on `end`.',
),
});
@@ -787,7 +815,7 @@ export const cyclePaths: NonNullable<ZodOpenApiObject["paths"]> = {
.array(cycleRestoredStart)
.optional()
.describe(
"v1.43 — starts given back because this edit cleared the flow on a start day.",
"v1.43 — starts given back because this edit cleared the flow on a start day; empty when it gave back none. Absent on older servers.",
),
}),
),
@@ -808,16 +836,11 @@ export const cyclePaths: NonNullable<ZodOpenApiObject["paths"]> = {
tags: ["Cycle"],
summary: "Soft-delete a cycle day-log (v1.15.0)",
description:
"Sets `deletedAt` + bumps `syncVersion`; surfaces as a tombstone on the next `/api/sync/changes` page. 204. Idempotent.",
"Sets `deletedAt` + bumps `syncVersion`; surfaces as a tombstone on the next `/api/sync/changes` page. A day that opened a cycle takes that cycle with it, which gives back any start that cycle had folded in. 204, or 200 with `CycleDeleteOutcome` under `Prefer: return=representation`. Idempotent.",
requestParams: { path: z.object({ id: z.string() }) },
parameters: [preferReturnRepresentationParameter()],
responses: {
"200": {
description:
"v1.43 — with `Prefer: return=representation`: what the delete changed.",
content: {
"application/json": { schema: cycleDeleteOutcomeEnvelope },
},
},
"200": cycleDeleteOutcomeResponse(),
"204": { description: "Soft-deleted (no body)." },
"404": {
description: "Day-log not found / not owned.",
@@ -970,16 +993,11 @@ export const cyclePaths: NonNullable<ZodOpenApiObject["paths"]> = {
tags: ["Cycle"],
summary: "Soft-delete a menstrual cycle (v1.15.0)",
description:
"Sets `deletedAt` + bumps `syncVersion`; tombstones on the next sync page. 204. Idempotent.",
"Sets `deletedAt` + bumps `syncVersion`; tombstones on the next sync page. Any start this cycle had folded in stands again. 204, or 200 with `CycleDeleteOutcome` under `Prefer: return=representation`. Idempotent.",
requestParams: { path: z.object({ id: z.string() }) },
parameters: [preferReturnRepresentationParameter()],
responses: {
"200": {
description:
"v1.43 — with `Prefer: return=representation`: what the delete changed.",
content: {
"application/json": { schema: cycleDeleteOutcomeEnvelope },
},
},
"200": cycleDeleteOutcomeResponse(),
"204": { description: "Soft-deleted (no body)." },
"404": {
description: "Cycle not found / not owned.",
+1 -1
View File
@@ -412,7 +412,7 @@ export const derivedMetricResponse = z
.record(z.string(), z.unknown())
.nullable()
.describe(
"Metric-specific value object when status is 'ok'; null when 'insufficient'. The shape is chosen by `metric`, and the record stays open because one route serves eighteen of them. `RECOVERY_SCORE` / `STRESS_SCORE` / `STRAIN_SCORE` return the `WellnessScoreValue` schema — field-by-field in `components.schemas`, including the `series`, `daysInWindow` and `asOf` a client would otherwise have to discover by inspecting a live payload. `VITALS_BASELINE` returns { type, center, low, high, spread, sampleDays, k, series }, where `series` is the trailing per-day means for the inline sparkline. The remaining metrics' value objects are not modelled here yet; their shapes are TypeScript interfaces beside their engines under `src/lib/insights/derived/`.",
"Metric-specific value object when status is 'ok'; null when 'insufficient'. The shape is chosen by `metric`, and the record stays open because one route serves eighteen of them. `RECOVERY_SCORE` / `STRESS_SCORE` / `STRAIN_SCORE` return the `WellnessScoreValue` schema — field-by-field in `components.schemas`, including the `series`, `daysInWindow` and `asOf` a client would otherwise have to discover by inspecting a live payload. `VITALS_BASELINE` returns { type, center, low, high, spread, sampleDays, k, series, direction }, where `series` is the trailing per-day means for the inline sparkline and `direction` (v1.43, a `TrendVerdict`) says which way a change of the vital counts for this person, with `center` placed against their own target or a profile default. The remaining metrics' value objects are not modelled here yet; their shapes are TypeScript interfaces beside their engines under `src/lib/insights/derived/`.",
),
coverage: derivedCoverage,
confidence: derivedConfidence
+7 -109
View File
@@ -9,7 +9,6 @@ import {
seriesChange,
seriesReferenceLine,
trendVerdict,
recordBenchmark,
} from "./trend-verdict";
import { z } from "zod/v4";
import type { ZodOpenApiObject } from "zod-openapi";
@@ -93,10 +92,11 @@ const batchEntrySchema = z
.min(1)
.max(120)
.describe(
"Dedup key for the `(userId, type, source, externalId)` composite index. Three shapes:\n" +
"Dedup key for the `(userId, type, source, externalId)` composite index. Four shapes:\n" +
"• `HKSample.uuid` — a per-sample reading. First-write-wins: a re-post returns `duplicate` and the stored row is immutable.\n" +
"• `stats:<HKQuantityTypeIdentifier>:<YYYY-MM-DD>` — a per-day cumulative total (Steps, Active Energy, Sleep Duration, Walking/Running Distance, Flights Climbed). A re-post OVERWRITES the row and returns `updated`.\n" +
"• `stats:HKQuantityTypeIdentifierHeartRate:<bucket-start>` — v1.19.0 (iOS #34), refined to 10-minute granularity in v1.30.7, heart-rate bucket carrying the bucket's AVERAGE bpm as one PULSE row. `<bucket-start>` is the ISO-8601 UTC 10-minute boundary — zeroed seconds/millis, minutes ∈ {00,10,20,30,40,50}, trailing `Z` (e.g. `2026-06-21T14:10:00.000Z`). A re-post OVERWRITES the row and returns `updated`, so iOS uploads ~144 rows/day instead of one per raw HR sample. A row that targets this prefix with a malformed bucket-start suffix is `skipped` with reason `malformed_hr_bucket_id`.\n" +
"• `stats:<HKQuantityTypeIdentifier>:<YYYY-MM-DD>T<HH>` — v1.43 (ios #77), only when `GET /api/auth/me` reports `features.cumulativeHourBuckets: true`: one local hour's SUM of steps, active energy, walking and running distance or flights climbed, the date and the zero-padded hour being the local hour in the account's zone (`timezone` on `/api/auth/me`). `value` is the hour's sum in the unit of the day form, `startDate` the first instant of that local hour, `endDate` one hour later. Such a row is never a measurement and never changes the day total, which stays the day form: it feeds the intraday profile behind the same-time baseline (a day with at least four posted hours is built from them). A re-post overwrites (`updated`). Skipped with `hour_bucket_misaligned` when `startDate` is not the start of that local hour, `hour_bucket_unsupported_type` for any other identifier or a `MANUAL` / bridge entry, `hour_bucket_too_old` when the day is more than 400 days back.\n" +
"The id MUST be stable across app restarts. An object description or memory address (`<Class: 0x…>`, `0x12ab34cd`) is a new value on every launch, so it can never match its own earlier row; such an entry is `skipped` with reason `unstable_external_id` while the rest of the batch still lands.",
),
externalSourceVersion: z.string().min(1).max(120).optional(),
@@ -133,10 +133,10 @@ const batchPayloadSchema = z
// used in attribution — sending it or omitting it produces byte-identical
// row outcomes. Omit on any client that does not track its wake reason.
syncTrigger: z
.enum(["foreground", "background", "push", "manual"])
.enum(["foreground", "background", "push", "manual", "backfill"])
.optional()
.describe(
'Diagnostic-only. Names what triggered this sync (foreground app open, background refresh, a push wake, or a user-initiated `manual` "Sync all" run, since v1.42). Recorded on the ingest wide event and kept on the account as `lastSyncTrigger` — plus `lastBackgroundSyncAt` for a `background` or `push` trigger — both readable from `GET /api/integrations/healthkit`. Since v1.42 it is also recorded per measurement type the batch carried (Apple Health rows only), served as `lastReceivedAt` / `lastTrigger` / `lastNewSampleAt` on each `metricFreshness` entry of that read, plus `lastBackgroundAt` (since v1.42.4) for a `background` trigger. It does not affect dedup, attribution, or how any sample is stored. Optional and backward-compatible: pre-#66 clients omit it.',
'Diagnostic-only. Names what triggered this sync (foreground app open, background refresh, a push wake, or a user-initiated `manual` "Sync all" run, since v1.42). `backfill` (v1.43, ios #77; send it only when `GET /api/auth/me` reports `features.syncTriggerBackfill: true`, an older server refuses the whole batch with 422) names a history catch-up: it moves only `syncProgress` and the per-type `lastBackfillAt` in `GET /api/integrations/healthkit`, never `lastReceivedAt`, `lastTrigger`, `lastBackgroundAt`, `lastSyncTrigger`, `lastBackgroundSyncAt` or the sync checkpoint. Recorded on the ingest wide event and kept on the account as `lastSyncTrigger` — plus `lastBackgroundSyncAt` for a `background` or `push` trigger — both readable from `GET /api/integrations/healthkit`. Since v1.42 it is also recorded per measurement type the batch carried (Apple Health rows only), served as `lastReceivedAt` / `lastTrigger` / `lastNewSampleAt` on each `metricFreshness` entry of that read, plus `lastBackgroundAt` (since v1.42.4) for a `background` trigger. It does not affect dedup, attribution, or how any sample is stored. Optional and backward-compatible: pre-#66 clients omit it.',
),
})
.meta({
@@ -157,7 +157,7 @@ const batchEntryResult = z
.string()
.optional()
.describe(
"Why a row was `skipped` or a `duplicate`. `folded_window` (since v1.42): a raw sample of a dense or mean type whose local day lies before the fold boundary and whose hour or day a live `stats:` row of the same source already covers. Nothing to retry; treat it like any duplicate.",
"Why a row was `skipped` or a `duplicate`. `folded_window` (since v1.42): a raw sample of a dense or mean type whose local day lies before the fold boundary and whose hour or day a live `stats:` row of the same source already covers. Nothing to retry; treat it like any duplicate. `hour_bucket_misaligned`, `hour_bucket_unsupported_type`, `hour_bucket_too_old` (since v1.43): an hourly `stats:<HK>:<YYYY-MM-DD>T<HH>` row the server refused, see `externalId`; permanent for the row as sent.",
),
convertedValue: z
.number()
@@ -595,86 +595,8 @@ export const sleepRhythmResponse = z
"Server-authoritative sleep-rhythm read: the outstanding sleep-debt balance over the rolling window + MCTQ chronotype (MSF/MSFsc band, social jetlag). Free vs work nights default to weekend = free in the user's timezone (no work calendar). A view over existing per-stage SLEEP_DURATION rows — no schema, no new type.",
});
// ── Personal records ─────────────────────────────────────────────────
//
// `GET /api/personal-records` shipped as a schema-only contract so the native
// client could build its query path against a stable shape before the
// detection worker landed. It answers the stored rows verbatim — including
// `userId` and the provenance foreign key — because the handler passes the
// Prisma result straight to `apiSuccess` with no projection.
const personalRecordQuery = z
.object({
metricType: measurementTypeEnum
.optional()
.describe(
"Restrict to one measurement type. A value outside the enum is DROPPED rather than refused — the read then covers every metric, which is indistinguishable from omitting the parameter.",
),
limit: z.coerce
.number()
.int()
.min(1)
.max(500)
.optional()
.default(100)
.describe(
"Row cap, 1..500, default 100. A non-integer, zero, negative or non-numeric value falls back to the default and a value above the ceiling clamps to 500 — none of them 422.",
),
})
.meta({ id: "PersonalRecordQuery" });
const personalRecordResource = z
.object({
id: z.string(),
userId: z
.string()
.describe(
"The RECORD owner's id, which is not the caller's id on a delegated read.",
),
metricType: measurementTypeEnum,
metricSlot: z
.string()
.nullable()
.describe(
"Per-sport-type bucket for workout-driven records (e.g. `running_5km_time`). Null for a plain measurement-driven record, where the metric type alone is the dimension.",
),
direction: z
.enum(["MAX", "MIN"])
.describe(
"Which way is better for this metric: `MAX` for steps / VO2 max / HRV / distance / daylight, `MIN` for resting heart rate / body fat / audio exposure.",
),
value: z.number(),
unit: z.string(),
achievedAt: z.iso
.datetime({ offset: true })
.describe(
"When the record was set. The list orders on this, newest first.",
),
sourceMeasurementId: z
.string()
.nullable()
.describe(
"The measurement row that achieved the record. Nulled rather than cascaded when that measurement is deleted, so the historical fact survives its source.",
),
source: measurementSourceEnum,
externalId: z.string().nullable(),
createdAt: z.iso
.datetime({ offset: true })
.describe(
"When the detection worker wrote the row — distinct from `achievedAt`, which is when the reading happened.",
),
benchmark: recordBenchmark
.nullable()
.optional()
.describe(
"v1.43 — an age- and sex-aware comparison band where a cited norm exists, in the record's unit; null otherwise.",
),
})
.meta({
id: "PersonalRecord",
description:
"One personal record: the best value a metric (optionally within a per-sport slot) has reached, in the direction that counts as better for it.",
});
// `GET /api/personal-records` is documented once, in `./awards.ts`, beside the
// other read of earned bests.
// ── Time-series adapter (iOS chart source) ───────────────────────────
//
@@ -1255,28 +1177,4 @@ export const measurementPaths: NonNullable<ZodOpenApiObject["paths"]> = {
},
},
},
"/api/personal-records": {
get: {
tags: ["Measurements"],
summary: "List the record's personal records",
description:
"Returns the account's personal-record rows, `achievedAt` descending. Both query parameters are FORGIVING rather than strict: an unrecognised `metricType` is dropped (the read falls back to every metric) and an unusable `limit` clamps to the default instead of 422-ing, so a loosely-typed client filter cannot take the page down. Rows are the stored entities verbatim, including `userId` and the provenance `sourceMeasurementId`. Delegable at READ level over the `measurements` section. Not module-gated: the list spans metrics from several modules and is filtered by the caller's `metricType`, not by the account's module map — a record achieved under a metric whose module is now off is still returned. Cookie or Bearer auth.",
requestParams: { query: personalRecordQuery },
responses: {
...recordRefusal(),
"200": {
description: "The personal-record rows (possibly empty).",
content: {
"application/json": {
schema: dataEnvelope(
z.array(personalRecordResource),
"PersonalRecordListEnvelope",
),
},
},
},
...stdResponses,
},
},
},
};
+2 -2
View File
@@ -199,10 +199,10 @@ const bulkIntakePayload = z
.object({
entries: z.array(bulkIntakeEntry).min(1).max(500),
syncTrigger: z
.enum(["foreground", "background", "push", "manual"])
.enum(["foreground", "background", "push", "manual", "backfill"])
.optional()
.describe(
'Diagnostic-only, the same field `POST /api/measurements/batch` takes (since v1.42.4): what woke the client for this sync. When the batch carries entries with `source: "APPLE_HEALTH"` that land (`inserted`, `updated` or `duplicate`), it is recorded on the `MEDICATION_DOSE` entry of `metricFreshness` in `GET /api/integrations/healthkit` as `lastTrigger`, and a `background` value also dates its `lastBackgroundAt`. It changes no dose row. Optional; omitted records a null trigger.',
'Diagnostic-only, the same field `POST /api/measurements/batch` takes (since v1.42.4): what woke the client for this sync. `backfill` (v1.43, only when `features.syncTriggerBackfill` is `true`) marks a history catch-up, which moves only the `MEDICATION_DOSE` entry\'s `lastBackfillAt` and leaves its live stamps and the sync checkpoint alone. When the batch carries entries with `source: "APPLE_HEALTH"` that land (`inserted`, `updated` or `duplicate`), it is recorded on the `MEDICATION_DOSE` entry of `metricFreshness` in `GET /api/integrations/healthkit` as `lastTrigger`, and a `background` value also dates its `lastBackgroundAt`. It changes no dose row. Optional; omitted records a null trigger.',
),
})
.meta({
@@ -0,0 +1,124 @@
import { describe, expect, it } from "vitest";
import { ageAt, resolveRecordBenchmark } from "../record-benchmark";
const DOB_1981 = new Date("1981-06-15T00:00:00.000Z");
const at = (iso: string) => new Date(iso);
const profile = {
dateOfBirth: DOB_1981,
sex: "MALE" as const,
heightCm: 180,
weightKg: 80,
};
function record(
metricType: Parameters<typeof resolveRecordBenchmark>[0]["metricType"],
unit: string,
achievedAt = "2026-07-01T08:00:00.000Z",
metricSlot: string | null = null,
) {
return { metricType, unit, metricSlot, achievedAt: at(achievedAt) };
}
describe("ageAt", () => {
it("counts whole years up to the day before the birthday", () => {
expect(ageAt(DOB_1981, at("2026-06-14T23:00:00.000Z"))).toBe(44);
expect(ageAt(DOB_1981, at("2026-06-15T00:00:00.000Z"))).toBe(45);
expect(ageAt(null, at("2026-06-15T00:00:00.000Z"))).toBeNull();
});
});
describe("resolveRecordBenchmark", () => {
it("gives a 45-year-old man's VO2 max record the FRIEND band in its unit", () => {
const band = resolveRecordBenchmark(
record("VO2_MAX", "mL/(kg·min)"),
profile,
);
expect(band).not.toBeNull();
expect(band!.unit).toBe("mL/(kg·min)");
expect(band!.source).toBe("friend_2015");
expect(band!.sourceLabel).toContain("Kaminsky");
// Between the 40s (35–45) and 50s (31–41) bands.
expect(band!.low).toBeGreaterThanOrEqual(31);
expect(band!.low).toBeLessThanOrEqual(35);
expect(band!.high).toBeGreaterThanOrEqual(41);
expect(band!.high).toBeLessThanOrEqual(45);
});
it("uses the age the record was set at, not today's", () => {
const old = resolveRecordBenchmark(
record("VO2_MAX", "mL/(kg·min)", "2004-07-01T08:00:00.000Z"),
profile,
);
// 23 then: below the 20s bracket centre, so the 20s male band itself.
expect(old).toMatchObject({ low: 42, high: 53 });
});
it("returns null for a metric without a cited norm", () => {
expect(
resolveRecordBenchmark(record("ACTIVITY_STEPS", "steps"), profile),
).toBeNull();
});
it("returns null without a date of birth", () => {
expect(
resolveRecordBenchmark(record("VO2_MAX", "mL/(kg·min)"), {
...profile,
dateOfBirth: null,
}),
).toBeNull();
});
it("returns null for a sex-split table without a sex", () => {
expect(
resolveRecordBenchmark(record("VO2_MAX", "mL/(kg·min)"), {
...profile,
sex: undefined,
}),
).toBeNull();
});
it("returns null for a record stored in another unit, never converted on a guess", () => {
expect(
resolveRecordBenchmark(record("GRIP_STRENGTH", "lb"), profile),
).toBeNull();
});
it("accepts a spelling of the canonical unit", () => {
expect(
resolveRecordBenchmark(record("RESPIRATORY_RATE", "count/min"), profile),
).toMatchObject({ unit: "count/min", source: "who_pals" });
});
it("returns null for a per-sport slot", () => {
expect(
resolveRecordBenchmark(
record("WALKING_RUNNING_DISTANCE", "m", undefined, "running_5km_time"),
profile,
),
).toBeNull();
});
it("places a six-minute walk between 80 % of the predicted distance and the prediction", () => {
const band = resolveRecordBenchmark(
record("SIX_MINUTE_WALK_DISTANCE", "m"),
profile,
);
// Enright & Sherrill, man, 45 y, 180 cm, 80 kg.
const predicted = 7.57 * 180 - 5.02 * 45 - 1.76 * 80 - 309;
expect(band).toEqual({
low: Math.round(predicted * 0.8),
high: Math.round(predicted),
unit: "m",
source: "enright_sherrill_1998",
sourceLabel: "Enright & Sherrill 1998",
});
expect(
resolveRecordBenchmark(record("SIX_MINUTE_WALK_DISTANCE", "m"), {
...profile,
weightKg: null,
}),
).toBeNull();
});
});
@@ -0,0 +1,166 @@
/**
* The comparison band published beside a personal record (v1.43, ios #116
* item 5).
*
* The native client used to place a record against hard-coded population
* values that knew neither the person's age nor their sex. The server owns the
* cited norms (`lookupNormalRange` in `src/lib/insights/derived/norms.ts`, the
* Enright & Sherrill equation for the six-minute walk), so it resolves the band
* here and the clients show it as delivered, or nothing when it is null.
*
* The band is for the age the person had when the record was set: a record
* from ten years ago is compared with what was typical then, not with today's
* bracket. Null whenever no honest band exists: a metric without a cited norm,
* no date of birth, a sex the table has no row for, a per-sport workout slot,
* or a record stored in a unit other than the norm's (the band is never
* converted on a guess).
*
* Pure: the caller reads the profile and the weight the walk equation needs.
*/
import type { MeasurementType } from "@/generated/prisma/client";
import {
lookupNormalRange,
predictSixMinuteWalkDistance,
type NormSex,
} from "@/lib/insights/derived/norms";
import type { MetricStatusMetricId } from "@/lib/insights/metric-status-registry";
import { getUnitForType } from "@/lib/measurements/unit-map";
export interface RecordBenchmark {
low: number;
high: number;
unit: string;
/** Stable identifier of the cited norm. */
source: string;
/** The citation as a reader sees it. */
sourceLabel: string;
}
/** The profile facts the norms are keyed on. */
export interface RecordBenchmarkProfile {
dateOfBirth: Date | null;
sex: NormSex;
heightCm: number | null;
/** Latest weight in kg, for the six-minute-walk equation only. */
weightKg: number | null;
}
interface NormCitation {
metricId: MetricStatusMetricId;
source: string;
sourceLabel: string;
}
const CITED_NORMS: Partial<Record<MeasurementType, NormCitation>> = {
VO2_MAX: {
metricId: "VO2_MAX",
source: "friend_2015",
sourceLabel: "FRIEND registry (Kaminsky et al. 2015)",
},
RESTING_HEART_RATE: {
metricId: "RESTING_HEART_RATE",
source: "fleming_2011",
sourceLabel: "Fleming et al. 2011, AHA",
},
RESPIRATORY_RATE: {
metricId: "RESPIRATORY_RATE",
source: "who_pals",
sourceLabel: "WHO / PALS reference",
},
GRIP_STRENGTH: {
metricId: "GRIP_STRENGTH",
source: "dodds_2014",
sourceLabel: "Dodds et al. 2014, EWGSOP2",
},
};
const SIX_MINUTE_WALK: { source: string; sourceLabel: string } = {
source: "enright_sherrill_1998",
sourceLabel: "Enright & Sherrill 1998",
};
/**
* The share of the predicted distance the six-minute-walk card already calls
* "green" (`six-minute-walk.ts`). The band runs from there to the predicted
* distance, so the record and the card speak about the same expectation.
*/
const SIX_MINUTE_WALK_LOW_SHARE = 0.8;
/** Spellings of one canonical unit that writers have used. */
const UNIT_ALIASES: Record<string, readonly string[]> = {
"breaths/min": ["count/min", "/min", "breaths/min", "brpm"],
};
function unitKey(unit: string): string {
return unit.toLowerCase().replace(/[^a-z0-9%]/g, "");
}
function sameUnit(type: MeasurementType, stored: string): boolean {
const canonical = getUnitForType(type);
const accepted = [canonical, ...(UNIT_ALIASES[canonical] ?? [])];
return accepted.some((u) => unitKey(u) === unitKey(stored));
}
/** Whole years between a date of birth and an instant. */
export function ageAt(dateOfBirth: Date | null, at: Date): number | null {
if (!dateOfBirth || Number.isNaN(dateOfBirth.getTime())) return null;
let age = at.getUTCFullYear() - dateOfBirth.getUTCFullYear();
const beforeBirthday =
at.getUTCMonth() < dateOfBirth.getUTCMonth() ||
(at.getUTCMonth() === dateOfBirth.getUTCMonth() &&
at.getUTCDate() < dateOfBirth.getUTCDate());
if (beforeBirthday) age -= 1;
return age >= 0 ? age : null;
}
const round1 = (n: number) => Math.round(n * 10) / 10;
export function resolveRecordBenchmark(
record: {
metricType: MeasurementType;
metricSlot: string | null;
unit: string;
achievedAt: Date;
},
profile: RecordBenchmarkProfile,
): RecordBenchmark | null {
// A per-sport slot (a 5 km time) has no population norm here.
if (record.metricSlot !== null) return null;
if (!sameUnit(record.metricType, record.unit)) return null;
const age = ageAt(profile.dateOfBirth, record.achievedAt);
// Every band below is age-stratified; without an age none is honest.
if (age === null) return null;
if (record.metricType === "SIX_MINUTE_WALK_DISTANCE") {
const predicted = predictSixMinuteWalkDistance(
age,
profile.heightCm,
profile.weightKg,
profile.sex,
);
if (predicted === null) return null;
return {
low: Math.round(predicted * SIX_MINUTE_WALK_LOW_SHARE),
high: Math.round(predicted),
unit: record.unit,
...SIX_MINUTE_WALK,
};
}
const citation = CITED_NORMS[record.metricType];
if (!citation) return null;
const band = lookupNormalRange(citation.metricId, age, profile.sex);
if (!band) return null;
return {
low: round1(band.low),
high: round1(band.high),
unit: record.unit,
source: citation.source,
sourceLabel: citation.sourceLabel,
};
}
/** Whether a record of this type can carry a band at all (query planning). */
export function mayCarryBenchmark(type: MeasurementType): boolean {
return type === "SIX_MINUTE_WALK_DISTANCE" || type in CITED_NORMS;
}
@@ -0,0 +1,204 @@
import { describe, expect, it } from "vitest";
import {
resolvePersonalBand,
resolveSummaryVerdicts,
resolveTrendVerdict,
trendSentimentOf,
trendSentimentOfTrend,
} from "../trend-verdict";
const NO_PROFILE = { dateOfBirth: null, gender: null, heightCm: null };
const MAN_41 = {
dateOfBirth: new Date("1985-01-01T00:00:00.000Z"),
gender: "MALE",
heightCm: 180,
};
const OWN_BP = { BLOOD_PRESSURE_SYS: { min: 115, max: 125 } };
function verdict(
key: string,
referenceValue: number | null,
thresholdsJson: unknown = null,
profile: typeof NO_PROFILE | typeof MAN_41 = NO_PROFILE,
) {
return resolveTrendVerdict(key, { profile, thresholdsJson, referenceValue });
}
describe("resolveTrendVerdict against the person's own target", () => {
it("calls a rising blood pressure above the target a setback", () => {
const v = verdict("BLOOD_PRESSURE_SYS", 132, OWN_BP);
expect(v).toEqual({
direction: "up-bad",
targetPosition: "above",
basis: "personal_target",
});
expect(trendSentimentOf(4, v)).toBe("negative");
});
it("calls the same rise below the target progress", () => {
const v = verdict("BLOOD_PRESSURE_SYS", 108, OWN_BP);
expect(v).toEqual({
direction: "up-good",
targetPosition: "below",
basis: "personal_target",
});
expect(trendSentimentOf(4, v)).toBe("positive");
});
it("holds inside the target, with the edges counted as inside", () => {
expect(verdict("BLOOD_PRESSURE_SYS", 125, OWN_BP)).toMatchObject({
direction: "hold",
targetPosition: "inside",
});
const v = verdict("BLOOD_PRESSURE_SYS", 120, OWN_BP);
expect(trendSentimentOf(0, v)).toBe("positive");
expect(trendSentimentOf(3, v)).toBe("neutral");
});
it("judges the BLOOD_PRESSURE target tile by the systolic band", () => {
expect(verdict("BLOOD_PRESSURE", 132, OWN_BP).direction).toBe("up-bad");
});
it("reads a sleep target in hours against a reference in minutes", () => {
const own = { SLEEP_DURATION: { min: 7, max: 8 } };
expect(verdict("SLEEP_DURATION", 6 * 60, own)).toMatchObject({
direction: "up-good",
targetPosition: "below",
});
expect(verdict("SLEEP_DURATION", 7.5 * 60, own).direction).toBe("hold");
});
it("ignores a malformed target and falls back", () => {
expect(
verdict("BLOOD_PRESSURE_SYS", 132, {
BLOOD_PRESSURE_SYS: { min: 130, max: 120 },
}),
).toEqual({
direction: "up-bad",
targetPosition: null,
basis: "metric_default",
});
});
});
describe("resolveTrendVerdict against a profile default", () => {
it("uses the age-based blood pressure band", () => {
expect(verdict("BLOOD_PRESSURE_SYS", 125, null, MAN_41)).toEqual({
direction: "hold",
targetPosition: "inside",
basis: "profile_default",
});
});
it("uses the age- and sex-based resting pulse band for both pulse keys", () => {
for (const key of ["PULSE", "RESTING_HEART_RATE"]) {
expect(verdict(key, 95, null, MAN_41)).toMatchObject({
direction: "up-bad",
targetPosition: "above",
basis: "profile_default",
});
}
});
it("does not call a band nobody is personalised for a profile default", () => {
// No age: the pulse band is the same for everyone.
expect(verdict("RESTING_HEART_RATE", 95)).toEqual({
direction: "up-bad",
targetPosition: null,
basis: "metric_default",
});
// No sex: no body fat band.
expect(verdict("BODY_FAT", 30).basis).toBe("metric_default");
});
it("prefers the person's own target over the profile default", () => {
expect(verdict("BLOOD_PRESSURE_SYS", 125, OWN_BP, MAN_41).basis).toBe(
"personal_target",
);
});
});
describe("resolveTrendVerdict metric defaults", () => {
it.each([
["ACTIVITY_STEPS", "up-good"],
["HEART_RATE_VARIABILITY", "up-good"],
["HRV_RMSSD", "up-good"],
["VO2_MAX", "up-good"],
["MOOD", "up-good"],
["BODY_FAT", "up-bad"],
["RESTING_HEART_RATE", "up-bad"],
["RESPIRATORY_RATE", "neutral"],
["TOTAL_BODY_WATER", "neutral"],
["BLOOD_GLUCOSE_FASTING", "neutral"],
])("%s defaults to %s", (key, direction) => {
expect(verdict(key, null)).toEqual({
direction,
targetPosition: null,
basis: "metric_default",
});
});
it("keeps weight's own rule: only a stored target counts", () => {
expect(verdict("WEIGHT", 70, null, MAN_41)).toEqual({
direction: "up-bad",
targetPosition: null,
basis: "metric_default",
});
expect(verdict("WEIGHT", 60, { WEIGHT: { min: 65, max: 72 } })).toEqual({
direction: "up-good",
targetPosition: "below",
basis: "personal_target",
});
// BMI follows the weight it is derived from.
expect(verdict("BMI", 60, { WEIGHT: { min: 65, max: 72 } }).direction).toBe(
"up-good",
);
});
});
describe("trend sentiment", () => {
it("applies the noise floor and treats no verdict as neutral", () => {
const upBad = verdict("RESTING_HEART_RATE", null);
expect(trendSentimentOf(0.01, upBad)).toBe("neutral");
expect(trendSentimentOf(-2, upBad)).toBe("positive");
expect(trendSentimentOf(2, null)).toBe("neutral");
});
it("reads a trend word the way it reads a signed change", () => {
const upGood = verdict("ACTIVITY_STEPS", null);
expect(trendSentimentOfTrend("up", upGood)).toBe("positive");
expect(trendSentimentOfTrend("down", upGood)).toBe("negative");
expect(trendSentimentOfTrend("flat", upGood)).toBe("neutral");
expect(trendSentimentOfTrend("unknown", upGood)).toBe("neutral");
expect(trendSentimentOfTrend(null, upGood)).toBe("neutral");
const hold = verdict("BLOOD_PRESSURE_SYS", 120, OWN_BP);
expect(trendSentimentOfTrend("stable", hold)).toBe("positive");
});
});
describe("resolvePersonalBand", () => {
it("is null when neither an own target nor a personalised default exists", () => {
expect(resolvePersonalBand("ACTIVITY_STEPS", NO_PROFILE, null)).toBeNull();
expect(resolvePersonalBand("BLOOD_PRESSURE_DIA", MAN_41, null)).toEqual({
band: { min: 70, max: 79 },
basis: "profile_default",
});
});
});
describe("resolveSummaryVerdicts", () => {
it("places each tile's seven-day average and adds the summaryless keys", () => {
const out = resolveSummaryVerdicts(
{
BLOOD_PRESSURE_SYS: { avg7: 132, latest: 118 },
ACTIVITY_STEPS: { avg7: null, latest: 9000 },
},
{ profile: NO_PROFILE, thresholdsJson: OWN_BP },
);
expect(out.BLOOD_PRESSURE_SYS.direction).toBe("up-bad");
expect(out.ACTIVITY_STEPS.direction).toBe("up-good");
expect(out.MOOD.direction).toBe("up-good");
expect(out.BLOOD_PRESSURE_IN_TARGET.direction).toBe("up-good");
});
});
+49
View File
@@ -39,6 +39,11 @@ import { buildSleepTarget } from "./sleep-builder";
import { buildTargetPageSummary } from "./summary-builder";
import { buildVitalTargets } from "./vitals-builder";
import type { TargetItem, TargetMoodEntry, TargetValueByType } from "./types";
import {
resolveTrendVerdict,
trendSentimentOfTrend,
type TrendVerdictContext,
} from "./trend-verdict";
import type { User } from "@/generated/prisma/client";
import { TRACKED_INTAKE_WHERE } from "@/lib/medications/intake-tracking";
@@ -362,6 +367,18 @@ export async function buildTargetsResponse(user: AuthedUser) {
}),
);
// v1.43 (ios #116 item 3) — every item carries which way its change
// counts, judged with the same rule the dashboard tiles use.
const verdictTargets = withTargetVerdicts(targets, {
profile: {
heightCm,
dateOfBirth: dbUser?.dateOfBirth ?? null,
gender: dbUser?.gender ?? null,
},
thresholdsJson: dbUser?.thresholdsJson ?? null,
});
targets.splice(0, targets.length, ...verdictTargets);
const pageSummary = buildTargetPageSummary(targets);
annotate({
action: { name: "insights.targets" },
@@ -393,3 +410,35 @@ export async function buildTargetsResponse(user: AuthedUser) {
},
};
}
/**
* Lay the verdict over each target item. The reference is the item's latest
* value, else its thirty-day average, in the unit the item is served in:
* sleep is served in hours and judged in minutes, and BMI is judged by the
* weight it is derived from.
*/
export function withTargetVerdicts(
targets: readonly TargetItem[],
ctx: Omit<TrendVerdictContext, "referenceValue">,
): TargetItem[] {
const weight = targets.find((item) => item.type === "WEIGHT");
const weightReference = weight ? (weight.current ?? weight.average30) : null;
return targets.map((item) => {
const own = item.current ?? item.average30;
const reference =
item.type === "BMI"
? weightReference
: own !== null && item.type === "SLEEP_DURATION"
? own * 60
: own;
const direction = resolveTrendVerdict(item.type, {
...ctx,
referenceValue: reference,
});
return {
...item,
direction,
trendSentiment: trendSentimentOfTrend(item.trend, direction),
};
});
}
+312
View File
@@ -0,0 +1,312 @@
/**
* Which way a change of a metric counts as progress, for this person (v1.43,
* ios #116 item 3). The generalisation of `resolveWeightTrend`.
*
* Every surface that shows a change (a dashboard arrow, a seven-day delta, a
* target tile, a chart's first-to-last change) used to carry its own fixed
* polarity: a rising blood pressure turned orange whatever the person's
* target was, and the native client kept a third copy of the table. The
* verdict is resolved here, once, and published on every route that serves a
* change; clients colour from it and never decide it themselves.
*
* The rule:
*
* - a band applies (the person's own target from `/targets`, else a
* profile-derived default: the age-based blood pressure target, the
* age- and sex-based resting pulse band, the sex-based body fat band):
* below the band → a rise is progress (`up-good`)
* above the band → a fall is progress (`up-bad`)
* inside the band → holding is progress (`hold`)
* - no band, or no value to place: the metric's own default direction.
*
* Weight keeps its own rule (`weight-trend.ts`): only a target the person set
* counts, never the height-derived band, because a band nobody chose says
* nothing about which way they want to move.
*
* Pure and client-safe: no DB read, no clock beyond the age the profile
* helpers compute.
*/
import {
getEffectiveRange,
resolveThresholdOverride,
resolveWeightTargetOverride,
type ThresholdMetric,
type UserProfileForRange,
} from "@/lib/analytics/effective-range";
import { getAgeFromDateOfBirth } from "@/lib/analytics/pulse-targets";
import {
getTrendSentiment,
type TrendDirectionSentiment,
type TrendSentimentDirection,
} from "@/lib/insights/trend-sentiment";
import { binaryReferenceSex } from "@/lib/profile/sex";
import { resolveWeightTrend } from "./weight-trend";
export type TrendTargetPosition = "below" | "inside" | "above";
export type TrendVerdictBasis =
"personal_target" | "profile_default" | "metric_default";
export interface TrendVerdict {
direction: TrendDirectionSentiment;
targetPosition: TrendTargetPosition | null;
basis: TrendVerdictBasis;
}
export type TrendSentiment = TrendSentimentDirection;
/**
* What a verdict can be asked for: a measurement type, or one of the derived
* keys a target tile or dashboard card carries (`BLOOD_PRESSURE` is the
* systolic side of the pair, `BMI` follows weight, `MOOD` / `MOOD_SCORE` the
* mood mean, `BLOOD_PRESSURE_IN_TARGET` and `MEDICATION_COMPLIANCE` a share).
*/
export type TrendVerdictKey = string;
/**
* The metric's own direction when no band applies. A rise is progress for the
* capacity and activity measures, a setback for the load measures, and
* carries no judgement for everything else (body water, bone mass, breathing
* rate, skin temperature, intake, …).
*/
const METRIC_DEFAULT_DIRECTION: Readonly<
Record<string, Exclude<TrendDirectionSentiment, "hold">>
> = {
ACTIVITY_STEPS: "up-good",
HEART_RATE_VARIABILITY: "up-good",
HRV_RMSSD: "up-good",
VO2_MAX: "up-good",
OXYGEN_SATURATION: "up-good",
MUSCLE_MASS: "up-good",
SLEEP_DURATION: "up-good",
SIX_MINUTE_WALK_DISTANCE: "up-good",
GRIP_STRENGTH: "up-good",
MOOD: "up-good",
MOOD_SCORE: "up-good",
BLOOD_PRESSURE_IN_TARGET: "up-good",
MEDICATION_COMPLIANCE: "up-good",
RESTING_HEART_RATE: "up-bad",
PULSE: "up-bad",
BODY_FAT: "up-bad",
BLOOD_PRESSURE: "up-bad",
BLOOD_PRESSURE_SYS: "up-bad",
BLOOD_PRESSURE_DIA: "up-bad",
BODY_TEMPERATURE: "up-bad",
BLOOD_GLUCOSE: "up-bad",
};
/** The `/targets` threshold a key is judged against, when it has one. */
const THRESHOLD_FOR_KEY: Readonly<Record<string, ThresholdMetric>> = {
BLOOD_PRESSURE: "BLOOD_PRESSURE_SYS",
BLOOD_PRESSURE_SYS: "BLOOD_PRESSURE_SYS",
BLOOD_PRESSURE_DIA: "BLOOD_PRESSURE_DIA",
// The resting pulse card and the dashboard pulse tile share one target.
PULSE: "PULSE",
RESTING_HEART_RATE: "PULSE",
BODY_FAT: "BODY_FAT",
SLEEP_DURATION: "SLEEP_DURATION",
ACTIVITY_STEPS: "ACTIVITY_STEPS",
TOTAL_BODY_WATER: "TOTAL_BODY_WATER",
BONE_MASS: "BONE_MASS",
OXYGEN_SATURATION: "OXYGEN_SATURATION",
};
/**
* Stored values the threshold is not written in. Sleep is stored in minutes
* and targeted in hours; every other threshold shares the stored unit.
*/
const TO_THRESHOLD_UNIT: Readonly<Record<string, (value: number) => number>> = {
SLEEP_DURATION: (minutes) => minutes / 60,
};
export interface TrendVerdictContext {
/** The profile of the RECORD on screen, not of the viewer. */
profile: UserProfileForRange;
/** `User.thresholdsJson` of the same record, unparsed. */
thresholdsJson: unknown;
/**
* The value placed against the band, in the stored (canonical) unit of the
* key: usually the seven-day average, else the latest reading. Null when
* there is nothing to place, which leaves the metric's default.
*/
referenceValue: number | null;
}
/**
* Whether the profile carries what the default band of this metric is
* personalised from. Without it the band is the same for everyone, which is a
* metric default and not a statement about this person.
*/
function hasProfileDefault(
metric: ThresholdMetric,
profile: UserProfileForRange,
): boolean {
switch (metric) {
case "BLOOD_PRESSURE_SYS":
case "BLOOD_PRESSURE_DIA":
return profile.dateOfBirth != null;
case "PULSE": {
const age = getAgeFromDateOfBirth(profile.dateOfBirth ?? null);
return age != null && age >= 20;
}
case "BODY_FAT":
return binaryReferenceSex(profile.gender) !== null;
default:
return false;
}
}
/**
* The band a person's values are judged against: their own target, else the
* profile default when the profile carries what it is personalised from.
* Null when neither applies. Shared with the chart reference lines, so the
* line drawn and the verdict coloured come from the same band.
*/
export function resolvePersonalBand(
metric: ThresholdMetric,
profile: UserProfileForRange,
thresholdsJson: unknown,
): {
band: { min: number; max: number };
basis: Exclude<TrendVerdictBasis, "metric_default">;
} | null {
const own = resolveThresholdOverride(thresholdsJson, metric);
if (own) return { band: own, basis: "personal_target" };
if (!hasProfileDefault(metric, profile)) return null;
const range = getEffectiveRange(metric, profile, null).range;
return range
? {
band: { min: range.greenMin, max: range.greenMax },
basis: "profile_default",
}
: null;
}
function metricDefault(key: string): TrendVerdict {
return {
direction: METRIC_DEFAULT_DIRECTION[key] ?? "neutral",
targetPosition: null,
basis: "metric_default",
};
}
/** Place a value against a band; the edges count as inside. */
export function placeAgainstBand(
band: { min: number; max: number },
value: number,
): TrendTargetPosition {
if (value < band.min) return "below";
if (value > band.max) return "above";
return "inside";
}
const DIRECTION_BY_POSITION: Record<
TrendTargetPosition,
TrendDirectionSentiment
> = {
below: "up-good",
above: "up-bad",
inside: "hold",
};
export function resolveTrendVerdict(
key: TrendVerdictKey,
ctx: TrendVerdictContext,
): TrendVerdict {
const reference =
ctx.referenceValue !== null && Number.isFinite(ctx.referenceValue)
? ctx.referenceValue
: null;
// Weight, and BMI which moves exactly when weight does: only the person's
// own target counts (see the module comment). For `BMI` the reference is
// the WEIGHT it is derived from, in kg, since the target is a weight.
if (key === "WEIGHT" || key === "BMI") {
const target = resolveWeightTargetOverride(ctx.thresholdsJson);
const judgement = resolveWeightTrend(target, reference);
return {
direction: judgement.direction,
targetPosition: judgement.targetPosition,
basis:
judgement.targetPosition !== null
? "personal_target"
: "metric_default",
};
}
const metric = THRESHOLD_FOR_KEY[key];
if (!metric || reference === null) return metricDefault(key);
const personal = resolvePersonalBand(metric, ctx.profile, ctx.thresholdsJson);
if (!personal) return metricDefault(key);
const toUnit = TO_THRESHOLD_UNIT[key];
const position = placeAgainstBand(
personal.band,
toUnit ? toUnit(reference) : reference,
);
return {
direction: DIRECTION_BY_POSITION[position],
targetPosition: position,
basis: personal.basis,
};
}
/**
* A delivered change judged by a verdict, with the noise floor the arrows
* have always used (`getTrendSentiment`). No verdict reads neutral.
*/
export function trendSentimentOf(
change: number | null | undefined,
verdict: TrendVerdict | null,
): TrendSentiment {
return getTrendSentiment(change, verdict?.direction ?? "neutral");
}
/**
* The same judgement for a change that is only known by its direction (the
* `trend` of a dashboard card or a target tile). A flat or unknown trend is
* no change: inside a band that is the one case where it is news.
*/
export function trendSentimentOfTrend(
trend: "up" | "down" | "flat" | "stable" | "unknown" | null | undefined,
verdict: TrendVerdict | null,
): TrendSentiment {
if (trend === "up") return trendSentimentOf(1, verdict);
if (trend === "down") return trendSentimentOf(-1, verdict);
if (trend === "flat" || trend === "stable")
return trendSentimentOf(0, verdict);
return "neutral";
}
/** Keys that carry a verdict without a measurement summary of their own. */
export const SUMMARYLESS_VERDICT_KEYS = [
"MOOD",
"BLOOD_PRESSURE_IN_TARGET",
] as const;
/**
* One verdict per summarised type, each placed with the value its tile shows
* as the reference (the seven-day average, else the latest reading), plus the
* summaryless keys at their metric default. The dashboard snapshot publishes
* this as `tiles.directions`.
*/
export function resolveSummaryVerdicts(
summaries: Readonly<
Record<string, { avg7: number | null; latest: number | null } | undefined>
>,
ctx: Omit<TrendVerdictContext, "referenceValue">,
): Record<string, TrendVerdict> {
const out: Record<string, TrendVerdict> = {};
for (const [type, summary] of Object.entries(summaries)) {
if (!summary) continue;
out[type] = resolveTrendVerdict(type, {
...ctx,
referenceValue: summary.avg7 ?? summary.latest ?? null,
});
}
for (const key of SUMMARYLESS_VERDICT_KEYS) {
out[key] ??= resolveTrendVerdict(key, { ...ctx, referenceValue: null });
}
return out;
}
+8
View File
@@ -10,6 +10,7 @@ import type {
} from "@/lib/analytics/compliance";
import type { ScheduleRevisionLike } from "@/lib/medications/scheduling/schedule-eras";
import type { ThresholdOverridesJson } from "@/lib/analytics/effective-range";
import type { TrendSentiment, TrendVerdict } from "./trend-verdict";
export type TargetTrend = "up" | "down" | "stable" | null;
export type DayBand = "in" | "near" | "out";
@@ -35,6 +36,13 @@ export interface TargetItem extends TargetConsistency {
range: { min: number; max: number } | null;
classification: { category: string; color: string } | null;
source: string;
/**
* v1.43 (ios #116 item 3) — which way a change of this target counts for
* the person (`src/lib/targets/trend-verdict.ts`), and that verdict
* applied to `trend`. Set on every item by `buildTargetsResponse`.
*/
direction?: TrendVerdict | null;
trendSentiment?: TrendSentiment | null;
details?: {
medications?: Array<{
name: string;
@@ -210,6 +210,69 @@ describe("backupPayloadSchema — v1.28 backup-completeness domains", () => {
}
});
// A qualitative marker is minted with an empty unit and its readings are
// stored under it; the file the account's own backup wrote must parse.
describe("units of qualitative lab markers", () => {
const base = {
schemaVersion: "1",
exportedAt: "2026-05-08T07:00:00.000Z",
userId: "u1",
};
const qualitativeMarker = { id: "bm-1", name: "Urine glucose", unit: "" };
const qualitativeReading = {
analyte: "Urine glucose",
value: null,
valueText: "negative",
unit: "",
takenAt: "2026-04-01T09:00:00.000Z",
source: "MANUAL",
biomarkerId: "bm-1",
biomarkerName: "Urine glucose",
};
it("accepts an empty unit on a qualitative marker and its reading", () => {
const r = backupPayloadSchema.safeParse({
...base,
biomarkers: [qualitativeMarker],
labResults: [qualitativeReading],
});
expect(r.success).toBe(true);
});
it("still needs the unit on a numeric reading", () => {
const r = backupPayloadSchema.safeParse({
...base,
labResults: [
{
...qualitativeReading,
value: 5,
valueText: null,
biomarkerId: null,
},
],
});
expect(r.success).toBe(false);
expect(r.error?.issues.map((i) => i.path.join("."))).toEqual([
"labResults.0.unit",
]);
});
it("refuses an empty unit on a marker a numeric reading is filed under", () => {
const r = backupPayloadSchema.safeParse({
...base,
biomarkers: [qualitativeMarker],
labResults: [
qualitativeReading,
{ ...qualitativeReading, value: 5, valueText: null, unit: "mg/dL" },
],
});
expect(r.success).toBe(false);
expect(r.error?.issues.map((i) => i.path.join("."))).toEqual([
"biomarkers.0.unit",
]);
});
});
it("round-trips an illness episode with a flare referencing its parent + nested day-logs", () => {
const r = backupPayloadSchema.safeParse({
schemaVersion: "1",
+52 -3
View File
@@ -993,6 +993,12 @@ const intradayProfileBackupSchema = z
hourlyCumulative: z.array(z.number()),
dayTotal: z.number(),
sampleCount: z.number().int(),
// v1.43 (ios #77) — the hours the phone posted, what the curve was built
// from, and how many hours there were. Defaulted so a file written before
// the hours existed restores as a fold of single samples, which it was.
hourlyIncrements: z.array(z.number()).default([]),
basis: z.enum(["PER_SAMPLE", "HOUR_BUCKETS"]).default("PER_SAMPLE"),
bucketCount: z.number().int().nonnegative().nullable().default(null),
// The zone the day was cut on. The reader compares it against the
// account's current zone and drops the mismatched days, so a restore that
// dropped this column would make old curves look comparable when their
@@ -1120,7 +1126,10 @@ const labResultBackupSchema = z
analyte: z.string().min(1),
value: z.number().nullable().optional(),
valueText: z.string().nullable().optional(),
unit: z.string().min(1),
// A qualitative reading ("positive", "trace") has no unit and is stored
// under its marker's empty one (`POST /api/labs`); the refine below keeps
// the unit required for every numeric reading.
unit: z.string(),
referenceLow: z.number().nullable().optional(),
referenceHigh: z.number().nullable().optional(),
// Optional so a backup written before these columns existed still
@@ -1142,13 +1151,29 @@ const labResultBackupSchema = z
deletedAt: isoDateTime.nullable().optional(),
updatedAt: isoDateTime.optional(),
})
.passthrough();
.passthrough()
.superRefine((reading, ctx) => {
const qualitative =
(reading.value === null || reading.value === undefined) &&
typeof reading.valueText === "string" &&
reading.valueText.length > 0;
if (reading.unit.length === 0 && !qualitative) {
ctx.addIssue({
code: "custom",
path: ["unit"],
message: "A numeric lab reading needs its unit",
});
}
});
const biomarkerBackupSchema = z
.object({
id: z.string().min(1).optional(),
name: z.string().min(1),
unit: z.string().min(1),
// Empty for a qualitative marker, minted from a reading with no unit. The
// payload refine below refuses an empty unit on a marker a numeric
// reading is filed under.
unit: z.string(),
lowerBound: z.number().nullable().optional(),
upperBound: z.number().nullable().optional(),
panel: z.string().nullable().optional(),
@@ -2246,6 +2271,30 @@ export const backupPayloadSchema = z
})
.passthrough()
.superRefine((payload, ctx) => {
// A marker without a unit is a qualitative one. A numeric reading filed
// under it would restore as a number with no unit, so the file is refused
// naming the marker rather than restored into that.
const numericByMarker = new Set<string>();
for (const reading of payload.labResults) {
if (reading.value === null || reading.value === undefined) continue;
if (reading.biomarkerId) numericByMarker.add(`id:${reading.biomarkerId}`);
if (reading.biomarkerName) {
numericByMarker.add(`name:${reading.biomarkerName}`);
}
}
payload.biomarkers.forEach((marker, index) => {
if (marker.unit.length > 0) return;
if (
(marker.id && numericByMarker.has(`id:${marker.id}`)) ||
numericByMarker.has(`name:${marker.name}`)
) {
ctx.addIssue({
code: "custom",
path: ["biomarkers", index, "unit"],
message: `Biomarker "${marker.name}" has numeric readings and needs its unit`,
});
}
});
if (payload.schemaVersion !== BACKUP_SCHEMA_VERSION) return;
payload.measurements.forEach((measurement, index) => {
if (!measurement.id) {
@@ -0,0 +1,137 @@
/**
* A backup holding a qualitative lab marker restores.
*
* A qualitative reading ("positive", "trace") has no unit, so its marker is
* minted with an empty unit and the reading stored under it
* (`POST /api/labs`, `isQualitative`). The backup schema demanded a non-empty
* unit on both rows, so the file the account's own backup wrote could not be
* read back: the parse refused it and the restore never started. Asserted on
* rows through the real writer, the real wire format, the real parser and the
* real restore, with the marker removed first so the file is the only copy.
*
* The empty unit is accepted for a qualitative reading and a marker none of
* whose readings is numeric; a numeric reading, or a marker a numeric reading
* is filed under, still needs its unit.
*/
import { NextRequest } from "next/server";
import { beforeEach, describe, expect, it, vi } from "vitest";
process.env.ENCRYPTION_KEY =
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
import { encrypt } from "@/lib/crypto";
import { buildFullBackupPayload } from "@/lib/export/full-backup-payload";
import { parseBackupPayload } from "@/lib/validations/backup";
import { POST as RESTORE } from "./restore-job-driver";
import { cookieJar, headerJar } from "./mock-next-headers";
import { getPrismaClient, truncateAllTables } from "./setup";
vi.mock("next/headers", async () => {
const { cookieJar, headerJar } = await import("./mock-next-headers");
return {
headers: vi.fn(async () => ({
get: (name: string) => headerJar.get(name.toLowerCase()) ?? null,
})),
cookies: vi.fn(async () => ({
get: (name: string) => {
const value = cookieJar.get(name);
return value ? { name, value } : undefined;
},
set: (name: string, value: string) => cookieJar.set(name, value),
delete: (name: string) => cookieJar.delete(name),
})),
};
});
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/cache/invalidate", async (importOriginal) => {
const actual =
await importOriginal<typeof import("@/lib/cache/invalidate")>();
return { ...actual, invalidateUserData: vi.fn() };
});
beforeEach(async () => {
await truncateAllTables(getPrismaClient());
cookieJar.clear();
headerJar.clear();
});
describe("a backup with a qualitative lab marker", () => {
it("restores the marker and its reading", async () => {
const prisma = getPrismaClient();
const owner = await prisma.user.create({
data: {
username: "qualitative-lab-owner",
email: "qualitative-lab-owner@example.test",
role: "ADMIN",
},
});
const session = await prisma.session.create({
data: { userId: owner.id, expiresAt: new Date(Date.now() + 60_000) },
});
cookieJar.set("healthlog_session", session.id);
// The real write path mints the marker with an empty unit.
const { POST: CREATE_LAB } = await import("@/app/api/labs/route");
const created = await CREATE_LAB(
new NextRequest("http://localhost/api/labs", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
analyte: "Urine glucose",
valueText: "negative",
takenAt: "2026-07-01T08:00:00.000Z",
}),
}),
);
expect(created.status).toBe(201);
const marker = await prisma.biomarker.findFirstOrThrow({
where: { userId: owner.id },
});
expect(marker.unit).toBe("");
const { payload: built } = await buildFullBackupPayload(prisma, owner.id, {
purpose: "disaster-recovery",
});
// The parse is where the file used to be refused.
const parsed = parseBackupPayload(JSON.stringify(built));
const backup = await prisma.dataBackup.create({
data: {
userId: owner.id,
type: "QUALITATIVE_LAB_ROUND_TRIP",
data: encrypt(JSON.stringify(parsed)),
},
});
await prisma.labResult.deleteMany({ where: { userId: owner.id } });
await prisma.biomarker.deleteMany({ where: { userId: owner.id } });
const response = await RESTORE(
new Request(`http://localhost/api/admin/backups/${backup.id}/restore`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ confirm: "RESTORE" }),
}) as unknown as Parameters<typeof RESTORE>[0],
{ params: Promise.resolve({ id: backup.id }) },
);
expect(response.status).toBe(200);
const restoredMarker = await prisma.biomarker.findFirstOrThrow({
where: { userId: owner.id, name: "Urine glucose" },
});
expect(restoredMarker.unit).toBe("");
const restoredReading = await prisma.labResult.findFirstOrThrow({
where: { userId: owner.id },
});
expect(restoredReading).toMatchObject({
biomarkerId: restoredMarker.id,
value: null,
valueText: "negative",
unit: "",
});
});
});
+7 -8
View File
@@ -3661,14 +3661,7 @@ const COLUMN_PRESENCE_ONLY: Readonly<Record<string, string>> = {
* starts coming back, so the entry has to go in the change that carries it.
* The list is empty again before the release is tagged.
*/
const RELEASE_PENDING_COLUMNS: Readonly<Record<string, string>> = {
"IntradayCumulativeProfile.hourlyIncrements":
"hour buckets for summed types (ios #77): the intraday section carries it with the hour-bucket ingest",
"IntradayCumulativeProfile.basis":
"hour buckets for summed types (ios #77): the intraday section carries it with the hour-bucket ingest",
"IntradayCumulativeProfile.bucketCount":
"hour buckets for summed types (ios #77): the intraday section carries it with the hour-bucket ingest",
};
const RELEASE_PENDING_COLUMNS: Readonly<Record<string, string>> = {};
/**
* Columns every reader parses through one function before using, compared
@@ -3697,6 +3690,12 @@ const FILL_OVERRIDES: Readonly<Record<string, unknown>> = {
"CorrelationPattern.dismissedEvidenceHash": "ab".repeat(32),
// CHECK in migration 0408: the profile is built from samples or from hour buckets.
"IntradayCumulativeProfile.basis": "HOUR_BUCKETS",
// The posted hours are a whole day of slots (-1 for an hour not posted);
// the restore refuses any other length, as it does for the curve.
"IntradayCumulativeProfile.hourlyIncrements": Array.from(
{ length: 24 },
(_, hour) => (hour < 6 ? -1 : 350),
),
};
/**
@@ -0,0 +1,234 @@
/**
* The cycle writes name the starts they fold in and give back (ios #116
* item 9).
*
* A start logged a few days before an existing one is the same period entered
* in the other order, so the later start is folded in. Taking the earlier one
* back gives the later one back. Both used to reach only the audit row; the
* client could refetch the grid but could not say which date moved. These
* pin the answers: `absorbed` on the start, `restored` on the PATCH that
* clears a start day's flow, and the opt-in 200 on the two deletes, with the
* 204 unchanged for a client that does not ask.
*/
import { NextRequest } from "next/server";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { cookieJar, headerJar } from "./mock-next-headers";
import { getPrismaClient, truncateAllTables } from "./setup";
process.env.API_TOKEN_HMAC_KEY ??=
"test-hmac-key-cycle-absorbed-integration-32-bytes-min-1234567890";
process.env.ENCRYPTION_KEY ??=
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
const USER_ID = "user-cycle-absorbed";
vi.mock("next/headers", async () => {
const { cookieJar, headerJar } = await import("./mock-next-headers");
return {
headers: vi.fn(async () => ({
get: (name: string) => headerJar.get(name.toLowerCase()) ?? null,
})),
cookies: vi.fn(async () => ({
get: (name: string) => {
const value = cookieJar.get(name);
return value ? { name, value } : undefined;
},
set: (name: string, value: string) => cookieJar.set(name, value),
delete: (name: string) => cookieJar.delete(name),
})),
};
});
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
beforeEach(async () => {
await truncateAllTables(getPrismaClient());
cookieJar.clear();
headerJar.clear();
await getPrismaClient().user.create({
data: {
id: USER_ID,
username: "cycle-absorbed",
email: "cycle-absorbed@example.test",
gender: "FEMALE",
},
});
const session = await getPrismaClient().session.create({
data: { userId: USER_ID, expiresAt: new Date(Date.now() + 3_600_000) },
});
cookieJar.set("healthlog_session", session.id);
});
function request(
path: string,
method: string,
body?: unknown,
headers: Record<string, string> = {},
): NextRequest {
return new NextRequest(`http://localhost${path}`, {
method,
headers: { "content-type": "application/json", ...headers },
body: body !== undefined ? JSON.stringify(body) : undefined,
});
}
async function startPeriod(date: string): Promise<{
cycle: { id: string; startDate: string } | null;
absorbed: { cycleId: string; startDate: string } | null;
}> {
const { POST } = await import("@/app/api/cycle/period/route");
const res = await POST(
request("/api/cycle/period", "POST", {
action: "start",
date,
loggedAt: `${date}T08:00:00.000Z`,
}),
);
expect(res.status).toBe(200);
return (await res.json()).data;
}
async function cycleIdOn(date: string): Promise<string> {
const row = await getPrismaClient().menstrualCycle.findFirstOrThrow({
where: { userId: USER_ID, startDate: date },
select: { id: true },
});
return row.id;
}
const PREFER = { prefer: "return=representation" };
describe("absorbed and restored cycle starts", () => {
it("names the later start a start folds in, and null when it folds none", async () => {
const first = await startPeriod("2026-09-17");
expect(first.absorbed).toBeNull();
const later = await cycleIdOn("2026-09-17");
const earlier = await startPeriod("2026-09-14");
expect(earlier.absorbed).toEqual({
cycleId: later,
startDate: "2026-09-17",
});
});
it("gives the folded start back on a cycle delete with Prefer, as 200", async () => {
await startPeriod("2026-09-17");
const later = await cycleIdOn("2026-09-17");
await startPeriod("2026-09-14");
const earlier = await cycleIdOn("2026-09-14");
const { DELETE } = await import("@/app/api/cycle/cycles/[id]/route");
const res = await DELETE(
request(`/api/cycle/cycles/${earlier}`, "DELETE", undefined, PREFER),
{ params: Promise.resolve({ id: earlier }) },
);
expect(res.status).toBe(200);
expect(res.headers.get("preference-applied")).toBe("return=representation");
expect((await res.json()).data).toEqual({
removedCycleId: earlier,
restored: [{ cycleId: later, startDate: "2026-09-17" }],
});
// The calendar shows 17 Sep again.
const live = await getPrismaClient().menstrualCycle.findMany({
where: { userId: USER_ID, deletedAt: null },
select: { startDate: true },
});
expect(live.map((c) => c.startDate)).toEqual(["2026-09-17"]);
});
it("keeps answering 204 without a body when the request does not ask", async () => {
await startPeriod("2026-09-17");
await startPeriod("2026-09-14");
const earlier = await cycleIdOn("2026-09-14");
const { DELETE } = await import("@/app/api/cycle/cycles/[id]/route");
const res = await DELETE(
request(`/api/cycle/cycles/${earlier}`, "DELETE"),
{ params: Promise.resolve({ id: earlier }) },
);
expect(res.status).toBe(204);
expect(res.headers.get("preference-applied")).toBeNull();
expect(await res.text()).toBe("");
});
it("answers a repeated delete with nothing removed and nothing restored", async () => {
await startPeriod("2026-09-17");
const id = await cycleIdOn("2026-09-17");
const { DELETE } = await import("@/app/api/cycle/cycles/[id]/route");
await DELETE(request(`/api/cycle/cycles/${id}`, "DELETE"), {
params: Promise.resolve({ id }),
});
const again = await DELETE(
request(`/api/cycle/cycles/${id}`, "DELETE", undefined, PREFER),
{ params: Promise.resolve({ id }) },
);
expect(again.status).toBe(200);
expect((await again.json()).data).toEqual({
removedCycleId: null,
restored: [],
});
});
it("gives the folded start back on a day-log delete with Prefer", async () => {
await startPeriod("2026-09-17");
const later = await cycleIdOn("2026-09-17");
await startPeriod("2026-09-14");
const earlier = await cycleIdOn("2026-09-14");
const dayLog = await getPrismaClient().cycleDayLog.findFirstOrThrow({
where: { userId: USER_ID, date: "2026-09-14" },
select: { id: true },
});
const { DELETE } = await import("@/app/api/cycle/day-logs/[id]/route");
const res = await DELETE(
request(`/api/cycle/day-logs/${dayLog.id}`, "DELETE", undefined, {
prefer: "handling=lenient, return=representation",
}),
{ params: Promise.resolve({ id: dayLog.id }) },
);
expect(res.status).toBe(200);
expect((await res.json()).data).toEqual({
removedCycleId: earlier,
restored: [{ cycleId: later, startDate: "2026-09-17" }],
});
});
it("names the start given back when an edit clears the flow on a start day", async () => {
await startPeriod("2026-09-17");
const later = await cycleIdOn("2026-09-17");
await startPeriod("2026-09-14");
const dayLog = await getPrismaClient().cycleDayLog.findFirstOrThrow({
where: { userId: USER_ID, date: "2026-09-14" },
select: { id: true },
});
const { PATCH } = await import("@/app/api/cycle/day-logs/[id]/route");
const res = await PATCH(
request(`/api/cycle/day-logs/${dayLog.id}`, "PATCH", { flow: "NONE" }),
{ params: Promise.resolve({ id: dayLog.id }) },
);
expect(res.status).toBe(200);
const data = (await res.json()).data;
expect(data.restored).toEqual([
{ cycleId: later, startDate: "2026-09-17" },
]);
expect(data.id).toBe(dayLog.id);
});
it("answers an edit that removes no start with an empty list", async () => {
await startPeriod("2026-09-17");
const dayLog = await getPrismaClient().cycleDayLog.findFirstOrThrow({
where: { userId: USER_ID, date: "2026-09-17" },
select: { id: true },
});
const { PATCH } = await import("@/app/api/cycle/day-logs/[id]/route");
const res = await PATCH(
request(`/api/cycle/day-logs/${dayLog.id}`, "PATCH", { flow: "HEAVY" }),
{ params: Promise.resolve({ id: dayLog.id }) },
);
expect((await res.json()).data.restored).toEqual([]);
});
});
@@ -0,0 +1,384 @@
/**
* Hour buckets for summed types and the `backfill` trigger (ios #77), against
* real Postgres.
*
* An hourly `stats:<HK>:<YYYY-MM-DD>T<HH>` row must never become a
* measurement: the day total stays the day row, and an hour stored as a
* measurement would be counted into that day a second time. The hours land in
* the intraday profile instead, which is what makes the same-time baseline
* usable on day one after a thirty-day backfill. And a backfill is a
* catch-up, so it must not move the live sync stamps.
*/
import { NextRequest } from "next/server";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { cookieJar, headerJar } from "./mock-next-headers";
import { getPrismaClient, truncateAllTables } from "./setup";
process.env.API_TOKEN_HMAC_KEY ??=
"test-hmac-key-hour-buckets-backfill-32-bytes-minimum-1234";
const USER_ID = "user-hour-buckets";
const STEPS = "HKQuantityTypeIdentifierStepCount";
vi.mock("next/headers", async () => {
const { cookieJar, headerJar } = await import("./mock-next-headers");
return {
headers: vi.fn(async () => ({
get: (name: string) => headerJar.get(name.toLowerCase()) ?? null,
})),
cookies: vi.fn(async () => ({
get: (name: string) => {
const value = cookieJar.get(name);
return value ? { name, value } : undefined;
},
set: (name: string, value: string) => cookieJar.set(name, value),
delete: (name: string) => cookieJar.delete(name),
})),
};
});
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
beforeEach(async () => {
await truncateAllTables(getPrismaClient());
cookieJar.clear();
headerJar.clear();
await getPrismaClient().user.create({
data: {
id: USER_ID,
username: "hour-buckets",
email: "hour-buckets@example.test",
timezone: "UTC",
},
});
const session = await getPrismaClient().session.create({
data: { userId: USER_ID, expiresAt: new Date(Date.now() + 3_600_000) },
});
cookieJar.set("healthlog_session", session.id);
});
async function postBatch(body: Record<string, unknown>) {
const { POST } = await import("@/app/api/measurements/batch/route");
const res = await POST(
new NextRequest("http://localhost/api/measurements/batch", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
}),
);
expect(res.status).toBe(200);
return (await res.json()).data as {
inserted: number;
updated: number;
entries: Array<{ index: number; status: string; reason?: string }>;
};
}
function dayKeyBack(days: number): string {
return new Date(Date.now() - days * 86_400_000).toISOString().slice(0, 10);
}
function hourEntry(dateKey: string, hour: number, value: number) {
const hh = String(hour).padStart(2, "0");
const start = `${dateKey}T${hh}:00:00.000Z`;
return {
hkIdentifier: STEPS,
value,
unit: "count",
startDate: start,
endDate: new Date(Date.parse(start) + 3_600_000).toISOString(),
externalId: `stats:${STEPS}:${dateKey}T${hh}`,
};
}
function dayEntry(dateKey: string, value: number) {
return {
hkIdentifier: STEPS,
value,
unit: "count",
startDate: `${dateKey}T00:00:00.000Z`,
endDate: `${dateKey}T12:00:00.000Z`,
externalId: `stats:${STEPS}:${dateKey}`,
};
}
describe("hour buckets for summed types", () => {
it("keeps the day total as the only measurement and builds the profile from the hours", async () => {
const day = dayKeyBack(2);
const hours = Array.from({ length: 24 }, (_, h) =>
hourEntry(day, h, h * 10),
);
const total = hours.reduce((s, e) => s + e.value, 0);
const first = await postBatch({
entries: [dayEntry(day, total), ...hours],
});
expect(first.entries.slice(1).every((e) => e.status === "inserted")).toBe(
true,
);
const rows = await getPrismaClient().measurement.findMany({
where: { userId: USER_ID, type: "ACTIVITY_STEPS" },
select: { value: true, externalId: true },
});
// The guard: no hour of a summed type ever reaches `measurements`.
expect(rows).toEqual([
{ value: total, externalId: `stats:${STEPS}:${day}` },
]);
const profile =
await getPrismaClient().intradayCumulativeProfile.findUniqueOrThrow({
where: {
userId_type_dateKey: {
userId: USER_ID,
type: "ACTIVITY_STEPS",
dateKey: day,
},
},
});
expect(profile.basis).toBe("HOUR_BUCKETS");
expect(profile.bucketCount).toBe(24);
expect(profile.hourlyIncrements).toEqual(hours.map((e) => e.value));
expect(profile.hourlyCumulative[23]).toBe(total);
expect(profile.dayTotal).toBe(total);
expect(profile.timezone).toBe("UTC");
// A re-post of one hour overwrites it.
const again = await postBatch({ entries: [hourEntry(day, 5, 999)] });
expect(again.entries[0]).toMatchObject({ status: "updated" });
expect(again.updated).toBe(1);
const after =
await getPrismaClient().intradayCumulativeProfile.findFirstOrThrow({
where: { userId: USER_ID, dateKey: day },
});
expect(after.hourlyIncrements[5]).toBe(999);
expect(after.dayTotal).toBe(total - 50 + 999);
// And the day row did not move.
const dayRow = await getPrismaClient().measurement.findFirstOrThrow({
where: { userId: USER_ID, type: "ACTIVITY_STEPS" },
});
expect(dayRow.value).toBe(total);
});
it("keeps a day under four hours on its per-sample shape", async () => {
const day = dayKeyBack(3);
await postBatch({
entries: [hourEntry(day, 8, 100), hourEntry(day, 9, 200)],
});
const profile =
await getPrismaClient().intradayCumulativeProfile.findFirstOrThrow({
where: { userId: USER_ID, dateKey: day },
});
expect(profile.basis).toBe("PER_SAMPLE");
expect(profile.bucketCount).toBe(2);
expect(profile.hourlyCumulative).toEqual([]);
// The drain's fold then fills the curve; the posted hours stay put.
const { mergeIntradayCumulativeProfile } =
await import("@/lib/measurements/intraday-cumulative-profile");
await getPrismaClient().$transaction((tx) =>
mergeIntradayCumulativeProfile(tx, {
userId: USER_ID,
type: "ACTIVITY_STEPS",
increment: {
dateKey: day,
timezone: "UTC",
hourlyCumulative: Array.from({ length: 24 }, (_, h) =>
h >= 8 ? 300 : 0,
),
dayTotal: 300,
sampleCount: 30,
},
}),
);
const folded =
await getPrismaClient().intradayCumulativeProfile.findFirstOrThrow({
where: { userId: USER_ID, dateKey: day },
});
expect(folded.dayTotal).toBe(300);
expect(folded.hourlyIncrements[9]).toBe(200);
});
it("does not let the per-sample fold count a bucket-built day twice", async () => {
const day = dayKeyBack(4);
await postBatch({
entries: Array.from({ length: 6 }, (_, h) => hourEntry(day, h + 8, 100)),
});
const { mergeIntradayCumulativeProfile } =
await import("@/lib/measurements/intraday-cumulative-profile");
await getPrismaClient().$transaction((tx) =>
mergeIntradayCumulativeProfile(tx, {
userId: USER_ID,
type: "ACTIVITY_STEPS",
increment: {
dateKey: day,
timezone: "UTC",
hourlyCumulative: new Array(24).fill(600),
dayTotal: 600,
sampleCount: 60,
},
}),
);
const profile =
await getPrismaClient().intradayCumulativeProfile.findFirstOrThrow({
where: { userId: USER_ID, dateKey: day },
});
expect(profile.basis).toBe("HOUR_BUCKETS");
expect(profile.dayTotal).toBe(600);
expect(profile.sampleCount).toBe(6);
});
it("names every refusal", async () => {
const day = dayKeyBack(2);
const misaligned = {
...hourEntry(day, 9, 10),
startDate: `${day}T09:30:00.000Z`,
};
const unsupported = {
...hourEntry(day, 9, 10),
hkIdentifier: "HKQuantityTypeIdentifierBodyMass",
unit: "kg",
externalId: `stats:HKQuantityTypeIdentifierBodyMass:${day}T09`,
};
const old = hourEntry("2024-01-02", 9, 10);
const result = await postBatch({ entries: [misaligned, unsupported, old] });
expect(result.entries.map((e) => e.reason)).toEqual([
"hour_bucket_misaligned",
"hour_bucket_unsupported_type",
"hour_bucket_too_old",
]);
expect(
await getPrismaClient().intradayCumulativeProfile.count({
where: { userId: USER_ID },
}),
).toBe(0);
});
it("makes the same-time baseline usable on day one after a thirty-day backfill", async () => {
const entries = [];
for (let back = 31; back >= 1; back -= 1) {
const day = dayKeyBack(back);
for (let h = 0; h < 24; h += 1) entries.push(hourEntry(day, h, 50 + h));
}
for (let i = 0; i < entries.length; i += 400) {
await postBatch({
syncTrigger: "backfill",
entries: entries.slice(i, i + 400),
});
}
const { computeSameTimeBaseline } =
await import("@/lib/insights/derived/same-time-baseline");
// "Today" is the last backfilled day, at 20:30.
const now = new Date(`${dayKeyBack(1)}T20:30:00.000Z`);
const derived = await computeSameTimeBaseline(USER_ID, null, {
type: "ACTIVITY_STEPS",
now,
});
expect(derived.status).toBe("ok");
});
});
describe('syncTrigger "backfill"', () => {
it("records lastBackfillAt and moves none of the live stamps", async () => {
const live = await postBatch({
syncTrigger: "foreground",
entries: [
{
hkIdentifier: "HKQuantityTypeIdentifierHeartRate",
value: 61,
unit: "count/min",
startDate: "2026-10-01T06:00:00.000Z",
endDate: "2026-10-01T06:00:00.000Z",
externalId: "uuid-live-1",
},
],
});
expect(live.inserted).toBe(1);
const prisma = getPrismaClient();
const userBefore = await prisma.user.findUniqueOrThrow({
where: { id: USER_ID },
select: {
lastSyncedAt: true,
healthKitLastSyncedAt: true,
healthKitLastSyncTrigger: true,
healthKitLastBackgroundSyncAt: true,
},
});
const ledgerBefore = await prisma.healthKitTypeSync.findUniqueOrThrow({
where: { userId_type: { userId: USER_ID, type: "PULSE" } },
});
const backfill = await postBatch({
syncTrigger: "backfill",
entries: [
{
hkIdentifier: "HKQuantityTypeIdentifierHeartRate",
value: 63,
unit: "count/min",
startDate: "2026-09-01T06:00:00.000Z",
endDate: "2026-09-01T06:00:00.000Z",
externalId: "uuid-backfill-1",
},
],
});
expect(backfill.inserted).toBe(1);
expect(
await prisma.user.findUniqueOrThrow({
where: { id: USER_ID },
select: {
lastSyncedAt: true,
healthKitLastSyncedAt: true,
healthKitLastSyncTrigger: true,
healthKitLastBackgroundSyncAt: true,
},
}),
).toEqual(userBefore);
expect(
await prisma.healthKitTypeSync.findUniqueOrThrow({
where: { userId_type: { userId: USER_ID, type: "PULSE" } },
}),
).toEqual(ledgerBefore);
const backfillRow = await prisma.healthKitTypeSync.findUniqueOrThrow({
where: { userId_type: { userId: USER_ID, type: "backfill:PULSE" } },
});
expect(backfillRow.lastTrigger).toBeNull();
// The status read serves it, and the live facts are the foreground ones.
const { GET } = await import("@/app/api/integrations/healthkit/route");
const res = await (GET as () => Promise<Response>)();
const body = (await res.json()).data as {
metricFreshness: Array<Record<string, unknown>>;
syncProgress: { recordsAccepted: number } | null;
};
const pulse = body.metricFreshness.find((e) => e.type === "PULSE");
expect(pulse).toMatchObject({
lastReceivedAt: ledgerBefore.lastReceivedAt.toISOString(),
lastTrigger: "foreground",
lastBackfillAt: backfillRow.lastReceivedAt.toISOString(),
});
// syncProgress counts what arrived, the backfill included.
expect(body.syncProgress?.recordsAccepted).toBe(2);
expect(
body.metricFreshness.some((e) => String(e.type).startsWith("backfill:")),
).toBe(false);
});
it("is refused by name for anything outside the enum", async () => {
const { POST } = await import("@/app/api/measurements/batch/route");
const res = await POST(
new NextRequest("http://localhost/api/measurements/batch", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
syncTrigger: "history",
entries: [dayEntry("2026-10-01", 1)],
}),
}),
);
expect(res.status).toBe(422);
});
});
+79
View File
@@ -0,0 +1,79 @@
/**
* Mood patterns from the real aggregate read (ios #116 item 6).
*
* The note days come from their own projection (`noteEncrypted` or the legacy
* `note` set, distinct by mood day), so the `notes` pattern is only as right
* as that query. This seeds a year with a weekend effect and notes on low
* days, runs `fetchMoodAggregates` against Postgres, and reads the patterns
* the wire serves for the year and for thirty days.
*/
import { beforeEach, describe, expect, it } from "vitest";
import { getPrismaClient, truncateAllTables } from "./setup";
process.env.ENCRYPTION_KEY ??=
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
const USER_ID = "user-mood-patterns";
beforeEach(async () => {
await truncateAllTables(getPrismaClient());
await getPrismaClient().user.create({
data: {
id: USER_ID,
username: "mood-patterns",
email: "mood-patterns@example.test",
timezone: "UTC",
},
});
});
describe("mood patterns", () => {
it("finds the weekend and the note days in a seeded year", async () => {
const { encryptToBytes } = await import("@/lib/ai/coach/bytes-codec");
const now = new Date();
const rows = [];
for (let back = 1; back <= 200; back += 1) {
const at = new Date(now.getTime() - back * 86_400_000);
const date = at.toISOString().slice(0, 10);
const weekday = (at.getUTCDay() + 6) % 7;
const low = back % 3 === 0;
const jitter = ((back * 37) % 10) / 20;
const score = Math.min(
5,
Math.max(
1,
Math.round((weekday >= 5 ? 4.4 : 3) - (low ? 1 : 0) + jitter),
),
);
rows.push({
userId: USER_ID,
date,
mood: "GUT" as const,
score,
source: "WEB" as const,
moodLoggedAt: at,
tz: "UTC",
...(low ? { noteEncrypted: encryptToBytes("a hard day") } : {}),
});
}
await getPrismaClient().moodEntry.createMany({ data: rows as never });
const { fetchMoodAggregates, moodInsightsWire } =
await import("@/lib/insights/mood-aggregates");
const aggregate = await fetchMoodAggregates(USER_ID, now);
const year = moodInsightsWire(aggregate, 365, (k) => k).patterns;
expect(year.map((p) => p.kind)).toContain("weekend");
expect(year.find((p) => p.kind === "notes")).toMatchObject({
direction: "lower",
});
const notes = year.find((p) => p.kind === "notes")!;
expect(notes.sampleSize.subject).toBeGreaterThanOrEqual(60);
const month = moodInsightsWire(aggregate, 30, (k) => k).patterns;
for (const p of month) {
expect(
p.sampleSize.subject + p.sampleSize.comparison,
).toBeLessThanOrEqual(30);
}
});
});