diff --git a/messages/de.json b/messages/de.json index bf5866849..7c7a8da86 100644 --- a/messages/de.json +++ b/messages/de.json @@ -1795,7 +1795,7 @@ "unpinnedToast": "Zuordnung gelöst", "dueContext": "fällig gewesen: {time} ({delta})", "confirmTitle": "Slot {time} zuordnen?", - "confirmBody": "Die Einnahme zählt für diesen Slot: pünktlich, wenn sie im Zeitfenster des Slots lag, sonst als verspätet. Die Quote wird also nicht geschönt. Die Zuordnung lässt sich jederzeit wieder lösen.", + "confirmBody": "Die Einnahme zählt für diesen Slot genau so, als wäre sie dort erfasst worden: pünktlich oder verspätet, je nachdem, wann sie genommen wurde. Die Quote wird also nicht geschönt. Die Zuordnung lässt sich jederzeit wieder lösen.", "confirmCancel": "Abbrechen", "confirmAction": "Zuordnen" } diff --git a/messages/en.json b/messages/en.json index b3fe58b4b..b84e3eb1d 100644 --- a/messages/en.json +++ b/messages/en.json @@ -1795,7 +1795,7 @@ "unpinnedToast": "Attribution released", "dueContext": "was due: {time} ({delta})", "confirmTitle": "Attribute to slot {time}?", - "confirmBody": "The intake counts for this slot: on time if it was taken inside the slot's on-time window, otherwise as late, so the rate is never flattered. You can release the attribution at any time.", + "confirmBody": "The intake counts for this slot just as it would if it had been logged there: on time or late depending on when it was taken, so the rate is never flattered. You can release the attribution at any time.", "confirmCancel": "Cancel", "confirmAction": "Attribute" } diff --git a/messages/es.json b/messages/es.json index 981494fb9..57961bd23 100644 --- a/messages/es.json +++ b/messages/es.json @@ -1795,7 +1795,7 @@ "unpinnedToast": "Asignación quitada", "dueContext": "prevista: {time} ({delta})", "confirmTitle": "¿Asignar al horario de las {time}?", - "confirmBody": "La toma contará para este horario: a tiempo si se tomó dentro de su ventana puntual y, si no, con retraso, así que el porcentaje no se maquilla. Puedes quitar la asignación en cualquier momento.", + "confirmBody": "La toma contará para este horario igual que si se hubiera registrado en él: a tiempo o con retraso según cuándo se tomó, así que el porcentaje no se maquilla. Puedes quitar la asignación en cualquier momento.", "confirmCancel": "Cancelar", "confirmAction": "Asignar" } diff --git a/messages/fr.json b/messages/fr.json index 809d70873..a5cebbec3 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -1795,7 +1795,7 @@ "unpinnedToast": "Prise détachée du créneau", "dueContext": "prévue à {time} ({delta})", "confirmTitle": "Rattacher au créneau de {time} ?", - "confirmBody": "La prise comptera pour ce créneau : à l'heure si elle a eu lieu dans la fenêtre à l'heure du créneau, sinon en retard. Le taux n'est donc pas embelli. Le rattachement peut être retiré à tout moment.", + "confirmBody": "La prise comptera pour ce créneau exactement comme si elle y avait été enregistrée : à l'heure ou en retard selon le moment où elle a été prise. Le taux n'est donc pas embelli. Le rattachement peut être retiré à tout moment.", "confirmCancel": "Annuler", "confirmAction": "Rattacher" } diff --git a/messages/it.json b/messages/it.json index 3c0616fe5..b41bc95c7 100644 --- a/messages/it.json +++ b/messages/it.json @@ -1795,7 +1795,7 @@ "unpinnedToast": "Attribuzione rimossa", "dueContext": "prevista alle {time} ({delta})", "confirmTitle": "Attribuire allo slot delle {time}?", - "confirmBody": "L'assunzione conterà per questo slot: puntuale se è avvenuta nella finestra puntuale dello slot, altrimenti in ritardo, quindi la percentuale non viene abbellita. L'attribuzione può essere rimossa in qualsiasi momento.", + "confirmBody": "L'assunzione conterà per questo slot esattamente come se fosse stata registrata lì: puntuale o in ritardo a seconda di quando è stata presa, quindi la percentuale non viene abbellita. L'attribuzione può essere rimossa in qualsiasi momento.", "confirmCancel": "Annulla", "confirmAction": "Attribuisci" } diff --git a/messages/ko.json b/messages/ko.json index b09497d07..3c418574d 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -1795,7 +1795,7 @@ "unpinnedToast": "배정을 해제했어요", "dueContext": "예정 시각: {time} ({delta})", "confirmTitle": "{time} 시간대에 배정할까요?", - "confirmBody": "이 복용은 해당 시간대로 계산돼요. 시간대의 정시 범위 안에 복용했다면 제시간, 아니면 늦게 복용한 것으로 계산되어 순응도가 부풀려지지 않아요. 배정은 언제든 해제할 수 있어요.", + "confirmBody": "이 복용은 처음부터 이 시간대에 기록한 것과 똑같이 계산돼요. 복용한 시각에 따라 제시간 또는 늦게 복용한 것으로 계산되어 순응도가 부풀려지지 않아요. 배정은 언제든 해제할 수 있어요.", "confirmCancel": "취소", "confirmAction": "배정" } diff --git a/messages/pl.json b/messages/pl.json index ffd3b387f..765f55ccc 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -1795,7 +1795,7 @@ "unpinnedToast": "Przypisanie usunięte", "dueContext": "termin: {time} ({delta})", "confirmTitle": "Przypisać do slotu {time}?", - "confirmBody": "Dawka zostanie policzona dla tego slotu: jako przyjęta na czas, jeśli mieści się w oknie punktualności slotu, a w przeciwnym razie jako przyjęta z opóźnieniem, więc wskaźnik nie jest zawyżany. Przypisanie można w każdej chwili usunąć.", + "confirmBody": "Dawka zostanie policzona dla tego slotu tak, jakby została w nim zapisana: jako przyjęta na czas lub z opóźnieniem, zależnie od tego, kiedy ją przyjęto, więc wskaźnik nie jest zawyżany. Przypisanie można w każdej chwili usunąć.", "confirmCancel": "Anuluj", "confirmAction": "Przypisz" } diff --git a/src/lib/medications/scheduling/__tests__/dose-history.test.ts b/src/lib/medications/scheduling/__tests__/dose-history.test.ts index 56eb094ce..fcd1a047f 100644 --- a/src/lib/medications/scheduling/__tests__/dose-history.test.ts +++ b/src/lib/medications/scheduling/__tests__/dose-history.test.ts @@ -15,7 +15,11 @@ */ import { describe, expect, it } from "vitest"; -import { buildSlotBands, type SlotWindowInput } from "../attribution"; +import { + attributeIntakeToSlot, + buildSlotBands, + type SlotWindowInput, +} from "../attribution"; import { reconstructDoseHistory, suggestNearestSlot, @@ -284,28 +288,36 @@ describe("reconstructDoseHistory", () => { expect(morning?.pinned).toBe(true); }); - // The pin confirm dialog promises exactly this split: on time inside the - // slot's on-time window (also ahead of the slot's clock time), late - // otherwise. - it("an early pin inside the on-time window reads on time, one before it reads late", () => { - const early = reconstructDoseHistory( - bands, - [intake({ takenAt: at(6, 15), scheduledFor: at(7, 0), pinned: true })], - nowEvening, - null, - ); - expect(early.find((r) => r.timeOfDay === "07:00")?.status).toBe( - "taken_on_time", - ); - const tooEarly = reconstructDoseHistory( - bands, - [intake({ takenAt: at(5, 30), scheduledFor: at(7, 0), pinned: true })], - nowEvening, - null, - ); - expect(tooEarly.find((r) => r.timeOfDay === "07:00")?.status).toBe( - "taken_late", - ); + // A pinned take reads exactly as the same take attributed on its own: + // on time from the early grace before the on-time window through the + // window's end, late otherwise. Pinning never changes the timing. + it("a pinned early take reads the same as the unpinned take", () => { + for (const [h, m] of [ + [6, 15], + [5, 30], + [4, 30], + ] as const) { + const takenAt = at(h, m); + const pinned = reconstructDoseHistory( + bands, + [intake({ takenAt, scheduledFor: at(7, 0), pinned: true })], + nowEvening, + null, + ).find((r) => r.timeOfDay === "07:00")?.status; + const unpinned = attributeIntakeToSlot(takenAt, bands); + const expected = + unpinned?.band.timeOfDay === "07:00" + ? unpinned.status === "on_time" + ? "taken_on_time" + : "taken_late" + : // Outside every capture band on its own: a pin can only make + // it late, never on time. + "taken_late"; + expect(pinned, `${h}:${m}`).toBe(expected); + } + // 05:30 lies in the early grace (on time either way), 04:30 before it. + expect(attributeIntakeToSlot(at(5, 30), bands)?.status).toBe("on_time"); + expect(attributeIntakeToSlot(at(4, 30), bands)).toBeNull(); }); it("a pin inside the late tail reads taken_late, never flattered", () => { diff --git a/src/lib/medications/scheduling/dose-history.ts b/src/lib/medications/scheduling/dose-history.ts index 357c679bf..e1f0cd52b 100644 --- a/src/lib/medications/scheduling/dose-history.ts +++ b/src/lib/medications/scheduling/dose-history.ts @@ -22,8 +22,9 @@ * `attributionSource = USER_PIN`) binds by its `scheduledFor` anchor like * a skip, NOT by takenAt-band membership, so a pin outside the late tail * never degrades back to ad-hoc. Status is `taken_late` unless the - * takenAt happens to sit inside the slot's on-time band — a pin can - * never flatter the timing; + * takenAt sits where the take would read on time on its own (the + * on-time band plus its bounded early grace) — a pin never changes the + * timing; * - a RELEASED pin (v1.16.0 — "Zuordnung lösen" persists `USER_PIN` with * `scheduledFor === takenAt`) is a deliberately ad-hoc take: it never * anchor-binds (not even when its instant sits within epsilon of a slot @@ -237,8 +238,9 @@ export function reconstructDoseHistory( // v1.15.20 — pinned takes bind by their stored slot anchor, NOT by // takenAt-band membership: the whole point of a pin is that the take sits // outside (or past the tail of) the band it belongs to. Status never - // flatters: taken_late, unless the takenAt happens to sit inside the - // slot's own on-time band anyway. A pin whose slot is gone (schedule + // flatters: taken_late, unless the takenAt sits where band attribution + // would read it on time anyway, the early grace included, so the same + // take reads the same pinned or not. A pin whose slot is gone (schedule // changed) or already claimed falls through to ad-hoc so nothing vanishes. for (const i of pinnedTaken) { // v1.16.0 — a released pin ("Zuordnung lösen") persists USER_PIN with @@ -254,7 +256,8 @@ export function reconstructDoseHistory( if (band && !claim.has(band)) { const t = (i.takenAt as Date).getTime(); const onTime = - t >= band.onTimeStart.getTime() && t <= band.onTimeEnd.getTime(); + t >= (band.earlyStart ?? band.onTimeStart).getTime() && + t <= band.onTimeEnd.getTime(); claim.set(band, { intake: i, status: onTime ? "taken_on_time" : "taken_late",