Add the Vorsorge skip/snooze columns, the completion ledger and the custom-metric tombstone

Migration 0334, all additive. MeasurementReminder gains snoozedUntil,
lastSkippedAt and skipCount; the new MeasurementReminderEvent table is the
append-only completion ledger (kind SATISFIED|SKIPPED, occurredAt, write-time
onTime, source). CustomMetricEntry gains the deletedAt tombstone for undo
parity with every other entry surface. Consumers land in the sibling commits
of this branch.

Refs #223
This commit is contained in:
Marc-André Bombeck committed 2026-08-14 08:54:43 +02:00
1 parent d8cbe2596f
commit 832aad9dd8
2 files changed
+128

No files matched your search

@@ -0,0 +1,54 @@
-- Refs #223 + iOS #68 — Vorsorge snooze/skip + the completion ledger.
-- All additive; every existing reminder keeps its exact behaviour.
-- Snooze cursor: non-NULL and in the future = "pushed back until this
-- instant". The snooze write sets next_due_at to the SAME instant, so every
-- due-state consumer moves with it; expiry is the clock passing the value.
ALTER TABLE "measurement_reminders" ADD COLUMN "snoozed_until" TIMESTAMP(3);
-- Last honest skip. A skip re-anchors next_due_at at the skip instant but
-- never moves last_satisfied_at — done and skipped stay distinguishable.
ALTER TABLE "measurement_reminders" ADD COLUMN "last_skipped_at" TIMESTAMP(3);
-- Lifetime skip counter ("skipped N times").
ALTER TABLE "measurement_reminders" ADD COLUMN "skip_count" INTEGER NOT NULL DEFAULT 0;
-- The completion ledger (iOS #68). The engine is single-cursor, so before
-- this table each satisfy overwrote the last and history was unrecoverable.
-- Append-only: one row per satisfy / skip, written by the one satisfy
-- primitive and the skip route. History starts with this release — the
-- cursor holds nothing to backfill from.
CREATE TYPE "measurement_reminder_event_kind" AS ENUM ('SATISFIED', 'SKIPPED');
CREATE TABLE "measurement_reminder_events" (
"id" TEXT NOT NULL,
"user_id" TEXT NOT NULL,
"reminder_id" TEXT NOT NULL,
"kind" "measurement_reminder_event_kind" NOT NULL,
"occurred_at" TIMESTAMP(3) NOT NULL,
"on_time" BOOLEAN NOT NULL,
"source" TEXT NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "measurement_reminder_events_pkey" PRIMARY KEY ("id")
);
CREATE INDEX "measurement_reminder_events_reminder_id_occurred_at_idx"
ON "measurement_reminder_events"("reminder_id", "occurred_at");
CREATE INDEX "measurement_reminder_events_user_id_idx"
ON "measurement_reminder_events"("user_id");
ALTER TABLE "measurement_reminder_events"
ADD CONSTRAINT "measurement_reminder_events_user_id_fkey"
FOREIGN KEY ("user_id") REFERENCES "users"("id")
ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "measurement_reminder_events"
ADD CONSTRAINT "measurement_reminder_events_reminder_id_fkey"
FOREIGN KEY ("reminder_id") REFERENCES "measurement_reminders"("id")
ON DELETE CASCADE ON UPDATE CASCADE;
-- Custom-metric entries gain the soft-delete tombstone every peer entry
-- surface already has (undo parity — the one hard delete left).
ALTER TABLE "custom_metric_entries" ADD COLUMN "deleted_at" TIMESTAMP(3);
+74
View File
@@ -879,6 +879,9 @@ model User {
// Server computes the canonical `nextDueAt`; the cron fires through the
// existing notification dispatcher. Soft-deleted.
measurementReminders MeasurementReminder[]
// v1.37.20 — completion ledger behind the reminders (iOS #68): one
// append-only row per satisfy / skip. Cascade-deleted with the account.
measurementReminderEvents MeasurementReminderEvent[]
// v1.17.1 — minimal structured lab store (LDL, HbA1c, ferritin, TSH, …)
// that pairs with the Vorsorge "annual blood panel" reminder so a result
// has somewhere to land. Soft-deleted.
@@ -5187,6 +5190,20 @@ model MeasurementReminder {
/// excluded everywhere), whereas a booster is the engine's native case —
/// recurring, satisfiable, cadence-bearing.
vaccinationAntigen String? @map("vaccination_antigen")
/// v1.37.20 — snooze cursor. Non-NULL and in the future = "pushed back until
/// this instant". The snooze write sets `nextDueAt` to the SAME instant, so
/// every due-state consumer (digest, dashboard, cron, AI block) moves with
/// it without knowing snooze exists. Self-expiring: once the instant passes,
/// the clock comparison stops being true and no cleanup is needed. Any
/// recompute of `nextDueAt` (satisfy, skip, cadence PATCH) nulls it.
snoozedUntil DateTime? @map("snoozed_until")
/// v1.37.20 — last honest skip. A skip re-anchors `nextDueAt` at the skip
/// instant but NEVER moves `lastSatisfiedAt` — done and skipped stay
/// distinguishable forever. "Skipped cycle" derives as
/// `lastSkippedAt > lastSatisfiedAt` (or non-NULL with no satisfy yet).
lastSkippedAt DateTime? @map("last_skipped_at")
/// v1.37.20 — lifetime skip counter ("skipped N times").
skipCount Int @default(0) @map("skip_count")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
/// Soft-delete tombstone. NULL = live; non-NULL = deleted at the instant.
@@ -5201,6 +5218,10 @@ model MeasurementReminder {
/// this reminder. SetNull on the far side — deleting the reminder leaves
/// the vaccination record intact.
vaccinations VaccinationRecord[]
/// v1.37.20 — the completion ledger (iOS #68): every satisfy and every
/// skip appends one row. Cascade: the ledger has no meaning without its
/// reminder.
events MeasurementReminderEvent[]
@@index([userId, deletedAt])
@@index([userId, nextDueAt])
@@ -5234,6 +5255,55 @@ enum ReminderOrigin {
@@map("reminder_origin")
}
/// v1.37.20 — what a completion-ledger row records: an honest fulfilment
/// (`SATISFIED` — a matching reading landed or the user marked it done) or an
/// honest skip (`SKIPPED` — the user deliberately let this cycle go, which is
/// NOT a completion and never touches `lastSatisfiedAt`).
enum MeasurementReminderEventKind {
SATISFIED
SKIPPED
@@map("measurement_reminder_event_kind")
}
/// v1.37.20 — the Vorsorge completion ledger (iOS #68). The reminder engine is
/// single-cursor (`nextDueAt` + `lastSatisfiedAt`), so before this table each
/// satisfy overwrote the previous one and history was unrecoverable. Every
/// path through the ONE satisfy primitive (`satisfyReminder`) and the skip
/// route appends one row here; nothing ever updates or deletes a row short of
/// deleting the reminder itself. History starts at the release that ships
/// this — the single cursor holds nothing to backfill from, which the iOS #68
/// thread records as agreed.
model MeasurementReminderEvent {
id String @id @default(cuid())
userId String @map("user_id")
reminderId String @map("reminder_id")
kind MeasurementReminderEventKind
/// The event instant: the satisfy instant (which may be the backdated
/// reading's own timestamp) or the skip instant (always the server clock).
occurredAt DateTime @map("occurred_at")
/// Derived AT WRITE TIME against the reminder's `nextDueAt` as it stood
/// before the event re-anchored it: `true` when the event landed at or
/// before the due instant (or the cadence had no computable due). Clients
/// (web + iOS) consume this resolved value and never re-derive it — the
/// pre-event `nextDueAt` is gone after the write, so write time is the only
/// honest moment to answer the question.
onTime Boolean @map("on_time")
/// Which path produced the event — a closed set of engine call sites
/// (`manual`, `auto_measurement`, `auto_lab`, `telegram`, `vaccination`,
/// `encounter`, `skip`), asserted in code, not free text.
source String
createdAt DateTime @default(now()) @map("created_at")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
reminder MeasurementReminder @relation(fields: [reminderId], references: [id], onDelete: Cascade)
/// The history endpoint reads newest-first per reminder.
@@index([reminderId, occurredAt])
@@index([userId])
@@map("measurement_reminder_events")
}
// ─── Lab results ─────────────────────────────────────────────
/// v1.17.1 — minimal structured lab store pairing with the Vorsorge "annual
@@ -5432,6 +5502,10 @@ model CustomMetricEntry {
measuredAt DateTime @map("measured_at")
note String?
createdAt DateTime @default(now()) @map("created_at")
/// v1.37.20 — soft-delete tombstone, peer parity with Measurement /
/// MoodEntry (every other entry surface deletes recoverably with an undo
/// toast; custom-metric entries were the one hard delete). NULL = live.
deletedAt DateTime? @map("deleted_at")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
metric CustomMetric @relation(fields: [customMetricId], references: [id], onDelete: Cascade)