mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-15 15:02:04 +00:00
Let a checkup be skipped or pushed back, and remember every outcome
Refs #223. A due Vorsorge item could only be done, deleted or ignored; Telegram's postpone moved the date with no trace. Both decisions are first-class now, and both are honest: - POST /{id}/skip (no body): the interval restarts from the skip instant through the recurrence engine, lastSkippedAt is stamped, skipCount increments, any snooze clears. lastSatisfiedAt is NEVER touched — done and skipped stay distinguishable forever, enforced in the primitive and pinned by tests. - POST /{id}/snooze ({until: YYYY-MM-DD}): the server resolves the day to the notifyHour in the profile timezone and moves snoozedUntil and nextDueAt to the same instant, so every due-state consumer follows without knowing snooze exists. At least tomorrow, at most five years. - GET /{id}/history: the new append-only completion ledger (MeasurementReminderEvent), one row per satisfy from every engine path and per skip, onTime derived at write time against the due instant that was current when the event landed. Two anchor fixes close the backdated-event gaps: the auto-resolve floor is now the later of last satisfy and last skip (an old reading synced late no longer resolves the cycle the user just skipped), and a backdated real result re-anchors the next due at the later of its own instant and the skip decision. Screenings stay skippable and snoozable — the satisfy-side 409 guards claimed fulfilment; a skip claims the opposite. Appointment reminders 404 on all three routes, in the lookup, like every by-id sibling. /checkups gains the postpone sheet (quick chips, date field, the skip zone with its own confirm) in both view branches, with muted meta lines and no card colour change. The MCP preventive-care tool carries the resolved fields; the OpenAPI contract publishes all three paths and the widened DTO.
This commit is contained in:
1 parent
cd12b3886e
commit
5619783648
35 files changed
+3715
-115
No files matched your search
@@ -210,6 +210,9 @@ export const measurementReminderDto = z
|
||||
location: z.string().nullable(),
|
||||
nextDueAt: z.iso.datetime({ offset: true }).nullable(),
|
||||
lastSatisfiedAt: z.iso.datetime({ offset: true }).nullable(),
|
||||
snoozedUntil: z.iso.datetime({ offset: true }).nullable(),
|
||||
lastSkippedAt: z.iso.datetime({ offset: true }).nullable(),
|
||||
skipCount: z.number().int(),
|
||||
enabled: z.boolean(),
|
||||
createdAt: z.iso.datetime({ offset: true }),
|
||||
updatedAt: z.iso.datetime({ offset: true }),
|
||||
@@ -217,7 +220,7 @@ export const measurementReminderDto = z
|
||||
.meta({
|
||||
id: "MeasurementReminderDTO",
|
||||
description:
|
||||
"A Vorsorge reminder. nextDueAt is server-computed (server-authoritative). A free-text reminder carries measurementType=null and resolves only on a manual satisfy. origin distinguishes a user-created (VORSORGE) reminder from one minted by a Coach cadence suggestion (COACH); endsOn bounds a finite course window (null = open-ended).",
|
||||
"A Vorsorge reminder. nextDueAt is server-computed (server-authoritative). A free-text reminder carries measurementType=null and resolves only on a manual satisfy. origin distinguishes a user-created (VORSORGE) reminder from one minted by a Coach cadence suggestion (COACH); endsOn bounds a finite course window (null = open-ended). snoozedUntil/lastSkippedAt/skipCount are resolved values: snoozed means snoozedUntil > now, a skipped cycle means lastSkippedAt > lastSatisfiedAt; clients compare against the clock and never recompute cadence. lastSatisfiedAt stays the only completed signal — skip and snooze never move it.",
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -240,5 +243,93 @@ export const measurementReminderCompletionDto = z
|
||||
.meta({
|
||||
id: "MeasurementReminderCompletion",
|
||||
description:
|
||||
"Result of an explicit reminder completion. completed=true when this call advanced lastSatisfiedAt; completed=false when an earlier satisfy or a matching reading had already fulfilled the current cycle (idempotent no-op). reminder is the canonical post-completion DTO.",
|
||||
"Result of an explicit reminder completion. completed=true when this call advanced lastSatisfiedAt; completed=false when an earlier satisfy or a matching reading already fulfilled the current cycle (idempotent no-op). reminder is the canonical post-completion DTO.",
|
||||
});
|
||||
|
||||
/**
|
||||
* v1.37.20 (#223) — snooze body. Day precision on purpose: the client names
|
||||
* a local calendar day, the server resolves it to that day's notifyHour in
|
||||
* the profile timezone. No client-supplied instant ever anchors the cadence.
|
||||
* The range gate (tomorrow .. +5 years) lives in the route, where the
|
||||
* profile timezone is known.
|
||||
*/
|
||||
export const snoozeMeasurementReminderSchema = z
|
||||
.object({
|
||||
until: z.iso.date(),
|
||||
})
|
||||
.meta({
|
||||
id: "MeasurementReminderSnooze",
|
||||
description:
|
||||
"Push a reminder's current due date back to a calendar day (YYYY-MM-DD). The server resolves the day to the reminder's notifyHour in the profile timezone. Must be at least tomorrow and at most five years out. The regular cadence is untouched: lastSatisfiedAt and the anchor stay where they are, and the reminder resumes its normal interval after the snoozed cycle.",
|
||||
});
|
||||
|
||||
export type SnoozeMeasurementReminderInput = z.infer<
|
||||
typeof snoozeMeasurementReminderSchema
|
||||
>;
|
||||
|
||||
/**
|
||||
* v1.37.20 (#223) — skip result. `skipped=false` is the forward-only no-op
|
||||
* (a concurrent skip or satisfy already advanced the row).
|
||||
*/
|
||||
export const measurementReminderSkipDto = z
|
||||
.object({
|
||||
skipped: z.boolean(),
|
||||
reminder: measurementReminderDto,
|
||||
})
|
||||
.meta({
|
||||
id: "MeasurementReminderSkip",
|
||||
description:
|
||||
"Result of skipping the current due cycle. skipped=true when this call recorded the skip and restarted the interval from the skip instant; skipped=false when a concurrent skip or satisfy already advanced the row (idempotent no-op). A skip never touches lastSatisfiedAt — it is recorded honestly as a skip, not a completion.",
|
||||
});
|
||||
|
||||
/**
|
||||
* v1.37.20 (iOS #68) — completion-ledger query + row. History starts with
|
||||
* the release that ships the ledger; the single-cursor engine holds nothing
|
||||
* to backfill from.
|
||||
*/
|
||||
export const listReminderHistorySchema = z
|
||||
.object({
|
||||
limit: z.coerce.number().int().min(1).max(100).default(50),
|
||||
offset: z.coerce.number().int().min(0).default(0),
|
||||
})
|
||||
.meta({
|
||||
id: "MeasurementReminderHistoryQuery",
|
||||
description: "Pagination for the reminder completion ledger.",
|
||||
});
|
||||
|
||||
export const measurementReminderEventDto = z
|
||||
.object({
|
||||
id: z.string(),
|
||||
kind: z.enum(["SATISFIED", "SKIPPED"]),
|
||||
occurredAt: z.iso.datetime({ offset: true }),
|
||||
onTime: z.boolean(),
|
||||
source: z.enum([
|
||||
"manual",
|
||||
"auto_measurement",
|
||||
"auto_lab",
|
||||
"telegram",
|
||||
"vaccination",
|
||||
"encounter",
|
||||
"skip",
|
||||
]),
|
||||
createdAt: z.iso.datetime({ offset: true }),
|
||||
})
|
||||
.meta({
|
||||
id: "MeasurementReminderEvent",
|
||||
description:
|
||||
"One completion-ledger row: an honest fulfilment (SATISFIED) or an honest skip (SKIPPED). onTime is derived server-side at write time against the due instant that was current when the event landed — clients never re-derive it. History begins at the release that introduced the ledger.",
|
||||
});
|
||||
|
||||
export const measurementReminderHistoryDto = z
|
||||
.object({
|
||||
events: z.array(measurementReminderEventDto),
|
||||
meta: z.object({
|
||||
total: z.number().int(),
|
||||
limit: z.number().int(),
|
||||
offset: z.number().int(),
|
||||
}),
|
||||
})
|
||||
.meta({
|
||||
id: "MeasurementReminderHistory",
|
||||
description: "Paginated completion ledger for one reminder, newest first.",
|
||||
});
|
||||
Reference in new issue
Block a user