mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 14:16:12 +00:00
Merge pull request #1106 from MBombeck/feat/v140-fixes
Workout insight cap, marker unit lock, natural durations, charts that include today, and display fixes
This commit is contained in:
77 files changed
+2084
-325
No files matched your search
+45
-35
@@ -4749,13 +4749,14 @@ paths:
|
||||
Server-side ingest endpoint for HKWorkout records (iOS) and Withings activity rows (server-to-server). Up to 100
|
||||
workouts per call; nested route geometry (GeoJSON LineString) is capped at 20 000 points and stored in a 1:1
|
||||
`WorkoutRoute` row keyed by `workoutId`. Idempotent via the `Idempotency-Key` header (replay window 24h).
|
||||
Per-entry status (`inserted | duplicate | enriched | skipped`) lets the iOS sync cursor checkpoint accurately.
|
||||
An entry matching a stored workout is first-write-wins for the workout row; when it carries a `samples` array
|
||||
and the stored workout has no heart-rate series, the series is attached and the entry reports `enriched`
|
||||
(repeatable: a workout that already has a series is a no-op). An entry whose `samples` array fails validation is
|
||||
refused on its own with `reason: "invalid_samples"` — the rest of the batch still lands. The request body
|
||||
ceiling is 5 MB enforced at the HTTP layer — clients above the ceiling receive a 413 with
|
||||
`workout.batch.payload_too_large`.
|
||||
Per-entry status (`inserted | duplicate | enriched | updated | skipped`) lets the iOS sync cursor checkpoint
|
||||
accurately. An entry matching a stored workout is first-write-wins for the workout row, except a hand-entered
|
||||
one (`source: MANUAL` with a `manual:` externalId), which a resend with other values overwrites and reports
|
||||
`updated`; when it carries a `samples` array and the stored workout has no heart-rate series, the series is
|
||||
attached and the entry reports `enriched` (repeatable: a workout that already has a series is a no-op). An entry
|
||||
whose `samples` array fails validation is refused on its own with `reason: "invalid_samples"` — the rest of the
|
||||
batch still lands. The request body ceiling is 5 MB enforced at the HTTP layer — clients above the ceiling
|
||||
receive a 413 with `workout.batch.payload_too_large`.
|
||||
|
||||
|
||||
**Narrow token.** Also reachable with a `workouts:write` Bearer (minted at `POST /api/tokens/workouts`), the
|
||||
@@ -14471,8 +14472,11 @@ paths:
|
||||
tags:
|
||||
- Labs
|
||||
summary: Edit a biomarker
|
||||
description: Partial edit; omitted fields are untouched, an explicit null clears `context` / `panel` / a bound. Audits
|
||||
as `biomarker.update`.
|
||||
description: "Partial edit; omitted fields are untouched, an explicit null clears `context` / `panel` / a bound. Audits
|
||||
as `biomarker.update`. Since v1.40.0 a `unit` different from the stored one is refused with 422
|
||||
`biomarkers.unit.locked` while any reading is linked to the marker (`meta.readingCount` carries how many):
|
||||
readers label every linked reading with the marker's unit, so a change would relabel stored numbers without
|
||||
converting them. Sending the current unit, or changing it on a marker with no readings, is accepted."
|
||||
parameters:
|
||||
- in: path
|
||||
name: id
|
||||
@@ -31870,29 +31874,30 @@ components:
|
||||
auth.reproof.required, auth.reproof.too_weak, auth.scope.insufficient, auth.session.insecure_transport,
|
||||
auth.stepup.required, auth.token.expired, auth.token.invalid; backup — backup.foreign_reference,
|
||||
backup.key.missing, backup.payload.undecryptable, backup.restore.active, backup.section.missing,
|
||||
backup.upload.too_large; body-site — body-site.invalid; coach — coach.budget.exceeded,
|
||||
coach.conversation.invalidTitle, coach.conversation.notFound, coach.fenced.attachmentConflict,
|
||||
coach.fenced.attachmentLimit, coach.fenced.attachmentNotFound, coach.fenced.attachmentNotIndexed,
|
||||
coach.fenced.attachmentRequired, coach.fenced.attachmentUnavailable, coach.fenced.invalid,
|
||||
coach.request.invalid; coach-prefs — coach-prefs.body.invalid_json, coach-prefs.body.invalid_shape;
|
||||
consent — consent.ai.required; cycle — cycle.bulk.invalid, cycle.bulk.too_large, cycle.calendar.invalid,
|
||||
cycle.calendar.range, cycle.cycles.invalid, cycle.day-log.conflict, cycle.day-log.invalid,
|
||||
cycle.disabled, cycle.period.invalid, cycle.symptom.custom.invalid, cycle.symptom.custom.limit;
|
||||
cycle-prefs — cycle-prefs.body.invalid_json, cycle-prefs.invalid; disable-coach —
|
||||
disable-coach.body.invalid_json; documents — documents.chat.conversationNotFound,
|
||||
documents.chat.invalid, documents.inbound.alreadyConfirmed, documents.inbound.alreadyPartlyConfirmed,
|
||||
documents.inbound.budgetExceeded, documents.inbound.decryptFailed, documents.inbound.encounterNotFound,
|
||||
documents.inbound.episodeNotFound, documents.inbound.extractFailed, documents.inbound.factNotFound,
|
||||
documents.inbound.factNotPending, documents.inbound.factTypeMismatch, documents.inbound.fileTooLarge,
|
||||
documents.inbound.fileType, documents.inbound.invalidMetadata, documents.inbound.invalidQuery,
|
||||
documents.inbound.invalidUpdate, documents.inbound.localOcrDisabled, documents.inbound.notFound,
|
||||
documents.inbound.notIndexed, documents.inbound.pdfNeedsAnthropic,
|
||||
documents.inbound.providerUnsupported, documents.inbound.quotaExceeded, documents.inbound.rateLimited,
|
||||
documents.inbound.restoreDuplicate, documents.inbound.restoreGone, documents.inbound.sourceAliasLimit,
|
||||
documents.inbound.uploadBusy, documents.inbound.uploadTimeout, documents.inbound.vaccinationNotFound,
|
||||
documents.sources.authRefused, documents.sources.badResponse, documents.sources.browserOnly,
|
||||
documents.sources.invalidAddress, documents.sources.linkTargetNotFound, documents.sources.notConnected,
|
||||
documents.sources.notFound, documents.sources.organizationRequired, documents.sources.originNotAllowed,
|
||||
backup.upload.too_large; biomarkers — biomarkers.unit.locked; body-site — body-site.invalid; coach —
|
||||
coach.budget.exceeded, coach.conversation.invalidTitle, coach.conversation.notFound,
|
||||
coach.fenced.attachmentConflict, coach.fenced.attachmentLimit, coach.fenced.attachmentNotFound,
|
||||
coach.fenced.attachmentNotIndexed, coach.fenced.attachmentRequired, coach.fenced.attachmentUnavailable,
|
||||
coach.fenced.invalid, coach.request.invalid; coach-prefs — coach-prefs.body.invalid_json,
|
||||
coach-prefs.body.invalid_shape; consent — consent.ai.required; cycle — cycle.bulk.invalid,
|
||||
cycle.bulk.too_large, cycle.calendar.invalid, cycle.calendar.range, cycle.cycles.invalid,
|
||||
cycle.day-log.conflict, cycle.day-log.invalid, cycle.disabled, cycle.period.invalid,
|
||||
cycle.symptom.custom.invalid, cycle.symptom.custom.limit; cycle-prefs — cycle-prefs.body.invalid_json,
|
||||
cycle-prefs.invalid; disable-coach — disable-coach.body.invalid_json; documents —
|
||||
documents.chat.conversationNotFound, documents.chat.invalid, documents.inbound.alreadyConfirmed,
|
||||
documents.inbound.alreadyPartlyConfirmed, documents.inbound.budgetExceeded,
|
||||
documents.inbound.decryptFailed, documents.inbound.encounterNotFound, documents.inbound.episodeNotFound,
|
||||
documents.inbound.extractFailed, documents.inbound.factNotFound, documents.inbound.factNotPending,
|
||||
documents.inbound.factTypeMismatch, documents.inbound.fileTooLarge, documents.inbound.fileType,
|
||||
documents.inbound.invalidMetadata, documents.inbound.invalidQuery, documents.inbound.invalidUpdate,
|
||||
documents.inbound.localOcrDisabled, documents.inbound.notFound, documents.inbound.notIndexed,
|
||||
documents.inbound.pdfNeedsAnthropic, documents.inbound.providerUnsupported,
|
||||
documents.inbound.quotaExceeded, documents.inbound.rateLimited, documents.inbound.restoreDuplicate,
|
||||
documents.inbound.restoreGone, documents.inbound.sourceAliasLimit, documents.inbound.uploadBusy,
|
||||
documents.inbound.uploadTimeout, documents.inbound.vaccinationNotFound, documents.sources.authRefused,
|
||||
documents.sources.badResponse, documents.sources.browserOnly, documents.sources.invalidAddress,
|
||||
documents.sources.linkTargetNotFound, documents.sources.notConnected, documents.sources.notFound,
|
||||
documents.sources.organizationRequired, documents.sources.originNotAllowed,
|
||||
documents.sources.permissionMissing, documents.sources.rateLimited, documents.sources.redirected,
|
||||
documents.sources.tokenRequired, documents.sources.unavailable, documents.sources.unreachable,
|
||||
documents.sources.versionTooOld; encounter — encounter.invalid, encounter.link.invalid,
|
||||
@@ -43350,11 +43355,14 @@ components:
|
||||
- inserted
|
||||
- duplicate
|
||||
- enriched
|
||||
- updated
|
||||
- skipped
|
||||
description: "`inserted`: a new workout row. `duplicate`: the workout was already stored and nothing was written.
|
||||
`enriched`: the workout was already stored and the heart-rate series this entry carried was attached to it;
|
||||
the workout row itself was not written, and the entry also counts towards the top-level `duplicates`.
|
||||
`skipped`: the server refused the entry, see `reason`."
|
||||
`updated` (since v1.40.0): a workout entered by hand (`source: MANUAL`, `externalId` starting `manual:`) was
|
||||
sent again with different values and the stored row now carries them; no row was inserted, so it also counts
|
||||
towards `duplicates`. `skipped`: the server refused the entry, see `reason`."
|
||||
reason:
|
||||
description: "Why a `skipped` entry was refused: `unstable_external_id` for an id that does not survive a client
|
||||
restart, `invalid_samples` for a heart-rate series that failed validation."
|
||||
@@ -71372,8 +71380,10 @@ components:
|
||||
anyOf:
|
||||
- type: string
|
||||
- type: "null"
|
||||
description: Explicit unit token, set only on the `sleep` card (`h`, because its value is a per-night total in hours
|
||||
rather than the canonical SLEEP_DURATION minutes). Null on every other kind.
|
||||
description: "Explicit unit token. When set it wins over `unitKey`. Set on the `sleep` card (`h`, because its value is a
|
||||
per-night total in hours rather than the canonical SLEEP_DURATION minutes) and, since v1.40.0, on the
|
||||
`glucose` card: the record owner's glucose unit (`mg/dL` or `mmol/L`), with `latestValue`, `secondaryValue`
|
||||
and `sparkline` converted to it (mmol/L to one decimal). Null on every other kind."
|
||||
sleepStages:
|
||||
anyOf:
|
||||
- type: object
|
||||
|
||||
+13
-1
@@ -135,6 +135,11 @@
|
||||
"required": "Bestätige dein aktuelles Passwort, um das zu ändern.",
|
||||
"failed": "Die Bestätigung ist fehlgeschlagen. Es wurde nichts geändert."
|
||||
}
|
||||
},
|
||||
"biomarkers": {
|
||||
"unit": {
|
||||
"locked": "Dieser Marker hat schon Werte, deshalb kannst du seine Einheit nicht ändern. Die gespeicherten Werte würden sonst in der neuen Einheit angezeigt, ohne umgerechnet zu werden."
|
||||
}
|
||||
}
|
||||
},
|
||||
"doctorReport": {
|
||||
@@ -805,7 +810,13 @@
|
||||
"unitRatio": "Verhältnis",
|
||||
"unitRating": "Stufe",
|
||||
"typeWho5Score": "WHO-5-Wert",
|
||||
"typeSciScore": "SCI-Wert"
|
||||
"typeSciScore": "SCI-Wert",
|
||||
"durationReadAs": "Gelesen als {duration}",
|
||||
"durationHoursMinutes": "{hours} Std. {minutes} Min.",
|
||||
"durationHoursOnly": "{hours} Std.",
|
||||
"durationMinutesOnly": "{minutes} Min.",
|
||||
"durationInvalid": "Gib eine Dauer ein, zum Beispiel 7,5, 7:30 oder 7h 30m.",
|
||||
"durationTooLong": "Ein Eintrag kann nicht länger als 24 Stunden sein."
|
||||
},
|
||||
"metricCatalog": {
|
||||
"title": "Was HealthLog erfassen kann",
|
||||
@@ -8980,6 +8991,7 @@
|
||||
"namePlaceholder": "z. B. LDL-Cholesterin",
|
||||
"unit": "Einheit",
|
||||
"unitPlaceholder": "z. B. mg/dl",
|
||||
"unitLockedHint": "Sobald dieser Marker Werte hat, bleibt seine Einheit, wie sie ist. Sonst würden die gespeicherten Werte in einer neuen Einheit angezeigt, ohne umgerechnet zu werden.",
|
||||
"panel": "Panel",
|
||||
"panelPlaceholder": "z. B. Lipide",
|
||||
"lowerBound": "Referenz unten",
|
||||
|
||||
+13
-1
@@ -135,6 +135,11 @@
|
||||
"required": "Confirm your current password to change this.",
|
||||
"failed": "That did not verify. Nothing was changed."
|
||||
}
|
||||
},
|
||||
"biomarkers": {
|
||||
"unit": {
|
||||
"locked": "This marker already has readings, so its unit can't be changed. The stored values would be shown in the new unit without being converted."
|
||||
}
|
||||
}
|
||||
},
|
||||
"doctorReport": {
|
||||
@@ -805,7 +810,13 @@
|
||||
"unitRatio": "ratio",
|
||||
"unitRating": "rating",
|
||||
"typeWho5Score": "WHO-5 score",
|
||||
"typeSciScore": "SCI score"
|
||||
"typeSciScore": "SCI score",
|
||||
"durationReadAs": "Read as {duration}",
|
||||
"durationHoursMinutes": "{hours} h {minutes} min",
|
||||
"durationHoursOnly": "{hours} h",
|
||||
"durationMinutesOnly": "{minutes} min",
|
||||
"durationInvalid": "Enter a duration like 7.5, 7:30 or 7h 30m.",
|
||||
"durationTooLong": "One entry can't be longer than 24 hours."
|
||||
},
|
||||
"metricCatalog": {
|
||||
"title": "What HealthLog can track",
|
||||
@@ -8980,6 +8991,7 @@
|
||||
"namePlaceholder": "e.g. LDL cholesterol",
|
||||
"unit": "Unit",
|
||||
"unitPlaceholder": "e.g. mg/dL",
|
||||
"unitLockedHint": "Once this marker has readings, its unit stays as it is. The stored values would otherwise be shown in a new unit without being converted.",
|
||||
"panel": "Panel",
|
||||
"panelPlaceholder": "e.g. Lipids",
|
||||
"lowerBound": "Reference low",
|
||||
|
||||
+13
-1
@@ -135,6 +135,11 @@
|
||||
"required": "Confirma tu contraseña actual para cambiar esto.",
|
||||
"failed": "La verificación ha fallado. No se ha cambiado nada."
|
||||
}
|
||||
},
|
||||
"biomarkers": {
|
||||
"unit": {
|
||||
"locked": "Este marcador ya tiene valores, así que no puedes cambiar su unidad. Los valores guardados se mostrarían en la nueva unidad sin convertirse."
|
||||
}
|
||||
}
|
||||
},
|
||||
"doctorReport": {
|
||||
@@ -805,7 +810,13 @@
|
||||
"unitRatio": "ratio",
|
||||
"unitRating": "nivel",
|
||||
"typeWho5Score": "Puntuación WHO-5",
|
||||
"typeSciScore": "Puntuación SCI"
|
||||
"typeSciScore": "Puntuación SCI",
|
||||
"durationReadAs": "Se lee como {duration}",
|
||||
"durationHoursMinutes": "{hours} h {minutes} min",
|
||||
"durationHoursOnly": "{hours} h",
|
||||
"durationMinutesOnly": "{minutes} min",
|
||||
"durationInvalid": "Escribe una duración como 7,5, 7:30 o 7h 30m.",
|
||||
"durationTooLong": "Una entrada no puede durar más de 24 horas."
|
||||
},
|
||||
"metricCatalog": {
|
||||
"title": "Lo que HealthLog puede registrar",
|
||||
@@ -8980,6 +8991,7 @@
|
||||
"namePlaceholder": "p. ej. Colesterol LDL",
|
||||
"unit": "Unidad",
|
||||
"unitPlaceholder": "p. ej. mg/dL",
|
||||
"unitLockedHint": "Cuando este marcador ya tiene valores, su unidad no cambia. De lo contrario, los valores guardados se mostrarían en una unidad nueva sin convertirse.",
|
||||
"panel": "Panel",
|
||||
"panelPlaceholder": "p. ej. Lípidos",
|
||||
"lowerBound": "Referencia mínima",
|
||||
|
||||
+13
-1
@@ -135,6 +135,11 @@
|
||||
"required": "Confirmez votre mot de passe actuel pour modifier ceci.",
|
||||
"failed": "La vérification a échoué. Rien n'a été modifié."
|
||||
}
|
||||
},
|
||||
"biomarkers": {
|
||||
"unit": {
|
||||
"locked": "Ce marqueur a déjà des valeurs, son unité ne peut donc pas être modifiée. Les valeurs enregistrées s'afficheraient dans la nouvelle unité sans être converties."
|
||||
}
|
||||
}
|
||||
},
|
||||
"doctorReport": {
|
||||
@@ -805,7 +810,13 @@
|
||||
"unitRatio": "ratio",
|
||||
"unitRating": "niveau",
|
||||
"typeWho5Score": "Score WHO-5",
|
||||
"typeSciScore": "Score SCI"
|
||||
"typeSciScore": "Score SCI",
|
||||
"durationReadAs": "Compris comme {duration}",
|
||||
"durationHoursMinutes": "{hours} h {minutes} min",
|
||||
"durationHoursOnly": "{hours} h",
|
||||
"durationMinutesOnly": "{minutes} min",
|
||||
"durationInvalid": "Saisis une durée comme 7,5, 7:30 ou 7h 30m.",
|
||||
"durationTooLong": "Une saisie ne peut pas dépasser 24 heures."
|
||||
},
|
||||
"metricCatalog": {
|
||||
"title": "Ce que HealthLog peut suivre",
|
||||
@@ -8980,6 +8991,7 @@
|
||||
"namePlaceholder": "ex. Cholestérol LDL",
|
||||
"unit": "Unité",
|
||||
"unitPlaceholder": "ex. mg/dL",
|
||||
"unitLockedHint": "Dès que ce marqueur a des valeurs, son unité reste la même. Sinon, les valeurs enregistrées s'afficheraient dans une nouvelle unité sans être converties.",
|
||||
"panel": "Panel",
|
||||
"panelPlaceholder": "ex. Lipides",
|
||||
"lowerBound": "Référence basse",
|
||||
|
||||
+13
-1
@@ -135,6 +135,11 @@
|
||||
"required": "Conferma la password attuale per modificarlo.",
|
||||
"failed": "La verifica non è riuscita. Non è stato modificato nulla."
|
||||
}
|
||||
},
|
||||
"biomarkers": {
|
||||
"unit": {
|
||||
"locked": "Questo marcatore ha già dei valori, quindi non puoi cambiarne l'unità. I valori salvati verrebbero mostrati nella nuova unità senza essere convertiti."
|
||||
}
|
||||
}
|
||||
},
|
||||
"doctorReport": {
|
||||
@@ -805,7 +810,13 @@
|
||||
"unitRatio": "rapporto",
|
||||
"unitRating": "livello",
|
||||
"typeWho5Score": "Punteggio WHO-5",
|
||||
"typeSciScore": "Punteggio SCI"
|
||||
"typeSciScore": "Punteggio SCI",
|
||||
"durationReadAs": "Letto come {duration}",
|
||||
"durationHoursMinutes": "{hours} h {minutes} min",
|
||||
"durationHoursOnly": "{hours} h",
|
||||
"durationMinutesOnly": "{minutes} min",
|
||||
"durationInvalid": "Inserisci una durata come 7,5, 7:30 o 7h 30m.",
|
||||
"durationTooLong": "Una voce non può superare le 24 ore."
|
||||
},
|
||||
"metricCatalog": {
|
||||
"title": "Cosa può monitorare HealthLog",
|
||||
@@ -8980,6 +8991,7 @@
|
||||
"namePlaceholder": "es. Colesterolo LDL",
|
||||
"unit": "Unità",
|
||||
"unitPlaceholder": "es. mg/dL",
|
||||
"unitLockedHint": "Quando questo marcatore ha già dei valori, la sua unità resta invariata. Altrimenti i valori salvati verrebbero mostrati in una nuova unità senza essere convertiti.",
|
||||
"panel": "Panel",
|
||||
"panelPlaceholder": "es. Lipidi",
|
||||
"lowerBound": "Riferimento minimo",
|
||||
|
||||
+13
-1
@@ -135,6 +135,11 @@
|
||||
"required": "변경하려면 현재 비밀번호를 확인하세요.",
|
||||
"failed": "확인에 실패했습니다. 아무것도 변경되지 않았습니다."
|
||||
}
|
||||
},
|
||||
"biomarkers": {
|
||||
"unit": {
|
||||
"locked": "이 마커에는 이미 기록된 값이 있어서 단위를 바꿀 수 없어요. 저장된 값이 변환되지 않은 채 새 단위로 표시되기 때문이에요."
|
||||
}
|
||||
}
|
||||
},
|
||||
"doctorReport": {
|
||||
@@ -805,7 +810,13 @@
|
||||
"unitRatio": "비율",
|
||||
"unitRating": "단계",
|
||||
"typeWho5Score": "WHO-5 점수",
|
||||
"typeSciScore": "SCI 점수"
|
||||
"typeSciScore": "SCI 점수",
|
||||
"durationReadAs": "{duration}(으)로 읽었어요",
|
||||
"durationHoursMinutes": "{hours}시간 {minutes}분",
|
||||
"durationHoursOnly": "{hours}시간",
|
||||
"durationMinutesOnly": "{minutes}분",
|
||||
"durationInvalid": "7.5, 7:30, 7h 30m처럼 시간을 입력해 주세요.",
|
||||
"durationTooLong": "한 번에 24시간보다 길게 입력할 수 없어요."
|
||||
},
|
||||
"metricCatalog": {
|
||||
"title": "HealthLog가 기록할 수 있는 것",
|
||||
@@ -8980,6 +8991,7 @@
|
||||
"namePlaceholder": "예: LDL 콜레스테롤",
|
||||
"unit": "단위",
|
||||
"unitPlaceholder": "예: mg/dL",
|
||||
"unitLockedHint": "이 마커에 기록된 값이 있으면 단위는 그대로 유지돼요. 그렇지 않으면 저장된 값이 변환되지 않은 채 새 단위로 표시돼요.",
|
||||
"panel": "패널",
|
||||
"panelPlaceholder": "예: 지질",
|
||||
"lowerBound": "참고 하한",
|
||||
|
||||
+13
-1
@@ -135,6 +135,11 @@
|
||||
"required": "Potwierdź obecne hasło, aby to zmienić.",
|
||||
"failed": "Weryfikacja nie powiodła się. Nic nie zostało zmienione."
|
||||
}
|
||||
},
|
||||
"biomarkers": {
|
||||
"unit": {
|
||||
"locked": "Ten marker ma już wyniki, więc nie możesz zmienić jego jednostki. Zapisane wartości byłyby pokazywane w nowej jednostce bez przeliczenia."
|
||||
}
|
||||
}
|
||||
},
|
||||
"doctorReport": {
|
||||
@@ -805,7 +810,13 @@
|
||||
"unitRatio": "stosunek",
|
||||
"unitRating": "poziom",
|
||||
"typeWho5Score": "Wynik WHO-5",
|
||||
"typeSciScore": "Wynik SCI"
|
||||
"typeSciScore": "Wynik SCI",
|
||||
"durationReadAs": "Odczytano jako {duration}",
|
||||
"durationHoursMinutes": "{hours} godz. {minutes} min",
|
||||
"durationHoursOnly": "{hours} godz.",
|
||||
"durationMinutesOnly": "{minutes} min",
|
||||
"durationInvalid": "Wpisz czas trwania, np. 7,5, 7:30 lub 7h 30m.",
|
||||
"durationTooLong": "Jeden wpis nie może przekraczać 24 godzin."
|
||||
},
|
||||
"metricCatalog": {
|
||||
"title": "Co HealthLog potrafi śledzić",
|
||||
@@ -8980,6 +8991,7 @@
|
||||
"namePlaceholder": "np. Cholesterol LDL",
|
||||
"unit": "Jednostka",
|
||||
"unitPlaceholder": "np. mg/dl",
|
||||
"unitLockedHint": "Gdy ten marker ma już wyniki, jego jednostka pozostaje bez zmian. W przeciwnym razie zapisane wartości byłyby pokazywane w nowej jednostce bez przeliczenia.",
|
||||
"panel": "Panel",
|
||||
"panelPlaceholder": "np. Lipidy",
|
||||
"lowerBound": "Dolna granica",
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
-- The per-workout insight claim is the ledger behind the daily insight cap.
|
||||
-- It used to cascade with its workout, so deleting a workout freed the day's
|
||||
-- slot and a delete-and-re-add loop could buy more paragraphs than the cap
|
||||
-- allows. The claim now outlives the workout with a null `workout_id`; a
|
||||
-- daily retention job drops rows older than a week. Idempotent: a re-run
|
||||
-- finds the column already nullable and the constraint already re-created.
|
||||
ALTER TABLE "workout_insight_generation_claims"
|
||||
ALTER COLUMN "workout_id" DROP NOT NULL;
|
||||
|
||||
ALTER TABLE "workout_insight_generation_claims"
|
||||
DROP CONSTRAINT IF EXISTS "workout_insight_generation_claims_workout_id_fkey";
|
||||
|
||||
ALTER TABLE "workout_insight_generation_claims"
|
||||
ADD CONSTRAINT "workout_insight_generation_claims_workout_id_fkey"
|
||||
FOREIGN KEY ("workout_id") REFERENCES "workouts"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
@@ -2410,7 +2410,10 @@ model WorkoutInsight {
|
||||
model WorkoutInsightGenerationClaim {
|
||||
id String @id @default(cuid())
|
||||
userId String @map("user_id")
|
||||
workoutId String @unique @map("workout_id")
|
||||
/// Null once the workout is deleted. The row stays as the day's ledger so
|
||||
/// the delete cannot free an insight slot; the retention job drops it after
|
||||
/// a week.
|
||||
workoutId String? @unique @map("workout_id")
|
||||
localDate String @map("local_date")
|
||||
claimId String? @unique @map("claim_id")
|
||||
claimedAt DateTime? @map("claimed_at")
|
||||
@@ -2419,8 +2422,8 @@ model WorkoutInsightGenerationClaim {
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
workout Workout @relation(fields: [workoutId], references: [id], onDelete: Cascade)
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
workout Workout? @relation(fields: [workoutId], references: [id], onDelete: SetNull)
|
||||
|
||||
@@index([userId, localDate])
|
||||
@@map("workout_insight_generation_claims")
|
||||
|
||||
@@ -99,6 +99,8 @@ const LEGIT_IDENTICAL = new Set<string>([
|
||||
"labs.catalog.hs-crp",
|
||||
"measurements.typeHrvRmssd",
|
||||
"measurements.typeVo2Max",
|
||||
// "{hours} h {minutes} min": the SI symbols for hour and minute.
|
||||
"measurements.durationHoursMinutes",
|
||||
"settings.sections.sources.metrics.vo2Max",
|
||||
// Brand / product / proper nouns.
|
||||
"settings.googleHealth",
|
||||
|
||||
@@ -183,3 +183,71 @@ describe("date formatters name the calendar they render in", () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* A stated calendar date must not go through a zone-aware short formatter.
|
||||
*
|
||||
* A day key is anchored at noon UTC (`${key}T12:00:00Z`) so that it is the
|
||||
* same calendar date everywhere, and then `dateShortSmart` read it in the
|
||||
* profile zone and showed the NEXT day in UTC+12..+14. The calendar sibling,
|
||||
* `dateShortSmartCalendar`, reads the key in UTC. This fails on any
|
||||
* `dateShortSmart(`/`dateShort(`/`date(` call whose argument builds a noon
|
||||
* anchor, and on the old local-noon form (`T12:00:00` with no zone), which a
|
||||
* browser reads in its own zone.
|
||||
*
|
||||
* A file whose `fmt` comes from `makeBucketLabelFormatters` is skipped: that
|
||||
* formatter set is pinned to UTC, so a noon anchor is already its calendar
|
||||
* date there.
|
||||
*/
|
||||
describe("a stated date is formatted on its calendar date", () => {
|
||||
const ZONED_CALL = /\bfmt\.(?:dateShortSmart|dateShort|date)\s*\(/g;
|
||||
const CALENDAR_CALL = /\bfmt\.dateShortSmartCalendar\s*\(/g;
|
||||
const NOON_ANCHOR = /T12:00:00/;
|
||||
|
||||
function scan() {
|
||||
const files = execFileSync(
|
||||
"grep",
|
||||
["-rlE", "fmt\\.date", "src", "--include=*.ts", "--include=*.tsx"],
|
||||
{ cwd: REPO_ROOT, encoding: "utf8" },
|
||||
)
|
||||
.trim()
|
||||
.split("\n")
|
||||
.filter(Boolean)
|
||||
.filter((f) => !f.includes("__tests__"));
|
||||
const offenders: string[] = [];
|
||||
let calendarCalls = 0;
|
||||
for (const file of files) {
|
||||
const source = readFileSync(resolve(REPO_ROOT, file), "utf8");
|
||||
calendarCalls += (source.match(CALENDAR_CALL) ?? []).length;
|
||||
if (/const fmt = makeBucketLabelFormatters\(/.test(source)) continue;
|
||||
ZONED_CALL.lastIndex = 0;
|
||||
for (let m = ZONED_CALL.exec(source); m; m = ZONED_CALL.exec(source)) {
|
||||
const args = argumentList(source, m.index + m[0].length - 1);
|
||||
if (NOON_ANCHOR.test(args)) {
|
||||
offenders.push(
|
||||
`${file}:${source.slice(0, m.index).split("\n").length}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
return { offenders, calendarCalls };
|
||||
}
|
||||
|
||||
it("finds the calendar formatter in use (the matcher still matches)", () => {
|
||||
expect(scan().calendarCalls).toBeGreaterThanOrEqual(6);
|
||||
});
|
||||
|
||||
it("never hands a noon anchor to a zone-aware formatter", () => {
|
||||
expect(scan().offenders).toEqual([]);
|
||||
});
|
||||
|
||||
it("the matcher flags the shape it exists for", () => {
|
||||
const source = "fmt.dateShortSmart(`${d}T12:00:00Z`)";
|
||||
ZONED_CALL.lastIndex = 0;
|
||||
const m = ZONED_CALL.exec(source);
|
||||
expect(m).not.toBeNull();
|
||||
expect(
|
||||
NOON_ANCHOR.test(argumentList(source, m!.index + m![0].length - 1)),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -143,6 +143,28 @@ export const PUT = apiHandler(
|
||||
}
|
||||
}
|
||||
|
||||
// A marker's unit labels every reading linked to it: readers resolve the
|
||||
// unit from the marker, and the stored numbers are in the old unit. A
|
||||
// change while readings exist would relabel history without converting a
|
||||
// single value, so it is refused. Tombstoned readings count too, since an
|
||||
// undo brings them back under the new label.
|
||||
if (d.unit !== undefined && d.unit !== existing.unit) {
|
||||
const readingCount = await prisma.labResult.count({
|
||||
where: { userId: user.id, biomarkerId: id },
|
||||
});
|
||||
if (readingCount > 0) {
|
||||
annotate({
|
||||
action: { name: "labs.biomarker.update.unit-locked" },
|
||||
meta: { biomarkerId: id, readingCount },
|
||||
});
|
||||
return apiError(
|
||||
"The unit of a biomarker with readings cannot be changed",
|
||||
422,
|
||||
{ errorCode: "biomarkers.unit.locked", readingCount },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Build `data` field-by-field. `undefined` → leave the column untouched;
|
||||
// an explicit `null` on `context` / `panel` / a bound clears it.
|
||||
const data: Record<string, unknown> = {};
|
||||
|
||||
@@ -561,6 +561,32 @@ describe("GET /api/dashboard/summary", () => {
|
||||
expect(glucose?.lastSeenAt).toBe(tenDaysAgo.toISOString());
|
||||
});
|
||||
|
||||
it("names the owner's glucose unit on the glucose card", async () => {
|
||||
// Applied after the summary cache: the unit is the record's own choice
|
||||
// and the card's numbers are in it (the conversion is pinned in
|
||||
// `src/lib/dashboard/__tests__/glucose-card.test.ts`).
|
||||
vi.mocked(getSession).mockResolvedValue({
|
||||
...SESSION_OK,
|
||||
user: { ...SESSION_OK.user, glucoseUnit: "mmol/L" },
|
||||
} as never);
|
||||
vi.mocked(prisma.measurement.groupBy).mockResolvedValue([
|
||||
{
|
||||
type: "BLOOD_GLUCOSE",
|
||||
_count: { _all: 4 },
|
||||
_max: { measuredAt: new Date(Date.now() - 10 * 86_400_000) },
|
||||
},
|
||||
] as never);
|
||||
const res = await callGet(makeReq());
|
||||
const body = (await res.json()) as {
|
||||
data: { metrics: Array<{ id: string; unit: string | null }> };
|
||||
};
|
||||
expect(body.data.metrics.find((m) => m.id === "glucose")?.unit).toBe(
|
||||
"mmol/L",
|
||||
);
|
||||
// Every other card keeps its own unit contract.
|
||||
expect(body.data.metrics.find((m) => m.id === "weight")?.unit).toBeNull();
|
||||
});
|
||||
|
||||
it("emits the sleep tile as the night TIME-ASLEEP total in hours, not one stage (v1.11.4)", async () => {
|
||||
// SLEEP_DURATION is stored one row per STAGE per night (minutes). The
|
||||
// tile must SUM the asleep stages of the latest night and convert to
|
||||
|
||||
@@ -75,6 +75,8 @@ import {
|
||||
} from "@/lib/dashboard/summary-reads";
|
||||
import { resolveModuleMap } from "@/lib/modules/gate";
|
||||
import { gateMetricCardsByModules } from "@/lib/dashboard/widget-modules";
|
||||
import { glucoseCardInDisplayUnit } from "@/lib/dashboard/glucose-card";
|
||||
import { resolveGlucoseUnit } from "@/lib/glucose";
|
||||
import {
|
||||
summarizeSleepNights,
|
||||
reconstructSleepNights,
|
||||
@@ -144,6 +146,9 @@ interface MetricCard {
|
||||
* `latestValue` is a per-NIGHT total expressed in HOURS (a float),
|
||||
* not the canonical `SLEEP_DURATION` minutes — see the sleep block
|
||||
* below for why the night total replaced the single-stage value.
|
||||
* v1.40.0 — the `glucose` tile sets it to the owner's glucose unit
|
||||
* (`"mg/dL"` or `"mmol/L"`), with its values converted to match
|
||||
* (`glucoseCardInDisplayUnit`, applied after the cache read).
|
||||
*/
|
||||
unit: string | null;
|
||||
/**
|
||||
@@ -325,7 +330,10 @@ export const GET = apiHandler(async () => {
|
||||
// it off. Filtering the cached body makes the toggle effective on the very
|
||||
// next request.
|
||||
const modules = await resolveModuleMap(user.id);
|
||||
const metrics = gateMetricCardsByModules(body.metrics, modules);
|
||||
const glucoseUnit = resolveGlucoseUnit(user.glucoseUnit);
|
||||
const metrics = gateMetricCardsByModules(body.metrics, modules).map((card) =>
|
||||
glucoseCardInDisplayUnit(card, glucoseUnit),
|
||||
);
|
||||
annotate({
|
||||
meta: {
|
||||
metrics_emitted: metrics.length,
|
||||
|
||||
@@ -61,8 +61,14 @@ vi.mock("@/lib/logging/context", () => ({
|
||||
annotate: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/ai/provider-runner", () => ({
|
||||
getLastWorkingProvider: vi.fn(),
|
||||
forgetLastWorkingProvider: vi.fn(),
|
||||
}));
|
||||
|
||||
import { PUT } from "../route";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { forgetLastWorkingProvider } from "@/lib/ai/provider-runner";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
|
||||
interface Envelope {
|
||||
@@ -108,6 +114,9 @@ describe("PUT /api/insights/provider-chain", () => {
|
||||
],
|
||||
});
|
||||
expect(annotate).toHaveBeenCalled();
|
||||
// The remembered working provider would reorder the chain the person
|
||||
// just arranged; a save drops it for this account.
|
||||
expect(forgetLastWorkingProvider).toHaveBeenCalledWith("u-1");
|
||||
});
|
||||
|
||||
it("rejects unknown provider types with 422", async () => {
|
||||
|
||||
@@ -30,6 +30,7 @@ vi.mock("@/lib/ai/provider", () => ({
|
||||
|
||||
vi.mock("@/lib/ai/provider-runner", () => ({
|
||||
getLastWorkingProvider: vi.fn(),
|
||||
forgetLastWorkingProvider: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/logging/context", () => ({
|
||||
|
||||
@@ -10,7 +10,10 @@ import {
|
||||
} from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { resolveProviderChain } from "@/lib/ai/provider";
|
||||
import { getLastWorkingProvider } from "@/lib/ai/provider-runner";
|
||||
import {
|
||||
forgetLastWorkingProvider,
|
||||
getLastWorkingProvider,
|
||||
} from "@/lib/ai/provider-runner";
|
||||
import { prisma } from "@/lib/db";
|
||||
import {
|
||||
parseProviderChain,
|
||||
@@ -149,6 +152,7 @@ export const PUT = apiHandler(async (request: NextRequest) => {
|
||||
where: { id: user.id },
|
||||
data: { aiProviderChain: normalised },
|
||||
});
|
||||
forgetLastWorkingProvider(user.id);
|
||||
|
||||
annotate({
|
||||
meta: {
|
||||
|
||||
@@ -36,6 +36,10 @@ import { annotate } from "@/lib/logging/context";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { invalidateUserMeasurements } from "@/lib/cache/invalidate";
|
||||
import { enqueuePrDetection } from "@/lib/jobs/pr-detection";
|
||||
import {
|
||||
lockPersonalRecordsForUser,
|
||||
workoutPrSlotsForSport,
|
||||
} from "@/lib/personal-records/pr-detection-worker";
|
||||
import { pickCanonicalWorkoutRows } from "@/lib/measurements/pick-canonical-workout-rows";
|
||||
import { requireModuleEnabled } from "@/lib/modules/gate";
|
||||
import { aiCapabilityToServe } from "@/lib/ai/capabilities/gate";
|
||||
@@ -380,7 +384,9 @@ export const GET = apiHandler(
|
||||
* (`module-route-gate-inventory.test.ts`).
|
||||
*
|
||||
* The workout table carries no tombstone, so the row is removed outright;
|
||||
* route, samples, insight and claim rows cascade with it. A personal record
|
||||
* route, samples and insight rows cascade with it. The insight's generation
|
||||
* claim stays behind with a null workout id: it is the day's cap ledger, and
|
||||
* a delete must not hand the slot back. A personal record
|
||||
* the workout set would otherwise outlive it (the detector only ever raises
|
||||
* a stored best), so the records keyed on this workout go in the same
|
||||
* transaction and a silent detection pass re-derives the honest best.
|
||||
@@ -421,14 +427,25 @@ export const DELETE = apiHandler(
|
||||
}
|
||||
|
||||
const removedRecords = await prisma.$transaction(async (tx) => {
|
||||
// The detector reads the best workout and writes its record under this
|
||||
// same lock, so it either wrote before this delete (and the record goes
|
||||
// below) or reads after it (and never sees the workout).
|
||||
await lockPersonalRecordsForUser(tx, user.id);
|
||||
const records = await tx.personalRecord.deleteMany({
|
||||
where: {
|
||||
userId: user.id,
|
||||
metricSlot: { not: null },
|
||||
source: "MANUAL",
|
||||
...(row.externalId !== null
|
||||
? { externalId: row.externalId }
|
||||
: { externalId: null, achievedAt: row.startedAt }),
|
||||
? { metricSlot: { not: null }, externalId: row.externalId }
|
||||
: {
|
||||
// A legacy record carries no external id, so the start time
|
||||
// is all that ties it to this workout. Scope it to the slots
|
||||
// of this workout's sport so a record another sport set at
|
||||
// the same instant stays.
|
||||
metricSlot: { in: workoutPrSlotsForSport(row.sportType) },
|
||||
externalId: null,
|
||||
achievedAt: row.startedAt,
|
||||
}),
|
||||
},
|
||||
});
|
||||
await tx.workout.delete({ where: { id: row.id } });
|
||||
|
||||
@@ -13,6 +13,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
const tx = {
|
||||
$queryRaw: vi.fn(async () => []),
|
||||
personalRecord: { deleteMany: vi.fn() },
|
||||
workout: { delete: vi.fn() },
|
||||
};
|
||||
@@ -129,8 +130,16 @@ describe("DELETE /api/workouts/{id}", () => {
|
||||
expect(tx.personalRecord.deleteMany).toHaveBeenCalledWith({
|
||||
where: {
|
||||
userId: "user-1",
|
||||
metricSlot: { not: null },
|
||||
source: "MANUAL",
|
||||
// Only the running slots: a cycling record set at the same instant
|
||||
// is not this workout's.
|
||||
metricSlot: {
|
||||
in: [
|
||||
"longest_run_duration",
|
||||
"longest_distance_run",
|
||||
"fastest_5km_time",
|
||||
],
|
||||
},
|
||||
externalId: null,
|
||||
achievedAt: MANUAL_ROW.startedAt,
|
||||
},
|
||||
@@ -139,6 +148,21 @@ describe("DELETE /api/workouts/{id}", () => {
|
||||
expect(enqueuePrDetection).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("takes the personal-records lock before it deletes anything", async () => {
|
||||
vi.mocked(prisma.workout.findUnique).mockResolvedValue(MANUAL_ROW as never);
|
||||
|
||||
await del();
|
||||
|
||||
expect(tx.$queryRaw).toHaveBeenCalledTimes(1);
|
||||
const lockOrder = tx.$queryRaw.mock.invocationCallOrder[0];
|
||||
expect(lockOrder).toBeLessThan(
|
||||
tx.personalRecord.deleteMany.mock.invocationCallOrder[0],
|
||||
);
|
||||
expect(lockOrder).toBeLessThan(
|
||||
tx.workout.delete.mock.invocationCallOrder[0],
|
||||
);
|
||||
});
|
||||
|
||||
it.each(["APPLE_HEALTH", "WITHINGS", "WHOOP", "EXTERNAL"])(
|
||||
"refuses a %s workout with 409 and touches nothing",
|
||||
async (source) => {
|
||||
|
||||
@@ -77,6 +77,7 @@ import {
|
||||
import { withIdempotency } from "@/lib/idempotency";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { enqueuePrDetection } from "@/lib/jobs/pr-detection";
|
||||
import { lockPersonalRecordsForUser } from "@/lib/personal-records/pr-detection-worker";
|
||||
import { invalidateUserMeasurements } from "@/lib/cache/invalidate";
|
||||
import { emitDataArrival } from "@/lib/arrivals/emit-shared";
|
||||
import { MAX_WORKOUTS_PER_BATCH } from "@/lib/validations/workout";
|
||||
@@ -129,8 +130,63 @@ const MAX_BODY_BYTES = 5 * 1024 * 1024;
|
||||
* to a workout that had none, which is what lets a history sweep count
|
||||
* its own progress. `skipped` covers entries the server refused: an
|
||||
* unusable external id, or a `samples` array that failed validation.
|
||||
* `updated` says a hand-entered workout (`manual:` id, source MANUAL) was
|
||||
* sent again with different values and the stored row now carries them.
|
||||
*/
|
||||
type EntryStatus = "inserted" | "duplicate" | "enriched" | "skipped";
|
||||
type EntryStatus =
|
||||
"inserted" | "duplicate" | "enriched" | "updated" | "skipped";
|
||||
|
||||
/** The workout columns a hand-entered workout's resubmit may overwrite. */
|
||||
const MANUAL_OVERWRITE_FIELDS = [
|
||||
"sportType",
|
||||
"startedAt",
|
||||
"endedAt",
|
||||
"durationSec",
|
||||
"totalEnergyKcal",
|
||||
"totalDistanceM",
|
||||
"avgHeartRate",
|
||||
"maxHeartRate",
|
||||
"minHeartRate",
|
||||
"stepCount",
|
||||
"elevationM",
|
||||
"pauseDurationSec",
|
||||
] as const;
|
||||
type ManualOverwriteField = (typeof MANUAL_OVERWRITE_FIELDS)[number];
|
||||
type ManualOverwriteValues = {
|
||||
[K in ManualOverwriteField]: string | number | Date | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* A workout entered by hand carries one id per form opening, and the form
|
||||
* resends it with the edited values when the person corrects a submit. Such a
|
||||
* resubmit is an edit of the stored row, the way a `stats:` measurement
|
||||
* re-post is. Every other id stays first-write-wins.
|
||||
*/
|
||||
function isManualOverwrite(source: unknown, externalId: string | null) {
|
||||
return (
|
||||
source === "MANUAL" &&
|
||||
externalId !== null &&
|
||||
externalId.startsWith("manual:")
|
||||
);
|
||||
}
|
||||
|
||||
/** The changed columns of a manual resubmit, or null when nothing differs. */
|
||||
function manualOverwriteChanges(
|
||||
stored: ManualOverwriteValues,
|
||||
sent: Prisma.WorkoutCreateManyInput,
|
||||
): Partial<ManualOverwriteValues> | null {
|
||||
const changes: Partial<ManualOverwriteValues> = {};
|
||||
for (const field of MANUAL_OVERWRITE_FIELDS) {
|
||||
const next = (sent[field] ?? null) as string | number | Date | null;
|
||||
const prev = stored[field];
|
||||
const same =
|
||||
next instanceof Date && prev instanceof Date
|
||||
? next.getTime() === prev.getTime()
|
||||
: next === prev;
|
||||
if (!same) changes[field] = next;
|
||||
}
|
||||
return Object.keys(changes).length > 0 ? changes : null;
|
||||
}
|
||||
interface EntryResult {
|
||||
index: number;
|
||||
status: EntryStatus;
|
||||
@@ -483,6 +539,7 @@ async function postBatch(request: NextRequest): Promise<Response> {
|
||||
let duplicateCount = droppedByWriteDedup.length;
|
||||
let insertedCount = 0;
|
||||
let enrichedCount = 0;
|
||||
let updatedCount = 0;
|
||||
const insertedIdByIndex = new Map<number, string>();
|
||||
|
||||
if (survivors.length > 0) {
|
||||
@@ -510,16 +567,45 @@ async function postBatch(request: NextRequest): Promise<Response> {
|
||||
source: true,
|
||||
externalId: true,
|
||||
samples: { select: { workoutId: true } },
|
||||
sportType: true,
|
||||
startedAt: true,
|
||||
endedAt: true,
|
||||
durationSec: true,
|
||||
totalEnergyKcal: true,
|
||||
totalDistanceM: true,
|
||||
avgHeartRate: true,
|
||||
maxHeartRate: true,
|
||||
minHeartRate: true,
|
||||
stepCount: true,
|
||||
elevationM: true,
|
||||
pauseDurationSec: true,
|
||||
},
|
||||
})
|
||||
: [];
|
||||
|
||||
const existingByKey = new Map<string, { id: string; hasSeries: boolean }>();
|
||||
const existingByKey = new Map<
|
||||
string,
|
||||
{ id: string; hasSeries: boolean; values: ManualOverwriteValues }
|
||||
>();
|
||||
for (const row of existing) {
|
||||
if (row.externalId === null) continue;
|
||||
existingByKey.set(`${row.source}::${row.externalId}`, {
|
||||
id: row.id,
|
||||
hasSeries: row.samples !== null,
|
||||
values: {
|
||||
sportType: row.sportType,
|
||||
startedAt: row.startedAt,
|
||||
endedAt: row.endedAt,
|
||||
durationSec: row.durationSec,
|
||||
totalEnergyKcal: row.totalEnergyKcal,
|
||||
totalDistanceM: row.totalDistanceM,
|
||||
avgHeartRate: row.avgHeartRate,
|
||||
maxHeartRate: row.maxHeartRate,
|
||||
minHeartRate: row.minHeartRate,
|
||||
stepCount: row.stepCount,
|
||||
elevationM: row.elevationM,
|
||||
pauseDurationSec: row.pauseDurationSec,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -530,6 +616,13 @@ async function postBatch(request: NextRequest): Promise<Response> {
|
||||
workoutId: string;
|
||||
samples: NonNullable<Prepared["samples"]>;
|
||||
}> = [];
|
||||
/** Hand-entered workouts this batch edits in place. */
|
||||
const toOverwrite: Array<{
|
||||
index: number;
|
||||
workoutId: string;
|
||||
externalId: string;
|
||||
changes: Partial<ManualOverwriteValues>;
|
||||
}> = [];
|
||||
/** Workouts already claimed by an earlier entry of THIS batch. */
|
||||
const enrichClaimed = new Set<string>();
|
||||
for (const p of survivors) {
|
||||
@@ -537,7 +630,27 @@ async function postBatch(request: NextRequest): Promise<Response> {
|
||||
? `${p.dedupKey.source}::${p.dedupKey.externalId}`
|
||||
: null;
|
||||
const known = keyTuple !== null ? existingByKey.get(keyTuple) : undefined;
|
||||
if (known) {
|
||||
if (
|
||||
known &&
|
||||
p.dedupKey &&
|
||||
isManualOverwrite(p.dedupKey.source, p.dedupKey.externalId)
|
||||
) {
|
||||
const changes = enrichClaimed.has(known.id)
|
||||
? null
|
||||
: manualOverwriteChanges(known.values, p.row);
|
||||
if (changes) {
|
||||
enrichClaimed.add(known.id);
|
||||
toOverwrite.push({
|
||||
index: p.index,
|
||||
workoutId: known.id,
|
||||
externalId: p.dedupKey.externalId,
|
||||
changes,
|
||||
});
|
||||
} else {
|
||||
results[p.index] = { index: p.index, status: "duplicate" };
|
||||
duplicateCount += 1;
|
||||
}
|
||||
} else if (known) {
|
||||
if (p.samples && !known.hasSeries && !enrichClaimed.has(known.id)) {
|
||||
enrichClaimed.add(known.id);
|
||||
// Enrichment. The status is assigned after the write, because
|
||||
@@ -560,12 +673,45 @@ async function postBatch(request: NextRequest): Promise<Response> {
|
||||
}
|
||||
}
|
||||
|
||||
if (toInsert.length > 0 || toEnrich.length > 0) {
|
||||
if (toInsert.length > 0 || toEnrich.length > 0 || toOverwrite.length > 0) {
|
||||
const CHUNK = 100;
|
||||
/** Workout ids whose series row this request actually wrote. */
|
||||
const seriesLanded = new Set<string>();
|
||||
/** Batch indices whose manual edit this request actually wrote. */
|
||||
const overwritten = new Set<number>();
|
||||
|
||||
await prisma.$transaction(async (tx) => {
|
||||
// A hand-entered workout sent again with other values. Columns are
|
||||
// copied one by one from the validated entry; the records the old
|
||||
// values set go with them and the detection pass below re-derives
|
||||
// the honest best, as the DELETE does.
|
||||
if (toOverwrite.length > 0) {
|
||||
await lockPersonalRecordsForUser(tx, user.id);
|
||||
}
|
||||
for (const o of toOverwrite) {
|
||||
const data: Prisma.WorkoutUpdateManyMutationInput = {};
|
||||
for (const field of MANUAL_OVERWRITE_FIELDS) {
|
||||
if (field in o.changes) {
|
||||
(data as Record<string, unknown>)[field] = o.changes[field];
|
||||
}
|
||||
}
|
||||
const written = await tx.workout.updateMany({
|
||||
where: { id: o.workoutId, userId: user.id, source: "MANUAL" },
|
||||
data,
|
||||
});
|
||||
if (written.count === 1) {
|
||||
overwritten.add(o.index);
|
||||
await tx.personalRecord.deleteMany({
|
||||
where: {
|
||||
userId: user.id,
|
||||
source: "MANUAL",
|
||||
metricSlot: { not: null },
|
||||
externalId: o.externalId,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const withExternalId = toInsert.filter((p) => p.dedupKey !== null);
|
||||
const withoutExternalId = toInsert.filter((p) => p.dedupKey === null);
|
||||
|
||||
@@ -686,6 +832,18 @@ async function postBatch(request: NextRequest): Promise<Response> {
|
||||
if (seriesLanded.has(e.workoutId)) enrichedCount += 1;
|
||||
duplicateCount += 1;
|
||||
}
|
||||
|
||||
// Like an enrichment, an edit inserts no row, so it counts towards
|
||||
// `duplicates`; the per-entry status says the stored row changed.
|
||||
for (const o of toOverwrite) {
|
||||
const landed = overwritten.has(o.index);
|
||||
results[o.index] = {
|
||||
index: o.index,
|
||||
status: landed ? "updated" : "duplicate",
|
||||
};
|
||||
if (landed) updatedCount += 1;
|
||||
duplicateCount += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -701,6 +859,7 @@ async function postBatch(request: NextRequest): Promise<Response> {
|
||||
inserted: insertedCount,
|
||||
duplicates: duplicateCount,
|
||||
enriched: enrichedCount,
|
||||
updated: updatedCount,
|
||||
skipped: skipped.length,
|
||||
},
|
||||
});
|
||||
@@ -739,6 +898,7 @@ async function postBatch(request: NextRequest): Promise<Response> {
|
||||
inserted: insertedCount,
|
||||
duplicates: duplicateCount,
|
||||
enriched: enrichedCount,
|
||||
updated: updatedCount,
|
||||
skipped: skipped.length,
|
||||
},
|
||||
});
|
||||
@@ -750,7 +910,7 @@ async function postBatch(request: NextRequest): Promise<Response> {
|
||||
// An enrichment changes what the workout reads as — the detail seam
|
||||
// gains its curve, the list gains its glyph — so it busts the cache
|
||||
// too, even though no workout row was written.
|
||||
if (insertedCount > 0 || enrichedCount > 0) {
|
||||
if (insertedCount > 0 || enrichedCount > 0 || updatedCount > 0) {
|
||||
invalidateUserMeasurements(user.id);
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,10 @@
|
||||
|
||||
import { chartSeriesParams } from "@/components/charts/chart-series-request";
|
||||
import { DEFAULT_TIMEZONE } from "@/lib/tz/format";
|
||||
import {
|
||||
localDayKeyFor,
|
||||
openEndedFetchWindow,
|
||||
} from "@/lib/charts/fetch-window";
|
||||
import { keepPreviousData, useQuery } from "@tanstack/react-query";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
|
||||
@@ -750,8 +754,14 @@ export function HealthChart({
|
||||
// across the whole history (downsampled by tier) rather than the most
|
||||
// recent ~365 daily buckets. The client folds those coarse buckets and
|
||||
// `bucketTimeSeries` re-buckets to the visible range as before.
|
||||
//
|
||||
// The window ends at the end of TODAY in the profile time zone, not at the
|
||||
// mount instant: a reading saved after the chart mounted (measuredAt = now)
|
||||
// has to fall inside the window the invalidated query refetches. `todayKey`
|
||||
// re-derives on every render, so the window stays put all day (a stable
|
||||
// cache key) and moves on by itself after midnight.
|
||||
const todayKey = localDayKeyFor(new Date(), userTimezone);
|
||||
const fetchWindow = useMemo(() => {
|
||||
const to = new Date();
|
||||
const windowDays = rangePoints > 0 ? rangePoints : ALL_RANGE_DAYS;
|
||||
const compareShift =
|
||||
effectiveCompareBaseline === "lastMonth"
|
||||
@@ -759,17 +769,12 @@ export function HealthChart({
|
||||
: effectiveCompareBaseline === "lastYear"
|
||||
? 365
|
||||
: 0;
|
||||
const totalDays = windowDays + compareShift;
|
||||
const from = new Date(to.getTime() - totalDays * 86_400_000);
|
||||
// Use ISO strings so the cache key is stable across the day; the
|
||||
// server treats `to` as the upper bound and the chart re-applies the
|
||||
// same day window client-side (see `visibleSlice`).
|
||||
return {
|
||||
from: from.toISOString(),
|
||||
to: to.toISOString(),
|
||||
windowDays: totalDays,
|
||||
};
|
||||
}, [rangePoints, effectiveCompareBaseline]);
|
||||
return openEndedFetchWindow(
|
||||
windowDays + compareShift,
|
||||
todayKey,
|
||||
userTimezone,
|
||||
);
|
||||
}, [rangePoints, effectiveCompareBaseline, userTimezone, todayKey]);
|
||||
|
||||
// v1.18.6 — honour the dashboard's batched slice ONLY for windows over
|
||||
// 7 days and ONLY when EVERY non-sleep type the chart renders is present
|
||||
|
||||
@@ -38,11 +38,6 @@ interface NutrientDailyBarChartProps {
|
||||
referenceValue?: number | null;
|
||||
}
|
||||
|
||||
/** Noon UTC so every viewer timezone renders the same calendar day. */
|
||||
function dayToDate(day: string): Date {
|
||||
return new Date(`${day}T12:00:00.000Z`);
|
||||
}
|
||||
|
||||
export function NutrientDailyBarChart({
|
||||
days,
|
||||
unit,
|
||||
@@ -78,7 +73,7 @@ export function NutrientDailyBarChart({
|
||||
/>
|
||||
<XAxis
|
||||
dataKey="day"
|
||||
tickFormatter={(day: string) => fmt.dateShortSmart(dayToDate(day))}
|
||||
tickFormatter={(day: string) => fmt.dateShortSmartCalendar(day)}
|
||||
tick={{ fontSize: 11, fill: "var(--muted-foreground)" }}
|
||||
stroke="var(--border)"
|
||||
minTickGap={24}
|
||||
@@ -117,7 +112,7 @@ export function NutrientDailyBarChart({
|
||||
return (
|
||||
<RichChartTooltip
|
||||
active
|
||||
label={fmt.dateShortSmart(dayToDate(point.day))}
|
||||
label={fmt.dateShortSmartCalendar(point.day)}
|
||||
rows={rows}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -253,3 +253,31 @@ describe("<ClinicianView> resolves every label in every locale", () => {
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
describe("<ClinicianView> numbers follow the owner's locale", () => {
|
||||
function text(locale: "de" | "en") {
|
||||
const { t } = getServerTranslator(locale);
|
||||
return renderToStaticMarkup(
|
||||
ClinicianView({
|
||||
t: (key, vars) => t(key, vars),
|
||||
label: "Clinic",
|
||||
expiresAt: "2026-03-01T00:00:00.000Z",
|
||||
report: FULL_RECORD,
|
||||
selection: selectionFromLeaves(ALL_LEAF_IDS),
|
||||
locale,
|
||||
timeFormat: "AUTO",
|
||||
dateFormat: "AUTO",
|
||||
}),
|
||||
).replace(/<[^>]*>/g, " ");
|
||||
}
|
||||
|
||||
it("writes a German decimal with a comma", () => {
|
||||
// BMI 24.5 in the fixture.
|
||||
expect(text("de")).toContain("24,5");
|
||||
expect(text("de")).not.toContain("24.5");
|
||||
});
|
||||
|
||||
it("keeps the point for an English reader", () => {
|
||||
expect(text("en")).toContain("24.5");
|
||||
});
|
||||
});
|
||||
@@ -149,7 +149,9 @@ export function ClinicianView({
|
||||
const fmt = makeFormatters(locale, timezone, timeFormat, dateFormat);
|
||||
const fmtDate = (iso: string) => fmt.date(new Date(iso));
|
||||
const fmtDateTime = (iso: string) => fmt.dateTime(new Date(iso));
|
||||
const fmtNum = (n: number) => Math.round(n * 100) / 100;
|
||||
// Two decimals at most, in the owner's locale: a German reader gets
|
||||
// "80,5", not the "80.5" a bare number prints.
|
||||
const fmtNum = (n: number) => fmt.number(Math.round(n * 100) / 100);
|
||||
const scope = makeLeafScope(selection, unavailableLeaves);
|
||||
|
||||
return (
|
||||
@@ -207,7 +209,7 @@ export function ClinicianView({
|
||||
>
|
||||
<StatRow
|
||||
label={t("clinicianView.bmi")}
|
||||
value={String(fmtNum(report.bmi))}
|
||||
value={fmtNum(report.bmi)}
|
||||
/>
|
||||
</Section>
|
||||
) : null}
|
||||
|
||||
@@ -53,7 +53,7 @@ export function LabResultsSection({
|
||||
report: DoctorReportData;
|
||||
scope: LeafScope;
|
||||
fmtDate: (iso: string) => string;
|
||||
fmtNum: (n: number) => number;
|
||||
fmtNum: (n: number) => string;
|
||||
}) {
|
||||
const results = report.labResults ?? [];
|
||||
|
||||
@@ -73,7 +73,7 @@ export function LabResultsSection({
|
||||
? lab.valueText
|
||||
: `${fmtNum(lab.value as number)} ${lab.unit}`.trim();
|
||||
const range = (low: number | null, high: number | null) =>
|
||||
formatReferenceRange(low, high, (value) => String(fmtNum(value)), {
|
||||
formatReferenceRange(low, high, fmtNum, {
|
||||
emptyText: "",
|
||||
});
|
||||
// The window the reading was judged against, printed as the source
|
||||
|
||||
@@ -204,7 +204,7 @@ export function MeasurementGroups({
|
||||
t: Translate;
|
||||
report: DoctorReportData;
|
||||
scope: LeafScope;
|
||||
fmtNum: (n: number) => number;
|
||||
fmtNum: (n: number) => string;
|
||||
}) {
|
||||
const groups = REPORT_GROUPS.map((group) => ({
|
||||
labelKey: group.labelKey,
|
||||
@@ -257,7 +257,7 @@ export function MeasurementGroups({
|
||||
function statSummary(
|
||||
t: Translate,
|
||||
unit: string,
|
||||
values: { latest: number; avg: number; min: number; max: number },
|
||||
values: { latest: string; avg: string; min: string; max: string },
|
||||
): string {
|
||||
return unit
|
||||
? t("clinicianView.statSummaryWithUnit", { ...values, unit })
|
||||
@@ -279,7 +279,7 @@ export function GlucoseSection({
|
||||
t: Translate;
|
||||
report: DoctorReportData;
|
||||
scope: LeafScope;
|
||||
fmtNum: (n: number) => number;
|
||||
fmtNum: (n: number) => string;
|
||||
}) {
|
||||
const entries = Object.entries(report.glucoseStats).filter(
|
||||
([, s]) => s.count > 0,
|
||||
@@ -506,7 +506,7 @@ export function WellnessSection({
|
||||
t: Translate;
|
||||
report: DoctorReportData;
|
||||
scope: LeafScope;
|
||||
fmtNum: (n: number) => number;
|
||||
fmtNum: (n: number) => string;
|
||||
}) {
|
||||
const wellness =
|
||||
report.wellnessScores?.filter(
|
||||
|
||||
@@ -89,7 +89,7 @@ export function MoodSection({
|
||||
t: Translate;
|
||||
report: DoctorReportData;
|
||||
scope: LeafScope;
|
||||
fmtNum: (n: number) => number;
|
||||
fmtNum: (n: number) => string;
|
||||
}) {
|
||||
const mood = report.mood ?? null;
|
||||
const buckets = mood ? Object.entries(mood.distribution) : [];
|
||||
@@ -145,7 +145,7 @@ export function CycleSection({
|
||||
report: DoctorReportData;
|
||||
scope: LeafScope;
|
||||
fmtDate: (iso: string) => string;
|
||||
fmtNum: (n: number) => number;
|
||||
fmtNum: (n: number) => string;
|
||||
}) {
|
||||
const cycle = report.cycle ?? null;
|
||||
// The summary carries plain dates (YYYY-MM-DD); midday keeps the rendered
|
||||
|
||||
@@ -47,7 +47,7 @@ export function Glp1Section({
|
||||
report: DoctorReportData;
|
||||
scope: LeafScope;
|
||||
fmtDate: (iso: string) => string;
|
||||
fmtNum: (n: number) => number;
|
||||
fmtNum: (n: number) => string;
|
||||
}) {
|
||||
const glp1 = report.glp1 ?? null;
|
||||
const medications = glp1?.medications ?? [];
|
||||
@@ -173,7 +173,7 @@ export function DoseLogSection({
|
||||
report: DoctorReportData;
|
||||
scope: LeafScope;
|
||||
fmtDateTime: (iso: string) => string;
|
||||
fmtNum: (n: number) => number;
|
||||
fmtNum: (n: number) => string;
|
||||
}) {
|
||||
const administrations = report.medicationAdministrations ?? [];
|
||||
// Newest first: a clinician reads "when did they last take it", not "when
|
||||
|
||||
@@ -32,12 +32,12 @@ import type { CyclePrediction, CycleHistoryResponse } from "./types";
|
||||
* locale, never the app's) with no year. Predictions run 1-2 cycles out, so
|
||||
* a window straddling New Year's would otherwise print an unadorned "Jan 3"
|
||||
* in December. Routes through the app-locale conditional-year formatter
|
||||
* instead; `dateShortSmart` already renders day+month in the locale's field
|
||||
* order and only adds the year when it differs from the current one.
|
||||
* instead; it renders day+month in the locale's field order and only adds
|
||||
* the year when it differs from the current one. The calendar variant reads
|
||||
* the key in UTC, so the date never rolls a day in UTC+12..+14.
|
||||
*/
|
||||
function formatDate(d: string, fmt: Formatters): string {
|
||||
// Render at noon UTC so the YYYY-MM-DD never rolls a day across tz.
|
||||
return fmt.dateShortSmart(`${d}T12:00:00Z`);
|
||||
return fmt.dateShortSmartCalendar(d);
|
||||
}
|
||||
|
||||
function confidenceTone(c: number): {
|
||||
|
||||
@@ -16,6 +16,7 @@ function render(uploadItems: UploadQueueItem[]) {
|
||||
return renderToStaticMarkup(
|
||||
<I18nProvider initialLocale="en">
|
||||
<DocumentTimeline
|
||||
timezone="UTC"
|
||||
documents={[]}
|
||||
uploadItems={uploadItems}
|
||||
onDismissUpload={() => {}}
|
||||
|
||||
@@ -133,7 +133,7 @@ describe("timeline items", () => {
|
||||
doc({ id: "c", documentDate: "2026-03-10" }),
|
||||
doc({ id: "d", documentDate: "2026-02-28" }),
|
||||
];
|
||||
const items = buildTimelineItems(docs, 2);
|
||||
const items = buildTimelineItems(docs, 2, "UTC");
|
||||
expect(items.map((i) => i.type)).toEqual([
|
||||
"month",
|
||||
"row",
|
||||
@@ -149,16 +149,37 @@ describe("timeline items", () => {
|
||||
|
||||
it("files an undated document under its upload day", () => {
|
||||
const d = doc({ documentDate: null, createdAt: "2026-01-05T10:00:00Z" });
|
||||
expect(documentDateKey(d)).toBe("2026-01-05");
|
||||
const items = buildTimelineItems([d], 3);
|
||||
expect(documentDateKey(d, "UTC")).toBe("2026-01-05");
|
||||
const items = buildTimelineItems([d], 3, "UTC");
|
||||
expect(items[0]).toEqual({ type: "month", key: "2026-01" });
|
||||
});
|
||||
|
||||
it("files an undated upload under the reader's day, not the UTC day", () => {
|
||||
// 22:30 on 31 January in Los Angeles is already 1 February in UTC.
|
||||
const d = doc({ documentDate: null, createdAt: "2026-02-01T06:30:00Z" });
|
||||
expect(documentDateKey(d, "America/Los_Angeles")).toBe("2026-01-31");
|
||||
expect(buildTimelineItems([d], 3, "America/Los_Angeles")[0]).toEqual({
|
||||
type: "month",
|
||||
key: "2026-01",
|
||||
});
|
||||
// And 1 March at 00:30 in Kiritimati is still 28 February in UTC.
|
||||
const e = doc({ documentDate: null, createdAt: "2026-02-28T10:30:00Z" });
|
||||
expect(documentDateKey(e, "Pacific/Kiritimati")).toBe("2026-03-01");
|
||||
});
|
||||
|
||||
it("keeps a stated document date whatever the zone", () => {
|
||||
const d = doc({
|
||||
documentDate: "2026-01-31",
|
||||
createdAt: "2026-02-01T06:30:00Z",
|
||||
});
|
||||
expect(documentDateKey(d, "Pacific/Kiritimati")).toBe("2026-01-31");
|
||||
});
|
||||
|
||||
it("keeps the DOM bounded: item count scales with rows, not documents", () => {
|
||||
const docs = Array.from({ length: 1000 }, (_, i) =>
|
||||
doc({ id: `d${i}`, documentDate: "2026-03-01" }),
|
||||
);
|
||||
const items = buildTimelineItems(docs, 4);
|
||||
const items = buildTimelineItems(docs, 4, "UTC");
|
||||
// 1 month header + 250 rows — the virtualizer then windows over these.
|
||||
expect(items).toHaveLength(251);
|
||||
});
|
||||
|
||||
@@ -55,7 +55,10 @@ export function DocumentTimeline({
|
||||
onDelete,
|
||||
highlightId,
|
||||
onPrefetch,
|
||||
timezone,
|
||||
}: {
|
||||
/** The reader's profile zone; an undated document files under its upload day there. */
|
||||
timezone: string;
|
||||
documents: InboundDocumentDto[];
|
||||
uploadItems: UploadQueueItem[];
|
||||
onDismissUpload: (localId: string) => void;
|
||||
@@ -93,8 +96,8 @@ export function DocumentTimeline({
|
||||
}, []);
|
||||
|
||||
const items = useMemo(
|
||||
() => buildTimelineItems(documents, columns),
|
||||
[documents, columns],
|
||||
() => buildTimelineItems(documents, columns, timezone),
|
||||
[documents, columns, timezone],
|
||||
);
|
||||
|
||||
// The timeline does not start at the scrollport's top edge (page header,
|
||||
|
||||
@@ -39,6 +39,7 @@ import { PageHeader } from "@/components/ui/page-header";
|
||||
import { QueryErrorCard } from "@/components/ui/query-error-card";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { DEFAULT_TIMEZONE } from "@/lib/tz/format";
|
||||
import { usePullToRefresh } from "@/hooks/use-pull-to-refresh";
|
||||
import { useUrlFilterSync } from "@/hooks/use-url-filter-sync";
|
||||
import { PullToRefreshIndicator } from "@/components/ui/pull-to-refresh-indicator";
|
||||
@@ -239,6 +240,7 @@ export function DocumentsView() {
|
||||
const { t } = useTranslations();
|
||||
const format = useFormatters();
|
||||
const { user, isLoading: authLoading, isAuthenticated } = useAuth();
|
||||
const timezone = user?.timezone || DEFAULT_TIMEZONE;
|
||||
// v1.36.x — uploading a document, filing it, sharing it and asking the AI
|
||||
// about it are none of them delegated verbs. Inside somebody else's record
|
||||
// the vault reads and nothing more: no upload path, no selection, no bulk
|
||||
@@ -506,11 +508,11 @@ export function DocumentsView() {
|
||||
const years = useMemo(() => {
|
||||
const set = new Set<number>();
|
||||
for (const doc of documents) {
|
||||
set.add(Number(documentDateKey(doc).slice(0, 4)));
|
||||
set.add(Number(documentDateKey(doc, timezone).slice(0, 4)));
|
||||
}
|
||||
if (filters.year !== undefined) set.add(filters.year);
|
||||
return [...set].sort((a, b) => b - a);
|
||||
}, [documents, filters.year]);
|
||||
}, [documents, filters.year, timezone]);
|
||||
|
||||
// ── Selection + bulk actions ──────────────────────────────────────────
|
||||
const [selectedIds, setSelectedIds] = useState<ReadonlySet<string>>(
|
||||
@@ -974,6 +976,7 @@ export function DocumentsView() {
|
||||
/>
|
||||
) : (
|
||||
<DocumentTimeline
|
||||
timezone={timezone}
|
||||
documents={documents}
|
||||
uploadItems={upload.items}
|
||||
onDismissUpload={upload.dismiss}
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
type InboundDocumentKindValue,
|
||||
} from "@/lib/validations/inbound-documents";
|
||||
import type { DocumentVaultFilters } from "@/lib/query-keys/documents";
|
||||
import { userDayKey } from "@/lib/tz/format";
|
||||
|
||||
const KIND_SET = new Set<string>(INBOUND_DOCUMENT_KINDS);
|
||||
|
||||
@@ -114,15 +115,23 @@ export function buildVaultListApiSearch(
|
||||
}
|
||||
|
||||
/** The date a document files under — its user filing date, else upload day. */
|
||||
export function documentDateKey(doc: InboundDocumentDto): string {
|
||||
export function documentDateKey(
|
||||
doc: InboundDocumentDto,
|
||||
timezone: string,
|
||||
): string {
|
||||
if (doc.documentDate) return doc.documentDate;
|
||||
// createdAt is an ISO timestamp; the leading YYYY-MM-DD is the upload day.
|
||||
return doc.createdAt.slice(0, 10);
|
||||
// An undated document files under its upload day in the reader's zone: the
|
||||
// leading YYYY-MM-DD of `createdAt` is the UTC day, which puts an upload
|
||||
// late on the last evening of a month west of UTC into the next month.
|
||||
return userDayKey(new Date(doc.createdAt), timezone);
|
||||
}
|
||||
|
||||
/** The YYYY-MM month bucket a document files under. */
|
||||
export function documentMonthKey(doc: InboundDocumentDto): string {
|
||||
return documentDateKey(doc).slice(0, 7);
|
||||
export function documentMonthKey(
|
||||
doc: InboundDocumentDto,
|
||||
timezone: string,
|
||||
): string {
|
||||
return documentDateKey(doc, timezone).slice(0, 7);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -186,6 +195,7 @@ export type TimelineItem =
|
||||
export function buildTimelineItems(
|
||||
documents: InboundDocumentDto[],
|
||||
columns: number,
|
||||
timezone: string,
|
||||
): TimelineItem[] {
|
||||
const cols = Math.max(1, columns);
|
||||
const items: TimelineItem[] = [];
|
||||
@@ -200,7 +210,7 @@ export function buildTimelineItems(
|
||||
};
|
||||
|
||||
for (const doc of documents) {
|
||||
const key = documentMonthKey(doc);
|
||||
const key = documentMonthKey(doc, timezone);
|
||||
if (key !== month) {
|
||||
flushRow();
|
||||
month = key;
|
||||
|
||||
@@ -38,7 +38,7 @@ function DayLogRow({ log }: { log: IllnessDayLogDTO }) {
|
||||
return (
|
||||
<div className="border-border/60 space-y-2 border-l-2 pl-3">
|
||||
<p className="text-muted-foreground text-xs font-medium tracking-wide uppercase">
|
||||
{fmt.dateShortSmart(`${log.date}T12:00:00`)}
|
||||
{fmt.dateShortSmartCalendar(log.date)}
|
||||
</p>
|
||||
|
||||
{log.symptoms.length > 0 ? (
|
||||
|
||||
@@ -84,7 +84,7 @@ function usePeriodLabel(): (key: string) => string {
|
||||
});
|
||||
return (key: string) => {
|
||||
if (DAY_KEY.test(key)) {
|
||||
return fmt.dateShortSmart(new Date(`${key}T12:00:00Z`));
|
||||
return fmt.dateShortSmartCalendar(key);
|
||||
}
|
||||
if (MONTH_KEY.test(key)) {
|
||||
return month.format(new Date(`${key}-01T12:00:00Z`));
|
||||
|
||||
@@ -188,7 +188,7 @@ export function IntradayPulseChart({
|
||||
|
||||
const dayLabel = isToday
|
||||
? t("insights.intradayPulse.today")
|
||||
: fmt.dateShortSmart(new Date(`${dateKey}T12:00:00.000Z`));
|
||||
: fmt.dateShortSmartCalendar(dateKey);
|
||||
|
||||
return (
|
||||
<div
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import { ApiError } from "@/lib/api/api-fetch";
|
||||
import { localizedApiError } from "@/lib/api/localized-error";
|
||||
import en from "../../../../messages/en.json";
|
||||
|
||||
/**
|
||||
* The marker editor says, before a save, that a marker's unit stays as it is
|
||||
* once readings exist, and the refusal the server sends for such a change
|
||||
* reads as that sentence rather than the generic save error.
|
||||
*/
|
||||
|
||||
vi.mock("@/lib/api/api-fetch", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("@/lib/api/api-fetch")>()),
|
||||
apiPost: vi.fn(),
|
||||
apiPut: vi.fn(),
|
||||
}));
|
||||
|
||||
import { BiomarkerForm } from "../biomarker-form";
|
||||
import type { BiomarkerDto } from "../types";
|
||||
|
||||
const MARKER: BiomarkerDto = {
|
||||
id: "bm-1",
|
||||
name: "Glucose",
|
||||
unit: "mg/dL",
|
||||
lowerBound: 70,
|
||||
upperBound: 99,
|
||||
panel: null,
|
||||
hasContext: false,
|
||||
context: null,
|
||||
hidden: false,
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||
};
|
||||
|
||||
function render(node: React.ReactNode) {
|
||||
return renderToStaticMarkup(
|
||||
<I18nProvider initialLocale="en">{node}</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("<BiomarkerForm> unit lock", () => {
|
||||
const hint = en.labs.biomarker.form.unitLockedHint;
|
||||
|
||||
it("tells the editor of an existing marker that the unit is locked by readings", () => {
|
||||
const html = render(<BiomarkerForm existing={MARKER} />);
|
||||
expect(hint.length).toBeGreaterThan(0);
|
||||
expect(html).toContain(hint.replace(/'/g, "'"));
|
||||
expect(html).toMatch(/aria-describedby="[^"]*-unit-hint"/);
|
||||
});
|
||||
|
||||
it("says nothing of a lock while a new marker is defined", () => {
|
||||
const html = render(<BiomarkerForm />);
|
||||
expect(html).not.toContain(hint.replace(/'/g, "'"));
|
||||
});
|
||||
|
||||
it("reads the server's refusal as its own sentence, not the save error", () => {
|
||||
const err = new ApiError("locked", 422, {
|
||||
errorCode: "biomarkers.unit.locked",
|
||||
readingCount: 2,
|
||||
});
|
||||
const t = (key: string) => {
|
||||
const value = key
|
||||
.split(".")
|
||||
.reduce<unknown>(
|
||||
(node, part) =>
|
||||
node && typeof node === "object"
|
||||
? (node as Record<string, unknown>)[part]
|
||||
: undefined,
|
||||
en,
|
||||
);
|
||||
return typeof value === "string" ? value : key;
|
||||
};
|
||||
expect(localizedApiError(err, t, "labs.biomarker.form.saveError")).toBe(
|
||||
en.apiErrors.biomarkers.unit.locked,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -70,6 +70,7 @@ export function BiomarkerForm({
|
||||
}: BiomarkerFormProps) {
|
||||
const { t } = useTranslations();
|
||||
const formId = useId();
|
||||
const unitHintId = `${formId}-unit-hint`;
|
||||
|
||||
const [name, setName] = useState(existing?.name ?? "");
|
||||
const [unit, setUnit] = useState(existing?.unit ?? "");
|
||||
@@ -225,7 +226,16 @@ export function BiomarkerForm({
|
||||
placeholder={t("labs.biomarker.form.unitPlaceholder")}
|
||||
maxLength={40}
|
||||
required
|
||||
aria-describedby={existing ? unitHintId : undefined}
|
||||
/>
|
||||
{existing ? (
|
||||
// The server refuses a unit change while readings are linked:
|
||||
// every reading is shown in the marker's unit, so a new unit
|
||||
// would relabel stored numbers without converting them.
|
||||
<p id={unitHintId} className="text-muted-foreground text-xs">
|
||||
{t("labs.biomarker.form.unitLockedHint")}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="biomarker-panel">
|
||||
|
||||
@@ -18,6 +18,7 @@ import { CHART_HEIGHT_PX } from "@/lib/charts/constants";
|
||||
import { prefersReducedMotion } from "@/lib/charts/reduced-motion";
|
||||
import { formatReferenceRange } from "@/lib/labs/reference-range";
|
||||
import { useTranslations, useFormatters } from "@/lib/i18n/context";
|
||||
import { isNoonUtcAnchor } from "@/lib/tz/date-only";
|
||||
|
||||
import { RichChartTooltip, type RichTooltipRow } from "../charts/chart-tooltip";
|
||||
import type { LabResultDto } from "./types";
|
||||
@@ -93,6 +94,15 @@ export function LabBiomarkerChart({
|
||||
const { t } = useTranslations();
|
||||
const labNumber = useLabNumber();
|
||||
const fmt = useFormatters();
|
||||
// A reading from a report that states only its date is stored at noon UTC
|
||||
// and is dated by that calendar date; one typed with its time of day is
|
||||
// dated in the reader's zone (the rule `useLabDate` applies to the lists).
|
||||
const labShortDate = (value: number | string) => {
|
||||
const instant = new Date(value);
|
||||
return isNoonUtcAnchor(instant)
|
||||
? fmt.dateShortSmartCalendar(instant)
|
||||
: fmt.dateShortSmart(instant);
|
||||
};
|
||||
const [range, setRange] = useState<RangeKey>("365");
|
||||
// Capture "now" once at mount so the recency filter stays pure across
|
||||
// re-renders (an inline `Date.now()` in render is impure).
|
||||
@@ -215,7 +225,7 @@ export function LabBiomarkerChart({
|
||||
type="number"
|
||||
scale="time"
|
||||
domain={["dataMin", "dataMax"]}
|
||||
tickFormatter={(ts: number) => fmt.dateShortSmart(new Date(ts))}
|
||||
tickFormatter={(ts: number) => labShortDate(ts)}
|
||||
tick={{ fontSize: 11, fill: "var(--muted-foreground)" }}
|
||||
stroke="var(--border)"
|
||||
minTickGap={32}
|
||||
@@ -326,7 +336,7 @@ export function LabBiomarkerChart({
|
||||
return (
|
||||
<RichChartTooltip
|
||||
active
|
||||
label={fmt.dateShortSmart(new Date(point.timestamp))}
|
||||
label={labShortDate(point.timestamp)}
|
||||
rows={rows}
|
||||
/>
|
||||
);
|
||||
@@ -348,7 +358,7 @@ export function LabBiomarkerChart({
|
||||
{sourceBand.mixed
|
||||
? t("labs.chart.sourceBandMixed")
|
||||
: t("labs.chart.sourceBandLegend", {
|
||||
date: fmt.dateShortSmart(new Date(sourceBand.takenAt)),
|
||||
date: labShortDate(sourceBand.takenAt),
|
||||
})}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import { parseDurationEntry } from "@/lib/measurements/parse-duration";
|
||||
import { DurationReadAs } from "../duration-read-as";
|
||||
|
||||
/**
|
||||
* The hint under a duration field says how the entry was read, so "7.5",
|
||||
* "7:30" and "7h30" can each be checked before saving.
|
||||
*/
|
||||
function render(minutes: number, locale: "en" | "de" | "ko" = "en") {
|
||||
return renderToStaticMarkup(
|
||||
<I18nProvider initialLocale={locale}>
|
||||
<DurationReadAs minutes={minutes} />
|
||||
</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
function readAs(raw: string): string {
|
||||
const read = parseDurationEntry(raw, "h");
|
||||
if (!read.ok) throw new Error(`refused ${raw}`);
|
||||
return render(read.minutes);
|
||||
}
|
||||
|
||||
describe("<DurationReadAs>", () => {
|
||||
it.each(["7.5", "7,5", "7:30", "7h30", "7 h 30 min"])(
|
||||
"shows %j as seven and a half hours",
|
||||
(raw) => {
|
||||
expect(readAs(raw)).toContain("Read as 7 h 30 min");
|
||||
},
|
||||
);
|
||||
|
||||
it("leaves out a zero part", () => {
|
||||
expect(render(480)).toContain("Read as 8 h<");
|
||||
expect(render(45)).toContain("Read as 45 min");
|
||||
});
|
||||
|
||||
it("speaks the reader's language", () => {
|
||||
expect(render(450, "de")).toContain("7 Std. 30 Min.");
|
||||
expect(render(450, "ko")).toContain("7시간 30분");
|
||||
});
|
||||
|
||||
it("is wired under the measurement form's value field", () => {
|
||||
const form = readFileSync(
|
||||
join(process.cwd(), "src/components/measurements/measurement-form.tsx"),
|
||||
"utf8",
|
||||
);
|
||||
expect(form).toMatch(
|
||||
/hint=\{\s*durationRead\?\.ok === true \? \(\s*<DurationReadAs minutes=\{durationRead\.minutes\} \/>/,
|
||||
);
|
||||
// A refused entry is an inline field error, never a toast.
|
||||
expect(form).toMatch(/setValueError\(\s*t\(/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { splitDurationMinutes } from "@/lib/measurements/parse-duration";
|
||||
|
||||
/**
|
||||
* How a duration entry was understood, under the field: "Read as 7 h 30 min".
|
||||
* Shown for every entry the parser accepts, so "7.5", "7:30" and "7h30" can
|
||||
* each be checked against what the person meant before saving.
|
||||
*/
|
||||
export function DurationReadAs({ minutes }: { minutes: number }) {
|
||||
const { t } = useTranslations();
|
||||
const parts = splitDurationMinutes(minutes);
|
||||
const duration =
|
||||
parts.hours === 0
|
||||
? t("measurements.durationMinutesOnly", { minutes: parts.minutes })
|
||||
: parts.minutes === 0
|
||||
? t("measurements.durationHoursOnly", { hours: parts.hours })
|
||||
: t("measurements.durationHoursMinutes", parts);
|
||||
return (
|
||||
<span aria-live="polite" data-duration-minutes={Math.round(minutes)}>
|
||||
{t("measurements.durationReadAs", { duration })}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -30,9 +30,11 @@ import { useAuth } from "@/hooks/use-auth";
|
||||
import { resolveGlucoseUnit, toCanonicalMgdl } from "@/lib/glucose";
|
||||
import { isSurfaceVisible, type SurfaceModuleMap } from "@/lib/modules/surface";
|
||||
import {
|
||||
durationEntryUnit,
|
||||
entryValueToCanonical,
|
||||
parseDecimalEntry,
|
||||
} from "@/lib/measurements/entry-units";
|
||||
import { parseDurationEntry } from "@/lib/measurements/parse-duration";
|
||||
import {
|
||||
invalidateKeys,
|
||||
measurementDependentKeys,
|
||||
@@ -46,6 +48,8 @@ import {
|
||||
setLastUsedMeasurementType,
|
||||
} from "@/lib/measurements/last-used-type";
|
||||
|
||||
import { DurationReadAs } from "./duration-read-as";
|
||||
|
||||
const MAX_COMMENT_LENGTH = MEASUREMENT_NOTES_MAX_LENGTH;
|
||||
|
||||
export const MEASUREMENT_TYPES = [
|
||||
@@ -343,6 +347,7 @@ export function MeasurementForm({
|
||||
? selectedType
|
||||
: (offeredTypes[0]?.value ?? "BLOOD_PRESSURE");
|
||||
const [value, setValue] = useState("");
|
||||
const [valueError, setValueError] = useState<string | null>(null);
|
||||
const [sysBp, setSysBp] = useState("");
|
||||
const [diaBp, setDiaBp] = useState("");
|
||||
const [pulse, setPulse] = useState("");
|
||||
@@ -367,6 +372,11 @@ export function MeasurementForm({
|
||||
const typeInfo = MEASUREMENT_TYPES.find((t) => t.value === type);
|
||||
const isBpMode = type === "BLOOD_PRESSURE";
|
||||
const isGlucoseMode = type === "BLOOD_GLUCOSE";
|
||||
// A duration field (sleep, in hours) also reads "7:30" and "7h 30m"; the
|
||||
// hint under it says how the entry was understood.
|
||||
const durationUnit = durationEntryUnit(type);
|
||||
const durationRead =
|
||||
durationUnit !== null ? parseDurationEntry(value, durationUnit) : null;
|
||||
|
||||
function resetForm() {
|
||||
setType(normalizedDefault || "BLOOD_PRESSURE");
|
||||
@@ -377,6 +387,7 @@ export function MeasurementForm({
|
||||
setNotes("");
|
||||
setMeasuredAt(getDefaultMeasuredAtValue());
|
||||
setError(null);
|
||||
setValueError(null);
|
||||
}
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
@@ -432,7 +443,22 @@ export function MeasurementForm({
|
||||
// minutes, and sending the typed number through unconverted filed a
|
||||
// 7.5-hour night as seven and a half minutes — inside the column's
|
||||
// plausibility band, so nothing objected.
|
||||
const typed = parseDecimalEntry(value);
|
||||
let typed: number | null;
|
||||
if (durationRead) {
|
||||
if (!durationRead.ok) {
|
||||
setValueError(
|
||||
t(
|
||||
durationRead.reason === "tooLong"
|
||||
? "measurements.durationTooLong"
|
||||
: "measurements.durationInvalid",
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
typed = durationRead.value;
|
||||
} else {
|
||||
typed = parseDecimalEntry(value);
|
||||
}
|
||||
if (typed === null) {
|
||||
setError(t("measurements.saveError"));
|
||||
return;
|
||||
@@ -539,7 +565,13 @@ export function MeasurementForm({
|
||||
return (
|
||||
<form id={formId} onSubmit={handleSubmit} className="space-y-4">
|
||||
<FieldGroup htmlFor="measurement-type" label={t("measurements.type")}>
|
||||
<Select value={type} onValueChange={setType}>
|
||||
<Select
|
||||
value={type}
|
||||
onValueChange={(next) => {
|
||||
setType(next);
|
||||
setValueError(null);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger id="measurement-type" className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
@@ -614,6 +646,12 @@ export function MeasurementForm({
|
||||
) : (
|
||||
<FieldGroup
|
||||
htmlFor="value"
|
||||
hint={
|
||||
durationRead?.ok === true ? (
|
||||
<DurationReadAs minutes={durationRead.minutes} />
|
||||
) : null
|
||||
}
|
||||
error={valueError}
|
||||
label={t("measurements.valueWithUnit", {
|
||||
// For a type with a metric/imperial transform the label follows the
|
||||
// user's preference (kg↔lb, cm↔in, °C↔°F); everything else keeps
|
||||
@@ -636,10 +674,15 @@ export function MeasurementForm({
|
||||
// arrives as an empty string with no explanation. `inputMode`
|
||||
// still raises the numeric keypad, and the submit path reads the
|
||||
// comma — the same shape the labs and custom-metric fields use.
|
||||
inputMode="decimal"
|
||||
// A duration also takes "7:30" and "7h 30m", which the decimal
|
||||
// keypad cannot type.
|
||||
inputMode={durationUnit !== null ? "text" : "decimal"}
|
||||
enterKeyHint="next"
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
onChange={(e) => {
|
||||
setValue(e.target.value);
|
||||
setValueError(null);
|
||||
}}
|
||||
placeholder={
|
||||
isGlucoseMode && glucoseUnit === "mmol/L"
|
||||
? "5.3"
|
||||
@@ -654,8 +697,12 @@ export function MeasurementForm({
|
||||
}
|
||||
required
|
||||
aria-required="true"
|
||||
aria-invalid={!!error || undefined}
|
||||
aria-describedby={errorDescriptor}
|
||||
aria-invalid={!!error || !!valueError || undefined}
|
||||
aria-describedby={
|
||||
[valueError ? "value-error" : null, errorDescriptor]
|
||||
.filter(Boolean)
|
||||
.join(" ") || undefined
|
||||
}
|
||||
/>
|
||||
</FieldGroup>
|
||||
)}
|
||||
|
||||
@@ -138,6 +138,18 @@ describe("saveManualWorkout — the wire", () => {
|
||||
).resolves.toBe("duplicate");
|
||||
});
|
||||
|
||||
it("treats an edited resubmit (`updated`) as saved and refreshes", async () => {
|
||||
fetchSpy.mockResolvedValue(
|
||||
envelope({ entries: [{ index: 0, status: "updated" }] }),
|
||||
);
|
||||
const client = fakeClient();
|
||||
|
||||
await expect(
|
||||
saveManualWorkout(ENTRY, client as unknown as QueryClient),
|
||||
).resolves.toBe("updated");
|
||||
expect(client.invalidateQueries).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("throws when the route skipped the entry, and refreshes nothing", async () => {
|
||||
fetchSpy.mockResolvedValue(
|
||||
envelope({ entries: [{ index: 0, status: "skipped" }] }),
|
||||
|
||||
@@ -59,7 +59,7 @@ interface BatchResult {
|
||||
entries?: Array<{ status: string }>;
|
||||
}
|
||||
|
||||
export type ManualWorkoutSaveOutcome = "inserted" | "duplicate";
|
||||
export type ManualWorkoutSaveOutcome = "inserted" | "duplicate" | "updated";
|
||||
|
||||
/**
|
||||
* Post the entry and refresh what shows workouts. A `duplicate` is a success:
|
||||
@@ -74,7 +74,7 @@ export async function saveManualWorkout(
|
||||
workouts: [entry],
|
||||
});
|
||||
const status = result?.entries?.[0]?.status;
|
||||
if (status !== "inserted" && status !== "duplicate") {
|
||||
if (status !== "inserted" && status !== "duplicate" && status !== "updated") {
|
||||
throw new Error(`workout entry not stored: ${status ?? "unknown"}`);
|
||||
}
|
||||
// The list, the dashboard tile and every detail read sit under this
|
||||
@@ -112,7 +112,8 @@ export function ManualWorkoutForm({
|
||||
const timezone = user?.timezone || DEFAULT_TIMEZONE;
|
||||
|
||||
// Minted once per opened form and sent with every submit, so a second tap
|
||||
// or a retried request lands as a duplicate rather than a second row.
|
||||
// or a retried request lands as a duplicate rather than a second row, and
|
||||
// a submit after an edit updates the row the first submit stored.
|
||||
const [externalId] = useState(newManualWorkoutExternalId);
|
||||
const [draft, setDraft] = useState<ManualWorkoutDraft>(() =>
|
||||
emptyManualWorkoutDraft(new Date(), timezone),
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { makeFormatters } from "../format-locale";
|
||||
|
||||
/**
|
||||
* `dateShortSmartCalendar` prints a stated calendar date (a day key, or its
|
||||
* noon-UTC anchor) as that date for every reader, where the zone-aware
|
||||
* `dateShortSmart` moves a noon anchor to the next day in UTC+12..+14.
|
||||
*/
|
||||
const previousTz = process.env.TZ;
|
||||
afterEach(() => {
|
||||
process.env.TZ = previousTz;
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe.each(["America/Los_Angeles", "Pacific/Kiritimati"])(
|
||||
"host zone %s",
|
||||
(hostTz) => {
|
||||
it.each(["Pacific/Kiritimati", "America/Los_Angeles", "Europe/Berlin"])(
|
||||
"keeps 4 October for a profile in %s",
|
||||
(profileTz) => {
|
||||
process.env.TZ = hostTz;
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-10-10T12:00:00.000Z"));
|
||||
const fmt = makeFormatters("en", profileTz, "AUTO", "AUTO");
|
||||
|
||||
expect(fmt.dateShortSmartCalendar("2026-10-04")).toBe("10/04");
|
||||
expect(
|
||||
fmt.dateShortSmartCalendar(new Date("2026-10-04T12:00:00.000Z")),
|
||||
).toBe("10/04");
|
||||
expect(fmt.dateShortSmartCalendar("2026-10-04T12:00:00Z")).toBe(
|
||||
"10/04",
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it("adds the year outside the current one", () => {
|
||||
process.env.TZ = hostTz;
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-10-10T12:00:00.000Z"));
|
||||
const fmt = makeFormatters("de", "Europe/Berlin", "AUTO", "AUTO");
|
||||
expect(fmt.dateShortSmartCalendar("2025-12-31")).toBe("31.12.2025");
|
||||
expect(fmt.dateShortSmartCalendar("2026-01-02")).toBe("02.01.");
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it("is the bug it replaces: the zone-aware formatter moves the date east of UTC+12", () => {
|
||||
const fmt = makeFormatters("en", "Pacific/Kiritimati", "AUTO", "AUTO");
|
||||
expect(fmt.dateShortSmart("2026-10-04T12:00:00Z")).toMatch(/^10\/05/);
|
||||
});
|
||||
@@ -98,6 +98,15 @@ export function getLastWorkingProvider(
|
||||
return entry.providerType;
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop one user's "last working provider" memo. Called when the person saves
|
||||
* a new chain: the memo reorders the chain they just arranged, and a provider
|
||||
* they removed or disabled must not keep leading it for up to an hour.
|
||||
*/
|
||||
export function forgetLastWorkingProvider(userId: string): void {
|
||||
lastWorkingCache.delete(userId);
|
||||
}
|
||||
|
||||
function rememberWorkingProvider(
|
||||
userId: string,
|
||||
providerType: ProviderChainType,
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
|
||||
import { localDayKeyFor, openEndedFetchWindow } from "../fetch-window";
|
||||
|
||||
/**
|
||||
* A chart that shows "the last N days" asks for a window that must still hold
|
||||
* a reading saved after the chart mounted. The window used to end at the mount
|
||||
* instant, so the invalidated refetch asked for the same old window and a new
|
||||
* reading stayed invisible until the range tab was switched.
|
||||
*/
|
||||
function windowAt(now: Date, tz: string, days = 30) {
|
||||
return openEndedFetchWindow(days, localDayKeyFor(now, tz), tz);
|
||||
}
|
||||
|
||||
function inside(w: { from: string; to: string }, at: Date) {
|
||||
return at >= new Date(w.from) && at <= new Date(w.to);
|
||||
}
|
||||
|
||||
const previousTz = process.env.TZ;
|
||||
afterEach(() => {
|
||||
process.env.TZ = previousTz;
|
||||
});
|
||||
|
||||
describe.each([
|
||||
["Europe/Berlin", "UTC"],
|
||||
["America/Los_Angeles", "America/Los_Angeles"],
|
||||
["Pacific/Kiritimati", "Pacific/Kiritimati"],
|
||||
])("open-ended chart window, profile %s, host %s", (profileTz, hostTz) => {
|
||||
it("holds a reading saved after the chart mounted, under the same key", () => {
|
||||
process.env.TZ = hostTz;
|
||||
const mountedAt = new Date("2026-10-03T08:00:00.000Z");
|
||||
const savedAt = new Date(mountedAt.getTime() + 5 * 60_000);
|
||||
|
||||
const atMount = windowAt(mountedAt, profileTz);
|
||||
const afterSave = windowAt(savedAt, profileTz);
|
||||
|
||||
expect(inside(atMount, savedAt)).toBe(true);
|
||||
// Same day, same window: the query key does not churn, and the
|
||||
// invalidation that follows a save refetches a window that holds it.
|
||||
expect(afterSave).toEqual(atMount);
|
||||
});
|
||||
|
||||
it("moves on to the next local day by itself", () => {
|
||||
process.env.TZ = hostTz;
|
||||
const today = new Date("2026-10-03T08:00:00.000Z");
|
||||
const tomorrow = new Date(today.getTime() + 86_400_000);
|
||||
|
||||
const w = windowAt(tomorrow, profileTz);
|
||||
|
||||
expect(inside(w, tomorrow)).toBe(true);
|
||||
expect(w).not.toEqual(windowAt(today, profileTz));
|
||||
});
|
||||
|
||||
it("spans the requested number of days", () => {
|
||||
process.env.TZ = hostTz;
|
||||
const w = windowAt(new Date("2026-10-03T08:00:00.000Z"), profileTz, 7);
|
||||
const span = new Date(w.to).getTime() - new Date(w.from).getTime();
|
||||
expect(span).toBe(7 * 86_400_000);
|
||||
expect(w.windowDays).toBe(7);
|
||||
});
|
||||
});
|
||||
|
||||
describe("health chart wiring", () => {
|
||||
it("builds its fetch window from today's key, not the mount instant", () => {
|
||||
const chart = readFileSync(
|
||||
join(process.cwd(), "src/components/charts/health-chart.tsx"),
|
||||
"utf8",
|
||||
);
|
||||
const memo = chart.slice(chart.indexOf("const fetchWindow = useMemo("));
|
||||
const body = memo.slice(0, memo.indexOf("}, ["));
|
||||
expect(body).toContain("openEndedFetchWindow(");
|
||||
expect(body).not.toMatch(/const to = new Date\(\)/);
|
||||
expect(memo).toMatch(/\}, \[[^\]]*todayKey[^\]]*\]\)/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,58 @@
|
||||
/**
|
||||
* The date window a chart asks the server for when it shows "the last N days".
|
||||
*
|
||||
* Such a window means "up to now", and "now" keeps moving after the chart
|
||||
* mounts. A window that ended at the mount instant froze there: the query key
|
||||
* kept it, an invalidation after a new reading refetched the same old window,
|
||||
* and a reading saved a minute later fell outside it until the person switched
|
||||
* the range tab and back. The window now ends at the end of the current day in
|
||||
* the profile time zone. That bound includes every reading saved today, stays
|
||||
* the same for the whole day so the cache key does not churn on every render,
|
||||
* and moves on by itself once the day changes. The server's own rules about
|
||||
* readings in the future are untouched: this only says how far a read looks.
|
||||
*/
|
||||
import { DEFAULT_TIMEZONE, isValidTimezone, userDayKey } from "@/lib/tz/format";
|
||||
import { localDayWindow } from "@/lib/tz/local-day";
|
||||
|
||||
const MS_PER_DAY = 86_400_000;
|
||||
|
||||
function safeZone(timezone: string): string {
|
||||
return isValidTimezone(timezone) ? timezone : DEFAULT_TIMEZONE;
|
||||
}
|
||||
|
||||
/** The local day (`YYYY-MM-DD`) `now` falls on in `timezone`. */
|
||||
export function localDayKeyFor(now: Date, timezone: string): string {
|
||||
return userDayKey(now, safeZone(timezone));
|
||||
}
|
||||
|
||||
/** Last millisecond of the local day `dayKey` in `timezone`, as a UTC instant. */
|
||||
function endOfDayKeyUtc(dayKey: string, timezone: string): Date {
|
||||
const { dayEnd } = localDayWindow(dayKey, safeZone(timezone));
|
||||
return new Date(dayEnd.getTime() - 1);
|
||||
}
|
||||
|
||||
/** Last millisecond of the local day `now` falls on in `timezone`. */
|
||||
export function endOfLocalDayUtc(now: Date, timezone: string): Date {
|
||||
return endOfDayKeyUtc(localDayKeyFor(now, timezone), timezone);
|
||||
}
|
||||
|
||||
export interface ChartFetchWindow {
|
||||
from: string;
|
||||
to: string;
|
||||
windowDays: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* A window of `days` days that ends with the local day `todayKey` (from
|
||||
* `localDayKeyFor(now, timezone)`). Keyed on the day, not the instant, so
|
||||
* every call on the same local day returns the identical window.
|
||||
*/
|
||||
export function openEndedFetchWindow(
|
||||
days: number,
|
||||
todayKey: string,
|
||||
timezone: string,
|
||||
): ChartFetchWindow {
|
||||
const to = endOfDayKeyUtc(todayKey, timezone);
|
||||
const from = new Date(to.getTime() - days * MS_PER_DAY);
|
||||
return { from: from.toISOString(), to: to.toISOString(), windowDays: days };
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { glucoseCardInDisplayUnit } from "../glucose-card";
|
||||
|
||||
const card = (over: Record<string, unknown> = {}) => ({
|
||||
kind: "glucose",
|
||||
latestValue: 101 as number | null,
|
||||
secondaryValue: null as number | null,
|
||||
sparkline: [90, 101, 126],
|
||||
unit: null as string | null,
|
||||
unitKey: "dashboard.metric.unit.glucose",
|
||||
...over,
|
||||
});
|
||||
|
||||
describe("glucoseCardInDisplayUnit", () => {
|
||||
it("converts the card to mmol/L and names the unit", () => {
|
||||
expect(glucoseCardInDisplayUnit(card(), "mmol/L")).toMatchObject({
|
||||
latestValue: 5.6,
|
||||
secondaryValue: null,
|
||||
sparkline: [5, 5.6, 7],
|
||||
unit: "mmol/L",
|
||||
unitKey: "dashboard.metric.unit.glucose",
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps mg/dL values and names that unit too", () => {
|
||||
expect(glucoseCardInDisplayUnit(card(), "mg/dL")).toMatchObject({
|
||||
latestValue: 101,
|
||||
sparkline: [90, 101, 126],
|
||||
unit: "mg/dL",
|
||||
});
|
||||
});
|
||||
|
||||
it("leaves an empty card empty", () => {
|
||||
expect(
|
||||
glucoseCardInDisplayUnit(
|
||||
card({ latestValue: null, sparkline: [] }),
|
||||
"mmol/L",
|
||||
),
|
||||
).toMatchObject({ latestValue: null, sparkline: [], unit: "mmol/L" });
|
||||
});
|
||||
|
||||
it("passes every other card through untouched", () => {
|
||||
const weight = card({ kind: "weight", latestValue: 80 });
|
||||
expect(glucoseCardInDisplayUnit(weight, "mmol/L")).toBe(weight);
|
||||
});
|
||||
});
|
||||
@@ -29,8 +29,7 @@
|
||||
* helpers and the layout type).
|
||||
*/
|
||||
import type { DashboardLayout } from "@/lib/dashboard-layout";
|
||||
import { DEFAULT_TIMEZONE, isValidTimezone, userDayKey } from "@/lib/tz/format";
|
||||
import { localDayWindow } from "@/lib/tz/local-day";
|
||||
import { endOfLocalDayUtc } from "@/lib/charts/fetch-window";
|
||||
|
||||
/**
|
||||
* Day-span the batched dashboard series (`series-batch`) fetches. Threaded to
|
||||
@@ -92,16 +91,6 @@ export interface BatchWindow {
|
||||
to: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Last millisecond of the local day `now` falls on in `timezone`, as a UTC
|
||||
* instant. Falls back to the project default zone for an unusable IANA id.
|
||||
*/
|
||||
function endOfLocalDayUtc(now: Date, timezone: string): Date {
|
||||
const safeTz = isValidTimezone(timezone) ? timezone : DEFAULT_TIMEZONE;
|
||||
const { dayEnd } = localDayWindow(userDayKey(now, safeTz), safeTz);
|
||||
return new Date(dayEnd.getTime() - 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* The batched-series window computed from the PROFILE timezone. End-of-current-
|
||||
* day in any zone (derived from `now`) is always ≥ `now`, so today's rows are
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import { convertGlucose, type GlucoseUnit } from "@/lib/glucose";
|
||||
|
||||
/** The fields of a dashboard summary card the glucose unit touches. */
|
||||
interface GlucoseConvertibleCard {
|
||||
kind: string;
|
||||
latestValue: number | null;
|
||||
secondaryValue: number | null;
|
||||
sparkline: number[];
|
||||
unit: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Put the glucose card in the record owner's display unit.
|
||||
*
|
||||
* Glucose is stored in mg/dL. The summary card used to send that number with
|
||||
* `unit: null` and a `unitKey` that reads "mg/dL", so a reader who chose
|
||||
* mmol/L was shown 101 "mg/dL" on a client that honours the card. The card now
|
||||
* carries its value, its secondary value and its sparkline in the chosen unit
|
||||
* and names that unit in `unit`, the field that wins over `unitKey` (as the
|
||||
* sleep card's `"h"` already does). Every other card passes through untouched.
|
||||
*
|
||||
* Applied after the summary cache is read, so changing the unit takes effect
|
||||
* on the next request rather than after the cache window.
|
||||
*/
|
||||
export function glucoseCardInDisplayUnit<T extends GlucoseConvertibleCard>(
|
||||
card: T,
|
||||
unit: GlucoseUnit,
|
||||
): T {
|
||||
if (card.kind !== "glucose") return card;
|
||||
const show = (value: number | null) =>
|
||||
value === null ? null : convertGlucose(value, unit);
|
||||
return {
|
||||
...card,
|
||||
latestValue: show(card.latestValue),
|
||||
secondaryValue: show(card.secondaryValue),
|
||||
sparkline: card.sparkline.map((value) => convertGlucose(value, unit)),
|
||||
unit,
|
||||
};
|
||||
}
|
||||
@@ -579,7 +579,7 @@ export const DERIVED_MODELS: Readonly<Record<string, string>> = {
|
||||
WorkoutInsight:
|
||||
"Generated narrative over `Workout`; regenerates on demand. Carrying it would restore an interpretation of data rather than the data.",
|
||||
WorkoutInsightGenerationClaim:
|
||||
"A concurrency claim guarding the generator above. Meaningless outside the run that took it.",
|
||||
"A concurrency claim and the daily-cap ledger for the generator above. It outlives a deleted workout for at most a week and means nothing on another instance or day.",
|
||||
InsightNarrative:
|
||||
"Generated prose over measurements. Same reasoning as WorkoutInsight — the record restores, the essay about it does not.",
|
||||
InsightStatusCache:
|
||||
|
||||
@@ -150,6 +150,15 @@ export interface Formatters {
|
||||
* überall" year-disambiguation rule.
|
||||
*/
|
||||
dateShortSmart: (value: DateInput) => string;
|
||||
/**
|
||||
* `dateShortSmart` for a STATED calendar date: a day key anchored at noon
|
||||
* UTC (`YYYY-MM-DDT12:00:00Z`), or the bare `YYYY-MM-DD` key itself. Read
|
||||
* in UTC so the date never moves: the zone-aware `dateShortSmart` shows a
|
||||
* noon-UTC anchor as the next day in UTC+12..+14 (and an evening anchor as
|
||||
* the previous day west of UTC-12). The year rule is the same: the year is
|
||||
* added when the date falls outside the reader's current year.
|
||||
*/
|
||||
dateShortSmartCalendar: (value: DateInput) => string;
|
||||
/** Full date + time; hour cycle follows the `timeFormat` preference. */
|
||||
dateTime: (value: DateInput) => string;
|
||||
/** Time only, e.g. "14:30" or "2:30 PM" per the `timeFormat` preference. */
|
||||
@@ -255,6 +264,27 @@ export function makeFormatters(
|
||||
}).format(d);
|
||||
},
|
||||
|
||||
dateShortSmartCalendar: (value) => {
|
||||
const d =
|
||||
typeof value === "string" && /^\d{4}-\d{2}-\d{2}$/.test(value)
|
||||
? new Date(`${value}T12:00:00.000Z`)
|
||||
: asDate(value);
|
||||
const year = getDateTimeFormat(dateLocale, {
|
||||
timeZone: "UTC",
|
||||
year: "numeric",
|
||||
}).format(d);
|
||||
const currentYear = getDateTimeFormat(dateLocale, {
|
||||
timeZone: tz,
|
||||
year: "numeric",
|
||||
}).format(new Date());
|
||||
return getDateTimeFormat(dateLocale, {
|
||||
timeZone: "UTC",
|
||||
day: "2-digit",
|
||||
month: "2-digit",
|
||||
...(year === currentYear ? {} : { year: "numeric" as const }),
|
||||
}).format(d);
|
||||
},
|
||||
|
||||
dateTime: (value) =>
|
||||
getDateTimeFormat(dateLocale, {
|
||||
timeZone: tz,
|
||||
|
||||
@@ -54,7 +54,10 @@ vi.mock("@/lib/measurements/pick-canonical-workout-rows", () => ({
|
||||
pickCanonicalWorkoutRows: vi.fn((rows) => rows),
|
||||
}));
|
||||
|
||||
import { runWorkoutInsightGenerate } from "../workout-insight-generate";
|
||||
import {
|
||||
runWorkoutInsightGenerate,
|
||||
workoutInsightCapWhere,
|
||||
} from "../workout-insight-generate";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { aiCapabilityForJob } from "@/lib/ai/capabilities/gate";
|
||||
import { resolveUserTimezone } from "@/lib/tz/resolver";
|
||||
@@ -224,8 +227,10 @@ describe("Activity Insight — the gate stack", () => {
|
||||
expect(outcome.status).toBe("generated");
|
||||
});
|
||||
|
||||
it("gate 3 — refuses once four paragraphs exist for the local day", async () => {
|
||||
vi.mocked(prisma.workoutInsight.count).mockResolvedValue(4 as never);
|
||||
it("gate 3 — refuses once four slots are spent for the local day", async () => {
|
||||
vi.mocked(prisma.workoutInsightGenerationClaim.count).mockResolvedValue(
|
||||
4 as never,
|
||||
);
|
||||
|
||||
const outcome = await runWorkoutInsightGenerate(
|
||||
{ userId: USER, workoutId: WORKOUT },
|
||||
@@ -237,36 +242,33 @@ describe("Activity Insight — the gate stack", () => {
|
||||
});
|
||||
|
||||
it("gate 3 — counts in the USER's timezone, not UTC", async () => {
|
||||
// 00:30 Berlin on the 19th is 22:30 UTC on the 18th. A UTC-keyed window
|
||||
// would still be counting the 18th's paragraphs against the new local day.
|
||||
// 00:30 Berlin on the 19th is 22:30 UTC on the 18th. A UTC-keyed day
|
||||
// would still be counting the 18th's claims against the new local day.
|
||||
vi.mocked(resolveUserTimezone).mockResolvedValue("Europe/Berlin");
|
||||
await runWorkoutInsightGenerate(
|
||||
{ userId: USER, workoutId: WORKOUT },
|
||||
new Date("2026-07-18T22:30:00.000Z"),
|
||||
);
|
||||
|
||||
const where = vi.mocked(prisma.workoutInsight.count).mock.calls[0][0]
|
||||
?.where as { generatedAt: { gte: Date } };
|
||||
// Midnight Berlin on the 19th == 22:00 UTC on the 18th.
|
||||
expect(where.generatedAt.gte.toISOString()).toBe(
|
||||
"2026-07-18T22:00:00.000Z",
|
||||
);
|
||||
const where = vi.mocked(prisma.workoutInsightGenerationClaim.count).mock
|
||||
.calls[0][0]?.where as { localDate: string };
|
||||
expect(where.localDate).toBe("2026-07-19");
|
||||
});
|
||||
|
||||
it("gate 3 — starts a midday cap window at exact local midnight", async () => {
|
||||
vi.mocked(resolveUserTimezone).mockResolvedValue("Europe/Berlin");
|
||||
vi.mocked(prisma.workoutInsight.count).mockResolvedValue(4 as never);
|
||||
it("gate 3 — the cap reads the claim ledger, never the insight rows", async () => {
|
||||
// A paragraph whose workout was deleted is gone from `workout_insights`,
|
||||
// so counting those rows would hand the slot back. Only the claims count.
|
||||
vi.mocked(prisma.workoutInsightGenerationClaim.count).mockResolvedValue(
|
||||
4 as never,
|
||||
);
|
||||
vi.mocked(prisma.workoutInsight.count).mockResolvedValue(0 as never);
|
||||
|
||||
const outcome = await runWorkoutInsightGenerate(
|
||||
{ userId: USER, workoutId: WORKOUT },
|
||||
new Date("2026-07-18T15:00:45.678Z"),
|
||||
);
|
||||
|
||||
const where = vi.mocked(prisma.workoutInsight.count).mock.calls[0][0]
|
||||
?.where as { generatedAt: { gte: Date } };
|
||||
expect(where.generatedAt.gte.toISOString()).toBe(
|
||||
"2026-07-17T22:00:00.000Z",
|
||||
);
|
||||
expect(prisma.workoutInsight.count).not.toHaveBeenCalled();
|
||||
expect(outcome).toEqual({ status: "skipped", reason: "daily_cap" });
|
||||
expect(runStatusCompletion).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -304,7 +306,9 @@ describe("Activity Insight — the gate stack", () => {
|
||||
|
||||
vi.clearAllMocks();
|
||||
arrangeHappyPath();
|
||||
vi.mocked(prisma.workoutInsight.count).mockResolvedValue(0 as never);
|
||||
vi.mocked(prisma.workoutInsightGenerationClaim.count).mockResolvedValue(
|
||||
0 as never,
|
||||
);
|
||||
vi.mocked(prisma.workoutInsight.findFirst).mockResolvedValue({
|
||||
id: "wi-1",
|
||||
inputHash: stored.inputHash,
|
||||
@@ -321,7 +325,9 @@ describe("Activity Insight — the gate stack", () => {
|
||||
// The mirror of the test above: a fresh workout (no stored row, so the hash
|
||||
// gate cannot fire) is still refused once the day's count is spent.
|
||||
vi.mocked(prisma.workoutInsight.findFirst).mockResolvedValue(null as never);
|
||||
vi.mocked(prisma.workoutInsight.count).mockResolvedValue(4 as never);
|
||||
vi.mocked(prisma.workoutInsightGenerationClaim.count).mockResolvedValue(
|
||||
4 as never,
|
||||
);
|
||||
|
||||
const outcome = await runWorkoutInsightGenerate(
|
||||
{ userId: USER, workoutId: WORKOUT },
|
||||
@@ -660,3 +666,37 @@ describe("Activity Insight — own-history comparison", () => {
|
||||
expect(where.id).toEqual({ not: WORKOUT });
|
||||
});
|
||||
});
|
||||
|
||||
describe("Activity Insight — the cap predicate", () => {
|
||||
const staleBefore = new Date("2026-07-18T14:50:00.000Z");
|
||||
|
||||
it("counts every provider attempt and every live lease, nothing narrower", () => {
|
||||
const where = workoutInsightCapWhere({
|
||||
userId: USER,
|
||||
localDate: "2026-07-18",
|
||||
staleBefore,
|
||||
});
|
||||
expect(where).toEqual({
|
||||
userId: USER,
|
||||
localDate: "2026-07-18",
|
||||
OR: [
|
||||
{ providerInvokedAt: { not: null } },
|
||||
{ claimId: { not: null }, claimedAt: { gte: staleBefore } },
|
||||
],
|
||||
});
|
||||
// A completed claim whose workout is gone is still a spent slot: the
|
||||
// predicate must not narrow on the workout or on completion.
|
||||
expect(JSON.stringify(where)).not.toContain("workoutId");
|
||||
expect(JSON.stringify(where)).not.toContain("completedAt");
|
||||
});
|
||||
|
||||
it("leaves the reclaiming row out of its own count", () => {
|
||||
const where = workoutInsightCapWhere({
|
||||
userId: USER,
|
||||
localDate: "2026-07-18",
|
||||
staleBefore,
|
||||
excludeClaimRowId: "claim-row-1",
|
||||
});
|
||||
expect(where).toMatchObject({ id: { not: "claim-row-1" } });
|
||||
});
|
||||
});
|
||||
@@ -115,7 +115,10 @@ async function runAdmitted(
|
||||
return;
|
||||
}
|
||||
case "lab-biomarker-backfill": {
|
||||
const { markers, linked } = await runLabBiomarkerBackfillForUser(userId);
|
||||
const { markers, linked } = await runLabBiomarkerBackfillForUser(
|
||||
userId,
|
||||
shouldStop,
|
||||
);
|
||||
totals.markers += markers;
|
||||
totals.linked += linked;
|
||||
workerLog(
|
||||
|
||||
@@ -103,9 +103,16 @@ export function groupRowsByAnalyte(rows: LegacyRow[]): AnalyteGroup[] {
|
||||
* Backfill one user: group un-linked live readings, create/reuse a Biomarker
|
||||
* per group, link the rows. Returns the count of markers touched + rows
|
||||
* linked.
|
||||
*
|
||||
* `shouldStop` is the admission job's budget (`jobBudget`). It is asked
|
||||
* before each analyte group; once it says stop, the pass throws without
|
||||
* touching the rest, like the Google Health backfill does. Each group commits
|
||||
* on its own and a linked reading drops out of the scan above, so the retry
|
||||
* pg-boss schedules for the failed job starts where this one stopped.
|
||||
*/
|
||||
export async function runLabBiomarkerBackfillForUser(
|
||||
userId: string,
|
||||
shouldStop: () => boolean = () => false,
|
||||
): Promise<{ markers: number; linked: number }> {
|
||||
const rows = await prisma.labResult.findMany({
|
||||
where: { userId, deletedAt: null, biomarkerId: null },
|
||||
@@ -129,6 +136,18 @@ export async function runLabBiomarkerBackfillForUser(
|
||||
let markers = 0;
|
||||
let linked = 0;
|
||||
for (const group of groups) {
|
||||
if (shouldStop()) {
|
||||
if (linked > 0) invalidateUserHealthScore(userId);
|
||||
annotate({
|
||||
action: {
|
||||
name: "labs.biomarker.backfill.stopped",
|
||||
details: { markers, linked, groupsLeft: groups.length - markers },
|
||||
},
|
||||
});
|
||||
throw new Error(
|
||||
`lab-biomarker backfill for user ${userId} stopped on its job budget after ${markers} of ${groups.length} groups; the retry resumes`,
|
||||
);
|
||||
}
|
||||
// Re-use a hand-defined marker of the same name if one exists; otherwise
|
||||
// mint it. `(userId, name)` is unique, so a concurrent re-run converges.
|
||||
const biomarker = await prisma.biomarker.upsert({
|
||||
|
||||
@@ -292,6 +292,7 @@ export const QUEUE_RUNTIME: Readonly<Record<string, QueueRuntime>> = {
|
||||
"idempotency-cleanup": short("One retention DELETE."),
|
||||
"step-up-elevation-cleanup": short("One retention DELETE."),
|
||||
"coach-message-cleanup": short("One retention DELETE."),
|
||||
"workout-insight-claim-cleanup": short("One retention DELETE."),
|
||||
"mcp-token-cleanup": short("One retention DELETE."),
|
||||
"audit-log-cleanup": short("At most 40 batches of 5 000 rows per leg."),
|
||||
"measurement-tombstone-cleanup": short(
|
||||
|
||||
@@ -31,6 +31,7 @@ import { cleanupExpiredIdempotencyKeys } from "@/lib/jobs/idempotency-cleanup";
|
||||
import { cleanupExpiredMcpTokens } from "@/lib/jobs/mcp-token-cleanup";
|
||||
import { cleanupOldAuditLogs } from "@/lib/jobs/audit-log-cleanup";
|
||||
import { cleanupOldCoachMessages } from "@/lib/jobs/coach-message-cleanup";
|
||||
import { cleanupOldWorkoutInsightClaims } from "@/lib/jobs/workout-insight-claim-cleanup";
|
||||
import { cleanupExpiredWithingsOAuthStates } from "@/lib/jobs/withings-oauth-state-cleanup";
|
||||
import { cleanupExpiredOidcNativeHandoffs } from "@/lib/jobs/oidc-handoff-cleanup";
|
||||
import {
|
||||
@@ -395,6 +396,30 @@ export async function handleCoachMessageCleanup(
|
||||
});
|
||||
}
|
||||
|
||||
export interface WorkoutInsightClaimCleanupPayload {
|
||||
triggeredAt: string;
|
||||
}
|
||||
|
||||
export async function handleWorkoutInsightClaimCleanup(
|
||||
jobs: Job<WorkoutInsightClaimCleanupPayload>[],
|
||||
): Promise<JobOutcome> {
|
||||
void jobs;
|
||||
return withBackgroundEvent(
|
||||
"job.workout_insight_claim_cleanup",
|
||||
async (evt) => {
|
||||
const p = getWorkerPrisma();
|
||||
try {
|
||||
const deleted = await cleanupOldWorkoutInsightClaims(p);
|
||||
evt.addMeta("workout_insight_claim_cleanup_deleted", deleted);
|
||||
return jobDone({ deleted });
|
||||
} catch (err) {
|
||||
evt.addWarning(`workout-insight-claim-cleanup failed: ${err}`);
|
||||
return jobFailed("workout-insight-claim cleanup failed", err);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export interface McpTokenCleanupPayload {
|
||||
triggeredAt: string;
|
||||
}
|
||||
|
||||
@@ -201,6 +201,8 @@ import {
|
||||
handlePushAttemptCleanup,
|
||||
ArrivalReactionCleanupPayload,
|
||||
handleArrivalReactionCleanup,
|
||||
WorkoutInsightClaimCleanupPayload,
|
||||
handleWorkoutInsightClaimCleanup,
|
||||
MeasurementTombstoneCleanupPayload,
|
||||
handleMeasurementTombstoneCleanup,
|
||||
handleRateLimitCleanup,
|
||||
@@ -342,6 +344,13 @@ const MEASUREMENT_TOMBSTONE_CLEANUP_CRON = "40 3 * * *";
|
||||
const COACH_MESSAGE_CLEANUP_QUEUE = "coach-message-cleanup";
|
||||
|
||||
const COACH_MESSAGE_CLEANUP_CRON = "50 3 * * *";
|
||||
// v1.40 — daily prune of the workout-insight claim ledger. Claims outlive a
|
||||
// deleted workout so the delete cannot free a daily slot; rows older than a
|
||||
// week are never read by the cap again.
|
||||
|
||||
const WORKOUT_INSIGHT_CLAIM_CLEANUP_QUEUE = "workout-insight-claim-cleanup";
|
||||
|
||||
const WORKOUT_INSIGHT_CLAIM_CLEANUP_CRON = "52 3 * * *";
|
||||
// v1.23 — converging backfill that migrates the free-text health-note columns
|
||||
// (mood + measurement) from plaintext to AES-256-GCM at rest. Boot discovery
|
||||
// enqueues one per-user job; a daily 03:55 Europe/Berlin discovery tick (empty
|
||||
@@ -457,6 +466,10 @@ const allQueues = [
|
||||
// the daily schedule silently no-ops and the encrypted coach_messages
|
||||
// table grows unbounded.
|
||||
COACH_MESSAGE_CLEANUP_QUEUE,
|
||||
// v1.40 — workout-insight claim ledger retention prune. Without this entry
|
||||
// the daily schedule silently no-ops and the claim table keeps every row
|
||||
// whose workout was deleted.
|
||||
WORKOUT_INSIGHT_CLAIM_CLEANUP_QUEUE,
|
||||
// v1.23 — free-text health-note encryption backfill. Boot discovery + a
|
||||
// daily discovery cron enqueue one per-user job per account still holding a
|
||||
// plaintext note; without this entry pg-boss never provisions the queue and
|
||||
@@ -625,6 +638,12 @@ const schedules: ScheduleEntry[] = [
|
||||
[MEASUREMENT_TOMBSTONE_CLEANUP_QUEUE, MEASUREMENT_TOMBSTONE_CLEANUP_CRON],
|
||||
// v1.18.7 — daily 03:50 Europe/Berlin prune for stale Coach history.
|
||||
[COACH_MESSAGE_CLEANUP_QUEUE, COACH_MESSAGE_CLEANUP_CRON, cronIsTheRetry],
|
||||
// v1.40 — daily 03:52 Europe/Berlin prune for the workout-insight claims.
|
||||
[
|
||||
WORKOUT_INSIGHT_CLAIM_CLEANUP_QUEUE,
|
||||
WORKOUT_INSIGHT_CLAIM_CLEANUP_CRON,
|
||||
cronIsTheRetry,
|
||||
],
|
||||
// v1.23 — daily 03:55 Europe/Berlin note-encryption backfill discovery.
|
||||
// The empty cron payload (no `userId`) is the handler's signal to fan out
|
||||
// one per-user job per account still holding a plaintext note.
|
||||
@@ -941,6 +960,14 @@ export async function registerMaintenanceQueues(
|
||||
{ localConcurrency: 1 },
|
||||
handleCoachMessageCleanup,
|
||||
);
|
||||
// v1.40 — daily prune of the workout-insight claim ledger. Single-flight
|
||||
// like every other cleanup queue.
|
||||
await createAndWork<WorkoutInsightClaimCleanupPayload>(
|
||||
boss,
|
||||
WORKOUT_INSIGHT_CLAIM_CLEANUP_QUEUE,
|
||||
{ localConcurrency: 1 },
|
||||
handleWorkoutInsightClaimCleanup,
|
||||
);
|
||||
// MCP Phase 3 (M2) — daily prune of dead MCP connector access tokens +
|
||||
// long-revoked connection anchors. Single-flight like every other cleanup.
|
||||
await createAndWork<McpTokenCleanupPayload>(
|
||||
|
||||
@@ -124,6 +124,7 @@ const subsystemSurface = {
|
||||
"achievement-unlock-sweep": { audience: "system" },
|
||||
"measurement-tombstone-cleanup": { audience: "system" },
|
||||
"coach-message-cleanup": { audience: "system" },
|
||||
"workout-insight-claim-cleanup": { audience: "system" },
|
||||
"note-encryption-backfill": { audience: "system" },
|
||||
"encryption-key-rotate": { audience: "system" },
|
||||
"mcp-token-cleanup": { audience: "system" },
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* Daily retention for `workout_insight_generation_claims`.
|
||||
*
|
||||
* The claim row is the ledger behind the per-workout insight daily cap, and it
|
||||
* outlives its workout on purpose: deleting a workout nulls the claim's
|
||||
* `workout_id` instead of removing the row, so the delete cannot free the
|
||||
* day's slot. The cap only ever reads today's rows, so anything older than the
|
||||
* retention window is dead weight and is dropped here.
|
||||
*
|
||||
* Keyed on `updated_at`, which moves on every claim, reclaim, provider
|
||||
* invocation and completion. A row written within the window is kept whatever
|
||||
* its `local_date` says, so no row that the cap can still read is ever
|
||||
* deleted.
|
||||
*/
|
||||
import type { PrismaClient } from "@/generated/prisma/client";
|
||||
|
||||
export const WORKOUT_INSIGHT_CLAIM_RETENTION_DAYS = 7;
|
||||
|
||||
export async function cleanupOldWorkoutInsightClaims(
|
||||
prisma: Pick<PrismaClient, "workoutInsightGenerationClaim">,
|
||||
now: Date = new Date(),
|
||||
): Promise<number> {
|
||||
const cutoff = new Date(
|
||||
now.getTime() - WORKOUT_INSIGHT_CLAIM_RETENTION_DAYS * 86_400_000,
|
||||
);
|
||||
const { count } = await prisma.workoutInsightGenerationClaim.deleteMany({
|
||||
where: { updatedAt: { lt: cutoff } },
|
||||
});
|
||||
return count;
|
||||
}
|
||||
@@ -55,7 +55,6 @@ import { withBackgroundEvent } from "@/lib/logging/background";
|
||||
import { aiCapabilityForJob } from "@/lib/ai/capabilities/gate";
|
||||
import { userDayKey } from "@/lib/tz/format";
|
||||
import { resolveUserTimezone } from "@/lib/tz/resolver";
|
||||
import { startOfLocalDayInTz } from "@/lib/tz/local-day";
|
||||
import { buildWorkoutHrSeries } from "@/lib/workouts/hr-series";
|
||||
import {
|
||||
buildWorkoutInsightEvidence,
|
||||
@@ -98,10 +97,42 @@ type ClaimWorkoutInsightGenerationArgs = {
|
||||
userId: string;
|
||||
workoutId: string;
|
||||
localDate: string;
|
||||
dayStart: Date;
|
||||
now: Date;
|
||||
};
|
||||
|
||||
/**
|
||||
* Which claim rows spend one of the day's slots.
|
||||
*
|
||||
* The claim row is the ledger. A slot is spent by every attempt that reached
|
||||
* the provider (completed or not, and whether or not its workout still exists:
|
||||
* deleting a workout nulls the claim's `workout_id` instead of removing the
|
||||
* row, so the delete cannot buy the slot back) plus every live lease that may
|
||||
* still reach the provider. A lease past its expiry that never invoked the
|
||||
* provider spent nothing and is reclaimable. Rows older than a week are
|
||||
* dropped by `workout-insight-claim-cleanup`.
|
||||
*/
|
||||
export function workoutInsightCapWhere({
|
||||
userId,
|
||||
localDate,
|
||||
staleBefore,
|
||||
excludeClaimRowId,
|
||||
}: {
|
||||
userId: string;
|
||||
localDate: string;
|
||||
staleBefore: Date;
|
||||
excludeClaimRowId?: string;
|
||||
}) {
|
||||
return {
|
||||
userId,
|
||||
localDate,
|
||||
...(excludeClaimRowId ? { id: { not: excludeClaimRowId } } : {}),
|
||||
OR: [
|
||||
{ providerInvokedAt: { not: null } },
|
||||
{ claimId: { not: null }, claimedAt: { gte: staleBefore } },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Serialize the local-day capacity check with the durable claim insert.
|
||||
* PostgreSQL advisory transaction locks keep the count and write atomic across
|
||||
@@ -112,7 +143,6 @@ export async function claimWorkoutInsightGeneration({
|
||||
userId,
|
||||
workoutId,
|
||||
localDate,
|
||||
dayStart,
|
||||
now,
|
||||
}: ClaimWorkoutInsightGenerationArgs): Promise<WorkoutInsightClaimResult> {
|
||||
const claimId = randomUUID();
|
||||
@@ -153,20 +183,15 @@ export async function claimWorkoutInsightGeneration({
|
||||
return { status: "skipped", reason: "already_claimed" };
|
||||
}
|
||||
|
||||
const [generatedToday, claimedToday] = await Promise.all([
|
||||
tx.workoutInsight.count({
|
||||
where: { userId, generatedAt: { gte: dayStart } },
|
||||
const spentToday = await tx.workoutInsightGenerationClaim.count({
|
||||
where: workoutInsightCapWhere({
|
||||
userId,
|
||||
localDate,
|
||||
staleBefore,
|
||||
excludeClaimRowId: existing?.id,
|
||||
}),
|
||||
tx.workoutInsightGenerationClaim.count({
|
||||
where: {
|
||||
userId,
|
||||
localDate,
|
||||
completedAt: null,
|
||||
...(existing ? { workoutId: { not: workoutId } } : {}),
|
||||
},
|
||||
}),
|
||||
]);
|
||||
if (generatedToday + claimedToday >= MAX_INSIGHTS_PER_DAY) {
|
||||
});
|
||||
if (spentToday >= MAX_INSIGHTS_PER_DAY) {
|
||||
return { status: "skipped", reason: "daily_cap" };
|
||||
}
|
||||
|
||||
@@ -270,13 +295,11 @@ export async function runWorkoutInsightGenerate(
|
||||
// The capacity check and durable claim write are one serialized PostgreSQL
|
||||
// transaction. A count performed before the write would let concurrent
|
||||
// workouts all observe the same free slot and exceed the cap.
|
||||
const dayStart = startOfLocalDayInTz(now, tz);
|
||||
const localDate = userDayKey(now, tz);
|
||||
const claim = await claimWorkoutInsightGeneration({
|
||||
userId,
|
||||
workoutId,
|
||||
localDate,
|
||||
dayStart,
|
||||
now,
|
||||
});
|
||||
if (claim.status === "skipped") {
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { parseDurationEntry, splitDurationMinutes } from "../parse-duration";
|
||||
|
||||
describe("parseDurationEntry — an hours field", () => {
|
||||
it.each([
|
||||
["7.5", 7.5, 450],
|
||||
["7,5", 7.5, 450],
|
||||
[" 8 ", 8, 480],
|
||||
["7:30", 7.5, 450],
|
||||
["7:05", 7 + 5 / 60, 425],
|
||||
["0:45", 0.75, 45],
|
||||
["7h", 7, 420],
|
||||
["7h30", 7.5, 450],
|
||||
["7h30m", 7.5, 450],
|
||||
["7h 30m", 7.5, 450],
|
||||
["7 h 30 min", 7.5, 450],
|
||||
["7 hrs. 30 mins.", 7.5, 450],
|
||||
["7H30M", 7.5, 450],
|
||||
["8h52m", 8 + 52 / 60, 532],
|
||||
["7,5h", 7.5, 450],
|
||||
["45m", 0.75, 45],
|
||||
["45 min", 0.75, 45],
|
||||
["7 Std 30 Min", 7.5, 450],
|
||||
["7 Stunden 30 Minuten", 7.5, 450],
|
||||
["7u30", 7.5, 450],
|
||||
["7시간 30분", 7.5, 450],
|
||||
["24", 24, 1440],
|
||||
])("reads %j as %d h", (raw, hours, minutes) => {
|
||||
const result = parseDurationEntry(raw, "h");
|
||||
expect(result.ok).toBe(true);
|
||||
if (!result.ok) return;
|
||||
expect(result.value).toBeCloseTo(hours, 10);
|
||||
expect(result.minutes).toBe(minutes);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["", "empty"],
|
||||
[" ", "empty"],
|
||||
["-7", "invalid"],
|
||||
["-7h", "invalid"],
|
||||
["abc", "invalid"],
|
||||
["7 30", "invalid"],
|
||||
["7:60", "invalid"],
|
||||
["7h 90m", "invalid"],
|
||||
["7.5.1", "invalid"],
|
||||
["7x", "invalid"],
|
||||
["30m 7h", "invalid"],
|
||||
["25", "tooLong"],
|
||||
["24h 1m", "tooLong"],
|
||||
["1e3", "invalid"],
|
||||
])("refuses %j (%s)", (raw, reason) => {
|
||||
expect(parseDurationEntry(raw, "h")).toEqual({ ok: false, reason });
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseDurationEntry — a minutes field", () => {
|
||||
it.each([
|
||||
["45", 45],
|
||||
["45,5", 45.5],
|
||||
["1h15", 75],
|
||||
["1h 15m", 75],
|
||||
["1:15", 75],
|
||||
["90 min", 90],
|
||||
["2h", 120],
|
||||
])("reads %j as %d min", (raw, minutes) => {
|
||||
const result = parseDurationEntry(raw, "min");
|
||||
expect(result).toMatchObject({ ok: true, value: minutes });
|
||||
});
|
||||
|
||||
it("refuses more than a day", () => {
|
||||
expect(parseDurationEntry("1441", "min")).toEqual({
|
||||
ok: false,
|
||||
reason: "tooLong",
|
||||
});
|
||||
});
|
||||
|
||||
it("honours a caller's own ceiling", () => {
|
||||
expect(parseDurationEntry("3h", "min", 120)).toEqual({
|
||||
ok: false,
|
||||
reason: "tooLong",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("splitDurationMinutes", () => {
|
||||
it("splits into whole hours and the minutes left over", () => {
|
||||
expect(splitDurationMinutes(450)).toEqual({ hours: 7, minutes: 30 });
|
||||
expect(splitDurationMinutes(45)).toEqual({ hours: 0, minutes: 45 });
|
||||
expect(splitDurationMinutes(479.6)).toEqual({ hours: 8, minutes: 0 });
|
||||
});
|
||||
});
|
||||
@@ -26,8 +26,23 @@
|
||||
* arrives.
|
||||
*/
|
||||
|
||||
import type { DurationFieldUnit } from "./parse-duration";
|
||||
|
||||
export const MINUTES_PER_HOUR = 60;
|
||||
|
||||
/**
|
||||
* Types whose entry field asks for a duration, and the unit it asks in. Such a
|
||||
* field reads `7:30` and `7h 30m` as well as a decimal (`parseDurationEntry`).
|
||||
*/
|
||||
const DURATION_ENTRY_UNIT: Readonly<Record<string, DurationFieldUnit>> = {
|
||||
SLEEP_DURATION: "h",
|
||||
};
|
||||
|
||||
/** The duration unit a type's entry field asks in, or null for a plain number. */
|
||||
export function durationEntryUnit(type: string): DurationFieldUnit | null {
|
||||
return DURATION_ENTRY_UNIT[type] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Entry unit → canonical unit multiplier, per measurement type. A type absent
|
||||
* from this table enters in the unit it is stored in.
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
/**
|
||||
* Read a duration the way a person writes one.
|
||||
*
|
||||
* A field that asks for hours used to take only a decimal, so a night of seven
|
||||
* and a half hours had to be typed as `7.5`. People write it as `7:30`,
|
||||
* `7h30m` or `7 h 30 min`, and a minutes field gets `1h15` just as often. This
|
||||
* reads all of those, with the decimal comma, and answers in the field's own
|
||||
* unit so the caller's existing conversion to the stored unit stays the one
|
||||
* place that knows about storage.
|
||||
*
|
||||
* Accepted shapes, `H` and `M` being whole or decimal numbers:
|
||||
*
|
||||
* - `7.5`, `7,5` a bare number in the field's unit
|
||||
* - `7:30` hours and minutes (minutes below 60)
|
||||
* - `7h`, `7h30`, `7h30m`, `7 h 30 min`, `45m`, `45 min`
|
||||
* hours and/or minutes with a unit word; a number
|
||||
* after an hours part without a unit is minutes
|
||||
*
|
||||
* Unit words: h, hr, hrs, hour, hours, std, stunde, stunden, u (Dutch), godz
|
||||
* (Polish), 시간 for hours; m, min, mins, minute, minutes, minuten, 분 for
|
||||
* minutes. Anything else is refused rather than guessed at.
|
||||
*/
|
||||
|
||||
export type DurationFieldUnit = "h" | "min";
|
||||
|
||||
export type DurationParseResult =
|
||||
| {
|
||||
ok: true;
|
||||
/** The value in the field's unit (hours or minutes). */
|
||||
value: number;
|
||||
/** The same duration in whole minutes, for display. */
|
||||
minutes: number;
|
||||
}
|
||||
| { ok: false; reason: "empty" | "invalid" | "tooLong" };
|
||||
|
||||
/** A single manual entry never covers more than a day. */
|
||||
export const MAX_DURATION_MINUTES = 24 * 60;
|
||||
|
||||
const NUMBER = String.raw`(\d+(?:[.,]\d+)?)`;
|
||||
const HOUR_WORD = String.raw`(?:hours?|hrs?|h|stunden?|std|godz|u|시간)`;
|
||||
const MINUTE_WORD = String.raw`(?:minutes?|minuten|mins?|m|분)`;
|
||||
|
||||
const BARE = new RegExp(String.raw`^${NUMBER}$`);
|
||||
const CLOCK = /^(\d+):(\d{1,2})$/;
|
||||
const WORDS = new RegExp(
|
||||
String.raw`^(?:${NUMBER}\s*${HOUR_WORD}\.?)?\s*(?:${NUMBER}\s*(${MINUTE_WORD})?\.?)?$`,
|
||||
"iu",
|
||||
);
|
||||
const MINUTES_ONLY = new RegExp(
|
||||
String.raw`^${NUMBER}\s*${MINUTE_WORD}\.?$`,
|
||||
"iu",
|
||||
);
|
||||
|
||||
function toNumber(raw: string): number {
|
||||
return Number(raw.replace(",", "."));
|
||||
}
|
||||
|
||||
function totalMinutes(
|
||||
raw: string,
|
||||
unit: DurationFieldUnit,
|
||||
): number | null | "invalid" {
|
||||
const text = raw.trim().replace(/\s+/g, " ");
|
||||
if (text === "") return null;
|
||||
|
||||
const bare = BARE.exec(text);
|
||||
if (bare) {
|
||||
const n = toNumber(bare[1]!);
|
||||
return unit === "h" ? n * 60 : n;
|
||||
}
|
||||
|
||||
const clock = CLOCK.exec(text);
|
||||
if (clock) {
|
||||
const minutes = Number(clock[2]);
|
||||
if (minutes >= 60) return "invalid";
|
||||
return Number(clock[1]) * 60 + minutes;
|
||||
}
|
||||
|
||||
const minutesOnly = MINUTES_ONLY.exec(text);
|
||||
if (minutesOnly) return toNumber(minutesOnly[1]!);
|
||||
|
||||
const words = WORDS.exec(text);
|
||||
// An hours part is required here: a lone number was handled above, and
|
||||
// "30m" by the minutes-only shape.
|
||||
if (words && words[1] !== undefined) {
|
||||
const hours = toNumber(words[1]);
|
||||
if (words[2] === undefined) return hours * 60;
|
||||
const minutes = toNumber(words[2]);
|
||||
// "7h 90" is more likely a typo than an hour and a half.
|
||||
if (minutes >= 60) return "invalid";
|
||||
return hours * 60 + minutes;
|
||||
}
|
||||
return "invalid";
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse `raw` for a field that asks in `unit`. A negative sign, an unknown
|
||||
* word or a duration longer than `maxMinutes` is refused.
|
||||
*/
|
||||
export function parseDurationEntry(
|
||||
raw: string,
|
||||
unit: DurationFieldUnit,
|
||||
maxMinutes: number = MAX_DURATION_MINUTES,
|
||||
): DurationParseResult {
|
||||
const minutes = totalMinutes(raw, unit);
|
||||
if (minutes === null) return { ok: false, reason: "empty" };
|
||||
if (minutes === "invalid" || !Number.isFinite(minutes)) {
|
||||
return { ok: false, reason: "invalid" };
|
||||
}
|
||||
if (minutes > maxMinutes) return { ok: false, reason: "tooLong" };
|
||||
return {
|
||||
ok: true,
|
||||
value: unit === "h" ? minutes / 60 : minutes,
|
||||
minutes: Math.round(minutes),
|
||||
};
|
||||
}
|
||||
|
||||
/** Whole hours and the minutes left over, for the "read as" hint. */
|
||||
export function splitDurationMinutes(minutes: number): {
|
||||
hours: number;
|
||||
minutes: number;
|
||||
} {
|
||||
const whole = Math.max(0, Math.round(minutes));
|
||||
return { hours: Math.floor(whole / 60), minutes: whole % 60 };
|
||||
}
|
||||
@@ -83,6 +83,7 @@ export const ERROR_CODE_CATALOGUE: Readonly<Record<string, readonly string[]>> =
|
||||
"backup.section.missing",
|
||||
"backup.upload.too_large",
|
||||
],
|
||||
biomarkers: ["biomarkers.unit.locked"],
|
||||
"body-site": ["body-site.invalid"],
|
||||
coach: [
|
||||
"coach.budget.exceeded",
|
||||
|
||||
@@ -155,7 +155,7 @@ export const biomarkerPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
tags: ["Labs"],
|
||||
summary: "Edit a biomarker",
|
||||
description:
|
||||
"Partial edit; omitted fields are untouched, an explicit null clears `context` / `panel` / a bound. Audits as `biomarker.update`.",
|
||||
"Partial edit; omitted fields are untouched, an explicit null clears `context` / `panel` / a bound. Audits as `biomarker.update`. Since v1.40.0 a `unit` different from the stored one is refused with 422 `biomarkers.unit.locked` while any reading is linked to the marker (`meta.readingCount` carries how many): readers label every linked reading with the marker's unit, so a change would relabel stored numbers without converting them. Sending the current unit, or changing it on a marker with no readings, is accepted.",
|
||||
requestParams: { path: z.object({ id: z.string() }) },
|
||||
requestBody: {
|
||||
required: true,
|
||||
|
||||
@@ -186,7 +186,7 @@ const dashboardMetricCard = z
|
||||
.string()
|
||||
.nullable()
|
||||
.describe(
|
||||
"Explicit unit token, set only on the `sleep` card (`h`, because its value is a per-night total in hours rather than the canonical SLEEP_DURATION minutes). Null on every other kind.",
|
||||
"Explicit unit token. When set it wins over `unitKey`. Set on the `sleep` card (`h`, because its value is a per-night total in hours rather than the canonical SLEEP_DURATION minutes) and, since v1.40.0, on the `glucose` card: the record owner's glucose unit (`mg/dL` or `mmol/L`), with `latestValue`, `secondaryValue` and `sparkline` converted to it (mmol/L to one decimal). Null on every other kind.",
|
||||
),
|
||||
sleepStages: z
|
||||
.record(z.string(), z.number())
|
||||
|
||||
@@ -38,9 +38,9 @@ const workoutBatchEntryResult = z
|
||||
.object({
|
||||
index: z.number().int().nonnegative(),
|
||||
status: z
|
||||
.enum(["inserted", "duplicate", "enriched", "skipped"])
|
||||
.enum(["inserted", "duplicate", "enriched", "updated", "skipped"])
|
||||
.describe(
|
||||
"`inserted`: a new workout row. `duplicate`: the workout was already stored and nothing was written. `enriched`: the workout was already stored and the heart-rate series this entry carried was attached to it; the workout row itself was not written, and the entry also counts towards the top-level `duplicates`. `skipped`: the server refused the entry, see `reason`.",
|
||||
"`inserted`: a new workout row. `duplicate`: the workout was already stored and nothing was written. `enriched`: the workout was already stored and the heart-rate series this entry carried was attached to it; the workout row itself was not written, and the entry also counts towards the top-level `duplicates`. `updated` (since v1.40.0): a workout entered by hand (`source: MANUAL`, `externalId` starting `manual:`) was sent again with different values and the stored row now carries them; no row was inserted, so it also counts towards `duplicates`. `skipped`: the server refused the entry, see `reason`.",
|
||||
),
|
||||
reason: z
|
||||
.string()
|
||||
@@ -349,7 +349,7 @@ export const workoutPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
tags: ["Measurements"],
|
||||
summary: "Typed workout batch ingest (v1.4.25 W16b)",
|
||||
description:
|
||||
'Server-side ingest endpoint for HKWorkout records (iOS) and Withings activity rows (server-to-server). Up to 100 workouts per call; nested route geometry (GeoJSON LineString) is capped at 20 000 points and stored in a 1:1 `WorkoutRoute` row keyed by `workoutId`. Idempotent via the `Idempotency-Key` header (replay window 24h). Per-entry status (`inserted | duplicate | enriched | skipped`) lets the iOS sync cursor checkpoint accurately. An entry matching a stored workout is first-write-wins for the workout row; when it carries a `samples` array and the stored workout has no heart-rate series, the series is attached and the entry reports `enriched` (repeatable: a workout that already has a series is a no-op). An entry whose `samples` array fails validation is refused on its own with `reason: "invalid_samples"` — the rest of the batch still lands. The request body ceiling is 5 MB enforced at the HTTP layer — clients above the ceiling receive a 413 with `workout.batch.payload_too_large`.\n\n' +
|
||||
'Server-side ingest endpoint for HKWorkout records (iOS) and Withings activity rows (server-to-server). Up to 100 workouts per call; nested route geometry (GeoJSON LineString) is capped at 20 000 points and stored in a 1:1 `WorkoutRoute` row keyed by `workoutId`. Idempotent via the `Idempotency-Key` header (replay window 24h). Per-entry status (`inserted | duplicate | enriched | updated | skipped`) lets the iOS sync cursor checkpoint accurately. An entry matching a stored workout is first-write-wins for the workout row, except a hand-entered one (`source: MANUAL` with a `manual:` externalId), which a resend with other values overwrites and reports `updated`; when it carries a `samples` array and the stored workout has no heart-rate series, the series is attached and the entry reports `enriched` (repeatable: a workout that already has a series is a no-op). An entry whose `samples` array fails validation is refused on its own with `reason: "invalid_samples"` — the rest of the batch still lands. The request body ceiling is 5 MB enforced at the HTTP layer — clients above the ceiling receive a 413 with `workout.batch.payload_too_large`.\n\n' +
|
||||
"**Narrow token.** Also reachable with a `workouts:write` Bearer (minted at `POST /api/tokens/workouts`), the supported way to push workouts from a bridge that is not the phone. This route is the scope's only one. Such a credential writes only on its owner's own record; its workouts are attributed `EXTERNAL`, resolved from the credential, and it may not name a source itself, so a batch in which any entry carries one, `MANUAL` and `APPLE_HEALTH` alike, is refused 422 (`workout.batch.source_not_permitted`) and nothing is written. `EXTERNAL` gives those writes their own `(userId, source, externalId)` namespace, so a bridge's ids can never collide with the phone's. Every other narrow token, `measurements:write` included, is refused 403.",
|
||||
requestBody: {
|
||||
required: true,
|
||||
|
||||
@@ -143,7 +143,7 @@ function makeFakePrisma(state: {
|
||||
return true;
|
||||
}
|
||||
|
||||
return {
|
||||
const fake = {
|
||||
// v1.29.6 — the cumulative-kind path no longer runs a `$queryRaw`
|
||||
// UTC `date_trunc` aggregate; it reads every matching row via
|
||||
// `measurement.findMany` and does the source-collapse + tz-aware
|
||||
@@ -277,6 +277,13 @@ function makeFakePrisma(state: {
|
||||
),
|
||||
},
|
||||
} as unknown as PrismaClient;
|
||||
// The workout-slot leg runs under a per-user advisory lock inside a
|
||||
// transaction; the fake runs the callback against itself.
|
||||
Object.assign(fake, {
|
||||
$queryRaw: vi.fn(async () => []),
|
||||
$transaction: vi.fn(async (cb: (tx: PrismaClient) => unknown) => cb(fake)),
|
||||
});
|
||||
return fake;
|
||||
}
|
||||
|
||||
const USER = "user-1";
|
||||
|
||||
@@ -386,58 +386,65 @@ export async function detectPersonalRecordsForUser(
|
||||
[slot.field]: { gt: 0 } as { gt: number },
|
||||
} as const;
|
||||
|
||||
const sampleCount = await prisma.workout.count({
|
||||
where: baseWhere,
|
||||
// Read the best workout and write its record under the same per-user
|
||||
// lock the workout DELETE takes. Without it a delete could land between
|
||||
// the read and the write and leave a record for a workout that no longer
|
||||
// exists (PersonalRecord has no FK to Workout to stop it).
|
||||
const outcome = await prisma.$transaction(async (tx) => {
|
||||
await lockPersonalRecordsForUser(tx, userId);
|
||||
|
||||
const sampleCount = await tx.workout.count({
|
||||
where: baseWhere,
|
||||
});
|
||||
if (sampleCount < PR_DETECTION_WARMUP_THRESHOLD) return null;
|
||||
|
||||
const best = await findBestWorkout(tx, baseWhere, slot);
|
||||
if (!best) return null;
|
||||
|
||||
const value =
|
||||
slot.field === "durationSec"
|
||||
? best.durationSec
|
||||
: (best.totalDistanceM ?? 0);
|
||||
if (!Number.isFinite(value) || value <= 0) return null;
|
||||
|
||||
const currentPR = await tx.personalRecord.findFirst({
|
||||
where: { userId, metricType: slot.metricType, metricSlot: slot.slot },
|
||||
orderBy:
|
||||
slot.direction === PersonalRecordDirection.MAX
|
||||
? { value: "desc" }
|
||||
: { value: "asc" },
|
||||
});
|
||||
|
||||
const comparison = compareToCurrentBest(
|
||||
value,
|
||||
currentPR?.value,
|
||||
slot.direction,
|
||||
);
|
||||
if (comparison === "no-improvement") return null;
|
||||
|
||||
const result = await tx.personalRecord.createMany({
|
||||
data: [
|
||||
{
|
||||
userId,
|
||||
metricType: slot.metricType,
|
||||
metricSlot: slot.slot,
|
||||
direction: slot.direction,
|
||||
value,
|
||||
unit: slot.unit,
|
||||
achievedAt: best.startedAt,
|
||||
sourceMeasurementId: null,
|
||||
source: best.source,
|
||||
externalId: best.externalId,
|
||||
},
|
||||
],
|
||||
skipDuplicates: true,
|
||||
});
|
||||
return result.count > 0 ? comparison : null;
|
||||
});
|
||||
if (sampleCount < PR_DETECTION_WARMUP_THRESHOLD) continue;
|
||||
|
||||
const best = await findBestWorkout(prisma, baseWhere, slot);
|
||||
if (!best) continue;
|
||||
|
||||
const value =
|
||||
slot.field === "durationSec"
|
||||
? best.durationSec
|
||||
: (best.totalDistanceM ?? 0);
|
||||
if (!Number.isFinite(value) || value <= 0) continue;
|
||||
|
||||
const currentPR = await prisma.personalRecord.findFirst({
|
||||
where: { userId, metricType: slot.metricType, metricSlot: slot.slot },
|
||||
orderBy:
|
||||
slot.direction === PersonalRecordDirection.MAX
|
||||
? { value: "desc" }
|
||||
: { value: "asc" },
|
||||
});
|
||||
|
||||
const outcome = compareToCurrentBest(
|
||||
value,
|
||||
currentPR?.value,
|
||||
slot.direction,
|
||||
);
|
||||
if (outcome === "no-improvement") continue;
|
||||
|
||||
const result = await prisma.personalRecord.createMany({
|
||||
data: [
|
||||
{
|
||||
userId,
|
||||
metricType: slot.metricType,
|
||||
metricSlot: slot.slot,
|
||||
direction: slot.direction,
|
||||
value,
|
||||
unit: slot.unit,
|
||||
achievedAt: best.startedAt,
|
||||
sourceMeasurementId: null,
|
||||
source: best.source,
|
||||
externalId: best.externalId,
|
||||
},
|
||||
],
|
||||
skipDuplicates: true,
|
||||
});
|
||||
if (result.count > 0) {
|
||||
if (outcome === "tie") {
|
||||
ties += 1;
|
||||
} else {
|
||||
inserted += 1;
|
||||
}
|
||||
if (outcome === "tie") {
|
||||
ties += 1;
|
||||
} else if (outcome === "improvement") {
|
||||
inserted += 1;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -669,7 +676,7 @@ async function findBestCumulativeDay(
|
||||
}
|
||||
|
||||
async function findBestWorkout(
|
||||
prisma: PrismaClient,
|
||||
prisma: Pick<PrismaClient, "workout">,
|
||||
where: Prisma.WorkoutWhereInput,
|
||||
slot: WorkoutSlotDefinition,
|
||||
): Promise<WorkoutCandidate | null> {
|
||||
@@ -704,6 +711,33 @@ async function findBestWorkout(
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The per-user lock shared by the workout-slot detection and the workout
|
||||
* DELETE. Transaction-scoped: it releases on commit or rollback.
|
||||
*/
|
||||
export async function lockPersonalRecordsForUser(
|
||||
tx: Pick<PrismaClient, "$queryRaw">,
|
||||
userId: string,
|
||||
): Promise<void> {
|
||||
await tx.$queryRaw`
|
||||
SELECT pg_advisory_xact_lock(
|
||||
hashtext('personal-records'),
|
||||
hashtext(${userId})
|
||||
)::text AS locked
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* The workout-PR slot names the detector derives for one sport. The DELETE
|
||||
* uses it to scope a legacy record match (no external id) to the sport of the
|
||||
* workout being removed.
|
||||
*/
|
||||
export function workoutPrSlotsForSport(sportType: string): string[] {
|
||||
return WORKOUT_SLOTS.filter((slot) => slot.sportType === sportType).map(
|
||||
(slot) => slot.slot,
|
||||
);
|
||||
}
|
||||
|
||||
/** Exposed for tests + the integration harness. */
|
||||
export const __test__ = {
|
||||
WORKOUT_SLOTS,
|
||||
|
||||
@@ -3,7 +3,9 @@
|
||||
* (`/api/biomarkers/{id}`) against a real Postgres.
|
||||
*
|
||||
* Covers the two detail-page mutations:
|
||||
* - PUT adjusts the target range (and rejects an inverted window),
|
||||
* - PUT adjusts the target range (and rejects an inverted window), and
|
||||
* refuses a unit change while readings are linked, because readers
|
||||
* label every reading with the marker's unit,
|
||||
* - DELETE drops the marker together with its readings in one
|
||||
* userId-narrowed transaction, leaving another user's marker untouched.
|
||||
*/
|
||||
@@ -141,6 +143,83 @@ describe("PUT /api/biomarkers/{id} — target range (real Postgres)", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("PUT /api/biomarkers/{id} — unit lock (real Postgres)", () => {
|
||||
it("refuses a unit change while readings are linked and changes nothing", async () => {
|
||||
const { PUT } = await import("@/app/api/biomarkers/[id]/route");
|
||||
const marker = await seedMarkerWithReadings(USER_ID, "Glucose");
|
||||
|
||||
const res = await PUT(
|
||||
putReq(marker.id, { unit: "mmol/L", upperBound: 120 }),
|
||||
params(marker.id),
|
||||
);
|
||||
|
||||
expect(res.status).toBe(422);
|
||||
const body = await res.json();
|
||||
expect(body.data).toBeNull();
|
||||
expect(body.meta).toMatchObject({
|
||||
errorCode: "biomarkers.unit.locked",
|
||||
readingCount: 2,
|
||||
});
|
||||
const row = await getPrismaClient().biomarker.findUniqueOrThrow({
|
||||
where: { id: marker.id },
|
||||
});
|
||||
// Nothing of the request landed, not even the bound beside the unit.
|
||||
expect(row.unit).toBe("mg/dL");
|
||||
expect(row.upperBound).toBe(100);
|
||||
});
|
||||
|
||||
it("counts a tombstoned reading, since an undo brings it back", async () => {
|
||||
const { PUT } = await import("@/app/api/biomarkers/[id]/route");
|
||||
const prisma = getPrismaClient();
|
||||
const marker = await seedMarkerWithReadings(USER_ID, "Ferritin");
|
||||
await prisma.labResult.updateMany({
|
||||
where: { biomarkerId: marker.id },
|
||||
data: { deletedAt: new Date() },
|
||||
});
|
||||
|
||||
const res = await PUT(
|
||||
putReq(marker.id, { unit: "ng/mL" }),
|
||||
params(marker.id),
|
||||
);
|
||||
|
||||
expect(res.status).toBe(422);
|
||||
});
|
||||
|
||||
it("accepts the unchanged unit beside another edit", async () => {
|
||||
const { PUT } = await import("@/app/api/biomarkers/[id]/route");
|
||||
const marker = await seedMarkerWithReadings(USER_ID, "HbA1c");
|
||||
|
||||
const res = await PUT(
|
||||
putReq(marker.id, { unit: "mg/dL", upperBound: 90 }),
|
||||
params(marker.id),
|
||||
);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
const row = await getPrismaClient().biomarker.findUniqueOrThrow({
|
||||
where: { id: marker.id },
|
||||
});
|
||||
expect(row.upperBound).toBe(90);
|
||||
});
|
||||
|
||||
it("allows a unit change on a marker with no readings", async () => {
|
||||
const { PUT } = await import("@/app/api/biomarkers/[id]/route");
|
||||
const marker = await getPrismaClient().biomarker.create({
|
||||
data: { userId: USER_ID, name: "TSH", unit: "mU/L" },
|
||||
});
|
||||
|
||||
const res = await PUT(
|
||||
putReq(marker.id, { unit: "µIU/mL" }),
|
||||
params(marker.id),
|
||||
);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
const row = await getPrismaClient().biomarker.findUniqueOrThrow({
|
||||
where: { id: marker.id },
|
||||
});
|
||||
expect(row.unit).toBe("µIU/mL");
|
||||
});
|
||||
});
|
||||
|
||||
describe("DELETE /api/biomarkers/{id} (real Postgres)", () => {
|
||||
it("drops the marker together with its readings", async () => {
|
||||
const { DELETE } = await import("@/app/api/biomarkers/[id]/route");
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
/**
|
||||
* The lab-biomarker backfill stops on its job budget between analyte groups,
|
||||
* against a real Postgres.
|
||||
*
|
||||
* A stop must leave every group it finished linked and every other reading
|
||||
* untouched, and the retry must pick up exactly the rest: a linked reading
|
||||
* drops out of the backfill's scan, so nothing is linked twice and nothing is
|
||||
* skipped.
|
||||
*/
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
|
||||
import { getPrismaClient, truncateAllTables } from "./setup";
|
||||
import { runLabBiomarkerBackfillForUser } from "@/lib/jobs/lab-biomarker-backfill";
|
||||
|
||||
const USER_ID = "user-lab-backfill-budget";
|
||||
|
||||
beforeEach(async () => {
|
||||
const prisma = getPrismaClient();
|
||||
await truncateAllTables(prisma);
|
||||
await prisma.user.create({
|
||||
data: {
|
||||
id: USER_ID,
|
||||
username: "lab-budget",
|
||||
email: "lab-budget@example.test",
|
||||
},
|
||||
});
|
||||
const takenAt = new Date("2026-06-01T08:00:00.000Z");
|
||||
await prisma.labResult.createMany({
|
||||
data: ["LDL", "LDL", "HbA1c", "TSH"].map((analyte, i) => ({
|
||||
userId: USER_ID,
|
||||
analyte,
|
||||
unit: "mg/dL",
|
||||
value: 100 + i,
|
||||
takenAt: new Date(takenAt.getTime() + i * 86_400_000),
|
||||
})),
|
||||
});
|
||||
});
|
||||
|
||||
async function unlinked() {
|
||||
return getPrismaClient().labResult.count({
|
||||
where: { userId: USER_ID, biomarkerId: null },
|
||||
});
|
||||
}
|
||||
|
||||
describe("lab-biomarker backfill under a job budget", () => {
|
||||
it("stops after the group in hand and the retry links the rest", async () => {
|
||||
let asked = 0;
|
||||
// Allow exactly one group, then say stop.
|
||||
const shouldStop = () => asked++ >= 1;
|
||||
|
||||
await expect(
|
||||
runLabBiomarkerBackfillForUser(USER_ID, shouldStop),
|
||||
).rejects.toThrow(/stopped on its job budget/);
|
||||
|
||||
const prisma = getPrismaClient();
|
||||
expect(await prisma.biomarker.count({ where: { userId: USER_ID } })).toBe(
|
||||
1,
|
||||
);
|
||||
const linkedAfterStop = 4 - (await unlinked());
|
||||
// One group: either the two LDL rows or one single-row analyte.
|
||||
expect([1, 2]).toContain(linkedAfterStop);
|
||||
|
||||
const retry = await runLabBiomarkerBackfillForUser(USER_ID);
|
||||
expect(retry.linked).toBe(4 - linkedAfterStop);
|
||||
expect(await unlinked()).toBe(0);
|
||||
expect(await prisma.biomarker.count({ where: { userId: USER_ID } })).toBe(
|
||||
3,
|
||||
);
|
||||
});
|
||||
|
||||
it("runs to the end when the budget never stops it", async () => {
|
||||
const result = await runLabBiomarkerBackfillForUser(USER_ID, () => false);
|
||||
expect(result).toEqual({ markers: 3, linked: 4 });
|
||||
expect(await unlinked()).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -11,8 +11,9 @@
|
||||
* a paragraph that outlived its workout would be an orphaned description of
|
||||
* a session the user believes they erased. That is a data-retention claim,
|
||||
* not a tidiness one, and only the database enforces it.
|
||||
* - **The daily-cap count** reads an index over `(user_id, generated_at)`.
|
||||
* Whether the window actually selects the right rows is a SQL fact.
|
||||
* - **The daily-cap count** reads the claim ledger, which outlives a deleted
|
||||
* workout (`ON DELETE SET NULL`). Whether a delete frees a slot is an FK
|
||||
* fact, so only the database can prove it does not.
|
||||
*
|
||||
* Every test writes through Prisma the way the worker does, so a schema change
|
||||
* that dropped a constraint fails here rather than in production.
|
||||
@@ -21,6 +22,7 @@ import { beforeEach, describe, expect, it } from "vitest";
|
||||
|
||||
import { getPrismaClient, truncateAllTables } from "./setup";
|
||||
import { claimWorkoutInsightGeneration } from "@/lib/jobs/workout-insight-generate";
|
||||
import { cleanupOldWorkoutInsightClaims } from "@/lib/jobs/workout-insight-claim-cleanup";
|
||||
|
||||
beforeEach(async () => {
|
||||
await truncateAllTables(getPrismaClient());
|
||||
@@ -189,67 +191,8 @@ describe("WorkoutInsight — cascade", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("WorkoutInsight — the daily-cap count", () => {
|
||||
it("counts only this user's rows inside the window", async () => {
|
||||
const prisma = getPrismaClient();
|
||||
const mine = await createUser("wi-cap-mine");
|
||||
const other = await createUser("wi-cap-other");
|
||||
|
||||
// Three of mine today, one of mine yesterday, one belonging to someone else.
|
||||
const today = new Date("2026-07-18T09:00:00Z");
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const w = await createWorkout(
|
||||
mine.id,
|
||||
new Date(today.getTime() + i * 3600_000),
|
||||
`ext-today-${i}`,
|
||||
);
|
||||
await prisma.workoutInsight.create({
|
||||
data: insightData(
|
||||
mine.id,
|
||||
w.id,
|
||||
new Date(today.getTime() + i * 3600_000),
|
||||
),
|
||||
});
|
||||
}
|
||||
const yesterdayWorkout = await createWorkout(
|
||||
mine.id,
|
||||
new Date("2026-07-17T09:00:00Z"),
|
||||
"ext-yesterday",
|
||||
);
|
||||
await prisma.workoutInsight.create({
|
||||
data: insightData(
|
||||
mine.id,
|
||||
yesterdayWorkout.id,
|
||||
new Date("2026-07-17T09:30:00Z"),
|
||||
),
|
||||
});
|
||||
const otherWorkout = await createWorkout(
|
||||
other.id,
|
||||
new Date("2026-07-18T09:00:00Z"),
|
||||
"ext-other",
|
||||
);
|
||||
await prisma.workoutInsight.create({
|
||||
data: insightData(
|
||||
other.id,
|
||||
otherWorkout.id,
|
||||
new Date("2026-07-18T10:00:00Z"),
|
||||
),
|
||||
});
|
||||
|
||||
const dayStart = new Date("2026-07-18T00:00:00Z");
|
||||
const count = await prisma.workoutInsight.count({
|
||||
where: { userId: mine.id, generatedAt: { gte: dayStart } },
|
||||
});
|
||||
|
||||
// Not 4 (yesterday's is outside the window) and not 5 (the other user's is
|
||||
// outside the tenancy narrow).
|
||||
expect(count).toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe("WorkoutInsightGenerationClaim — concurrent ownership and cap", () => {
|
||||
const now = new Date("2026-07-18T15:00:00.000Z");
|
||||
const dayStart = new Date("2026-07-17T22:00:00.000Z");
|
||||
const localDate = "2026-07-18";
|
||||
|
||||
it("atomically grants at most four different workouts for one user-local day", async () => {
|
||||
@@ -271,7 +214,6 @@ describe("WorkoutInsightGenerationClaim — concurrent ownership and cap", () =>
|
||||
userId: user.id,
|
||||
workoutId: workout.id,
|
||||
localDate,
|
||||
dayStart,
|
||||
now,
|
||||
}),
|
||||
),
|
||||
@@ -315,7 +257,6 @@ describe("WorkoutInsightGenerationClaim — concurrent ownership and cap", () =>
|
||||
userId: user.id,
|
||||
workoutId: workout.id,
|
||||
localDate,
|
||||
dayStart,
|
||||
now,
|
||||
});
|
||||
|
||||
@@ -329,49 +270,132 @@ describe("WorkoutInsightGenerationClaim — concurrent ownership and cap", () =>
|
||||
expect(rows[0]?.claimId).toBe(outcome.claimId);
|
||||
});
|
||||
|
||||
it("counts a pre-migration insight row against the local-day cap", async () => {
|
||||
it("keeps a deleted workout's slot spent, so a re-add hits the cap", async () => {
|
||||
// Generate four, delete them all, add a fifth: the deletes must not buy
|
||||
// the day's slots back. The claim row outlives its workout with a null
|
||||
// workout id and is still counted.
|
||||
const prisma = getPrismaClient();
|
||||
const user = await createUser("wi-legacy-cap");
|
||||
const legacyWorkout = await createWorkout(
|
||||
user.id,
|
||||
new Date("2026-07-18T07:00:00.000Z"),
|
||||
"ext-legacy",
|
||||
);
|
||||
await prisma.workoutInsight.create({
|
||||
data: insightData(
|
||||
user.id,
|
||||
legacyWorkout.id,
|
||||
new Date("2026-07-18T08:00:00.000Z"),
|
||||
),
|
||||
});
|
||||
const candidates = await Promise.all(
|
||||
const user = await createUser("wi-delete-cap");
|
||||
const first = await Promise.all(
|
||||
Array.from({ length: 4 }, (_, index) =>
|
||||
createWorkout(
|
||||
user.id,
|
||||
new Date(`2026-07-18T${10 + index}:00:00.000Z`),
|
||||
`ext-new-${index}`,
|
||||
`ext-first-${index}`,
|
||||
),
|
||||
),
|
||||
);
|
||||
for (const workout of first) {
|
||||
const outcome = await claimWorkoutInsightGeneration({
|
||||
userId: user.id,
|
||||
workoutId: workout.id,
|
||||
localDate,
|
||||
now,
|
||||
});
|
||||
expect(outcome.status).toBe("claimed");
|
||||
// The worker marks the provider call and completes the claim.
|
||||
await prisma.workoutInsightGenerationClaim.update({
|
||||
where: { workoutId: workout.id },
|
||||
data: {
|
||||
providerInvokedAt: now,
|
||||
completedAt: now,
|
||||
claimId: null,
|
||||
claimedAt: null,
|
||||
},
|
||||
});
|
||||
await prisma.workoutInsight.create({
|
||||
data: insightData(user.id, workout.id, now),
|
||||
});
|
||||
}
|
||||
|
||||
const outcomes = await Promise.all(
|
||||
candidates.map((workout) =>
|
||||
claimWorkoutInsightGeneration({
|
||||
await prisma.workout.deleteMany({
|
||||
where: { id: { in: first.map((w) => w.id) } },
|
||||
});
|
||||
|
||||
expect(await prisma.workoutInsight.count()).toBe(0);
|
||||
const orphans = await prisma.workoutInsightGenerationClaim.findMany({
|
||||
where: { userId: user.id },
|
||||
});
|
||||
expect(orphans).toHaveLength(4);
|
||||
expect(orphans.every((row) => row.workoutId === null)).toBe(true);
|
||||
|
||||
const readded = await createWorkout(
|
||||
user.id,
|
||||
new Date("2026-07-18T14:30:00.000Z"),
|
||||
"ext-readded",
|
||||
);
|
||||
const outcome = await claimWorkoutInsightGeneration({
|
||||
userId: user.id,
|
||||
workoutId: readded.id,
|
||||
localDate,
|
||||
now,
|
||||
});
|
||||
expect(outcome).toEqual({ status: "skipped", reason: "daily_cap" });
|
||||
});
|
||||
|
||||
it("does not count a lapsed lease that never reached the provider", async () => {
|
||||
const prisma = getPrismaClient();
|
||||
const user = await createUser("wi-lapsed-lease");
|
||||
const lapsed = await Promise.all(
|
||||
Array.from({ length: 4 }, (_, index) =>
|
||||
createWorkout(
|
||||
user.id,
|
||||
new Date(Date.UTC(2026, 6, 18, 6 + index)),
|
||||
`ext-lapsed-${index}`,
|
||||
),
|
||||
),
|
||||
);
|
||||
for (const workout of lapsed) {
|
||||
await prisma.workoutInsightGenerationClaim.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
workoutId: workout.id,
|
||||
localDate,
|
||||
dayStart,
|
||||
now,
|
||||
}),
|
||||
),
|
||||
claimId: `dead-${workout.id}`,
|
||||
claimedAt: new Date("2026-07-18T12:00:00.000Z"),
|
||||
},
|
||||
});
|
||||
}
|
||||
const fresh = await createWorkout(
|
||||
user.id,
|
||||
new Date("2026-07-18T14:00:00.000Z"),
|
||||
"ext-fresh",
|
||||
);
|
||||
|
||||
expect(
|
||||
outcomes.filter((outcome) => outcome.status === "claimed"),
|
||||
).toHaveLength(3);
|
||||
expect(outcomes).toContainEqual({
|
||||
status: "skipped",
|
||||
reason: "daily_cap",
|
||||
const outcome = await claimWorkoutInsightGeneration({
|
||||
userId: user.id,
|
||||
workoutId: fresh.id,
|
||||
localDate,
|
||||
now,
|
||||
});
|
||||
expect(outcome.status).toBe("claimed");
|
||||
});
|
||||
});
|
||||
|
||||
describe("WorkoutInsightGenerationClaim — retention", () => {
|
||||
it("drops rows untouched for a week and keeps the rest", async () => {
|
||||
const prisma = getPrismaClient();
|
||||
const user = await createUser("wi-claim-retention");
|
||||
const now = new Date();
|
||||
const old = await prisma.workoutInsightGenerationClaim.create({
|
||||
data: { userId: user.id, workoutId: null, localDate: "2000-01-01" },
|
||||
});
|
||||
const recent = await prisma.workoutInsightGenerationClaim.create({
|
||||
data: { userId: user.id, workoutId: null, localDate: "2000-01-02" },
|
||||
});
|
||||
// `updated_at` is Prisma-managed; age one row through raw SQL.
|
||||
await prisma.$executeRaw`
|
||||
UPDATE "workout_insight_generation_claims"
|
||||
SET "updated_at" = ${new Date(now.getTime() - 8 * 86_400_000)}
|
||||
WHERE "id" = ${old.id}
|
||||
`;
|
||||
|
||||
const deleted = await cleanupOldWorkoutInsightClaims(prisma, now);
|
||||
|
||||
expect(deleted).toBe(1);
|
||||
const left = await prisma.workoutInsightGenerationClaim.findMany({
|
||||
select: { id: true },
|
||||
});
|
||||
expect(left).toEqual([{ id: recent.id }]);
|
||||
});
|
||||
});
|
||||
@@ -9,6 +9,8 @@
|
||||
* - the entry lands as `MANUAL`, with the start, duration and metres the
|
||||
* form computed, and the list serves it;
|
||||
* - a second submit of the same form is a `duplicate`, not a second row;
|
||||
* - a submit after an edit is `updated` and the one row carries the edit,
|
||||
* while a synced id with changed values stays first-write-wins;
|
||||
* - delete removes a hand-entered workout and refuses a synced one.
|
||||
*/
|
||||
import { NextRequest } from "next/server";
|
||||
@@ -163,6 +165,79 @@ describe("a workout entered by hand", () => {
|
||||
).toBe(1);
|
||||
});
|
||||
|
||||
it("updates the stored row when the same form is sent again with an edit", async () => {
|
||||
const prisma = getPrismaClient();
|
||||
const entry = formEntry(newManualWorkoutExternalId());
|
||||
await post({ workouts: [entry] });
|
||||
const stored = await prisma.workout.findFirstOrThrow({
|
||||
where: { userId: TEST_USER_ID },
|
||||
});
|
||||
// A record the first values set; the edit must not leave it behind.
|
||||
await prisma.personalRecord.create({
|
||||
data: {
|
||||
userId: TEST_USER_ID,
|
||||
metricType: "WALKING_RUNNING_DISTANCE",
|
||||
metricSlot: "longest_distance_run",
|
||||
direction: "MAX",
|
||||
value: 8046.7,
|
||||
unit: "m",
|
||||
achievedAt: stored.startedAt,
|
||||
source: "MANUAL",
|
||||
externalId: entry.externalId,
|
||||
},
|
||||
});
|
||||
|
||||
const edited = { ...entry, totalDistanceM: 6000, totalEnergyKcal: 380 };
|
||||
const res = await post({ workouts: [edited] });
|
||||
|
||||
expect((await res.json()).data.entries).toEqual([
|
||||
{ index: 0, status: "updated" },
|
||||
]);
|
||||
const rows = await prisma.workout.findMany({
|
||||
where: { userId: TEST_USER_ID },
|
||||
});
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0]).toMatchObject({
|
||||
id: stored.id,
|
||||
totalDistanceM: 6000,
|
||||
totalEnergyKcal: 380,
|
||||
durationSec: 45 * 60,
|
||||
});
|
||||
expect(
|
||||
await prisma.personalRecord.count({
|
||||
where: { userId: TEST_USER_ID, externalId: entry.externalId },
|
||||
}),
|
||||
).toBe(0);
|
||||
|
||||
// The same edit once more changes nothing and says so.
|
||||
const again = await post({ workouts: [edited] });
|
||||
expect((await again.json()).data.entries).toEqual([
|
||||
{ index: 0, status: "duplicate" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps a synced workout first-write-wins under the same resend", async () => {
|
||||
const prisma = getPrismaClient();
|
||||
const entry = {
|
||||
...formEntry("hk-uuid-1"),
|
||||
source: "APPLE_HEALTH" as const,
|
||||
externalId: "8F14E45F-CEEA-467A-9575-0F5D6C5A1B2C",
|
||||
};
|
||||
await post({ workouts: [entry] });
|
||||
|
||||
const res = await post({
|
||||
workouts: [{ ...entry, totalDistanceM: 1234 }],
|
||||
});
|
||||
|
||||
expect((await res.json()).data.entries).toEqual([
|
||||
{ index: 0, status: "duplicate" },
|
||||
]);
|
||||
const row = await prisma.workout.findFirstOrThrow({
|
||||
where: { userId: TEST_USER_ID },
|
||||
});
|
||||
expect(row.totalDistanceM).not.toBe(1234);
|
||||
});
|
||||
|
||||
it("can be deleted, and is gone from the list", async () => {
|
||||
await post({ workouts: [formEntry(newManualWorkoutExternalId())] });
|
||||
const stored = await getPrismaClient().workout.findFirstOrThrow({
|
||||
|
||||
Reference in new issue
Block a user