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:
Marc-André Bombeck authored and GitHub committed 2026-10-03 20:32:09 +02:00
commit 7cd323499e
77 files changed
+2084 -325

No files matched your search

+45 -35
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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;
+6 -3
View File
@@ -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);
});
});
+22
View File
@@ -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
+9 -1
View File
@@ -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", () => ({
+5 -1
View File
@@ -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: {
+21 -4
View File
@@ -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 } });
+25 -1
View File
@@ -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) => {
+165 -5
View File
@@ -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);
}
+17 -12
View File
@@ -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");
});
});
+4 -2
View File
@@ -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
+4 -4
View File
@@ -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
+4 -4
View File
@@ -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,
+5 -2
View File
@@ -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}
+16 -6
View File
@@ -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, "&#x27;"));
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, "&#x27;"));
});
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,
);
});
});
+10
View File
@@ -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">
+13 -3
View File
@@ -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/);
});
+9
View File
@@ -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[^\]]*\]\)/);
});
});
+58
View File
@@ -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);
});
});
+1 -12
View File
@@ -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
+39
View File
@@ -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,
};
}
+1 -1
View File
@@ -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:
+30
View File
@@ -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" } });
});
});
+4 -1
View File
@@ -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(
+19
View File
@@ -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({
+1
View File
@@ -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(
+25
View File
@@ -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>(
+1
View File
@@ -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;
}
+41 -18
View File
@@ -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 });
});
});
+15
View File
@@ -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.
+124
View File
@@ -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 };
}
+1
View File
@@ -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",
+1 -1
View File
@@ -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,
+1 -1
View File
@@ -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())
+3 -3
View File
@@ -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";
+86 -52
View File
@@ -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,
+80 -1
View File
@@ -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);
});
});
+116 -92
View File
@@ -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({