mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
fix(medications): rebase dashboard compliance denominator onto the schedule
The dashboard compliance tile set scheduled = COUNT(*) of logged intake
rows, so its rate (taken / scheduled) sat at ~100% across every window
regardless of real adherence — every logged row was both numerator and
denominator, and the 7/30/90 windows never diverged.
Anchor scheduled to the canonical recurrence engine's expected-dose count
per day, summed across active medications, so days the schedule expected a
dose the user missed pull the rate down and the windows genuinely diverge
with partial adherence. taken is capped per day at the expected count so a
duplicate log can't push a day above 100%. The response shape stays
{date, scheduled, taken} for the existing chart consumer.
Regenerate the OpenAPI contract for the new expected/missed count fields.
This commit is contained in:
1 parent
cb239c2803
commit
7bbb473070
4 files changed
+275
-134
No files matched your search
@@ -8460,6 +8460,18 @@ components:
|
||||
type: integer
|
||||
minimum: 0
|
||||
maximum: 9007199254740991
|
||||
expected:
|
||||
type: integer
|
||||
minimum: 0
|
||||
maximum: 9007199254740991
|
||||
description: v1.15.9 rate denominator over the short window (`taken + missed`); user skips are excluded. Render `taken /
|
||||
expected · rate%`.
|
||||
missed:
|
||||
type: integer
|
||||
minimum: 0
|
||||
maximum: 9007199254740991
|
||||
description: v1.15.9 doses counted against the rate over the short window — includes forgotten doses the auto-miss cron
|
||||
flipped.
|
||||
streak:
|
||||
type: integer
|
||||
minimum: 0
|
||||
@@ -8467,6 +8479,8 @@ components:
|
||||
required:
|
||||
- rate
|
||||
- taken
|
||||
- expected
|
||||
- missed
|
||||
- streak
|
||||
additionalProperties: false
|
||||
long:
|
||||
@@ -8480,9 +8494,21 @@ components:
|
||||
type: integer
|
||||
minimum: 0
|
||||
maximum: 9007199254740991
|
||||
expected:
|
||||
type: integer
|
||||
minimum: 0
|
||||
maximum: 9007199254740991
|
||||
description: v1.15.9 rate denominator over the long window (`taken + missed`).
|
||||
missed:
|
||||
type: integer
|
||||
minimum: 0
|
||||
maximum: 9007199254740991
|
||||
description: v1.15.9 doses counted against the rate over the long window.
|
||||
required:
|
||||
- rate
|
||||
- taken
|
||||
- expected
|
||||
- missed
|
||||
additionalProperties: false
|
||||
currentCycle:
|
||||
type: object
|
||||
|
||||
@@ -369,43 +369,59 @@ describe("POST /api/medications/intake", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("v1.4.39 W-MED — compliance rollup read swap", () => {
|
||||
it("reads the rollup tier when coverage is present", async () => {
|
||||
describe("v1.15.9 — schedule-anchored compliance buckets (BUG #1)", () => {
|
||||
// The dashboard tile's `scheduled` is now the canonical recurrence
|
||||
// engine's expected-dose count per day (not the count of logged intake
|
||||
// rows). With partial adherence the per-day rate `taken / scheduled`
|
||||
// genuinely reflects taken-of-expected — it is NOT pinned at ~100%.
|
||||
function activeDailyMed(createdAt: Date) {
|
||||
return {
|
||||
id: "m1",
|
||||
userId: "user-1",
|
||||
active: true,
|
||||
startsOn: null,
|
||||
endsOn: null,
|
||||
oneShot: false,
|
||||
createdAt,
|
||||
schedules: [
|
||||
{
|
||||
windowStart: "08:00",
|
||||
windowEnd: "09:00",
|
||||
daysOfWeek: null,
|
||||
rrule: "FREQ=DAILY",
|
||||
rollingIntervalDays: null,
|
||||
timesOfDay: ["08:00"],
|
||||
reminderGraceMinutes: null,
|
||||
scheduleType: "SCHEDULED",
|
||||
cyclicOnWeeks: null,
|
||||
cyclicOffWeeks: null,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
it("anchors `scheduled` to the schedule — a day with a dose due but none taken reads < 100%", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
// QA F-H-01 (v1.4.39): coverage probe returns
|
||||
// `{ rolled_days >= event_days }` so the route lands on the
|
||||
// rollup tier. Match the trailing-7-day window.
|
||||
vi.mocked(prisma.$queryRaw).mockResolvedValue([
|
||||
{ rolled_days: BigInt(7), event_days: BigInt(7) },
|
||||
const createdAt = new Date(Date.now() - 60 * 86_400_000);
|
||||
vi.mocked(prisma.medication.findMany).mockResolvedValue([
|
||||
activeDailyMed(createdAt),
|
||||
] as never);
|
||||
// v1.4.39.1 — anchor the seed row on the runtime's "today" so the
|
||||
// test stays green across the calendar regardless of when it
|
||||
// runs. The pre-fix shape hard-coded `2026-05-18`, which fell out
|
||||
// of the trailing-7-day window on every subsequent wall-clock
|
||||
// day and silently shifted the body.data tail off the seed.
|
||||
//
|
||||
// v1.4.49.4 — compute todayKey in the SAME zone the route uses
|
||||
// (`readMedicationCompliance` → `userDayKey(now, safeTz)`), not in
|
||||
// UTC. SESSION_OK carries no `timezone`, so the route falls
|
||||
// through to `DEFAULT_TIMEZONE = "Europe/Berlin"`. The pre-fix
|
||||
// UTC computation diverged from the route's Berlin computation by
|
||||
// one calendar day during the 22:00-24:00 UTC window every night,
|
||||
// causing the test to fail deterministically once the clock
|
||||
// crossed midnight Berlin while still on the previous UTC day.
|
||||
const todayKey = (() => {
|
||||
const now = new Date();
|
||||
const fmt = new Intl.DateTimeFormat("en-CA", {
|
||||
timeZone: "Europe/Berlin",
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
});
|
||||
// `en-CA` short-date format is `YYYY-MM-DD`, matching the
|
||||
// route's `userDayKey` output (see `src/lib/tz/resolver.ts`).
|
||||
return fmt.format(now);
|
||||
})();
|
||||
vi.mocked(prisma.medicationComplianceRollup.findMany).mockResolvedValue([
|
||||
{ day: todayKey, scheduled: 3, taken: 2, skipped: 1 },
|
||||
// Only ONE taken dose across the whole 7-day window. A logged-row
|
||||
// denominator would read 1 scheduled / 1 taken = 100% on that one day
|
||||
// and nothing elsewhere; the engine instead expects ~7 doses (one per
|
||||
// day) and counts the six un-taken days as missed → an aggregate rate
|
||||
// well below 100%.
|
||||
const yesterday8 = new Date();
|
||||
yesterday8.setUTCDate(yesterday8.getUTCDate() - 1);
|
||||
yesterday8.setUTCHours(8, 30, 0, 0);
|
||||
vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue([
|
||||
{
|
||||
medicationId: "m1",
|
||||
scheduledFor: yesterday8,
|
||||
takenAt: yesterday8,
|
||||
skipped: false,
|
||||
autoMissed: false,
|
||||
},
|
||||
] as never);
|
||||
|
||||
const res = await GET(
|
||||
@@ -418,35 +434,53 @@ describe("v1.4.39 W-MED — compliance rollup read swap", () => {
|
||||
data: Array<{ date: string; scheduled: number; taken: number }>;
|
||||
};
|
||||
expect(body.data).toHaveLength(7);
|
||||
const today = body.data[body.data.length - 1];
|
||||
expect(today.scheduled).toBe(3);
|
||||
expect(today.taken).toBe(2);
|
||||
// The legacy live aggregator must not have run when coverage is hot.
|
||||
expect(prisma.medicationIntakeEvent.findMany).not.toHaveBeenCalled();
|
||||
// Engine expects a dose on (nearly) every day → total scheduled ≫ the
|
||||
// single logged row, and total taken is just the one.
|
||||
const totalScheduled = body.data.reduce((s, d) => s + d.scheduled, 0);
|
||||
const totalTaken = body.data.reduce((s, d) => s + d.taken, 0);
|
||||
expect(totalScheduled).toBeGreaterThanOrEqual(5);
|
||||
expect(totalTaken).toBe(1);
|
||||
// Aggregate rate is far from the degenerate ~100% the logged-row
|
||||
// denominator produced.
|
||||
expect(totalTaken / totalScheduled).toBeLessThan(0.5);
|
||||
});
|
||||
|
||||
it("falls back to the live aggregator on coverage miss", async () => {
|
||||
it("a fully-adherent daily med reads ~100% on every dosed day", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
// QA F-H-01 (v1.4.39): partial coverage — events present but
|
||||
// rollups missing — forces fall-through to the live aggregator.
|
||||
vi.mocked(prisma.$queryRaw).mockResolvedValue([
|
||||
{ rolled_days: BigInt(0), event_days: BigInt(7) },
|
||||
const createdAt = new Date(Date.now() - 60 * 86_400_000);
|
||||
vi.mocked(prisma.medication.findMany).mockResolvedValue([
|
||||
activeDailyMed(createdAt),
|
||||
] as never);
|
||||
vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue([
|
||||
{
|
||||
scheduledFor: new Date(),
|
||||
takenAt: new Date(),
|
||||
// One taken dose per day across the window.
|
||||
const events = [];
|
||||
for (let d = 0; d < 8; d++) {
|
||||
const at = new Date();
|
||||
at.setUTCDate(at.getUTCDate() - d);
|
||||
at.setUTCHours(8, 10, 0, 0);
|
||||
events.push({
|
||||
medicationId: "m1",
|
||||
scheduledFor: at,
|
||||
takenAt: at,
|
||||
skipped: false,
|
||||
},
|
||||
] as never);
|
||||
autoMissed: false,
|
||||
});
|
||||
}
|
||||
vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue(
|
||||
events as never,
|
||||
);
|
||||
|
||||
const res = await GET(
|
||||
new NextRequest(
|
||||
"http://localhost/api/medications/intake?scope=compliance&days=7",
|
||||
),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
expect(prisma.medicationIntakeEvent.findMany).toHaveBeenCalled();
|
||||
const body = (await res.json()) as {
|
||||
data: Array<{ date: string; scheduled: number; taken: number }>;
|
||||
};
|
||||
// Every dosed day has taken === scheduled → 100% throughout.
|
||||
for (const day of body.data) {
|
||||
if (day.scheduled > 0) expect(day.taken).toBe(day.scheduled);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -28,13 +28,13 @@ import { auditLog } from "@/lib/auth/audit";
|
||||
import { userDayKey, DEFAULT_TIMEZONE } from "@/lib/tz/resolver";
|
||||
import { cached, caches, type ServerCache } from "@/lib/cache/server-cache";
|
||||
import { invalidateUserMedications } from "@/lib/cache/invalidate";
|
||||
import { recomputeMedicationComplianceForEvent } from "@/lib/rollups/medication-compliance-rollups";
|
||||
import {
|
||||
readMedicationCompliance,
|
||||
hasMedicationComplianceCoverage,
|
||||
recomputeMedicationComplianceForEvent,
|
||||
enqueueUserMedicationComplianceBackfill,
|
||||
type ComplianceBucket as RollupComplianceBucket,
|
||||
} from "@/lib/rollups/medication-compliance-rollups";
|
||||
buildComplianceMedicationContext,
|
||||
expectedSlotCountForDay,
|
||||
lastNonSkippedTakenAt,
|
||||
} from "@/lib/analytics/compliance";
|
||||
import { getUserTodayBounds } from "@/lib/timezone";
|
||||
import { projectTodayIntakesAndRecompute } from "@/lib/medications/scheduling/project-today-intakes";
|
||||
import { resolveInjectionSiteForWrite } from "@/lib/medications/injection-site-write";
|
||||
import {
|
||||
@@ -167,10 +167,16 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
? "skipped"
|
||||
: e.takenAt
|
||||
? "taken"
|
||||
: e.medication.snoozedUntil &&
|
||||
e.medication.snoozedUntil > new Date()
|
||||
? "snoozed"
|
||||
: "pending",
|
||||
: // v1.15.9 — a never-acted dose the auto-miss cron flipped is a
|
||||
// terminal MISS, not a perpetual "pending". (Today's freshly
|
||||
// projected rows are < 24 h old so this rarely fires here, but
|
||||
// a stale row surfacing in the window reads honestly.)
|
||||
e.autoMissed
|
||||
? "missed"
|
||||
: e.medication.snoozedUntil &&
|
||||
e.medication.snoozedUntil > new Date()
|
||||
? "snoozed"
|
||||
: "pending",
|
||||
snoozedUntil: e.medication.snoozedUntil?.toISOString() ?? null,
|
||||
})),
|
||||
);
|
||||
@@ -182,15 +188,18 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
// move). Cache key carries the userTz so a user who changes timezone
|
||||
// doesn't read another tz's bucketing.
|
||||
//
|
||||
// v1.4.39 W-MED — read path now consumes the persistent
|
||||
// `medication_compliance_rollups` tier. A coverage probe falls back
|
||||
// to the legacy live aggregator when the user has intake events but
|
||||
// zero rollup rows; the fallback fires a boot-backfill enqueue in the
|
||||
// background so the next request hits the rollup tier.
|
||||
// v1.15.9 — `scheduled` is now the SCHEDULE-ANCHORED expected-dose count
|
||||
// per day (the canonical recurrence engine), NOT the count of logged
|
||||
// intake rows. The old rollup-backed path set `scheduled = COUNT(*)` of
|
||||
// intake rows, so the dashboard tile's rate (`taken / scheduled`) was ~100%
|
||||
// across every window regardless of real adherence — every logged row was
|
||||
// both numerator and denominator. Anchoring `scheduled` to the schedule
|
||||
// makes the rate genuinely reflect taken-of-expected and lets the 7/30/90
|
||||
// windows diverge with partial adherence.
|
||||
const result = await cached(
|
||||
caches.medicationsIntake as ServerCache<ComplianceBucket[]>,
|
||||
`${user.id}|compliance|${days}|${userTz}`,
|
||||
() => readComplianceBucketsWithFallback(user.id, days, userTz),
|
||||
() => buildScheduleAnchoredComplianceBuckets(user.id, days, userTz),
|
||||
annotate,
|
||||
);
|
||||
|
||||
@@ -202,50 +211,6 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
return apiSuccess(result);
|
||||
});
|
||||
|
||||
/**
|
||||
* v1.4.39 W-MED — read from the persistent rollup tier when covered,
|
||||
* fall through to the legacy live aggregator on coverage miss. The
|
||||
* fallback fires the boot backfill in the background so subsequent
|
||||
* requests hit the rollup path; the current request still returns a
|
||||
* correct response built from the raw events.
|
||||
*/
|
||||
async function readComplianceBucketsWithFallback(
|
||||
userId: string,
|
||||
days: number,
|
||||
userTz: string,
|
||||
): Promise<ComplianceBucket[]> {
|
||||
const covered = await hasMedicationComplianceCoverage(userId, days, userTz);
|
||||
if (covered) {
|
||||
const buckets = await readMedicationCompliance(userId, days, userTz);
|
||||
annotate({
|
||||
meta: {
|
||||
medication_compliance_path: "rollup",
|
||||
medication_compliance_days: days,
|
||||
},
|
||||
});
|
||||
return buckets satisfies RollupComplianceBucket[];
|
||||
}
|
||||
|
||||
// Coverage miss — the legacy aggregator stays correct over an empty
|
||||
// rollup window. Fire a user-scoped backfill in the background so
|
||||
// the next request lands on the rollup tier; the current request
|
||||
// still returns the live-derived buckets.
|
||||
//
|
||||
// QA F-SEC-M-01 (v1.4.39): pre-fix this fired the cluster-wide
|
||||
// `enqueueBootTimeMedicationComplianceBackfill` on every coverage-
|
||||
// miss request, opening a soft-DoS amplifier (every authenticated
|
||||
// user could drive a multi-tenant `LEFT JOIN` scan on each hit).
|
||||
// The user-scoped helper enqueues exactly the caller's account.
|
||||
void enqueueUserMedicationComplianceBackfill(userId);
|
||||
annotate({
|
||||
meta: {
|
||||
medication_compliance_path: "live-fallback",
|
||||
medication_compliance_days: days,
|
||||
},
|
||||
});
|
||||
return buildComplianceBuckets(userId, days, userTz);
|
||||
}
|
||||
|
||||
interface ComplianceBucket {
|
||||
date: string;
|
||||
scheduled: number;
|
||||
@@ -253,40 +218,132 @@ interface ComplianceBucket {
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.4.34 IW-G — pulled out of the GET handler so `cached()` can wrap
|
||||
* the per-user compliance aggregation. Pure function over the user's
|
||||
* intake events + the requested window; deterministic given a fixed
|
||||
* "now" wall-clock (we anchor on `Date.now()` once at call time so a
|
||||
* 15-minute cached row stays internally consistent).
|
||||
* v1.15.9 — schedule-anchored per-day compliance buckets for the dashboard
|
||||
* tile. Replaces the rollup/legacy path whose `scheduled` was the count of
|
||||
* logged intake rows (BUG #1: rate `taken / scheduled` ≈ 100% across every
|
||||
* window because every logged row was both numerator and denominator).
|
||||
*
|
||||
* `scheduled` is now the canonical recurrence engine's expected-dose count
|
||||
* for the day, summed across the user's active medications, so days the
|
||||
* schedule expected a dose that the user missed pull the rate down — and the
|
||||
* 7/30/90 windows genuinely diverge with partial adherence. `taken` stays the
|
||||
* count of taken (non-skipped, non-auto-missed) doses that day, capped at the
|
||||
* day's expected count so a duplicate log can't push a day above 100%.
|
||||
*
|
||||
* Pure-ish over a single pinned `now` so a 15-minute cached row is internally
|
||||
* consistent. Bounded: one active-medications query + one window-events query,
|
||||
* then per-medication-per-day engine expansion over the trailing window.
|
||||
*/
|
||||
async function buildComplianceBuckets(
|
||||
async function buildScheduleAnchoredComplianceBuckets(
|
||||
userId: string,
|
||||
days: number,
|
||||
userTz: string,
|
||||
): Promise<ComplianceBucket[]> {
|
||||
const nowMs = Date.now();
|
||||
const now = new Date();
|
||||
const nowMs = now.getTime();
|
||||
const start = new Date(nowMs - days * 86_400_000);
|
||||
|
||||
const medications = await prisma.medication.findMany({
|
||||
where: { userId, active: true },
|
||||
include: { schedules: true },
|
||||
});
|
||||
|
||||
const events = await prisma.medicationIntakeEvent.findMany({
|
||||
// v1.7.0 sync — exclude tombstoned rows from the compliance buckets.
|
||||
where: { userId, deletedAt: null, scheduledFor: { gte: start } },
|
||||
select: { scheduledFor: true, takenAt: true, skipped: true },
|
||||
select: {
|
||||
medicationId: true,
|
||||
scheduledFor: true,
|
||||
takenAt: true,
|
||||
skipped: true,
|
||||
autoMissed: true,
|
||||
},
|
||||
});
|
||||
|
||||
const buckets = new Map<string, { scheduled: number; taken: number }>();
|
||||
for (let i = 0; i < days; i++) {
|
||||
const d = new Date(nowMs - i * 86_400_000);
|
||||
buckets.set(userDayKey(d, userTz), { scheduled: 0, taken: 0 });
|
||||
}
|
||||
for (const e of events) {
|
||||
const key = userDayKey(e.scheduledFor, userTz);
|
||||
const bucket = buckets.get(key);
|
||||
if (!bucket) continue;
|
||||
bucket.scheduled += 1;
|
||||
if (e.takenAt && !e.skipped) bucket.taken += 1;
|
||||
// Pre-compute each local day's [start, end) bounds + key, oldest → newest.
|
||||
const dayKeys: string[] = [];
|
||||
const dayBounds = new Map<string, { start: Date; end: Date }>();
|
||||
for (let i = days - 1; i >= 0; i--) {
|
||||
const representative = new Date(nowMs - i * 86_400_000 - 12 * 60 * 60 * 1000);
|
||||
const { start: dayStart, end: dayEndInclusive } = getUserTodayBounds(
|
||||
representative,
|
||||
userTz,
|
||||
);
|
||||
const key = userDayKey(dayStart, userTz);
|
||||
if (dayBounds.has(key)) continue;
|
||||
dayKeys.push(key);
|
||||
dayBounds.set(key, {
|
||||
start: dayStart,
|
||||
end: new Date(dayEndInclusive.getTime() + 1), // half-open [start, end)
|
||||
});
|
||||
}
|
||||
|
||||
return [...buckets.entries()]
|
||||
.map(([date, v]) => ({ date, scheduled: v.scheduled, taken: v.taken }))
|
||||
const totals = new Map<string, { scheduled: number; taken: number }>();
|
||||
for (const key of dayKeys) totals.set(key, { scheduled: 0, taken: 0 });
|
||||
|
||||
// Group events by medication so each med's engine context is built once.
|
||||
const eventsByMed = new Map<
|
||||
string,
|
||||
{ scheduledFor: Date; takenAt: Date | null; skipped: boolean; autoMissed: boolean }[]
|
||||
>();
|
||||
for (const e of events) {
|
||||
const list = eventsByMed.get(e.medicationId) ?? [];
|
||||
list.push({
|
||||
scheduledFor: e.scheduledFor,
|
||||
takenAt: e.takenAt,
|
||||
skipped: e.skipped,
|
||||
autoMissed: e.autoMissed,
|
||||
});
|
||||
eventsByMed.set(e.medicationId, list);
|
||||
}
|
||||
|
||||
for (const med of medications) {
|
||||
if (med.schedules.length === 0) continue;
|
||||
const medEvents = eventsByMed.get(med.id) ?? [];
|
||||
const ctx = buildComplianceMedicationContext(
|
||||
med,
|
||||
lastNonSkippedTakenAt(medEvents),
|
||||
userTz,
|
||||
);
|
||||
|
||||
for (const key of dayKeys) {
|
||||
const bounds = dayBounds.get(key)!;
|
||||
// Skip days before the medication existed so a young med doesn't paint
|
||||
// missed-denominator days it could not have been dosed on.
|
||||
if (bounds.end <= med.createdAt) continue;
|
||||
|
||||
const scheduled = expectedSlotCountForDay(
|
||||
med.schedules,
|
||||
bounds.start,
|
||||
bounds.end,
|
||||
ctx,
|
||||
medEvents,
|
||||
);
|
||||
if (scheduled === 0) continue;
|
||||
|
||||
// Taken doses that landed in this day's window (non-skipped, non-auto-
|
||||
// missed), capped at the expected count so a duplicate log can't push a
|
||||
// single day above 100%.
|
||||
const takenThisDay = medEvents.filter(
|
||||
(e) =>
|
||||
e.takenAt !== null &&
|
||||
!e.skipped &&
|
||||
!e.autoMissed &&
|
||||
e.scheduledFor >= bounds.start &&
|
||||
e.scheduledFor < bounds.end,
|
||||
).length;
|
||||
|
||||
const bucket = totals.get(key)!;
|
||||
bucket.scheduled += scheduled;
|
||||
bucket.taken += Math.min(takenThisDay, scheduled);
|
||||
}
|
||||
}
|
||||
|
||||
return dayKeys
|
||||
.map((date) => {
|
||||
const v = totals.get(date)!;
|
||||
return { date, scheduled: v.scheduled, taken: v.taken };
|
||||
})
|
||||
.sort((a, b) => a.date.localeCompare(b.date));
|
||||
}
|
||||
|
||||
|
||||
@@ -1274,11 +1274,35 @@ const complianceDisplay = z
|
||||
short: z.object({
|
||||
rate: z.number().int().min(0).max(100),
|
||||
taken: z.number().int().nonnegative(),
|
||||
expected: z
|
||||
.number()
|
||||
.int()
|
||||
.nonnegative()
|
||||
.describe(
|
||||
"v1.15.9 rate denominator over the short window (`taken + missed`); user skips are excluded. Render `taken / expected · rate%`.",
|
||||
),
|
||||
missed: z
|
||||
.number()
|
||||
.int()
|
||||
.nonnegative()
|
||||
.describe(
|
||||
"v1.15.9 doses counted against the rate over the short window — includes forgotten doses the auto-miss cron flipped.",
|
||||
),
|
||||
streak: z.number().int().nonnegative(),
|
||||
}),
|
||||
long: z.object({
|
||||
rate: z.number().int().min(0).max(100),
|
||||
taken: z.number().int().nonnegative(),
|
||||
expected: z
|
||||
.number()
|
||||
.int()
|
||||
.nonnegative()
|
||||
.describe("v1.15.9 rate denominator over the long window (`taken + missed`)."),
|
||||
missed: z
|
||||
.number()
|
||||
.int()
|
||||
.nonnegative()
|
||||
.describe("v1.15.9 doses counted against the rate over the long window."),
|
||||
}),
|
||||
currentCycle: z
|
||||
.object({
|
||||
|
||||
Reference in new issue
Block a user