diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 48c0fcada..cf0c25eb9 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -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 diff --git a/messages/de.json b/messages/de.json index e88b227b4..623f3de78 100644 --- a/messages/de.json +++ b/messages/de.json @@ -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", diff --git a/messages/en.json b/messages/en.json index 7267df474..d1ed93c4a 100644 --- a/messages/en.json +++ b/messages/en.json @@ -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", diff --git a/messages/es.json b/messages/es.json index 3ee101d49..c0f17a81e 100644 --- a/messages/es.json +++ b/messages/es.json @@ -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", diff --git a/messages/fr.json b/messages/fr.json index 39efae99f..c8b7ddd3f 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -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", diff --git a/messages/it.json b/messages/it.json index 1354a3dc5..cd85c6b40 100644 --- a/messages/it.json +++ b/messages/it.json @@ -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", diff --git a/messages/ko.json b/messages/ko.json index 93d4bc1a2..2ec2108d9 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -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": "참고 하한", diff --git a/messages/pl.json b/messages/pl.json index 11d281f22..d1fa76a78 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -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", diff --git a/prisma/migrations/0367_workout_insight_claim_set_null/migration.sql b/prisma/migrations/0367_workout_insight_claim_set_null/migration.sql new file mode 100644 index 000000000..d561de0cf --- /dev/null +++ b/prisma/migrations/0367_workout_insight_claim_set_null/migration.sql @@ -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; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 28a41a95c..36dbabbce 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -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") diff --git a/src/__tests__/i18n-english-leak-guard.test.ts b/src/__tests__/i18n-english-leak-guard.test.ts index 897cd03bb..b5e5ad23a 100644 --- a/src/__tests__/i18n-english-leak-guard.test.ts +++ b/src/__tests__/i18n-english-leak-guard.test.ts @@ -99,6 +99,8 @@ const LEGIT_IDENTICAL = new Set([ "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", diff --git a/src/__tests__/timezone-explicit-formatter-guard.test.ts b/src/__tests__/timezone-explicit-formatter-guard.test.ts index 553a6a971..9eaf1456e 100644 --- a/src/__tests__/timezone-explicit-formatter-guard.test.ts +++ b/src/__tests__/timezone-explicit-formatter-guard.test.ts @@ -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); + }); +}); diff --git a/src/app/api/biomarkers/[id]/route.ts b/src/app/api/biomarkers/[id]/route.ts index 45cc9f98a..a17d0b015 100644 --- a/src/app/api/biomarkers/[id]/route.ts +++ b/src/app/api/biomarkers/[id]/route.ts @@ -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 = {}; diff --git a/src/app/api/dashboard/summary/__tests__/route.test.ts b/src/app/api/dashboard/summary/__tests__/route.test.ts index e61286e5b..b1c5dc30e 100644 --- a/src/app/api/dashboard/summary/__tests__/route.test.ts +++ b/src/app/api/dashboard/summary/__tests__/route.test.ts @@ -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 diff --git a/src/app/api/dashboard/summary/route.ts b/src/app/api/dashboard/summary/route.ts index 26d76552f..40d0f9994 100644 --- a/src/app/api/dashboard/summary/route.ts +++ b/src/app/api/dashboard/summary/route.ts @@ -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, diff --git a/src/app/api/insights/provider-chain/__tests__/put.test.ts b/src/app/api/insights/provider-chain/__tests__/put.test.ts index 7721d1d46..510fa5fe7 100644 --- a/src/app/api/insights/provider-chain/__tests__/put.test.ts +++ b/src/app/api/insights/provider-chain/__tests__/put.test.ts @@ -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 () => { diff --git a/src/app/api/insights/provider-chain/__tests__/route.test.ts b/src/app/api/insights/provider-chain/__tests__/route.test.ts index fff8209ea..efb66afab 100644 --- a/src/app/api/insights/provider-chain/__tests__/route.test.ts +++ b/src/app/api/insights/provider-chain/__tests__/route.test.ts @@ -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", () => ({ diff --git a/src/app/api/insights/provider-chain/route.ts b/src/app/api/insights/provider-chain/route.ts index b5afc4379..8ea535990 100644 --- a/src/app/api/insights/provider-chain/route.ts +++ b/src/app/api/insights/provider-chain/route.ts @@ -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: { diff --git a/src/app/api/workouts/[id]/route.ts b/src/app/api/workouts/[id]/route.ts index 21f04d8a5..a5ff667fa 100644 --- a/src/app/api/workouts/[id]/route.ts +++ b/src/app/api/workouts/[id]/route.ts @@ -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 } }); diff --git a/src/app/api/workouts/__tests__/delete.test.ts b/src/app/api/workouts/__tests__/delete.test.ts index 340fd2cee..8af47cf6a 100644 --- a/src/app/api/workouts/__tests__/delete.test.ts +++ b/src/app/api/workouts/__tests__/delete.test.ts @@ -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) => { diff --git a/src/app/api/workouts/batch/route.ts b/src/app/api/workouts/batch/route.ts index 8c42af76a..5b885aa9e 100644 --- a/src/app/api/workouts/batch/route.ts +++ b/src/app/api/workouts/batch/route.ts @@ -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 | null { + const changes: Partial = {}; + 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 { let duplicateCount = droppedByWriteDedup.length; let insertedCount = 0; let enrichedCount = 0; + let updatedCount = 0; const insertedIdByIndex = new Map(); if (survivors.length > 0) { @@ -510,16 +567,45 @@ async function postBatch(request: NextRequest): Promise { 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(); + 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 { workoutId: string; samples: NonNullable; }> = []; + /** Hand-entered workouts this batch edits in place. */ + const toOverwrite: Array<{ + index: number; + workoutId: string; + externalId: string; + changes: Partial; + }> = []; /** Workouts already claimed by an earlier entry of THIS batch. */ const enrichClaimed = new Set(); for (const p of survivors) { @@ -537,7 +630,27 @@ async function postBatch(request: NextRequest): Promise { ? `${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 { } } - 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(); + /** Batch indices whose manual edit this request actually wrote. */ + const overwritten = new Set(); 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)[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 { 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 { inserted: insertedCount, duplicates: duplicateCount, enriched: enrichedCount, + updated: updatedCount, skipped: skipped.length, }, }); @@ -739,6 +898,7 @@ async function postBatch(request: NextRequest): Promise { inserted: insertedCount, duplicates: duplicateCount, enriched: enrichedCount, + updated: updatedCount, skipped: skipped.length, }, }); @@ -750,7 +910,7 @@ async function postBatch(request: NextRequest): Promise { // 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); } diff --git a/src/components/charts/health-chart.tsx b/src/components/charts/health-chart.tsx index 60cfe4cb8..ee00071dd 100644 --- a/src/components/charts/health-chart.tsx +++ b/src/components/charts/health-chart.tsx @@ -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 diff --git a/src/components/charts/nutrient-daily-bar-chart.tsx b/src/components/charts/nutrient-daily-bar-chart.tsx index 074418f28..f66395121 100644 --- a/src/components/charts/nutrient-daily-bar-chart.tsx +++ b/src/components/charts/nutrient-daily-bar-chart.tsx @@ -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({ /> 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 ( ); diff --git a/src/components/clinician/__tests__/clinician-view-locales.test.tsx b/src/components/clinician/__tests__/clinician-view-locales.test.tsx index 4ba27dab5..75ce2d798 100644 --- a/src/components/clinician/__tests__/clinician-view-locales.test.tsx +++ b/src/components/clinician/__tests__/clinician-view-locales.test.tsx @@ -253,3 +253,31 @@ describe(" resolves every label in every locale", () => { }); } }); + +describe(" 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"); + }); +}); diff --git a/src/components/clinician/clinician-view.tsx b/src/components/clinician/clinician-view.tsx index fb49c7b2e..2a5105fb1 100644 --- a/src/components/clinician/clinician-view.tsx +++ b/src/components/clinician/clinician-view.tsx @@ -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({ > ) : null} diff --git a/src/components/clinician/history-sections.tsx b/src/components/clinician/history-sections.tsx index 048dcf088..06feac289 100644 --- a/src/components/clinician/history-sections.tsx +++ b/src/components/clinician/history-sections.tsx @@ -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 diff --git a/src/components/clinician/report-sections.tsx b/src/components/clinician/report-sections.tsx index db44a8de2..d994310b2 100644 --- a/src/components/clinician/report-sections.tsx +++ b/src/components/clinician/report-sections.tsx @@ -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( diff --git a/src/components/clinician/sensitive-sections.tsx b/src/components/clinician/sensitive-sections.tsx index ae0ca8dc1..ebc620a62 100644 --- a/src/components/clinician/sensitive-sections.tsx +++ b/src/components/clinician/sensitive-sections.tsx @@ -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 diff --git a/src/components/clinician/therapy-sections.tsx b/src/components/clinician/therapy-sections.tsx index 097eb6497..9d7f0a788 100644 --- a/src/components/clinician/therapy-sections.tsx +++ b/src/components/clinician/therapy-sections.tsx @@ -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 diff --git a/src/components/cycle/predictions-panel.tsx b/src/components/cycle/predictions-panel.tsx index 66e428f0d..2b14a619d 100644 --- a/src/components/cycle/predictions-panel.tsx +++ b/src/components/cycle/predictions-panel.tsx @@ -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): { diff --git a/src/components/documents/__tests__/document-timeline.test.tsx b/src/components/documents/__tests__/document-timeline.test.tsx index 3c080f3e6..ca520bbad 100644 --- a/src/components/documents/__tests__/document-timeline.test.tsx +++ b/src/components/documents/__tests__/document-timeline.test.tsx @@ -16,6 +16,7 @@ function render(uploadItems: UploadQueueItem[]) { return renderToStaticMarkup( {}} diff --git a/src/components/documents/__tests__/vault-utils.test.ts b/src/components/documents/__tests__/vault-utils.test.ts index ace2e807e..fff5fc7bf 100644 --- a/src/components/documents/__tests__/vault-utils.test.ts +++ b/src/components/documents/__tests__/vault-utils.test.ts @@ -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); }); diff --git a/src/components/documents/document-timeline.tsx b/src/components/documents/document-timeline.tsx index 7afb9c70c..25faca739 100644 --- a/src/components/documents/document-timeline.tsx +++ b/src/components/documents/document-timeline.tsx @@ -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, diff --git a/src/components/documents/documents-view.tsx b/src/components/documents/documents-view.tsx index c68bb8f5f..f35c9c7c3 100644 --- a/src/components/documents/documents-view.tsx +++ b/src/components/documents/documents-view.tsx @@ -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(); 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>( @@ -974,6 +976,7 @@ export function DocumentsView() { /> ) : ( (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; diff --git a/src/components/illness/illness-day-timeline.tsx b/src/components/illness/illness-day-timeline.tsx index 3dd029e55..37e327d46 100644 --- a/src/components/illness/illness-day-timeline.tsx +++ b/src/components/illness/illness-day-timeline.tsx @@ -38,7 +38,7 @@ function DayLogRow({ log }: { log: IllnessDayLogDTO }) { return (

- {fmt.dateShortSmart(`${log.date}T12:00:00`)} + {fmt.dateShortSmartCalendar(log.date)}

{log.symptoms.length > 0 ? ( diff --git a/src/components/insights/coach-panel/result-chart.tsx b/src/components/insights/coach-panel/result-chart.tsx index 35cfa9d26..9c9064f7c 100644 --- a/src/components/insights/coach-panel/result-chart.tsx +++ b/src/components/insights/coach-panel/result-chart.tsx @@ -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`)); diff --git a/src/components/insights/intraday-pulse-chart.tsx b/src/components/insights/intraday-pulse-chart.tsx index 5fac93a99..cb50fcd43 100644 --- a/src/components/insights/intraday-pulse-chart.tsx +++ b/src/components/insights/intraday-pulse-chart.tsx @@ -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 (
({ + ...(await importOriginal()), + 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( + {node}, + ); +} + +describe(" 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(); + 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(); + 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( + (node, part) => + node && typeof node === "object" + ? (node as Record)[part] + : undefined, + en, + ); + return typeof value === "string" ? value : key; + }; + expect(localizedApiError(err, t, "labs.biomarker.form.saveError")).toBe( + en.apiErrors.biomarkers.unit.locked, + ); + }); +}); diff --git a/src/components/labs/biomarker-form.tsx b/src/components/labs/biomarker-form.tsx index 220c6b927..51503c242 100644 --- a/src/components/labs/biomarker-form.tsx +++ b/src/components/labs/biomarker-form.tsx @@ -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. +

+ {t("labs.biomarker.form.unitLockedHint")} +

+ ) : null}