mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
Merge branch 'fix/checkup-stays-due' into release/v1.39.2
This commit is contained in:
commit
b2bfeb57c8
31 files changed
+1459
-162
No files matched your search
+20
-9
@@ -13274,8 +13274,12 @@ paths:
|
||||
tags:
|
||||
- MeasurementReminders
|
||||
summary: List Vorsorge reminders (v1.17.1)
|
||||
description: Returns the owner's live (non-tombstoned) Vorsorge reminders, sorted by server-computed nextDueAt ascending
|
||||
(nulls last). Each row carries the canonical nextDueAt the client renders without recomputing.
|
||||
description: "Returns the owner's live (non-tombstoned) Vorsorge reminders, sorted by server-computed nextDueAt
|
||||
ascending (nulls last). Each row carries the canonical nextDueAt the client renders without recomputing. Since
|
||||
v1.39.2 a check-up (a reminder with no measurementType) keeps its nextDueAt after its reminder is sent: an open
|
||||
check-up stays due, then overdue, until it is completed, skipped or snoozed, and the server repeats the reminder
|
||||
at most once a week while it stays open. A check-up on a weekly or shorter cadence, and a reminder with a
|
||||
measurementType, still roll on to the next slot after a delivered reminder."
|
||||
responses:
|
||||
"200":
|
||||
description: The owner's measurement reminders.
|
||||
@@ -13346,8 +13350,11 @@ paths:
|
||||
tags:
|
||||
- MeasurementReminders
|
||||
summary: Edit a Vorsorge reminder (v1.17.1)
|
||||
description: Partial edit; omitted fields are left untouched. nextDueAt is recomputed server-side after the cadence
|
||||
merge. Owner-scoped.
|
||||
description: Partial edit; omitted fields are left untouched. nextDueAt is recomputed server-side only when the edit
|
||||
changes when the reminder recurs (intervalDays, rrule, anchorDate compared by value, or enabled switched back
|
||||
on); that recompute also clears a snooze. A notifyHour-only edit keeps the due day and moves the hour on it. Any
|
||||
other edit (label, location, measurementType) leaves nextDueAt as it is, so an open, overdue check-up stays due.
|
||||
Owner-scoped.
|
||||
parameters:
|
||||
- in: path
|
||||
name: id
|
||||
@@ -18896,10 +18903,14 @@ paths:
|
||||
- Insights
|
||||
summary: The unified daily digest
|
||||
description: "Assembles the day's read from already-cached data: the nightly briefing lifted read-only from the insights
|
||||
cache, the dashboard-snapshot health score / meds-today / sleep freshness, plus deterministic integration-status
|
||||
and Vorsorge reads for the 'worth a look' rail. No provider call, no warm-on-mount. No module gate and no AI
|
||||
gate: the digest is data, and the `insights` module is the AI analysis opt-out. Its AI parts follow their
|
||||
capabilities, published in `ai`. Cookie or Bearer auth."
|
||||
cache, the dashboard-snapshot health score / meds-today / sleep freshness, plus deterministic
|
||||
integration-status, Vorsorge and visit reads for the 'worth a look' rail. The rail's `preventive_care` item
|
||||
covers every check-up due today or overdue and names up to three of them; `upcoming_visit` items cover every
|
||||
planned visit on the local today (also once it has started, until the day ends) in one item, and the next day
|
||||
with a visit inside the next 48 hours in a second. A due check-up and today's visits always keep a place within
|
||||
the three-item cap. No provider call, no warm-on-mount. No module gate and no AI gate: the digest is data, and
|
||||
the `insights` module is the AI analysis opt-out. Its AI parts follow their capabilities, published in `ai`.
|
||||
Cookie or Bearer auth."
|
||||
responses:
|
||||
"200":
|
||||
description: The daily digest.
|
||||
@@ -26945,7 +26956,7 @@ components:
|
||||
enabled:
|
||||
type: boolean
|
||||
description: Partial edit of a Vorsorge reminder. Omitted fields are left untouched; nextDueAt is recomputed server-side
|
||||
after the patch applies.
|
||||
when the cadence changes and otherwise kept (a notifyHour edit moves only the hour on the same due day).
|
||||
MeasurementReminderSnooze:
|
||||
type: object
|
||||
properties:
|
||||
|
||||
+4
-2
@@ -8527,6 +8527,7 @@
|
||||
"inDays": "In {days} Tagen"
|
||||
},
|
||||
"overdueByDays": "Überfällig seit {days} Tagen",
|
||||
"overdueSinceYesterday": "Überfällig seit gestern",
|
||||
"cadence": {
|
||||
"everyNDays": "Alle {days} Tage",
|
||||
"custom": "Eigener Zeitplan",
|
||||
@@ -10321,7 +10322,7 @@
|
||||
"preventiveCare": {
|
||||
"title": "Vorsorge fällig",
|
||||
"body": "{label} ist fällig.",
|
||||
"bodyMany": "{count} Vorsorgetermine sind fällig."
|
||||
"bodyManyNamed": "Fällig: {labels}"
|
||||
},
|
||||
"syncIssue": {
|
||||
"title": "Synchronisierung braucht Aufmerksamkeit",
|
||||
@@ -10366,7 +10367,8 @@
|
||||
"upcomingVisit": {
|
||||
"title": "Termin steht an",
|
||||
"bodyToday": "{what} — heute",
|
||||
"bodyTomorrow": "{what} — morgen"
|
||||
"bodyTomorrow": "{what} — morgen",
|
||||
"bodyDayAfterTomorrow": "{what} — übermorgen"
|
||||
}
|
||||
},
|
||||
"action": {
|
||||
|
||||
+4
-2
@@ -8527,6 +8527,7 @@
|
||||
"inDays": "In {days} days"
|
||||
},
|
||||
"overdueByDays": "Overdue by {days} days",
|
||||
"overdueSinceYesterday": "Overdue since yesterday",
|
||||
"cadence": {
|
||||
"everyNDays": "Every {days} days",
|
||||
"custom": "Custom schedule",
|
||||
@@ -10321,7 +10322,7 @@
|
||||
"preventiveCare": {
|
||||
"title": "Check-up due",
|
||||
"body": "{label} is due.",
|
||||
"bodyMany": "{count} check-ups are due."
|
||||
"bodyManyNamed": "Due: {labels}"
|
||||
},
|
||||
"syncIssue": {
|
||||
"title": "Sync needs attention",
|
||||
@@ -10366,7 +10367,8 @@
|
||||
"upcomingVisit": {
|
||||
"title": "Appointment coming up",
|
||||
"bodyToday": "{what} — today",
|
||||
"bodyTomorrow": "{what} — tomorrow"
|
||||
"bodyTomorrow": "{what} — tomorrow",
|
||||
"bodyDayAfterTomorrow": "{what} — the day after tomorrow"
|
||||
}
|
||||
},
|
||||
"action": {
|
||||
|
||||
+4
-2
@@ -8527,6 +8527,7 @@
|
||||
"inDays": "En {days} días"
|
||||
},
|
||||
"overdueByDays": "Vencido hace {days} días",
|
||||
"overdueSinceYesterday": "Vencido desde ayer",
|
||||
"cadence": {
|
||||
"everyNDays": "Cada {days} días",
|
||||
"custom": "Programación personalizada",
|
||||
@@ -10321,7 +10322,7 @@
|
||||
"preventiveCare": {
|
||||
"title": "Revisión pendiente",
|
||||
"body": "{label} está pendiente.",
|
||||
"bodyMany": "Hay {count} revisiones pendientes."
|
||||
"bodyManyNamed": "Pendientes: {labels}"
|
||||
},
|
||||
"syncIssue": {
|
||||
"title": "La sincronización necesita atención",
|
||||
@@ -10366,7 +10367,8 @@
|
||||
"upcomingVisit": {
|
||||
"title": "Cita próxima",
|
||||
"bodyToday": "{what}: hoy",
|
||||
"bodyTomorrow": "{what}: mañana"
|
||||
"bodyTomorrow": "{what}: mañana",
|
||||
"bodyDayAfterTomorrow": "{what}: pasado mañana"
|
||||
}
|
||||
},
|
||||
"action": {
|
||||
|
||||
+4
-2
@@ -8527,6 +8527,7 @@
|
||||
"inDays": "Dans {days} jours"
|
||||
},
|
||||
"overdueByDays": "En retard de {days} jours",
|
||||
"overdueSinceYesterday": "En retard depuis hier",
|
||||
"cadence": {
|
||||
"everyNDays": "Tous les {days} jours",
|
||||
"custom": "Planning personnalisé",
|
||||
@@ -10321,7 +10322,7 @@
|
||||
"preventiveCare": {
|
||||
"title": "Examen à faire",
|
||||
"body": "{label} est à faire.",
|
||||
"bodyMany": "{count} examens sont à faire."
|
||||
"bodyManyNamed": "À faire : {labels}"
|
||||
},
|
||||
"syncIssue": {
|
||||
"title": "La synchronisation demande votre attention",
|
||||
@@ -10366,7 +10367,8 @@
|
||||
"upcomingVisit": {
|
||||
"title": "Rendez-vous à venir",
|
||||
"bodyToday": "{what} — aujourd'hui",
|
||||
"bodyTomorrow": "{what} — demain"
|
||||
"bodyTomorrow": "{what} — demain",
|
||||
"bodyDayAfterTomorrow": "{what} — après-demain"
|
||||
}
|
||||
},
|
||||
"action": {
|
||||
|
||||
+4
-2
@@ -8527,6 +8527,7 @@
|
||||
"inDays": "Tra {days} giorni"
|
||||
},
|
||||
"overdueByDays": "In ritardo di {days} giorni",
|
||||
"overdueSinceYesterday": "In ritardo da ieri",
|
||||
"cadence": {
|
||||
"everyNDays": "Ogni {days} giorni",
|
||||
"custom": "Pianificazione personalizzata",
|
||||
@@ -10321,7 +10322,7 @@
|
||||
"preventiveCare": {
|
||||
"title": "Controllo da fare",
|
||||
"body": "{label} è da fare.",
|
||||
"bodyMany": "Ci sono {count} controlli da fare."
|
||||
"bodyManyNamed": "Da fare: {labels}"
|
||||
},
|
||||
"syncIssue": {
|
||||
"title": "La sincronizzazione richiede attenzione",
|
||||
@@ -10366,7 +10367,8 @@
|
||||
"upcomingVisit": {
|
||||
"title": "Appuntamento in arrivo",
|
||||
"bodyToday": "{what} — oggi",
|
||||
"bodyTomorrow": "{what} — domani"
|
||||
"bodyTomorrow": "{what} — domani",
|
||||
"bodyDayAfterTomorrow": "{what} — dopodomani"
|
||||
}
|
||||
},
|
||||
"action": {
|
||||
|
||||
+4
-2
@@ -8527,6 +8527,7 @@
|
||||
"inDays": "{days}일 후"
|
||||
},
|
||||
"overdueByDays": "{days}일 지남",
|
||||
"overdueSinceYesterday": "어제부터 지남",
|
||||
"cadence": {
|
||||
"everyNDays": "{days}일마다",
|
||||
"custom": "사용자 지정 일정",
|
||||
@@ -10321,7 +10322,7 @@
|
||||
"preventiveCare": {
|
||||
"title": "건강검진 시기",
|
||||
"body": "{label} — 받을 때가 됐어요.",
|
||||
"bodyMany": "건강검진 {count}건을 받을 때가 됐어요."
|
||||
"bodyManyNamed": "받을 때가 된 건강검진: {labels}"
|
||||
},
|
||||
"syncIssue": {
|
||||
"title": "동기화 확인이 필요해요",
|
||||
@@ -10366,7 +10367,8 @@
|
||||
"upcomingVisit": {
|
||||
"title": "다가오는 예약",
|
||||
"bodyToday": "{what} — 오늘",
|
||||
"bodyTomorrow": "{what} — 내일"
|
||||
"bodyTomorrow": "{what} — 내일",
|
||||
"bodyDayAfterTomorrow": "{what} — 모레"
|
||||
}
|
||||
},
|
||||
"action": {
|
||||
|
||||
+4
-2
@@ -8527,6 +8527,7 @@
|
||||
"inDays": "Za {days} dni"
|
||||
},
|
||||
"overdueByDays": "Zaległe od {days} dni",
|
||||
"overdueSinceYesterday": "Zaległe od wczoraj",
|
||||
"cadence": {
|
||||
"everyNDays": "Co {days} dni",
|
||||
"custom": "Własny harmonogram",
|
||||
@@ -10321,7 +10322,7 @@
|
||||
"preventiveCare": {
|
||||
"title": "Badanie do wykonania",
|
||||
"body": "{label} jest do wykonania.",
|
||||
"bodyMany": "Do wykonania jest {count} badań."
|
||||
"bodyManyNamed": "Do wykonania: {labels}"
|
||||
},
|
||||
"syncIssue": {
|
||||
"title": "Synchronizacja wymaga uwagi",
|
||||
@@ -10366,7 +10367,8 @@
|
||||
"upcomingVisit": {
|
||||
"title": "Zbliża się wizyta",
|
||||
"bodyToday": "{what} — dzisiaj",
|
||||
"bodyTomorrow": "{what} — jutro"
|
||||
"bodyTomorrow": "{what} — jutro",
|
||||
"bodyDayAfterTomorrow": "{what} — pojutrze"
|
||||
}
|
||||
},
|
||||
"action": {
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
-- An open check-up stays due after its reminder.
|
||||
--
|
||||
-- Until now the reminder tick rolled every delivered reminder on to its next
|
||||
-- slot. For a check-up (a reminder with no measurement type, made by the
|
||||
-- person or suggested by the Coach) that filed an open task under next month
|
||||
-- or next year the moment it was reminded, without it having been done: the
|
||||
-- start page and the check-up card then showed it as scheduled far ahead,
|
||||
-- and a one-shot check-up disappeared altogether.
|
||||
--
|
||||
-- From this release a check-up keeps its due date until it is marked done,
|
||||
-- skipped or snoozed, and is reminded again at most once a week while it
|
||||
-- stays open. Three steps.
|
||||
|
||||
-- 1. The cursor that keeps the repeat to once a week: when a reminder for
|
||||
-- this row last reached a channel. Nullable, no default; a row that was
|
||||
-- never reminded simply has none.
|
||||
ALTER TABLE "measurement_reminders" ADD COLUMN "last_notified_at" TIMESTAMP(3);
|
||||
|
||||
-- 2. Put back the due date of check-ups the old behaviour rolled on.
|
||||
--
|
||||
-- The evidence is the reminder claim ledger: every reminder send first
|
||||
-- claims `measurement:<reminder id>:<local date>` in `notification_events`
|
||||
-- (event type MEASUREMENT_REMINDER). The claim is written whether or not a
|
||||
-- channel delivered, but the roll-on only happened after a delivery, so a
|
||||
-- row counts as rolled on by a send only when ALL of these hold:
|
||||
--
|
||||
-- - it is a live, enabled check-up: not deleted, not disabled, origin
|
||||
-- VORSORGE or COACH (never an appointment), no measurement type;
|
||||
-- - it was not done or skipped after that send, and it carries no
|
||||
-- snooze;
|
||||
-- - the send was its most recent claim, and the row's last write
|
||||
-- (`updated_at`) lands within a minute before to ten minutes after
|
||||
-- that claim. The roll-on is the write that follows a delivery by
|
||||
-- seconds; any later write (an edit, a snooze, a skip, a Telegram
|
||||
-- "later") moves `updated_at` past the window and the row is left
|
||||
-- alone;
|
||||
-- - the due date it carries now is what the roll-on would have written
|
||||
-- and what the new behaviour no longer writes: more than a week past
|
||||
-- the send (a weekly or shorter check-up still rolls on, so it is not
|
||||
-- repaired), or no due date at all for a check-up with no cadence.
|
||||
--
|
||||
-- The due date goes back to the instant of that send, which is the day
|
||||
-- the check-up was reminded and at its notify hour. `last_notified_at`
|
||||
-- takes the same instant, so the repeat waits a week from that send
|
||||
-- instead of firing again at the next notify hour. A claim older than the
|
||||
-- ledger's 90-day retention is gone, and a row rolled on by such a send
|
||||
-- stays as it is: without the claim there is no evidence of which slot
|
||||
-- was reminded. Nothing is deleted.
|
||||
WITH latest_claim AS (
|
||||
SELECT DISTINCT ON (r."id")
|
||||
r."id" AS "reminder_id",
|
||||
ne."created_at" AS "sent_at"
|
||||
FROM "measurement_reminders" r
|
||||
JOIN "notification_events" ne
|
||||
ON ne."record_user_id" = r."user_id"
|
||||
AND ne."event_type" = 'MEASUREMENT_REMINDER'
|
||||
AND ne."dedup_key" LIKE 'measurement:' || r."id" || ':%'
|
||||
ORDER BY r."id", ne."created_at" DESC
|
||||
)
|
||||
UPDATE "measurement_reminders" r
|
||||
SET "next_due_at" = lc."sent_at",
|
||||
"last_notified_at" = lc."sent_at"
|
||||
FROM latest_claim lc
|
||||
WHERE r."id" = lc."reminder_id"
|
||||
AND r."deleted_at" IS NULL
|
||||
AND r."enabled" = TRUE
|
||||
AND r."origin" IN ('VORSORGE', 'COACH')
|
||||
AND r."measurement_type" IS NULL
|
||||
AND r."snoozed_until" IS NULL
|
||||
AND r."last_notified_at" IS NULL
|
||||
AND (r."last_satisfied_at" IS NULL OR r."last_satisfied_at" < lc."sent_at")
|
||||
AND (r."last_skipped_at" IS NULL OR r."last_skipped_at" < lc."sent_at")
|
||||
AND (r."ends_on" IS NULL OR r."ends_on" > lc."sent_at")
|
||||
AND r."updated_at" >= lc."sent_at" - INTERVAL '1 minute'
|
||||
AND r."updated_at" <= lc."sent_at" + INTERVAL '10 minutes'
|
||||
AND (
|
||||
r."next_due_at" > lc."sent_at" + INTERVAL '7 days 12 hours'
|
||||
OR (
|
||||
r."next_due_at" IS NULL
|
||||
AND r."interval_days" IS NULL
|
||||
AND r."rrule" IS NULL
|
||||
)
|
||||
);
|
||||
|
||||
-- 3. Show booked visits on the start page for people who chose its items
|
||||
-- before visits were one of them.
|
||||
--
|
||||
-- The start page's item choice is stored as the list of ENABLED kinds, and
|
||||
-- only when it is not "all of them". A list saved before `upcoming_visit`
|
||||
-- existed (v1.34.0 to v1.37.1) therefore reads as "visits switched off",
|
||||
-- although nobody switched them off. Append the kind to every stored,
|
||||
-- non-empty list that lacks it. An empty list is the whole rail turned off
|
||||
-- and stays empty. A person who has deliberately switched visits off since
|
||||
-- v1.37.2 cannot be told apart from one who never had the choice; they see
|
||||
-- visits again and can switch them off once more, which now sticks.
|
||||
UPDATE "users"
|
||||
SET "dashboard_widgets_json" = jsonb_set(
|
||||
"dashboard_widgets_json",
|
||||
'{enabledHeroItemKinds}',
|
||||
("dashboard_widgets_json" -> 'enabledHeroItemKinds') || '["upcoming_visit"]'::jsonb
|
||||
)
|
||||
WHERE jsonb_typeof("dashboard_widgets_json" -> 'enabledHeroItemKinds') = 'array'
|
||||
AND jsonb_array_length("dashboard_widgets_json" -> 'enabledHeroItemKinds') > 0
|
||||
AND NOT ("dashboard_widgets_json" -> 'enabledHeroItemKinds') ? 'upcoming_visit';
|
||||
@@ -5481,6 +5481,17 @@ model MeasurementReminder {
|
||||
lastSkippedAt DateTime? @map("last_skipped_at")
|
||||
/// v1.37.20 — lifetime skip counter ("skipped N times").
|
||||
skipCount Int @default(0) @map("skip_count")
|
||||
/// v1.39.2 — when a reminder for this row last reached at least one channel.
|
||||
/// Written by the reminder tick after a delivered dispatch, read by the same
|
||||
/// tick. A check-up (no `measurementType`, not an appointment) no longer
|
||||
/// rolls `nextDueAt` forward when it is reminded: it stays due until it is
|
||||
/// done, skipped or snoozed, and this cursor is what keeps the repeat to at
|
||||
/// most once a week. A nudge counts as "already sent for this slot" only
|
||||
/// while `lastNotifiedAt >= nextDueAt`, so a snooze, a skip or a satisfy
|
||||
/// (each of which moves `nextDueAt`) opens a fresh slot that fires on its
|
||||
/// own day. Operational state, deliberately not in the backup: a restored
|
||||
/// row without it re-reminds at most once, on its next notify hour.
|
||||
lastNotifiedAt DateTime? @map("last_notified_at")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
/// Soft-delete tombstone. NULL = live; non-NULL = deleted at the instant.
|
||||
|
||||
@@ -100,6 +100,7 @@ function reminderRow(
|
||||
snoozedUntil: null,
|
||||
lastSkippedAt: null,
|
||||
skipCount: 0,
|
||||
lastNotifiedAt: null,
|
||||
enabled: true,
|
||||
vaccinationAntigen: null,
|
||||
createdAt: at,
|
||||
|
||||
@@ -52,6 +52,7 @@ const MINTED_ONLY_BY_THE_BUCKET = [
|
||||
"measurementReminders.nextDue.tomorrow",
|
||||
"measurementReminders.nextDue.inDays",
|
||||
"measurementReminders.nextDue.none",
|
||||
"measurementReminders.overdueSinceYesterday",
|
||||
];
|
||||
|
||||
/** The hook that yields the zone the surrounding dates are printed in. */
|
||||
|
||||
@@ -231,6 +231,55 @@ describe("PATCH /api/measurement-reminders/[id] — nextDueAt honours explicit-n
|
||||
expect("rrule" in persisted).toBe(false);
|
||||
expect("anchorDate" in persisted).toBe(false);
|
||||
|
||||
// v1.39.2 — and nextDueAt is not written either. It used to be
|
||||
// recomputed on every PATCH, so correcting the label of an open,
|
||||
// overdue check-up (BASE_ROW is due 2026-01-31, NOW is June) quietly
|
||||
// moved it to its next slot, the same "reminded and gone" defect the
|
||||
// reminder tick had. Only a change to when it recurs reschedules it.
|
||||
expect("nextDueAt" in persisted).toBe(false);
|
||||
expect("snoozedUntil" in persisted).toBe(false);
|
||||
});
|
||||
|
||||
it("an edit that resends the unchanged cadence keeps an overdue slot", async () => {
|
||||
findFirstMock.mockResolvedValue(BASE_ROW);
|
||||
|
||||
const res = await PATCH(
|
||||
makeRequest({ label: "New label", intervalDays: 30, rrule: null }),
|
||||
params,
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
const persisted = updateMock.mock.calls[0]?.[0]?.data as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
expect("nextDueAt" in persisted).toBe(false);
|
||||
});
|
||||
|
||||
it("a notify-hour edit keeps the due day and moves only the hour", async () => {
|
||||
findFirstMock.mockResolvedValue(BASE_ROW);
|
||||
|
||||
const res = await PATCH(makeRequest({ notifyHour: 18 }), params);
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
const persisted = updateMock.mock.calls[0]?.[0]?.data as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
// 2026-01-31 18:00 in Berlin (UTC+1 in winter).
|
||||
expect(persisted.nextDueAt).toEqual(new Date("2026-01-31T17:00:00Z"));
|
||||
});
|
||||
|
||||
it("re-enabling a disabled reminder schedules it from now", async () => {
|
||||
findFirstMock.mockResolvedValue({ ...BASE_ROW, enabled: false });
|
||||
|
||||
const res = await PATCH(makeRequest({ enabled: true }), params);
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
const persisted = updateMock.mock.calls[0]?.[0]?.data as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
const expected = computeReminderNextDueAt(BASE_ROW, TZ, NOW);
|
||||
expect(persisted.nextDueAt).toEqual(expected);
|
||||
});
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
/**
|
||||
* v1.17.1 — Vorsorge (measurement) reminder by id: get + patch + delete.
|
||||
*
|
||||
* PATCH re-derives the server-authoritative `nextDueAt` after applying
|
||||
* the (mutually-exclusive cadence) edit. DELETE removes the row; the
|
||||
* PATCH re-derives the server-authoritative `nextDueAt` when the edit
|
||||
* changes when the reminder recurs (interval, rule, anchor, or re-enabling
|
||||
* it); a notify-hour edit moves the hour on the same due day, and any other
|
||||
* edit leaves the due date alone. DELETE removes the row; the
|
||||
* confirmation dialog says the reminder is permanently deleted, and nothing
|
||||
* here needs a tombstone to keep that promise honest.
|
||||
*/
|
||||
@@ -30,6 +32,7 @@ import {
|
||||
type ReminderScheduleInput,
|
||||
} from "@/lib/measurement-reminders/scheduling";
|
||||
import { toMeasurementReminderDto } from "@/lib/measurement-reminders/dto";
|
||||
import { wallClockInTz, zonedWallClockToUtc } from "@/lib/tz/wall-clock";
|
||||
|
||||
type RouteParams = { params: Promise<{ id: string }> };
|
||||
|
||||
@@ -184,14 +187,50 @@ export const PATCH = apiHandler(
|
||||
// (Coach-suggested) cadence keeps self-expiring.
|
||||
endsOn: existing.endsOn,
|
||||
};
|
||||
const after =
|
||||
existing.lastSatisfiedAt && existing.lastSatisfiedAt > now
|
||||
? existing.lastSatisfiedAt
|
||||
: now;
|
||||
updateData.nextDueAt = computeReminderNextDueAt(merged, timezone, after);
|
||||
// v1.37.20 (#223) — a cadence edit recomputes `nextDueAt`, so it clears
|
||||
// the snooze cursor: the cycle the snooze pushed back no longer exists.
|
||||
updateData.snoozedUntil = null;
|
||||
// v1.39.2 — only an edit to WHEN the reminder recurs reschedules it. A
|
||||
// label, location or type edit used to recompute too, and since the
|
||||
// recompute searches strictly after now, correcting the label of an
|
||||
// open, overdue check-up quietly moved it to its next slot. Compared by
|
||||
// value, because the edit forms resend every field on each save.
|
||||
const sameInstant = (a: Date | null, b: Date | null) =>
|
||||
(a?.getTime() ?? null) === (b?.getTime() ?? null);
|
||||
const cadenceChanged =
|
||||
merged.intervalDays !== existing.intervalDays ||
|
||||
merged.rrule !== existing.rrule ||
|
||||
!sameInstant(merged.anchorDate, existing.anchorDate) ||
|
||||
(updateData.enabled === true && !existing.enabled);
|
||||
if (cadenceChanged) {
|
||||
const after =
|
||||
existing.lastSatisfiedAt && existing.lastSatisfiedAt > now
|
||||
? existing.lastSatisfiedAt
|
||||
: now;
|
||||
updateData.nextDueAt = computeReminderNextDueAt(merged, timezone, after);
|
||||
// v1.37.20 (#223) — a cadence edit recomputes `nextDueAt`, so it clears
|
||||
// the snooze cursor: the cycle the snooze pushed back no longer exists.
|
||||
updateData.snoozedUntil = null;
|
||||
} else if (
|
||||
merged.notifyHour !== existing.notifyHour &&
|
||||
existing.nextDueAt !== null
|
||||
) {
|
||||
// A new notify hour keeps the due DAY and moves the hour on it, so an
|
||||
// open slot stays open. A snooze pinned to that same slot moves with it.
|
||||
const day = wallClockInTz(existing.nextDueAt, timezone);
|
||||
const moved = zonedWallClockToUtc(
|
||||
{
|
||||
year: day.year,
|
||||
month: day.month,
|
||||
day: day.day,
|
||||
hour: merged.notifyHour,
|
||||
minute: 0,
|
||||
second: 0,
|
||||
},
|
||||
timezone,
|
||||
);
|
||||
updateData.nextDueAt = moved;
|
||||
if (sameInstant(existing.snoozedUntil, existing.nextDueAt)) {
|
||||
updateData.snoozedUntil = moved;
|
||||
}
|
||||
}
|
||||
|
||||
const updated = await prisma.measurementReminder.update({
|
||||
where: { id },
|
||||
|
||||
@@ -275,7 +275,9 @@ describe("<VorsorgeSection> loading + empty", () => {
|
||||
} as MeasurementReminder;
|
||||
remindersMock.mockReturnValue({ data: [reminder], isLoading: false });
|
||||
const html = render(<VorsorgeSection />);
|
||||
expect(html).toContain("Overdue by 1 days");
|
||||
// One day over reads "since yesterday": the counted form printed
|
||||
// "Overdue by 1 days", and the warning hue must follow both phrases.
|
||||
expect(html).toContain("Overdue since yesterday");
|
||||
expect(html).toContain("text-warning");
|
||||
});
|
||||
|
||||
|
||||
@@ -47,7 +47,10 @@ import {
|
||||
useDisplayTimezone,
|
||||
} from "@/lib/i18n/context";
|
||||
import { relativeCalendarDate } from "@/lib/i18n/relative-time";
|
||||
import { relativeDueKey } from "@/lib/measurement-reminders/due-day";
|
||||
import {
|
||||
isOverdueDue,
|
||||
relativeDueKey,
|
||||
} from "@/lib/measurement-reminders/due-day";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { applyOrder, useModuleListPrefs } from "@/lib/module-list-prefs";
|
||||
import { EncounterSheet } from "@/components/encounters/encounter-sheet";
|
||||
@@ -899,8 +902,7 @@ function VorsorgeCard({
|
||||
// routes to the check-in page), never the numeric value-entry wording.
|
||||
const isScreening = isScreeningReminderType(reminder.measurementType);
|
||||
const isDue =
|
||||
due.key === "measurementReminders.nextDue.today" ||
|
||||
due.key === "measurementReminders.overdueByDays";
|
||||
due.key === "measurementReminders.nextDue.today" || isOverdueDue(due);
|
||||
const progress = intervalProgress(reminder, now);
|
||||
|
||||
// Category-style header badge: the measurement label, or "self-planned"
|
||||
@@ -1361,7 +1363,7 @@ function VorsorgeCard({
|
||||
// normal muted-resolved date. The card surface stays neutral per the house
|
||||
// rule; only this inline label carries the status hue.
|
||||
const dueIsToday = due.key === "measurementReminders.nextDue.today";
|
||||
const dueIsOverdue = due.key === "measurementReminders.overdueByDays";
|
||||
const dueIsOverdue = isOverdueDue(due);
|
||||
const nextLastSlot = (
|
||||
<div className="min-h-[2.75rem] space-y-1.5 text-sm">
|
||||
<div className="text-muted-foreground flex items-baseline justify-between gap-3">
|
||||
|
||||
@@ -177,7 +177,7 @@ const fakeDb = vi.hoisted(() => {
|
||||
ecgRecording: { findFirst: async () => null },
|
||||
personalRecord: { findMany: none },
|
||||
arrivalReaction: { findMany: none },
|
||||
encounter: { findFirst: async () => null },
|
||||
encounter: { findMany: none },
|
||||
// The AI capability loader's consent read: no receipts.
|
||||
consentReceipt: { findMany: none },
|
||||
};
|
||||
|
||||
@@ -639,7 +639,18 @@ describe("buildDailyDigest — worth-a-look rail item builders", () => {
|
||||
);
|
||||
const care = d.worthALook.filter((i) => i.kind === "preventive_care");
|
||||
expect(care).toHaveLength(1);
|
||||
expect(care[0].body).toBe("3 check-ups are due.");
|
||||
expect(care[0].body).toBe("Due: A, B, C");
|
||||
});
|
||||
|
||||
it("names the first three due check-ups and counts the rest", () => {
|
||||
const d = buildDailyDigest(
|
||||
input({
|
||||
preventiveDue: ["A", "B", "C", "D", "E"].map((label) => ({ label })),
|
||||
}),
|
||||
t,
|
||||
);
|
||||
const care = d.worthALook.find((i) => i.kind === "preventive_care");
|
||||
expect(care?.body).toBe("Due: A, B, C +2");
|
||||
});
|
||||
|
||||
it("bounds the rail at three items, never padded", () => {
|
||||
@@ -1317,3 +1328,121 @@ describe("buildDailyDigest — AI text follows its capability", () => {
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
// v1.39.2 — a check-up that is due today or overdue, and a visit booked for
|
||||
// today, are the two rail items a person can miss for good: the check-up
|
||||
// because the reminder already went out, the visit because the day ends.
|
||||
// Watched red: with the plain `slice(0, 3)` over the priority order, the
|
||||
// first two cases drop the item behind an overdue dose and three sync issues.
|
||||
describe("buildDailyDigest — due check-ups and today's visits hold their place", () => {
|
||||
const crowd = {
|
||||
medsToday: meds({ nextDueOverdue: true, nextDueMedicationName: "X" }),
|
||||
syncIssues: [
|
||||
{ integration: "withings", state: "error_reauth" },
|
||||
{ integration: "nightscout", state: "parked" },
|
||||
{ integration: "fitbit", state: "error_reauth" },
|
||||
],
|
||||
};
|
||||
const visit = (over: Record<string, unknown> = {}) => ({
|
||||
id: "v1",
|
||||
kind: "ROUTINE",
|
||||
occurredAt: "2026-07-16T13:00:00.000Z",
|
||||
practitionerName: "Dr. Weiss",
|
||||
dayOffset: 0,
|
||||
...over,
|
||||
});
|
||||
|
||||
it("keeps a due check-up on a full rail", () => {
|
||||
const d = buildDailyDigest(
|
||||
input({ ...crowd, preventiveDue: [{ label: "Skin check" }] }),
|
||||
t,
|
||||
);
|
||||
expect(d.worthALook).toHaveLength(MAX_WORTH_A_LOOK);
|
||||
expect(d.worthALook.map((i) => i.kind)).toContain("preventive_care");
|
||||
// The overdue dose still leads; the pinned item takes a later slot.
|
||||
expect(d.worthALook[0].kind).toBe("dose_window");
|
||||
});
|
||||
|
||||
it("keeps today's visit on a full rail", () => {
|
||||
const d = buildDailyDigest(
|
||||
input({ ...crowd, upcomingVisits: [visit()] }),
|
||||
t,
|
||||
);
|
||||
expect(d.worthALook.map((i) => i.kind)).toContain("upcoming_visit");
|
||||
});
|
||||
|
||||
it("keeps both a due check-up and today's visit on a full rail", () => {
|
||||
const d = buildDailyDigest(
|
||||
input({
|
||||
...crowd,
|
||||
preventiveDue: [{ label: "Skin check" }],
|
||||
upcomingVisits: [visit()],
|
||||
}),
|
||||
t,
|
||||
);
|
||||
expect(d.worthALook.map((i) => i.kind)).toEqual([
|
||||
"dose_window",
|
||||
"preventive_care",
|
||||
"upcoming_visit",
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not pin a visit that is not today", () => {
|
||||
const d = buildDailyDigest(
|
||||
input({ ...crowd, upcomingVisits: [visit({ dayOffset: 1 })] }),
|
||||
t,
|
||||
);
|
||||
expect(d.worthALook.map((i) => i.kind)).not.toContain("upcoming_visit");
|
||||
});
|
||||
|
||||
it("names every visit of the day in one item, including one already under way", () => {
|
||||
const d = buildDailyDigest(
|
||||
input({
|
||||
upcomingVisits: [
|
||||
// Started an hour before NOW: still today's, still on the rail.
|
||||
visit({ occurredAt: "2026-07-16T08:00:00.000Z" }),
|
||||
visit({ id: "v2", practitionerName: "Dentist" }),
|
||||
],
|
||||
}),
|
||||
t,
|
||||
);
|
||||
const visits = d.worthALook.filter((i) => i.kind === "upcoming_visit");
|
||||
expect(visits).toHaveLength(1);
|
||||
expect(visits[0].body).toBe("Dr. Weiss, Dentist — today");
|
||||
});
|
||||
|
||||
it("says tomorrow and the day after by calendar day", () => {
|
||||
const tomorrow = buildDailyDigest(
|
||||
input({ upcomingVisits: [visit({ dayOffset: 1 })] }),
|
||||
t,
|
||||
);
|
||||
expect(
|
||||
tomorrow.worthALook.find((i) => i.kind === "upcoming_visit")?.body,
|
||||
).toBe("Dr. Weiss — tomorrow");
|
||||
const later = buildDailyDigest(
|
||||
input({ upcomingVisits: [visit({ dayOffset: 2 })] }),
|
||||
t,
|
||||
);
|
||||
expect(
|
||||
later.worthALook.find((i) => i.kind === "upcoming_visit")?.body,
|
||||
).toBe("Dr. Weiss — the day after tomorrow");
|
||||
});
|
||||
|
||||
it("shows today's visits and the next day's as two items", () => {
|
||||
const d = buildDailyDigest(
|
||||
input({
|
||||
upcomingVisits: [
|
||||
visit(),
|
||||
visit({ id: "v2", practitionerName: "Dentist", dayOffset: 1 }),
|
||||
visit({ id: "v3", practitionerName: "Lab", dayOffset: 2 }),
|
||||
],
|
||||
}),
|
||||
t,
|
||||
);
|
||||
expect(
|
||||
d.worthALook
|
||||
.filter((i) => i.kind === "upcoming_visit")
|
||||
.map((i) => i.body),
|
||||
).toEqual(["Dr. Weiss — today", "Dentist — tomorrow"]);
|
||||
});
|
||||
});
|
||||
@@ -20,7 +20,7 @@ vi.mock("@/lib/db", () => ({
|
||||
dismissedPriorityItem: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
personalRecord: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
arrivalReaction: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
encounter: { findFirst: vi.fn().mockResolvedValue(null) },
|
||||
encounter: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -301,3 +301,83 @@ describe("loadDailyDigest — AI parts", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// v1.39.2 — the two reads behind the check-up and visit rail items. NOW is
|
||||
// 11:00 in Berlin on 2026-07-17, so the local day runs 2026-07-16T22:00Z to
|
||||
// 2026-07-17T22:00Z.
|
||||
describe("loadDailyDigest — due check-ups and today's visits", () => {
|
||||
it("reads check-ups due before the end of the local day, not only before now", async () => {
|
||||
await loadDailyDigest(USER, NOW);
|
||||
|
||||
const args = vi.mocked(prisma.measurementReminder.findMany).mock
|
||||
.calls[0][0] as { where: Record<string, unknown> };
|
||||
expect(args.where.nextDueAt).toEqual({
|
||||
not: null,
|
||||
lt: new Date("2026-07-17T22:00:00.000Z"),
|
||||
});
|
||||
// Appointments stay off the preventive read.
|
||||
expect(args.where.origin).toEqual({ not: "ENCOUNTER" });
|
||||
});
|
||||
|
||||
it("reads every planned visit from the start of the local day", async () => {
|
||||
await loadDailyDigest(USER, NOW);
|
||||
|
||||
const args = vi.mocked(prisma.encounter.findMany).mock.calls[0][0] as {
|
||||
where: { status: string; occurredAt: { gte: Date; lte: Date } };
|
||||
};
|
||||
expect(args.where.status).toBe("PLANNED");
|
||||
expect(args.where.occurredAt.gte).toEqual(
|
||||
new Date("2026-07-16T22:00:00.000Z"),
|
||||
);
|
||||
expect(args.where.occurredAt.lte).toEqual(
|
||||
new Date("2026-07-19T09:00:00.000Z"),
|
||||
);
|
||||
});
|
||||
|
||||
it("puts a visit that started this morning on the rail as today's", async () => {
|
||||
vi.mocked(prisma.encounter.findMany).mockResolvedValueOnce([
|
||||
{
|
||||
id: "v1",
|
||||
kind: "ROUTINE",
|
||||
occurredAt: new Date("2026-07-17T07:00:00.000Z"), // 09:00 Berlin
|
||||
practitioner: { name: "Dr. Weiss" },
|
||||
},
|
||||
{
|
||||
id: "v2",
|
||||
kind: "ROUTINE",
|
||||
occurredAt: new Date("2026-07-17T23:30:00.000Z"), // 01:30 tomorrow
|
||||
practitioner: { name: "Lab" },
|
||||
},
|
||||
] as never);
|
||||
|
||||
const digest = await loadDailyDigest(USER, NOW);
|
||||
|
||||
const visits = digest.worthALook.filter(
|
||||
(item) => item.kind === "upcoming_visit",
|
||||
);
|
||||
// The translator is stubbed to echo keys, so the bucket shows in the key.
|
||||
expect(visits.map((item) => item.body)).toEqual([
|
||||
"daily.item.upcomingVisit.bodyToday",
|
||||
"daily.item.upcomingVisit.bodyTomorrow",
|
||||
]);
|
||||
});
|
||||
|
||||
it("translates a Coach cadence label and keeps a free-text one", async () => {
|
||||
const t = vi.fn((key: string) =>
|
||||
key === "coach.cadence.bp7day.label" ? "Blood pressure week" : key,
|
||||
);
|
||||
const { getServerTranslator } =
|
||||
await import("@/lib/i18n/server-translator");
|
||||
vi.mocked(getServerTranslator).mockReturnValueOnce({ t } as never);
|
||||
vi.mocked(prisma.measurementReminder.findMany).mockResolvedValueOnce([
|
||||
{ label: "coach.cadence.bp7day.label", origin: "COACH" },
|
||||
{ label: "coach.cadence.bp7day.label", origin: "VORSORGE" },
|
||||
] as never);
|
||||
|
||||
await loadDailyDigest(USER, NOW);
|
||||
|
||||
expect(t).toHaveBeenCalledWith("daily.item.preventiveCare.bodyManyNamed", {
|
||||
labels: "Blood pressure week, coach.cadence.bp7day.label",
|
||||
});
|
||||
});
|
||||
});
|
||||
+132
-48
@@ -133,13 +133,20 @@ export interface DailyDigestSyncIssue {
|
||||
state: string;
|
||||
}
|
||||
|
||||
/** A Vorsorge / measurement reminder whose next-due instant has passed. */
|
||||
/**
|
||||
* A Vorsorge / measurement reminder that is due today or overdue: its
|
||||
* next-due instant falls before the end of the person's local day. Since
|
||||
* v1.39.2 an open check-up keeps its due date after it is reminded, so this
|
||||
* list holds it until it is done, skipped or snoozed.
|
||||
*/
|
||||
export interface DailyDigestPreventiveDue {
|
||||
/** Display label, already resolved (a Coach cadence key is translated). */
|
||||
label: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* A booked visit falling today or tomorrow.
|
||||
* A booked visit on the person's local today (even once it has started) or
|
||||
* within the next two days.
|
||||
*
|
||||
* Read off the VISIT table, never off the reminder engine: an appointment's
|
||||
* one-shot reminder is excluded from every preventive-care read, and this is
|
||||
@@ -151,6 +158,12 @@ export interface DailyDigestUpcomingVisit {
|
||||
kind: string;
|
||||
occurredAt: string;
|
||||
practitionerName: string | null;
|
||||
/**
|
||||
* Calendar days from the person's local today, in their profile timezone:
|
||||
* 0 today, 1 tomorrow, 2 the day after. Resolved by the IO seam so the
|
||||
* builder never needs the timezone.
|
||||
*/
|
||||
dayOffset: number;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -301,8 +314,13 @@ export interface DailyDigestInput {
|
||||
morningRefreshedToday: boolean;
|
||||
syncIssues: DailyDigestSyncIssue[];
|
||||
preventiveDue: DailyDigestPreventiveDue[];
|
||||
/** The booked visit falling today or tomorrow, or null. Honest-absent. */
|
||||
upcomingVisit?: DailyDigestUpcomingVisit | null;
|
||||
/**
|
||||
* Every booked visit on the local today and within the next two days,
|
||||
* soonest first. Optional so a consumer that predates it stays valid; a
|
||||
* missing value means no visit. The digest DTO has no visit field of its
|
||||
* own: visits reach the wire only as `upcoming_visit` rail items.
|
||||
*/
|
||||
upcomingVisits?: readonly DailyDigestUpcomingVisit[];
|
||||
/** Standing coach plans (active + reviewed) — check-in candidates (§2.3). */
|
||||
coachPlans: DailyDigestCoachPlan[];
|
||||
/**
|
||||
@@ -544,16 +562,32 @@ function buildSyncIssueItems(
|
||||
}));
|
||||
}
|
||||
|
||||
/** A single preventive-care item summarising the due Vorsorge reminders. */
|
||||
/** How many due check-ups the rail item names before it counts the rest. */
|
||||
const PREVENTIVE_NAMED_MAX = 3;
|
||||
|
||||
/**
|
||||
* A single preventive-care item summarising the check-ups that are due today
|
||||
* or overdue. One names itself; several are named up to three, with the rest
|
||||
* counted as "+N" so the line needs no plural form in any language.
|
||||
*/
|
||||
function buildPreventiveCareItem(
|
||||
due: DailyDigestPreventiveDue[],
|
||||
t: Translate,
|
||||
): PriorityItem | null {
|
||||
if (due.length === 0) return null;
|
||||
const body =
|
||||
due.length === 1
|
||||
? t("daily.item.preventiveCare.body", { label: due[0].label })
|
||||
: t("daily.item.preventiveCare.bodyMany", { count: due.length });
|
||||
let body: string;
|
||||
if (due.length === 1) {
|
||||
body = t("daily.item.preventiveCare.body", { label: due[0].label });
|
||||
} else {
|
||||
const named = due
|
||||
.slice(0, PREVENTIVE_NAMED_MAX)
|
||||
.map((d) => d.label)
|
||||
.join(", ");
|
||||
const rest = due.length - PREVENTIVE_NAMED_MAX;
|
||||
body = t("daily.item.preventiveCare.bodyManyNamed", {
|
||||
labels: rest > 0 ? `${named} +${rest}` : named,
|
||||
});
|
||||
}
|
||||
return {
|
||||
kind: "preventive_care",
|
||||
title: t("daily.item.preventiveCare.title"),
|
||||
@@ -570,46 +604,90 @@ function buildPreventiveCareItem(
|
||||
}
|
||||
|
||||
/**
|
||||
* The booked visit falling today or tomorrow, as one rail line.
|
||||
* Booked visits as rail lines: one item for everything booked today, and one
|
||||
* for the next day that has a visit within the two-day horizon.
|
||||
*
|
||||
* Today's item stays for the whole local day, also after the visit's start
|
||||
* time: the visit's reminder fires at that time, and the rail used to drop the
|
||||
* visit at exactly that moment. Several visits on one day share one item and
|
||||
* are named together, so a busy day cannot fill the three-item rail with
|
||||
* appointments alone.
|
||||
*
|
||||
* No module gate: a visit is core, like the checkups page it lives on. No
|
||||
* action beyond opening the page either — there is nothing to do about an
|
||||
* appointment except keep it, and a "mark done" here would duplicate the
|
||||
* visit's own status field with a second place to set it.
|
||||
*/
|
||||
function buildUpcomingVisitItem(
|
||||
visit: DailyDigestUpcomingVisit | null,
|
||||
now: Date,
|
||||
function buildUpcomingVisitItems(
|
||||
visits: readonly DailyDigestUpcomingVisit[],
|
||||
t: Translate,
|
||||
): PriorityItem | null {
|
||||
if (!visit) return null;
|
||||
const at = new Date(visit.occurredAt);
|
||||
if (Number.isNaN(at.getTime())) return null;
|
||||
// Today vs tomorrow is decided on the SAME instants the read window used, so
|
||||
// a visit admitted by the query can never be described as neither.
|
||||
const hoursAway = (at.getTime() - now.getTime()) / (60 * 60 * 1000);
|
||||
// Through the key resolver, never `encounters.kind.${kind}`: the enum is
|
||||
// `ROUTINE` and the bundle leaf is `routine`, so the interpolated form put
|
||||
// raw dot notation on a lock screen.
|
||||
const what =
|
||||
visit.practitionerName ??
|
||||
t(encounterKindLabelKey(visit.kind as EncounterKind));
|
||||
return {
|
||||
kind: "upcoming_visit",
|
||||
title: t("daily.item.upcomingVisit.title"),
|
||||
body:
|
||||
hoursAway <= 24
|
||||
? t("daily.item.upcomingVisit.bodyToday", { what })
|
||||
: t("daily.item.upcomingVisit.bodyTomorrow", { what }),
|
||||
status: "info",
|
||||
actions: [
|
||||
{
|
||||
labelKey: "daily.action.viewCheckups",
|
||||
intent: "checkup.view",
|
||||
href: "/checkups",
|
||||
},
|
||||
],
|
||||
): { today: PriorityItem | null; next: PriorityItem | null } {
|
||||
const item = (group: readonly DailyDigestUpcomingVisit[]): PriorityItem => {
|
||||
// Through the key resolver, never `encounters.kind.${kind}`: the enum is
|
||||
// `ROUTINE` and the bundle leaf is `routine`, so the interpolated form put
|
||||
// raw dot notation on a lock screen.
|
||||
const names = group.map(
|
||||
(visit) =>
|
||||
visit.practitionerName ??
|
||||
t(encounterKindLabelKey(visit.kind as EncounterKind)),
|
||||
);
|
||||
const what = Array.from(new Set(names)).join(", ");
|
||||
const offset = group[0].dayOffset;
|
||||
return {
|
||||
kind: "upcoming_visit",
|
||||
title: t("daily.item.upcomingVisit.title"),
|
||||
body:
|
||||
offset <= 0
|
||||
? t("daily.item.upcomingVisit.bodyToday", { what })
|
||||
: offset === 1
|
||||
? t("daily.item.upcomingVisit.bodyTomorrow", { what })
|
||||
: t("daily.item.upcomingVisit.bodyDayAfterTomorrow", { what }),
|
||||
status: "info",
|
||||
actions: [
|
||||
{
|
||||
labelKey: "daily.action.viewCheckups",
|
||||
intent: "checkup.view",
|
||||
href: "/checkups",
|
||||
},
|
||||
],
|
||||
};
|
||||
};
|
||||
|
||||
const valid = visits.filter(
|
||||
(visit) => !Number.isNaN(new Date(visit.occurredAt).getTime()),
|
||||
);
|
||||
const today = valid.filter((visit) => visit.dayOffset <= 0);
|
||||
const later = valid.filter((visit) => visit.dayOffset > 0);
|
||||
const nextOffset =
|
||||
later.length > 0 ? Math.min(...later.map((v) => v.dayOffset)) : null;
|
||||
const next = later.filter((visit) => visit.dayOffset === nextOffset);
|
||||
return {
|
||||
today: today.length > 0 ? item(today) : null,
|
||||
next: next.length > 0 ? item(next) : null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The bounded rail: at most `max` items, in priority order, with the pinned
|
||||
* items guaranteed a place. A pinned item is one a person can miss for good if
|
||||
* the cap drops it today: a check-up that is due or overdue (its reminder has
|
||||
* already gone out) and a visit booked for today (the day ends). The rest fill
|
||||
* the remaining slots in order, and the result keeps the priority order, so an
|
||||
* overdue dose still leads.
|
||||
*/
|
||||
function capWithPinned(
|
||||
items: readonly PriorityItem[],
|
||||
pinned: ReadonlySet<PriorityItem>,
|
||||
max: number,
|
||||
): PriorityItem[] {
|
||||
const keep = new Set<PriorityItem>();
|
||||
for (const item of items) {
|
||||
if (keep.size < max && pinned.has(item)) keep.add(item);
|
||||
}
|
||||
for (const item of items) {
|
||||
if (keep.size < max) keep.add(item);
|
||||
}
|
||||
return items.filter((item) => keep.has(item));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -993,7 +1071,9 @@ export function buildDailyDigest(
|
||||
// Priority order: a due or overdue dose is the most time-sensitive daily
|
||||
// action, a broken sync next, then the calm coach check-in, a preventive
|
||||
// check-up least urgent. Bounded to 3 — a check-in the cap crowds out
|
||||
// resurfaces on a following day (within its window), never lost.
|
||||
// resurfaces on a following day (within its window), never lost. A due
|
||||
// check-up and today's visits are not left to the order: the cap keeps
|
||||
// them (see `capWithPinned`).
|
||||
const worthALook: PriorityItem[] = [];
|
||||
worthALook.push(
|
||||
...buildDoseWindowItems(
|
||||
@@ -1030,12 +1110,16 @@ export function buildDailyDigest(
|
||||
// rail because a person reads "what am I meant to arrange" and "where am I
|
||||
// meant to be" in one glance, and they stay separate items because one is
|
||||
// overdue and the other is simply scheduled.
|
||||
const visit = buildUpcomingVisitItem(
|
||||
input.upcomingVisit ?? null,
|
||||
input.now,
|
||||
t,
|
||||
const visits = buildUpcomingVisitItems(input.upcomingVisits ?? [], t);
|
||||
if (visits.today) worthALook.push(visits.today);
|
||||
if (visits.next) worthALook.push(visits.next);
|
||||
// Due check-ups and today's visits keep their place under the cap (see
|
||||
// `capWithPinned`); they still sit below a dose in the displayed order.
|
||||
const pinned = new Set<PriorityItem>(
|
||||
[preventive, visits.today].filter(
|
||||
(item): item is PriorityItem => item !== null,
|
||||
),
|
||||
);
|
||||
if (visit) worthALook.push(visit);
|
||||
// S11 — the calm, informational tension marker sits last: it is context, not
|
||||
// an action that expires, so a time-sensitive dose / sync / check-in wins the
|
||||
// bounded rail ahead of it.
|
||||
@@ -1086,7 +1170,7 @@ export function buildDailyDigest(
|
||||
topSignal,
|
||||
briefingLead,
|
||||
line: composeLine(briefingLead, topSignal, input.score, t),
|
||||
worthALook: visible.slice(0, MAX_WORTH_A_LOOK),
|
||||
worthALook: capWithPinned(visible, pinned, MAX_WORTH_A_LOOK),
|
||||
justIn,
|
||||
reactionLine,
|
||||
ai: input.ai,
|
||||
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
} from "@/lib/ai/capabilities/gate";
|
||||
import { userDayKey } from "@/lib/tz/format";
|
||||
import { getUserTodayBounds } from "@/lib/tz/local-day";
|
||||
import { calendarDaysUntil } from "@/lib/measurement-reminders/due-day";
|
||||
import { cachedSwr, caches, type ServerCache } from "@/lib/cache/server-cache";
|
||||
import { DASHBOARD_REFETCH_INTERVAL_MS } from "@/lib/queries/refetch-interval";
|
||||
import { isArrivalKind } from "@/lib/arrivals/types";
|
||||
@@ -71,7 +72,8 @@ import {
|
||||
const SYNC_ISSUE_STATES = ["error_reauth", "parked"] as const;
|
||||
|
||||
/**
|
||||
* How far ahead the rail mentions a booked appointment: today or tomorrow.
|
||||
* How far ahead the rail mentions a booked appointment: the next 48 hours, on
|
||||
* top of everything booked for the local today.
|
||||
*
|
||||
* The rail is a "what about today" surface, so anything further out is not a
|
||||
* thing to act on this morning. Two days rather than one because an evening
|
||||
@@ -80,6 +82,9 @@ const SYNC_ISSUE_STATES = ["error_reauth", "parked"] as const;
|
||||
*/
|
||||
const UPCOMING_VISIT_WINDOW_MS = 2 * 24 * 60 * 60 * 1000;
|
||||
|
||||
/** Defensive cap on how many planned visits the rail read returns. */
|
||||
const UPCOMING_VISIT_READ_LIMIT = 10;
|
||||
|
||||
/** Defensive cap on how many overdue reminders we read for the rail summary. */
|
||||
const PREVENTIVE_DUE_READ_LIMIT = 20;
|
||||
|
||||
@@ -372,9 +377,8 @@ export async function loadDailyDigest(
|
||||
// First instant of the user's NEXT local day (profile tz) — bounds the
|
||||
// dose-window rail to today's business. `getUserTodayBounds` returns the
|
||||
// inclusive last millisecond of today; +1 ms is exactly tomorrow's start.
|
||||
const todayEndExclusive = new Date(
|
||||
getUserTodayBounds(now, user.timezone).end.getTime() + 1,
|
||||
);
|
||||
const todayBounds = getUserTodayBounds(now, user.timezone);
|
||||
const todayEndExclusive = new Date(todayBounds.end.getTime() + 1);
|
||||
|
||||
const [
|
||||
{ body: snapshot, locale },
|
||||
@@ -384,7 +388,7 @@ export async function loadDailyDigest(
|
||||
planRows,
|
||||
ecgRow,
|
||||
arrivalRows,
|
||||
nextVisitRow,
|
||||
visitRows,
|
||||
coachAi,
|
||||
reactionLinesAi,
|
||||
] = await Promise.all([
|
||||
@@ -402,14 +406,19 @@ export async function loadDailyDigest(
|
||||
// sites carry this exclusion; `encounter-reminder-exclusion.test.ts`
|
||||
// proves every one of them, and the DTO mapper refuses such a row
|
||||
// outright so a site that lost its filter fails loudly.
|
||||
//
|
||||
// Due TODAY or overdue: anything whose due instant falls before the end
|
||||
// of the person's local day, not only what has passed by now. Since
|
||||
// v1.39.2 an open check-up keeps its due date after the reminder, so
|
||||
// the overdue ones stay here until they are done, skipped or snoozed.
|
||||
where: {
|
||||
userId: user.id,
|
||||
enabled: true,
|
||||
deletedAt: null,
|
||||
origin: { not: "ENCOUNTER" },
|
||||
nextDueAt: { not: null, lte: now },
|
||||
nextDueAt: { not: null, lt: todayEndExclusive },
|
||||
},
|
||||
select: { label: true },
|
||||
select: { label: true, origin: true },
|
||||
orderBy: { nextDueAt: "asc" },
|
||||
take: PREVENTIVE_DUE_READ_LIMIT,
|
||||
}),
|
||||
@@ -460,20 +469,22 @@ export async function loadDailyDigest(
|
||||
generatedAt: true,
|
||||
},
|
||||
}),
|
||||
// The next PLANNED visit inside the two-day horizon. Read straight off the
|
||||
// visit table, never off the reminder engine — see the mapping below for
|
||||
// why the exclusion above must stay untouched.
|
||||
prisma.encounter.findFirst({
|
||||
// Every PLANNED visit from the start of the local today to the end of the
|
||||
// two-day horizon. Read straight off the visit table, never off the
|
||||
// reminder engine — see the mapping below for why the exclusion above
|
||||
// must stay untouched.
|
||||
prisma.encounter.findMany({
|
||||
where: {
|
||||
userId: user.id,
|
||||
deletedAt: null,
|
||||
status: "PLANNED",
|
||||
occurredAt: {
|
||||
gt: now,
|
||||
gte: todayBounds.start,
|
||||
lte: new Date(now.getTime() + UPCOMING_VISIT_WINDOW_MS),
|
||||
},
|
||||
},
|
||||
orderBy: { occurredAt: "asc" },
|
||||
take: UPCOMING_VISIT_READ_LIMIT,
|
||||
select: {
|
||||
id: true,
|
||||
kind: true,
|
||||
@@ -531,12 +542,9 @@ export async function loadDailyDigest(
|
||||
state: row.state,
|
||||
}));
|
||||
|
||||
const preventiveDue: DailyDigestPreventiveDue[] = dueReminders.map((row) => ({
|
||||
label: row.label,
|
||||
}));
|
||||
|
||||
/**
|
||||
* The appointment falling today or tomorrow, read from the VISIT table.
|
||||
* The booked visits of the local today and the next 48 hours, read from the
|
||||
* VISIT table.
|
||||
*
|
||||
* Not from the reminder table. An ENCOUNTER-origin reminder is excluded from
|
||||
* every preventive-care read, which is right — an appointment is not a
|
||||
@@ -545,18 +553,18 @@ export async function loadDailyDigest(
|
||||
* in, and the exclusion above stays exactly as it is. A diff here that
|
||||
* touches `origin` is the wrong fix.
|
||||
*
|
||||
* `occurredAt > now` deliberately, not `>= todayStart`: an appointment that
|
||||
* has already happened today is not something to be reminded about, and the
|
||||
* person is more likely to be at it than looking at this.
|
||||
* `occurredAt >= todayStart`, not `> now`: a visit's reminder fires at its
|
||||
* start time, and a rail that dropped the visit at that very moment showed a
|
||||
* notification about something the start page no longer mentioned. A visit
|
||||
* that is still PLANNED stays on the rail until its day ends.
|
||||
*/
|
||||
const upcomingVisit: DailyDigestUpcomingVisit | null = nextVisitRow
|
||||
? {
|
||||
id: nextVisitRow.id,
|
||||
kind: nextVisitRow.kind,
|
||||
occurredAt: nextVisitRow.occurredAt.toISOString(),
|
||||
practitionerName: nextVisitRow.practitioner?.name ?? null,
|
||||
}
|
||||
: null;
|
||||
const upcomingVisits: DailyDigestUpcomingVisit[] = visitRows.map((row) => ({
|
||||
id: row.id,
|
||||
kind: row.kind,
|
||||
occurredAt: row.occurredAt.toISOString(),
|
||||
practitionerName: row.practitioner?.name ?? null,
|
||||
dayOffset: calendarDaysUntil(row.occurredAt, now, user.timezone),
|
||||
}));
|
||||
|
||||
// S10 — the freshest ECG recording (device verdict + recordedAt only). The
|
||||
// builder decides "new" and gates on the `insights` module. An ECG row only
|
||||
@@ -626,6 +634,15 @@ export async function loadDailyDigest(
|
||||
const resolvedLocale: Locale = locale;
|
||||
const { t } = getServerTranslator(resolvedLocale);
|
||||
|
||||
// A Coach-suggested measurement cadence stores its label as a bundle key;
|
||||
// the checkups page and the dashboard card translate it, and so does this
|
||||
// line. A free-text label (every other row) is the person's own words.
|
||||
const preventiveDue: DailyDigestPreventiveDue[] = dueReminders.map((row) => {
|
||||
if (row.origin !== "COACH") return { label: row.label };
|
||||
const translated = t(row.label);
|
||||
return { label: translated === row.label ? row.label : translated };
|
||||
});
|
||||
|
||||
// Group the two figures for the reader. `Intl.NumberFormat` is the only
|
||||
// locale-aware step the digest takes on its own; everything else it renders
|
||||
// comes through the translator.
|
||||
@@ -655,7 +672,7 @@ export async function loadDailyDigest(
|
||||
morningRefreshedToday,
|
||||
syncIssues,
|
||||
preventiveDue,
|
||||
upcomingVisit,
|
||||
upcomingVisits,
|
||||
coachPlans,
|
||||
milestone,
|
||||
tensionWindow,
|
||||
|
||||
@@ -25,6 +25,13 @@ import type { ModuleKey } from "@/lib/modules/registry";
|
||||
* dashboard-layout label. Typed consumers derive from this tuple so stored
|
||||
* visibility settings and API validation cannot accept vocabulary the current
|
||||
* build does not understand.
|
||||
*
|
||||
* A new kind also needs a data migration that appends it to every stored,
|
||||
* non-empty `enabledHeroItemKinds` list. The layout stores the ENABLED kinds
|
||||
* whenever they are not all of them, so a list saved before the kind existed
|
||||
* reads as "switched off" and the new kind never shows for that person.
|
||||
* `upcoming_visit` shipped without one and stayed hidden that way until
|
||||
* migration 0355 appended it.
|
||||
*/
|
||||
export const PRIORITY_ITEM_KINDS = [
|
||||
"coach_checkin",
|
||||
|
||||
@@ -9,8 +9,10 @@
|
||||
* logged since the last satisfy advances lastSatisfiedAt + recomputes
|
||||
* nextDueAt and suppresses the nudge. Free-text reminders never
|
||||
* auto-resolve.
|
||||
* - Ledger-free dedup: a successful dispatch advances nextDueAt past
|
||||
* now so the same due cycle never re-fires.
|
||||
* - A measurement reminder rolls on: a successful dispatch advances
|
||||
* nextDueAt past now. A check-up (no measurementType, not an
|
||||
* appointment) does NOT roll on: it stays due until it is done, and a
|
||||
* second nudge for the same open slot waits a week.
|
||||
* - clientManaged suppresses the APNs leg only: the tick still
|
||||
* dispatches, and a reminder nothing delivered stays overdue.
|
||||
*
|
||||
@@ -136,6 +138,8 @@ interface FakeReminder {
|
||||
location: string | null;
|
||||
nextDueAt: Date | null;
|
||||
lastSatisfiedAt: Date | null;
|
||||
/** v1.39.2 — the last delivered nudge; absent on most fixtures. */
|
||||
lastNotifiedAt?: Date | null;
|
||||
enabled: boolean;
|
||||
createdAt: Date;
|
||||
user: {
|
||||
@@ -581,4 +585,197 @@ describe("runMeasurementReminderTick", () => {
|
||||
expect(summary.skippedOutsideWindow).toBe(1);
|
||||
expect(dispatch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// ── v1.39.2 — an open check-up stays due after its reminder ─────────
|
||||
//
|
||||
// Watched red: with the tick advancing every delivered reminder (the
|
||||
// pre-v1.39.2 behaviour), the first two cases fail — the yearly check-up's
|
||||
// update carries a `nextDueAt` a year out, and the dashboard and the digest
|
||||
// then read the open check-up as scheduled for next year.
|
||||
const checkup = (overrides: Partial<FakeReminder> = {}) =>
|
||||
reminder({
|
||||
measurementType: null,
|
||||
intervalDays: null,
|
||||
rrule: "FREQ=YEARLY;INTERVAL=1",
|
||||
nextDueAt: new Date("2026-06-15T07:00:00Z"), // due today at 09:00
|
||||
...overrides,
|
||||
});
|
||||
|
||||
it("keeps a reminded check-up due and records the delivery instead", async () => {
|
||||
const { prisma, updates } = makePrisma({
|
||||
reminders: [checkup()],
|
||||
labMatch: null,
|
||||
});
|
||||
const dispatch = vi.fn<DispatchFn>(async () => OK);
|
||||
|
||||
const summary = await runMeasurementReminderTick(
|
||||
prisma as never,
|
||||
NINE_LOCAL,
|
||||
{ dispatch },
|
||||
);
|
||||
|
||||
expect(summary.dispatched).toBe(1);
|
||||
expect(updates).toHaveLength(1);
|
||||
expect(updates[0].data).toEqual({ lastNotifiedAt: NINE_LOCAL });
|
||||
expect("nextDueAt" in updates[0].data).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps a Coach-suggested check-up due the same way", async () => {
|
||||
const { prisma, updates } = makePrisma({
|
||||
reminders: [checkup({ origin: "COACH" })],
|
||||
labMatch: null,
|
||||
});
|
||||
const dispatch = vi.fn<DispatchFn>(async () => OK);
|
||||
|
||||
await runMeasurementReminderTick(prisma as never, NINE_LOCAL, { dispatch });
|
||||
|
||||
expect(updates).toHaveLength(1);
|
||||
expect("nextDueAt" in updates[0].data).toBe(false);
|
||||
});
|
||||
|
||||
it("still rolls a measurement reminder on after its reminder", async () => {
|
||||
const { prisma, updates } = makePrisma({
|
||||
reminders: [reminder({ origin: "COACH" })],
|
||||
measurementMatch: null,
|
||||
});
|
||||
const dispatch = vi.fn<DispatchFn>(async () => OK);
|
||||
|
||||
await runMeasurementReminderTick(prisma as never, NINE_LOCAL, { dispatch });
|
||||
|
||||
expect(updates).toHaveLength(1);
|
||||
const advanced = updates[0].data.nextDueAt as Date;
|
||||
expect(advanced.getTime()).toBeGreaterThan(NINE_LOCAL.getTime());
|
||||
expect(updates[0].data.lastNotifiedAt).toEqual(NINE_LOCAL);
|
||||
});
|
||||
|
||||
it("still closes an appointment reminder after its one nudge", async () => {
|
||||
const { prisma, updates } = makePrisma({
|
||||
reminders: [
|
||||
reminder({
|
||||
origin: "ENCOUNTER",
|
||||
measurementType: null,
|
||||
intervalDays: null,
|
||||
}),
|
||||
],
|
||||
labMatch: null,
|
||||
});
|
||||
const dispatch = vi.fn<DispatchFn>(async () => OK);
|
||||
|
||||
await runMeasurementReminderTick(prisma as never, NINE_LOCAL, { dispatch });
|
||||
|
||||
expect(updates).toHaveLength(1);
|
||||
expect(updates[0].data.nextDueAt).toBeNull();
|
||||
});
|
||||
|
||||
it("does not re-remind an open check-up the day after", async () => {
|
||||
const { prisma } = makePrisma({
|
||||
reminders: [
|
||||
checkup({
|
||||
nextDueAt: new Date("2026-06-14T07:00:00Z"),
|
||||
lastNotifiedAt: new Date("2026-06-14T07:00:04Z"),
|
||||
}),
|
||||
],
|
||||
labMatch: null,
|
||||
});
|
||||
const dispatch = vi.fn<DispatchFn>(async () => OK);
|
||||
|
||||
const summary = await runMeasurementReminderTick(
|
||||
prisma as never,
|
||||
NINE_LOCAL,
|
||||
{ dispatch },
|
||||
);
|
||||
|
||||
expect(summary.skippedRepeatHeld).toBe(1);
|
||||
expect(claimMock).not.toHaveBeenCalled();
|
||||
expect(dispatch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("holds the repeat for six days and sends it on the seventh", async () => {
|
||||
const notifiedAt = new Date("2026-06-08T07:00:04Z");
|
||||
const open = checkup({
|
||||
nextDueAt: new Date("2026-06-08T07:00:00Z"),
|
||||
lastNotifiedAt: notifiedAt,
|
||||
});
|
||||
|
||||
const sixDays = makePrisma({ reminders: [open], labMatch: null });
|
||||
const heldDispatch = vi.fn<DispatchFn>(async () => OK);
|
||||
await runMeasurementReminderTick(
|
||||
sixDays.prisma as never,
|
||||
new Date("2026-06-14T07:00:00Z"),
|
||||
{ dispatch: heldDispatch },
|
||||
);
|
||||
expect(heldDispatch).not.toHaveBeenCalled();
|
||||
|
||||
// Seven calendar days on, at the same notify hour — a few seconds EARLIER
|
||||
// on the clock than the first send, which an hour count would still read
|
||||
// as "under a week".
|
||||
const sevenDays = makePrisma({ reminders: [open], labMatch: null });
|
||||
const dispatch = vi.fn<DispatchFn>(async () => OK);
|
||||
const summary = await runMeasurementReminderTick(
|
||||
sevenDays.prisma as never,
|
||||
NINE_LOCAL,
|
||||
{ dispatch },
|
||||
);
|
||||
expect(dispatch).toHaveBeenCalledTimes(1);
|
||||
expect(summary.dispatched).toBe(1);
|
||||
expect(sevenDays.updates[0].data).toEqual({ lastNotifiedAt: NINE_LOCAL });
|
||||
});
|
||||
|
||||
it("fires on the day a check-up was snoozed to, whatever the last send", async () => {
|
||||
// A snooze moves `nextDueAt` past the last send: that is a new slot the
|
||||
// person asked for, not a repeat of the old one.
|
||||
const { prisma } = makePrisma({
|
||||
reminders: [
|
||||
checkup({
|
||||
nextDueAt: new Date("2026-06-15T07:00:00Z"),
|
||||
lastNotifiedAt: new Date("2026-06-14T07:00:04Z"),
|
||||
}),
|
||||
],
|
||||
labMatch: null,
|
||||
});
|
||||
const dispatch = vi.fn<DispatchFn>(async () => OK);
|
||||
|
||||
await runMeasurementReminderTick(prisma as never, NINE_LOCAL, { dispatch });
|
||||
|
||||
expect(dispatch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("keeps a one-shot check-up due instead of dropping it", async () => {
|
||||
// No cadence at all: rolling it on used to write `nextDueAt: null`, and
|
||||
// the open check-up vanished from every surface.
|
||||
const { prisma, updates } = makePrisma({
|
||||
reminders: [checkup({ rrule: null, intervalDays: null })],
|
||||
labMatch: null,
|
||||
});
|
||||
const dispatch = vi.fn<DispatchFn>(async () => OK);
|
||||
|
||||
await runMeasurementReminderTick(prisma as never, NINE_LOCAL, { dispatch });
|
||||
|
||||
expect(updates[0].data).toEqual({ lastNotifiedAt: NINE_LOCAL });
|
||||
});
|
||||
|
||||
it("rolls a weekly check-up on, since its next slot is the next nudge", async () => {
|
||||
const { prisma, updates } = makePrisma({
|
||||
reminders: [checkup({ rrule: null, intervalDays: 7 })],
|
||||
labMatch: null,
|
||||
});
|
||||
const dispatch = vi.fn<DispatchFn>(async () => OK);
|
||||
|
||||
await runMeasurementReminderTick(prisma as never, NINE_LOCAL, { dispatch });
|
||||
|
||||
const advanced = updates[0].data.nextDueAt as Date;
|
||||
expect(advanced.getTime()).toBeGreaterThan(NINE_LOCAL.getTime());
|
||||
});
|
||||
|
||||
it("keeps a fortnightly check-up due", async () => {
|
||||
const { prisma, updates } = makePrisma({
|
||||
reminders: [checkup({ rrule: null, intervalDays: 14 })],
|
||||
labMatch: null,
|
||||
});
|
||||
const dispatch = vi.fn<DispatchFn>(async () => OK);
|
||||
|
||||
await runMeasurementReminderTick(prisma as never, NINE_LOCAL, { dispatch });
|
||||
|
||||
expect(updates[0].data).toEqual({ lastNotifiedAt: NINE_LOCAL });
|
||||
});
|
||||
});
|
||||
@@ -9,11 +9,21 @@
|
||||
*
|
||||
* Differences from the mood reminder:
|
||||
*
|
||||
* 1. No dedicated dispatch-ledger table. The reminder's own
|
||||
* `nextDueAt` IS the dedup guard: after a successful dispatch the
|
||||
* runner advances `nextDueAt` to the next occurrence strictly past
|
||||
* now, so the same due cycle never re-fires. This collapses one
|
||||
* table + one cleanup queue versus the mood pattern.
|
||||
* 1. What a delivered reminder does to the row depends on what the row
|
||||
* is. A measurement reminder (it names a `measurementType`) is a
|
||||
* rhythm: after a delivered dispatch `nextDueAt` rolls on to the next
|
||||
* occurrence, and the next reading satisfies it. An appointment
|
||||
* (`origin: ENCOUNTER`) is one-shot and closes. A check-up (no
|
||||
* `measurementType`, whether the person or the Coach made it) is a
|
||||
* task: it stays due until it is marked done, skipped or snoozed, so
|
||||
* the start page and the check-up card keep showing it as due and
|
||||
* then overdue. Rolling it on silently filed an open check-up under
|
||||
* next year. While it stays open it is reminded again at most once a
|
||||
* week, gated on `lastNotifiedAt` (see `isCheckupRepeatHeld`). A
|
||||
* check-up on a weekly or shorter cadence still rolls on, because its
|
||||
* own next slot is the next nudge (see `staysDueAfterReminder`). The
|
||||
* per-day claim in `notification_events` stays the guard against a
|
||||
* second send on the same local day.
|
||||
* 2. Auto-resolve from an incoming measurement. Before deciding to
|
||||
* fire, the runner checks whether a matching reading of the
|
||||
* reminder's `measurementType` has landed since the last satisfy
|
||||
@@ -48,6 +58,7 @@ import {
|
||||
import { findSatisfyingEvent } from "@/lib/measurement-reminders/resolve";
|
||||
import { satisfyReminder } from "@/lib/measurement-reminders/satisfy";
|
||||
import { evaluateCoachContextReminders } from "@/lib/ai/coach/context-reminders";
|
||||
import { calendarDaysUntil } from "@/lib/measurement-reminders/due-day";
|
||||
|
||||
/**
|
||||
* v1.18.0 — map a reminder's `measurementType` to the toggleable module
|
||||
@@ -87,6 +98,82 @@ function moduleForMeasurementType(
|
||||
*/
|
||||
const DUE_QUERY_SLACK_MS = 15 * 60_000;
|
||||
|
||||
/**
|
||||
* v1.39.2 — whether a reminder is a check-up: a task that is done by marking
|
||||
* it done (or by a lab result landing), not by logging a reading.
|
||||
*
|
||||
* Decided by `measurementType`, not by `origin`. Both `VORSORGE` and `COACH`
|
||||
* rows come in two shapes: a free-text check-up ("blood panel", a Coach
|
||||
* "checkup.create" with a yearly preset) and a measurement rhythm ("weigh in
|
||||
* weekly", the Coach's twice-daily blood-pressure course). The rhythm is
|
||||
* satisfied by the next reading and has a next slot of its own that comes
|
||||
* round soon; holding it on a missed slot would stall a daily course and
|
||||
* paint a routine prompt as overdue. An appointment (`ENCOUNTER`) is neither
|
||||
* and stays one-shot.
|
||||
*/
|
||||
export function isCheckupReminder(reminder: {
|
||||
origin?: string | null;
|
||||
measurementType: MeasurementType | string | null;
|
||||
}): boolean {
|
||||
return reminder.origin !== "ENCOUNTER" && reminder.measurementType === null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calendar days an open check-up waits before it is reminded again, and the
|
||||
* cadence length at or under which a check-up rolls on instead of staying
|
||||
* due: a weekly or daily item's own next slot is a reminder at least as soon
|
||||
* as the repeat would be.
|
||||
*/
|
||||
export const CHECKUP_REPEAT_DAYS = 7;
|
||||
|
||||
/**
|
||||
* v1.39.2 — whether a delivered reminder leaves the row due.
|
||||
*
|
||||
* A check-up stays due when its next slot is more than a week out, or when it
|
||||
* has no next slot at all (a one-shot): rolling it on would file an open task
|
||||
* under next month or next year, and a one-shot would drop off every surface.
|
||||
* A check-up on a weekly or shorter cadence rolls on as before, because its
|
||||
* next slot already brings the next nudge within the week. Measurement
|
||||
* reminders and appointments always roll on.
|
||||
*/
|
||||
export function staysDueAfterReminder(
|
||||
reminder: {
|
||||
origin?: string | null;
|
||||
measurementType: MeasurementType | string | null;
|
||||
},
|
||||
rolledTo: Date | null,
|
||||
timezone: string,
|
||||
now: Date,
|
||||
): boolean {
|
||||
if (!isCheckupReminder(reminder)) return false;
|
||||
if (rolledTo === null) return true;
|
||||
return calendarDaysUntil(rolledTo, now, timezone) > CHECKUP_REPEAT_DAYS;
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.39.2 — whether an open check-up's repeat nudge is still being held.
|
||||
*
|
||||
* Held when the last delivered nudge was for THIS slot (`lastNotifiedAt` at
|
||||
* or after `nextDueAt`) and fewer than {@link CHECKUP_REPEAT_DAYS} calendar
|
||||
* days have passed in the person's timezone. Calendar days rather than a
|
||||
* 7 × 24 h gap, because the tick fires within the notify hour and the next
|
||||
* week's tick can land a few seconds earlier on the clock than the first
|
||||
* send did.
|
||||
*
|
||||
* A slot that moved after the last nudge — a snooze to a named day, a
|
||||
* Telegram "later", a skip, a satisfy — is a new slot and is never held.
|
||||
*/
|
||||
export function isCheckupRepeatHeld(
|
||||
reminder: { nextDueAt: Date | null; lastNotifiedAt?: Date | null },
|
||||
timezone: string,
|
||||
now: Date,
|
||||
): boolean {
|
||||
const { nextDueAt, lastNotifiedAt } = reminder;
|
||||
if (!nextDueAt || !lastNotifiedAt) return false;
|
||||
if (lastNotifiedAt.getTime() < nextDueAt.getTime()) return false;
|
||||
return calendarDaysUntil(now, lastNotifiedAt, timezone) < CHECKUP_REPEAT_DAYS;
|
||||
}
|
||||
|
||||
export interface MeasurementReminderSummary {
|
||||
candidatesScanned: number;
|
||||
inWindow: number;
|
||||
@@ -98,6 +185,8 @@ export interface MeasurementReminderSummary {
|
||||
skippedNoChannel: number;
|
||||
/** v1.37.19 (A6-7) — slot already claimed for this local day (racing worker or crash-recovery replay). */
|
||||
skippedAlreadyClaimed: number;
|
||||
/** v1.39.2 — open check-up already reminded for this slot within the week. */
|
||||
skippedRepeatHeld: number;
|
||||
/** v1.18.1 — expired COACH course-window reminders soft-deleted this tick. */
|
||||
expiredCleaned: number;
|
||||
failed: number;
|
||||
@@ -205,6 +294,7 @@ export async function runMeasurementReminderTick(
|
||||
skippedModuleDisabled: 0,
|
||||
skippedNoChannel: 0,
|
||||
skippedAlreadyClaimed: 0,
|
||||
skippedRepeatHeld: 0,
|
||||
expiredCleaned: 0,
|
||||
failed: 0,
|
||||
};
|
||||
@@ -294,6 +384,17 @@ export async function runMeasurementReminderTick(
|
||||
continue;
|
||||
}
|
||||
|
||||
// v1.39.2 — an open check-up stays due after its nudge, so without
|
||||
// this it would qualify again at every notify hour. Once a week is the
|
||||
// repeat; the per-day claim below is not enough on its own.
|
||||
if (
|
||||
isCheckupReminder(reminder) &&
|
||||
isCheckupRepeatHeld(reminder, timezone, now)
|
||||
) {
|
||||
summary.skippedRepeatHeld += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
summary.inWindow += 1;
|
||||
|
||||
// v1.18.0 module gate — a reminder whose measurement type belongs to
|
||||
@@ -395,9 +496,16 @@ export async function runMeasurementReminderTick(
|
||||
continue;
|
||||
}
|
||||
|
||||
// Dispatch succeeded — advance `nextDueAt` past now so this due
|
||||
// cycle never re-fires (the ledger-free dedup guard).
|
||||
await advanceNextDue(prisma, reminder, timezone, now);
|
||||
// Dispatch succeeded. Record the delivery; a check-up keeps its due
|
||||
// date (it is still open, and `lastNotifiedAt` holds the repeat to a
|
||||
// week), everything else rolls on past this slot.
|
||||
const rolledTo = nextSlotAfterReminder(reminder, timezone, now);
|
||||
await prisma.measurementReminder.update({
|
||||
where: { id: reminder.id },
|
||||
data: staysDueAfterReminder(reminder, rolledTo, timezone, now)
|
||||
? { lastNotifiedAt: now }
|
||||
: { nextDueAt: rolledTo, lastNotifiedAt: now },
|
||||
});
|
||||
summary.dispatched += 1;
|
||||
} catch (err: unknown) {
|
||||
summary.failed += 1;
|
||||
@@ -534,35 +642,34 @@ export async function runReminderSatisfyForUser(
|
||||
return summary;
|
||||
}
|
||||
|
||||
type RollableReminder = {
|
||||
id: string;
|
||||
intervalDays: number | null;
|
||||
rrule: string | null;
|
||||
anchorDate: Date | null;
|
||||
notifyHour: number;
|
||||
lastSatisfiedAt: Date | null;
|
||||
createdAt: Date;
|
||||
};
|
||||
|
||||
/**
|
||||
* Advance `nextDueAt` to the next occurrence strictly after `now`. Used
|
||||
* after a successful dispatch (and after a client-managed skip) as the
|
||||
* ledger-free dedup guard. Does NOT touch `lastSatisfiedAt` — a fired
|
||||
* reminder is not "satisfied", it just rolls to its next slot.
|
||||
* The slot a reminder rolls on to after it fired at `now`: the next
|
||||
* occurrence strictly after `now`, or `null` when there is none (a one-shot,
|
||||
* or a course window that has ended). Does NOT touch `lastSatisfiedAt` — a
|
||||
* fired reminder is not "satisfied", it just rolls to its next slot.
|
||||
*/
|
||||
async function advanceNextDue(
|
||||
prisma: PrismaClient,
|
||||
reminder: {
|
||||
id: string;
|
||||
intervalDays: number | null;
|
||||
rrule: string | null;
|
||||
anchorDate: Date | null;
|
||||
notifyHour: number;
|
||||
lastSatisfiedAt: Date | null;
|
||||
createdAt: Date;
|
||||
},
|
||||
function nextSlotAfterReminder(
|
||||
reminder: RollableReminder,
|
||||
timezone: string,
|
||||
now: Date,
|
||||
): Promise<void> {
|
||||
// The dedup guard must move the slot strictly forward. For an `rrule`
|
||||
// the engine already walks to the next strictly-after-now occurrence,
|
||||
// so passing the row as-is is correct. For a ROLLING reminder the
|
||||
// engine anchors the first-due slot AT `anchorDate ?? createdAt` when
|
||||
// never satisfied, which stays ≤ now and would re-fire every tick — so
|
||||
// re-anchor the rolling cadence on `now` (a fire is the rhythm
|
||||
// restarting from this dispatch) to roll it forward by exactly one
|
||||
// interval. This does NOT advance `lastSatisfiedAt`: a fired reminder
|
||||
// is not satisfied, just rescheduled past the slot it nagged on.
|
||||
): Date | null {
|
||||
// The slot must move strictly forward. For an `rrule` the engine already
|
||||
// walks to the next strictly-after-now occurrence, so passing the row
|
||||
// as-is is correct. For a ROLLING reminder the engine anchors the
|
||||
// first-due slot AT `anchorDate ?? createdAt` when never satisfied, which
|
||||
// stays ≤ now and would re-fire every tick — so re-anchor the rolling
|
||||
// cadence on `now` (a fire is the rhythm restarting from this dispatch) to
|
||||
// roll it forward by exactly one interval.
|
||||
const rolling = reminder.intervalDays !== null;
|
||||
const scheduleInput: ReminderScheduleInput = {
|
||||
intervalDays: reminder.intervalDays,
|
||||
@@ -572,7 +679,20 @@ async function advanceNextDue(
|
||||
lastSatisfiedAt: rolling ? now : reminder.lastSatisfiedAt,
|
||||
createdAt: reminder.createdAt,
|
||||
};
|
||||
const nextDueAt = computeReminderNextDueAt(scheduleInput, timezone, now);
|
||||
return computeReminderNextDueAt(scheduleInput, timezone, now);
|
||||
}
|
||||
|
||||
/**
|
||||
* Advance `nextDueAt` past the current slot. Used after a disabled-module
|
||||
* skip, where nothing the reminder could deliver is left for this cycle.
|
||||
*/
|
||||
async function advanceNextDue(
|
||||
prisma: PrismaClient,
|
||||
reminder: RollableReminder,
|
||||
timezone: string,
|
||||
now: Date,
|
||||
): Promise<void> {
|
||||
const nextDueAt = nextSlotAfterReminder(reminder, timezone, now);
|
||||
await prisma.measurementReminder.update({
|
||||
where: { id: reminder.id },
|
||||
data: { nextDueAt },
|
||||
|
||||
@@ -134,6 +134,7 @@ export async function handleMeasurementReminderCheck(
|
||||
skipped_not_due: summary.skippedNotDue,
|
||||
skipped_outside_window: summary.skippedOutsideWindow,
|
||||
skipped_no_channel: summary.skippedNoChannel,
|
||||
skipped_repeat_held: summary.skippedRepeatHeld,
|
||||
failed: summary.failed,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
*/
|
||||
import { describe, it, expect } from "vitest";
|
||||
|
||||
import { relativeDueKey } from "../due-day";
|
||||
import { isOverdueDue, relativeDueKey } from "../due-day";
|
||||
|
||||
/**
|
||||
* The profile zone under test. July is deliberate: no IANA zone shifts its
|
||||
@@ -84,11 +84,33 @@ describe("relativeDueKey", () => {
|
||||
).toEqual(TOMORROW);
|
||||
});
|
||||
|
||||
it("counts a checkup from yesterday as one day overdue, however recent", () => {
|
||||
// Forty-five minutes in the past. It was still yesterday's checkup.
|
||||
it("calls a checkup from yesterday overdue since yesterday, however recent", () => {
|
||||
// Forty-five minutes in the past. It was still yesterday's checkup. Its
|
||||
// own phrase, because "overdue by 1 days" is what the counted form reads
|
||||
// as on the one day an open check-up is most likely to be looked at.
|
||||
expect(
|
||||
relativeDueKey(zoned(FRIDAY, 23, 30), at(SATURDAY, 0, 15), ZONE),
|
||||
).toEqual({ key: "measurementReminders.overdueByDays", days: 1 });
|
||||
).toEqual({ key: "measurementReminders.overdueSinceYesterday", days: 1 });
|
||||
});
|
||||
|
||||
it("counts the days for a checkup overdue longer than that", () => {
|
||||
expect(relativeDueKey(zoned(FRIDAY, 9), at(FRIDAY + 2, 10), ZONE)).toEqual({
|
||||
key: "measurementReminders.overdueByDays",
|
||||
days: 2,
|
||||
});
|
||||
});
|
||||
|
||||
it("reads both overdue phrases as overdue, and nothing else", () => {
|
||||
const now = at(SATURDAY, 12);
|
||||
expect(isOverdueDue(relativeDueKey(zoned(FRIDAY, 9), now, ZONE))).toBe(
|
||||
true,
|
||||
);
|
||||
expect(isOverdueDue(relativeDueKey(zoned(FRIDAY - 3, 9), now, ZONE))).toBe(
|
||||
true,
|
||||
);
|
||||
expect(isOverdueDue(relativeDueKey(zoned(SATURDAY, 9), now, ZONE))).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it("counts whole calendar days for a checkup further out", () => {
|
||||
|
||||
@@ -86,6 +86,11 @@ export function relativeDueKey(
|
||||
if (Number.isNaN(due.getTime()))
|
||||
return { key: "measurementReminders.nextDue.none", days: 0 };
|
||||
const deltaDays = calendarDaysUntil(due, new Date(now), timeZone);
|
||||
// Its own phrase rather than the counted one: "overdue by 1 days" is what
|
||||
// the counted form reads as, on the one day an open check-up (which since
|
||||
// v1.39.2 stays due after its reminder) is most likely to be looked at.
|
||||
if (deltaDays === -1)
|
||||
return { key: "measurementReminders.overdueSinceYesterday", days: 1 };
|
||||
if (deltaDays < 0)
|
||||
return {
|
||||
key: "measurementReminders.overdueByDays",
|
||||
@@ -97,3 +102,15 @@ export function relativeDueKey(
|
||||
return { key: "measurementReminders.nextDue.tomorrow", days: 1 };
|
||||
return { key: "measurementReminders.nextDue.inDays", days: deltaDays };
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a phrase from {@link relativeDueKey} says "overdue". Two keys do —
|
||||
* "since yesterday" and the counted one — so a surface that colours an
|
||||
* overdue badge asks here rather than comparing against one of them.
|
||||
*/
|
||||
export function isOverdueDue(due: RelativeDue): boolean {
|
||||
return (
|
||||
due.key === "measurementReminders.overdueByDays" ||
|
||||
due.key === "measurementReminders.overdueSinceYesterday"
|
||||
);
|
||||
}
|
||||
@@ -177,7 +177,7 @@ export const dailyPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
tags: ["Insights"],
|
||||
summary: "The unified daily digest",
|
||||
description:
|
||||
"Assembles the day's read from already-cached data: the nightly briefing lifted read-only from the insights cache, the dashboard-snapshot health score / meds-today / sleep freshness, plus deterministic integration-status and Vorsorge reads for the 'worth a look' rail. No provider call, no warm-on-mount. No module gate and no AI gate: the digest is data, and the `insights` module is the AI analysis opt-out. Its AI parts follow their capabilities, published in `ai`. Cookie or Bearer auth.",
|
||||
"Assembles the day's read from already-cached data: the nightly briefing lifted read-only from the insights cache, the dashboard-snapshot health score / meds-today / sleep freshness, plus deterministic integration-status, Vorsorge and visit reads for the 'worth a look' rail. The rail's `preventive_care` item covers every check-up due today or overdue and names up to three of them; `upcoming_visit` items cover every planned visit on the local today (also once it has started, until the day ends) in one item, and the next day with a visit inside the next 48 hours in a second. A due check-up and today's visits always keep a place within the three-item cap. No provider call, no warm-on-mount. No module gate and no AI gate: the digest is data, and the `insights` module is the AI analysis opt-out. Its AI parts follow their capabilities, published in `ai`. Cookie or Bearer auth.",
|
||||
responses: {
|
||||
"200": {
|
||||
description: "The daily digest.",
|
||||
|
||||
@@ -41,7 +41,7 @@ export const measurementReminderPaths: NonNullable<ZodOpenApiObject["paths"]> =
|
||||
tags: ["MeasurementReminders"],
|
||||
summary: "List Vorsorge reminders (v1.17.1)",
|
||||
description:
|
||||
"Returns the owner's live (non-tombstoned) Vorsorge reminders, sorted by server-computed nextDueAt ascending (nulls last). Each row carries the canonical nextDueAt the client renders without recomputing.",
|
||||
"Returns the owner's live (non-tombstoned) Vorsorge reminders, sorted by server-computed nextDueAt ascending (nulls last). Each row carries the canonical nextDueAt the client renders without recomputing. Since v1.39.2 a check-up (a reminder with no measurementType) keeps its nextDueAt after its reminder is sent: an open check-up stays due, then overdue, until it is completed, skipped or snoozed, and the server repeats the reminder at most once a week while it stays open. A check-up on a weekly or shorter cadence, and a reminder with a measurementType, still roll on to the next slot after a delivered reminder.",
|
||||
responses: {
|
||||
...recordRefusal(),
|
||||
"200": {
|
||||
@@ -115,7 +115,7 @@ export const measurementReminderPaths: NonNullable<ZodOpenApiObject["paths"]> =
|
||||
tags: ["MeasurementReminders"],
|
||||
summary: "Edit a Vorsorge reminder (v1.17.1)",
|
||||
description:
|
||||
"Partial edit; omitted fields are left untouched. nextDueAt is recomputed server-side after the cadence merge. Owner-scoped.",
|
||||
"Partial edit; omitted fields are left untouched. nextDueAt is recomputed server-side only when the edit changes when the reminder recurs (intervalDays, rrule, anchorDate compared by value, or enabled switched back on); that recompute also clears a snooze. A notifyHour-only edit keeps the due day and moves the hour on it. Any other edit (label, location, measurementType) leaves nextDueAt as it is, so an open, overdue check-up stays due. Owner-scoped.",
|
||||
requestParams: { path: z.object({ id: z.string() }) },
|
||||
requestBody: {
|
||||
required: true,
|
||||
|
||||
@@ -185,7 +185,7 @@ export const updateMeasurementReminderSchema = z
|
||||
.meta({
|
||||
id: "MeasurementReminderUpdate",
|
||||
description:
|
||||
"Partial edit of a Vorsorge reminder. Omitted fields are left untouched; nextDueAt is recomputed server-side after the patch applies.",
|
||||
"Partial edit of a Vorsorge reminder. Omitted fields are left untouched; nextDueAt is recomputed server-side when the cadence changes and otherwise kept (a notifyHour edit moves only the hour on the same due day).",
|
||||
});
|
||||
|
||||
export type UpdateMeasurementReminderInput = z.infer<
|
||||
|
||||
@@ -0,0 +1,389 @@
|
||||
/**
|
||||
* An open check-up stays due after its reminder, against real Postgres.
|
||||
*
|
||||
* Two halves.
|
||||
*
|
||||
* 1. **The whole loop, end to end.** A yearly check-up due today goes
|
||||
* through the real reminder tick (real claim ledger, stubbed channel):
|
||||
* it is reminded once, keeps its due date, shows on the start page's
|
||||
* digest, is not reminded again the same day or the next, is reminded
|
||||
* once more seven days on, and moves to next year when it is marked done.
|
||||
* 2. **Migration 0355's repair.** The container has every migration applied
|
||||
* already, so the data steps are pulled out of the `.sql` file and run
|
||||
* again against rows seeded the way an upgrading instance holds them:
|
||||
* check-ups the old tick rolled on after a send go back to the reminded
|
||||
* slot, and every row whose evidence is ambiguous is left alone. The
|
||||
* steps run twice; the second run must change nothing.
|
||||
*/
|
||||
import { readFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type { Prisma } from "@/generated/prisma/client";
|
||||
import { runMeasurementReminderTick } from "@/lib/jobs/measurement-reminder";
|
||||
import { satisfyReminder } from "@/lib/measurement-reminders/satisfy";
|
||||
import { loadDailyDigest } from "@/lib/daily/load-digest";
|
||||
import type { DispatchOutcome } from "@/lib/notifications/dispatcher";
|
||||
|
||||
import { getPrismaClient, truncateAllTables } from "./setup";
|
||||
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
const MIGRATION = readFileSync(
|
||||
path.join(
|
||||
process.cwd(),
|
||||
"prisma",
|
||||
"migrations",
|
||||
"0355_checkup_stays_due",
|
||||
"migration.sql",
|
||||
),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
/** The migration's data statements (the repair and the hero-kind append). */
|
||||
function dataSteps(): string[] {
|
||||
const sql = MIGRATION.split("\n")
|
||||
.filter((line) => !line.trimStart().startsWith("--"))
|
||||
.join("\n");
|
||||
const steps = sql
|
||||
.split(";")
|
||||
.map((statement) => statement.trim())
|
||||
.filter((statement) => /^(WITH|UPDATE)\b/i.test(statement));
|
||||
// Both data steps are the point; a matcher that finds fewer is a failure.
|
||||
expect(steps.length).toBe(2);
|
||||
return steps;
|
||||
}
|
||||
|
||||
async function runDataSteps(): Promise<void> {
|
||||
const prisma = getPrismaClient();
|
||||
for (const step of dataSteps()) await prisma.$executeRawUnsafe(step);
|
||||
}
|
||||
|
||||
const OK: DispatchOutcome = {
|
||||
dispatched: true,
|
||||
channelsAttempted: 1,
|
||||
channelsSucceeded: 1,
|
||||
};
|
||||
|
||||
const TZ = "Europe/Berlin";
|
||||
const DAY = 24 * 60 * 60 * 1000;
|
||||
|
||||
beforeEach(async () => {
|
||||
await truncateAllTables(getPrismaClient());
|
||||
});
|
||||
|
||||
async function seedUser(
|
||||
id: string,
|
||||
extra: Partial<Prisma.UserCreateInput> = {},
|
||||
) {
|
||||
return getPrismaClient().user.create({
|
||||
data: {
|
||||
id,
|
||||
username: id,
|
||||
email: `${id}@example.test`,
|
||||
timezone: TZ,
|
||||
...extra,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
describe("an open check-up through the reminder tick", () => {
|
||||
// 09:00 in Berlin.
|
||||
const DUE = new Date("2026-09-26T07:00:00.000Z");
|
||||
|
||||
it("stays due, shows on the digest, repeats weekly, and moves on when done", async () => {
|
||||
const prisma = getPrismaClient();
|
||||
const user = await seedUser("checkup-owner");
|
||||
const created = await prisma.measurementReminder.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
label: "Skin check",
|
||||
measurementType: null,
|
||||
intervalDays: null,
|
||||
rrule: "FREQ=YEARLY;INTERVAL=1",
|
||||
notifyHour: 9,
|
||||
origin: "VORSORGE",
|
||||
enabled: true,
|
||||
lastSatisfiedAt: new Date("2025-09-26T07:00:00.000Z"),
|
||||
nextDueAt: DUE,
|
||||
createdAt: new Date("2025-01-01T00:00:00.000Z"),
|
||||
},
|
||||
});
|
||||
const dispatch = vi.fn(async () => OK);
|
||||
const tick = (at: Date) =>
|
||||
runMeasurementReminderTick(prisma, at, { dispatch });
|
||||
|
||||
// The due day's notify hour: reminded once, still due.
|
||||
const first = await tick(DUE);
|
||||
expect(first.dispatched).toBe(1);
|
||||
expect(dispatch).toHaveBeenCalledTimes(1);
|
||||
let row = await prisma.measurementReminder.findUniqueOrThrow({
|
||||
where: { id: created.id },
|
||||
});
|
||||
expect(row.nextDueAt).toEqual(DUE);
|
||||
expect(row.lastNotifiedAt).toEqual(DUE);
|
||||
expect(row.lastSatisfiedAt).toEqual(new Date("2025-09-26T07:00:00.000Z"));
|
||||
const events = await prisma.notificationEvent.findMany({
|
||||
where: { recordUserId: user.id },
|
||||
});
|
||||
expect(events.map((e) => e.dedupKey)).toEqual([
|
||||
`measurement:${created.id}:2026-09-26`,
|
||||
]);
|
||||
|
||||
// The start page names it, in the afternoon as much as in the morning.
|
||||
const fullUser = await prisma.user.findUniqueOrThrow({
|
||||
where: { id: user.id },
|
||||
});
|
||||
const digest = await loadDailyDigest(
|
||||
fullUser,
|
||||
new Date(DUE.getTime() + 6 * 60 * 60 * 1000),
|
||||
{ locale: "en" },
|
||||
);
|
||||
const care = digest.worthALook.find((i) => i.kind === "preventive_care");
|
||||
expect(care?.body).toContain("Skin check");
|
||||
|
||||
// Fifteen minutes later, the same hour: not again.
|
||||
await tick(new Date(DUE.getTime() + 15 * 60 * 1000));
|
||||
// The next day at the notify hour: not again either.
|
||||
const nextDay = await tick(new Date(DUE.getTime() + DAY));
|
||||
expect(nextDay.skippedRepeatHeld).toBe(1);
|
||||
// Six days on: still held.
|
||||
await tick(new Date(DUE.getTime() + 6 * DAY));
|
||||
expect(dispatch).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Seven days on: reminded once more, and only once.
|
||||
const week = new Date(DUE.getTime() + 7 * DAY);
|
||||
await tick(week);
|
||||
await tick(new Date(week.getTime() + 15 * 60 * 1000));
|
||||
expect(dispatch).toHaveBeenCalledTimes(2);
|
||||
row = await prisma.measurementReminder.findUniqueOrThrow({
|
||||
where: { id: created.id },
|
||||
});
|
||||
expect(row.nextDueAt).toEqual(DUE);
|
||||
expect(row.lastNotifiedAt).toEqual(week);
|
||||
|
||||
// Done: the next cycle is next year, counted from the completion.
|
||||
const doneAt = new Date(week.getTime() + 60 * 60 * 1000);
|
||||
const done = await satisfyReminder(prisma, row, TZ, doneAt, "manual");
|
||||
expect(done.satisfied).toBe(true);
|
||||
row = await prisma.measurementReminder.findUniqueOrThrow({
|
||||
where: { id: created.id },
|
||||
});
|
||||
expect(row.nextDueAt?.getUTCFullYear()).toBe(2027);
|
||||
await tick(new Date(week.getTime() + DAY));
|
||||
expect(dispatch).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("migration 0355", () => {
|
||||
it("adds the nullable last_notified_at column", async () => {
|
||||
const rows = await getPrismaClient().$queryRawUnsafe<
|
||||
Array<{ is_nullable: string; data_type: string }>
|
||||
>(
|
||||
`SELECT is_nullable, data_type
|
||||
FROM information_schema.columns
|
||||
WHERE table_name = 'measurement_reminders'
|
||||
AND column_name = 'last_notified_at'`,
|
||||
);
|
||||
expect(rows).toEqual([
|
||||
{ is_nullable: "YES", data_type: "timestamp without time zone" },
|
||||
]);
|
||||
});
|
||||
|
||||
// The instant the old tick claimed and delivered the reminder, and the
|
||||
// roll-on write a few seconds behind it.
|
||||
const SENT = new Date("2026-09-26T07:00:03.000Z");
|
||||
const ROLLED = new Date("2026-09-26T07:00:05.000Z");
|
||||
const NEXT_YEAR = new Date("2027-09-26T07:00:00.000Z");
|
||||
|
||||
interface Case {
|
||||
id: string;
|
||||
data?: Partial<Prisma.MeasurementReminderUncheckedCreateInput>;
|
||||
/** When the row was last written; defaults to the roll-on. */
|
||||
updatedAt?: Date;
|
||||
/** Claim instants for this row; defaults to the one send. */
|
||||
claims?: Date[];
|
||||
}
|
||||
|
||||
async function seedCase(userId: string, c: Case) {
|
||||
const prisma = getPrismaClient();
|
||||
await prisma.measurementReminder.create({
|
||||
data: {
|
||||
id: c.id,
|
||||
userId,
|
||||
label: c.id,
|
||||
measurementType: null,
|
||||
rrule: "FREQ=YEARLY;INTERVAL=1",
|
||||
notifyHour: 9,
|
||||
origin: "VORSORGE",
|
||||
enabled: true,
|
||||
nextDueAt: NEXT_YEAR,
|
||||
createdAt: new Date("2025-01-01T00:00:00.000Z"),
|
||||
...c.data,
|
||||
},
|
||||
});
|
||||
await prisma.$executeRaw`
|
||||
UPDATE "measurement_reminders"
|
||||
SET "updated_at" = ${c.updatedAt ?? ROLLED}
|
||||
WHERE "id" = ${c.id}`;
|
||||
for (const at of c.claims ?? [SENT]) {
|
||||
await prisma.notificationEvent.create({
|
||||
data: {
|
||||
recordUserId: userId,
|
||||
eventType: "MEASUREMENT_REMINDER",
|
||||
dedupKey: `measurement:${c.id}:${at.toISOString().slice(0, 10)}`,
|
||||
createdAt: at,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
it("puts rolled-on check-ups back on the reminded slot and leaves everything ambiguous", async () => {
|
||||
const prisma = getPrismaClient();
|
||||
const user = await seedUser("migration-owner");
|
||||
|
||||
const repaired: Case[] = [
|
||||
{ id: "yearly-checkup" },
|
||||
{ id: "coach-checkup", data: { origin: "COACH" } },
|
||||
{
|
||||
// A one-shot check-up the old tick rolled to nothing.
|
||||
id: "one-shot",
|
||||
data: { rrule: null, intervalDays: null, nextDueAt: null },
|
||||
},
|
||||
{
|
||||
// An earlier cycle's send is older; the latest send decides.
|
||||
id: "monthly-many-sends",
|
||||
data: {
|
||||
rrule: "FREQ=MONTHLY;INTERVAL=1",
|
||||
nextDueAt: new Date("2026-10-26T07:00:00.000Z"),
|
||||
},
|
||||
claims: [new Date("2026-08-26T07:00:03.000Z"), SENT],
|
||||
},
|
||||
];
|
||||
const untouched: Case[] = [
|
||||
{
|
||||
id: "done-after-send",
|
||||
data: { lastSatisfiedAt: new Date("2026-09-26T10:00:00.000Z") },
|
||||
updatedAt: new Date("2026-09-26T10:00:00.000Z"),
|
||||
},
|
||||
{
|
||||
id: "skipped-after-send",
|
||||
data: { lastSkippedAt: new Date("2026-09-26T07:02:00.000Z") },
|
||||
updatedAt: new Date("2026-09-26T07:02:00.000Z"),
|
||||
},
|
||||
{
|
||||
// Snoozed a minute after the send to a day well past a week out, so
|
||||
// only the snooze cursor tells it apart from a roll-on.
|
||||
id: "snoozed",
|
||||
data: {
|
||||
snoozedUntil: new Date("2026-11-02T08:00:00.000Z"),
|
||||
nextDueAt: new Date("2026-11-02T08:00:00.000Z"),
|
||||
},
|
||||
updatedAt: new Date("2026-09-26T07:01:00.000Z"),
|
||||
},
|
||||
{
|
||||
// Edited two days after the send: the last write is not the roll-on.
|
||||
id: "edited-later",
|
||||
updatedAt: new Date("2026-09-28T12:00:00.000Z"),
|
||||
},
|
||||
{
|
||||
// A Telegram "later" pressed a minute after the send.
|
||||
id: "telegram-later",
|
||||
data: { nextDueAt: new Date("2026-09-26T10:01:00.000Z") },
|
||||
updatedAt: new Date("2026-09-26T07:01:00.000Z"),
|
||||
},
|
||||
{
|
||||
// A weekly check-up still rolls on under the new tick.
|
||||
id: "weekly",
|
||||
data: {
|
||||
rrule: null,
|
||||
intervalDays: 7,
|
||||
nextDueAt: new Date("2026-10-03T07:00:00.000Z"),
|
||||
},
|
||||
},
|
||||
{ id: "measurement", data: { measurementType: "WEIGHT" } },
|
||||
{ id: "appointment", data: { origin: "ENCOUNTER", rrule: null } },
|
||||
{ id: "deleted", data: { deletedAt: ROLLED } },
|
||||
{ id: "disabled", data: { enabled: false } },
|
||||
{ id: "never-sent", claims: [] },
|
||||
{
|
||||
// The last send found no channel: nothing was rolled on.
|
||||
id: "no-channel",
|
||||
data: { nextDueAt: new Date("2026-09-25T07:00:00.000Z") },
|
||||
},
|
||||
];
|
||||
|
||||
for (const c of [...repaired, ...untouched]) await seedCase(user.id, c);
|
||||
const before = new Map(
|
||||
(await prisma.measurementReminder.findMany()).map((r) => [r.id, r]),
|
||||
);
|
||||
|
||||
await runDataSteps();
|
||||
const afterFirst = await prisma.measurementReminder.findMany();
|
||||
await runDataSteps();
|
||||
const afterSecond = await prisma.measurementReminder.findMany();
|
||||
|
||||
const byId = new Map(afterFirst.map((r) => [r.id, r]));
|
||||
for (const c of repaired) {
|
||||
expect(byId.get(c.id)?.nextDueAt, c.id).toEqual(SENT);
|
||||
expect(byId.get(c.id)?.lastNotifiedAt, c.id).toEqual(SENT);
|
||||
}
|
||||
for (const c of untouched) {
|
||||
const was = before.get(c.id)!;
|
||||
const now = byId.get(c.id)!;
|
||||
expect(now.nextDueAt, c.id).toEqual(was.nextDueAt);
|
||||
expect(now.lastNotifiedAt, c.id).toBeNull();
|
||||
}
|
||||
// Nothing deleted, and the second run is a no-op.
|
||||
expect(afterFirst).toHaveLength(repaired.length + untouched.length);
|
||||
const key = (rows: typeof afterFirst) =>
|
||||
rows
|
||||
.map(
|
||||
(r) =>
|
||||
`${r.id}|${r.nextDueAt?.toISOString()}|${r.lastNotifiedAt?.toISOString()}`,
|
||||
)
|
||||
.sort();
|
||||
expect(key(afterSecond)).toEqual(key(afterFirst));
|
||||
});
|
||||
|
||||
it("adds visits to a stored start-page choice that predates them, and only there", async () => {
|
||||
const prisma = getPrismaClient();
|
||||
const legacy = ["dose_window", "preventive_care", "sync_issue"];
|
||||
await seedUser("hero-legacy", {
|
||||
dashboardWidgetsJson: { version: 1, enabledHeroItemKinds: legacy },
|
||||
});
|
||||
await seedUser("hero-off", {
|
||||
dashboardWidgetsJson: { version: 1, enabledHeroItemKinds: [] },
|
||||
});
|
||||
await seedUser("hero-has-visits", {
|
||||
dashboardWidgetsJson: {
|
||||
version: 1,
|
||||
enabledHeroItemKinds: ["upcoming_visit"],
|
||||
},
|
||||
});
|
||||
await seedUser("hero-default", {
|
||||
dashboardWidgetsJson: { version: 1, widgets: [] },
|
||||
});
|
||||
await seedUser("hero-none");
|
||||
|
||||
await runDataSteps();
|
||||
await runDataSteps();
|
||||
|
||||
const layout = async (id: string) =>
|
||||
(await prisma.user.findUniqueOrThrow({ where: { id } }))
|
||||
.dashboardWidgetsJson as Record<string, unknown> | null;
|
||||
expect((await layout("hero-legacy"))?.enabledHeroItemKinds).toEqual([
|
||||
...legacy,
|
||||
"upcoming_visit",
|
||||
]);
|
||||
expect((await layout("hero-off"))?.enabledHeroItemKinds).toEqual([]);
|
||||
expect((await layout("hero-has-visits"))?.enabledHeroItemKinds).toEqual([
|
||||
"upcoming_visit",
|
||||
]);
|
||||
expect(await layout("hero-default")).toEqual({ version: 1, widgets: [] });
|
||||
expect(await layout("hero-none")).toBeNull();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user