mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 06:06:10 +00:00
fix(medications): unify compliance % and per-dose status onto one dose-history ledger
The medication compliance percentage and the history view drew their per-dose attribution from four different matchers — the ±12h pairDoses proximity used by calculateCompliance, the deriveDoseStatus bands (open dose only), the 3h+120min classifyIntakeTiming heatmap heuristic, and a raw takenAt-IS-NOT-NULL per-day rollup. They could and did disagree: a dose could read "taken late" in the rate while the history called it an off-schedule ad-hoc take. Route the rate through the shared cadence-aware band minter + reconstructDoseHistory so the percentage is a tally over the SAME ledger the history view renders. The same intake can no longer be taken in the % and ad-hoc in the ledger. - calculateCompliance: when a medication context is supplied, the count fields (taken / skipped / missed / rate) come from tallyComplianceFromLedger. Numerator = taken_on_time + taken_late (a late dose still counts as taken — the taking-vs-timing split is kept available); denominator = taken + missed; skipped, ad-hoc, upcoming and whole PRN groups are excluded; rate capped at 100. The streak still walks the cadence timeline. Context-less pure-math callers keep the legacy timeline tally byte-stable. - complianceChips + missedDoses: adherence rate + missed count come from the same ledger when an engine context is threaded. - /api/medications/[id]/compliance heatmap: the per-day timing split and missed marks are bucketed from the one ledger instead of the per-day classifyIntakeTiming + expectedSlotCountForDay walk, so the cells agree with the headline rate. - medication_compliance_rollups stays a raw cross-medication daily coverage count, documented as distinct from the band-attributed % (the band minter is per-cadence and cannot run inside a single SQL tally). Downstream readers (health score, insight compliance status) keep calling calculateCompliance; their numbers shift to the honest band-attributed values. Some currently-counted off-time takes (e.g. an 11:29 take of a 07:00 dose) now read ad-hoc with the slot missed rather than taken-late — the configurable per-dose window is the lever to widen the on-time band. Tests: the irregular twice-daily, single-time-late heatmap, and legacy parity fixtures are updated with rationale for the intended number changes; new tally tests pin the taking/timing split, the skip / ad-hoc / upcoming / PRN exclusions, auto-missed=missed, the 100% cap, rolling retrospective families, and the cross-consistency invariant.
This commit is contained in:
1 parent
56bc46417e
commit
79e7c866e4
7 files changed
+1128
-233
No files matched your search
@@ -169,7 +169,15 @@ describe("GET /api/medications/[id]/compliance — per-slot timing", () => {
|
||||
expect(entry!.onTime).toBe(2);
|
||||
});
|
||||
|
||||
it("keeps single-time schedules byte-identical: a 12h-late dose is still very_late", async () => {
|
||||
it("a 12h-late dose is an ad-hoc take + a missed 07:00 slot (band model)", async () => {
|
||||
// v1.15.18 — the heatmap now buckets the unified dose-history ledger. A
|
||||
// single 07:00 daily dose logged at 19:00 is 12h outside the 07:00 slot's
|
||||
// on-time band (±60min) AND its late tail (to ~10:00), so it is an AD-HOC
|
||||
// off-schedule take — a real logged dose (taken / on-time tone), NOT a
|
||||
// "very late" attribution onto the 07:00 slot. The 07:00 slot itself reads
|
||||
// missed (no intake inside its window past its cutoff). This is the audit's
|
||||
// intent: the cell no longer mislabels an off-schedule take as a punctuality
|
||||
// grade against a slot it never belonged to.
|
||||
const { start: dayStart } = dayBoundsForOffset(DAY_OFFSET);
|
||||
const dateKey = userDayKey(dayStart, TZ);
|
||||
|
||||
@@ -179,7 +187,7 @@ describe("GET /api/medications/[id]/compliance — per-slot timing", () => {
|
||||
id: "sched-1",
|
||||
windowStart: "07:00",
|
||||
windowEnd: "08:00",
|
||||
timesOfDay: [], // single-time: candidate = [windowStart]
|
||||
timesOfDay: [], // single-time daily: the minter mints a 07:00 band
|
||||
daysOfWeek: null,
|
||||
rrule: null,
|
||||
rollingIntervalDays: null,
|
||||
@@ -191,7 +199,7 @@ describe("GET /api/medications/[id]/compliance — per-slot timing", () => {
|
||||
]) as never,
|
||||
);
|
||||
|
||||
// Single 07:00 schedule, dose taken at 19:00 → ~12h late → very_late.
|
||||
// Single 07:00 schedule, dose logged at 19:00 → outside every band → ad-hoc.
|
||||
vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue([
|
||||
{
|
||||
takenAt: atSlotUTC(dayStart, "19:00"),
|
||||
@@ -203,9 +211,14 @@ describe("GET /api/medications/[id]/compliance — per-slot timing", () => {
|
||||
const daily = await callRoute();
|
||||
const entry = daily[dateKey] as Record<string, number> | undefined;
|
||||
expect(entry).toBeDefined();
|
||||
expect(entry!.veryLate).toBe(1);
|
||||
expect(entry!.onTime).toBe(0);
|
||||
// The off-schedule take reads as a logged dose; no late-attribution.
|
||||
expect(entry!.veryLate).toBe(0);
|
||||
expect(entry!.late).toBe(0);
|
||||
expect(entry!.onTime).toBe(1);
|
||||
expect(entry!.taken).toBe(1);
|
||||
// The 07:00 slot itself is missed (uncovered, past its cutoff).
|
||||
// expected = the missed slot + the ad-hoc take's own slot.
|
||||
expect(entry!.expected).toBe(2);
|
||||
});
|
||||
|
||||
it("handles an overnight per-slot window (23:00 slot, 1h span) as on_time near the slot", async () => {
|
||||
|
||||
@@ -6,83 +6,128 @@ import {
|
||||
buildComplianceDisplay,
|
||||
buildComplianceMedicationContext,
|
||||
calculateCompliance,
|
||||
classifyIntakeTiming,
|
||||
expectedSlotCountForDay,
|
||||
lastNonSkippedTakenAt,
|
||||
} from "@/lib/analytics/compliance";
|
||||
import type { DailyComplianceEntry } from "@/lib/analytics/compliance";
|
||||
import type { SlotBand } from "@/lib/medications/scheduling/attribution";
|
||||
import {
|
||||
buildBandsForSchedules,
|
||||
type BandMinterMedication,
|
||||
} from "@/lib/medications/scheduling/band-minter";
|
||||
import {
|
||||
reconstructDoseHistory,
|
||||
type DoseHistoryRow,
|
||||
type HistoryIntake,
|
||||
} from "@/lib/medications/scheduling/dose-history";
|
||||
import {
|
||||
type CanonicalSchedule,
|
||||
type RecurrenceContext,
|
||||
} from "@/lib/medications/scheduling/recurrence";
|
||||
import { assertMedicationOwnership } from "@/lib/medications/route-guards";
|
||||
import { getUserTodayBounds } from "@/lib/timezone";
|
||||
import { userDayKey } from "@/lib/tz/format";
|
||||
|
||||
type RouteParams = { params: Promise<{ id: string }> };
|
||||
|
||||
/** A single effective dose slot with its derived punctuality window. */
|
||||
interface SlotWindow {
|
||||
/** Minute-of-day of the slot's start time (used for nearest-slot match). */
|
||||
startMinutes: number;
|
||||
/** "HH:mm" slot start — fed to `classifyIntakeTiming` as `windowStart`. */
|
||||
type MedicationScheduleRow = {
|
||||
id: string;
|
||||
rrule: string | null;
|
||||
rollingIntervalDays: number | null;
|
||||
timesOfDay: string[];
|
||||
daysOfWeek: string | null;
|
||||
windowStart: string;
|
||||
/** "HH:mm" slot end = slot start + the schedule's window span. */
|
||||
windowEnd: string;
|
||||
}
|
||||
reminderGraceMinutes: number | null;
|
||||
scheduleType: "SCHEDULED" | "PRN" | "CYCLIC";
|
||||
cyclicOnWeeks: number | null;
|
||||
cyclicOffWeeks: number | null;
|
||||
};
|
||||
|
||||
const MINUTES_PER_DAY = 24 * 60;
|
||||
|
||||
/** Parse "HH:mm" into minute-of-day. */
|
||||
function hhmmToMinutes(time: string): number {
|
||||
const [h, m] = time.split(":").map(Number);
|
||||
return h * 60 + m;
|
||||
}
|
||||
|
||||
/** Format a minute-of-day (wrapped into 0..1439) back to "HH:mm". */
|
||||
function minutesToHHmm(minutes: number): string {
|
||||
const wrapped = ((minutes % MINUTES_PER_DAY) + MINUTES_PER_DAY) %
|
||||
MINUTES_PER_DAY;
|
||||
const h = Math.floor(wrapped / 60);
|
||||
const m = wrapped % 60;
|
||||
return `${String(h).padStart(2, "0")}:${String(m).padStart(2, "0")}`;
|
||||
/**
|
||||
* v1.15.18 — adapt a Prisma schedule row to the canonical engine shape the
|
||||
* band minter consumes. A legacy daily row carrying only `windowStart`
|
||||
* surfaces it as the single `timeOfDay` so the minter mints its daily band.
|
||||
*/
|
||||
function toCanonicalForBands(
|
||||
s: MedicationScheduleRow,
|
||||
oneShot: boolean,
|
||||
): CanonicalSchedule {
|
||||
const base: CanonicalSchedule = {
|
||||
id: s.id,
|
||||
rrule: s.rrule,
|
||||
rollingIntervalDays: s.rollingIntervalDays,
|
||||
timesOfDay: s.timesOfDay,
|
||||
daysOfWeek: s.daysOfWeek,
|
||||
windowStart: s.windowStart,
|
||||
windowEnd: s.windowEnd,
|
||||
reminderGraceMinutes: s.reminderGraceMinutes,
|
||||
scheduleType: s.scheduleType,
|
||||
cyclicOnWeeks: s.cyclicOnWeeks,
|
||||
cyclicOffWeeks: s.cyclicOffWeeks,
|
||||
};
|
||||
if (
|
||||
base.timesOfDay.length === 0 &&
|
||||
base.rrule === null &&
|
||||
base.rollingIntervalDays === null &&
|
||||
base.scheduleType !== "PRN" &&
|
||||
!oneShot
|
||||
) {
|
||||
return { ...base, timesOfDay: [base.windowStart] };
|
||||
}
|
||||
return base;
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.8.1 B15 follow-on — expand every schedule into its effective dose
|
||||
* slots so timing classification matches against the right time-of-day.
|
||||
* v1.15.18 — fold one dose-history ledger row into a per-day heatmap entry.
|
||||
*
|
||||
* Candidate slot times for a schedule are its `timesOfDay` when non-empty,
|
||||
* else `[windowStart]` (so single-time schedules collapse to one slot at
|
||||
* `windowStart` and stay byte-identical to the pre-fix matcher). Each
|
||||
* slot's punctuality window is anchored at the slot time and spans the
|
||||
* schedule's `windowEnd − windowStart` duration; overnight windows
|
||||
* (`windowEnd ≤ windowStart`) add a day to the span before deriving the
|
||||
* per-slot end. Multi-schedule meds union their slots — the matcher then
|
||||
* picks the single closest slot across all of them.
|
||||
* Slot rows contribute to `expected` / `expectedCount` (the day's due-slot
|
||||
* count): a taken slot adds to `taken` and its timing bucket; a missed slot
|
||||
* stays uncounted in `taken`; a skipped slot lands in `skipped`; an upcoming
|
||||
* slot is still due but not yet acted on. An ad-hoc row is a real off-schedule
|
||||
* take — it counts as `taken` AND adds its own `expected` slot (so the
|
||||
* heatmap's `missed = expected − taken − skipped` math stays non-negative) and
|
||||
* reads on-time (a logged dose colours green).
|
||||
*/
|
||||
function buildSlotCandidates(
|
||||
schedules: { windowStart: string; windowEnd: string; timesOfDay: string[] }[],
|
||||
): SlotWindow[] {
|
||||
const slots: SlotWindow[] = [];
|
||||
for (const sched of schedules) {
|
||||
const startMin = hhmmToMinutes(sched.windowStart);
|
||||
let endMin = hhmmToMinutes(sched.windowEnd);
|
||||
// Overnight window: the end is on the following day.
|
||||
if (endMin <= startMin) endMin += MINUTES_PER_DAY;
|
||||
const spanMinutes = endMin - startMin;
|
||||
|
||||
const times =
|
||||
sched.timesOfDay.length > 0 ? sched.timesOfDay : [sched.windowStart];
|
||||
|
||||
for (const time of times) {
|
||||
const slotStartMin = hhmmToMinutes(time);
|
||||
slots.push({
|
||||
startMinutes: slotStartMin,
|
||||
windowStart: minutesToHHmm(slotStartMin),
|
||||
// `classifyIntakeTiming` re-detects the overnight wrap from the
|
||||
// HH:mm pair, so a wrapped end formats back into 0..1439 cleanly.
|
||||
windowEnd: minutesToHHmm(slotStartMin + spanMinutes),
|
||||
});
|
||||
}
|
||||
function bucketLedgerRow(
|
||||
entry: DailyComplianceEntry,
|
||||
row: DoseHistoryRow,
|
||||
): void {
|
||||
switch (row.status) {
|
||||
case "taken_on_time":
|
||||
entry.expected++;
|
||||
entry.expectedCount++;
|
||||
entry.taken++;
|
||||
entry.onTime++;
|
||||
break;
|
||||
case "taken_late":
|
||||
entry.expected++;
|
||||
entry.expectedCount++;
|
||||
entry.taken++;
|
||||
entry.late++;
|
||||
break;
|
||||
case "missed":
|
||||
entry.expected++;
|
||||
entry.expectedCount++;
|
||||
break;
|
||||
case "skipped":
|
||||
entry.expected++;
|
||||
entry.expectedCount++;
|
||||
entry.skipped++;
|
||||
break;
|
||||
case "upcoming":
|
||||
// A future / still-takeable slot is due but not yet acted on. It counts
|
||||
// toward the day's expected/due grid but not toward taken or missed.
|
||||
entry.expected++;
|
||||
entry.expectedCount++;
|
||||
break;
|
||||
case "ad_hoc":
|
||||
// An off-schedule take: a real taken dose with no scheduled slot. Count
|
||||
// it as taken + its own expected slot so the heatmap missed math holds.
|
||||
entry.expected++;
|
||||
entry.expectedCount++;
|
||||
entry.taken++;
|
||||
entry.onTime++;
|
||||
break;
|
||||
}
|
||||
return slots;
|
||||
}
|
||||
|
||||
export const GET = apiHandler(
|
||||
@@ -165,134 +210,103 @@ export const GET = apiHandler(
|
||||
{ now },
|
||||
);
|
||||
|
||||
// Build daily compliance map for heatmap/line chart (90 days)
|
||||
// v1.15.18 — the heatmap / line-chart daily map is now bucketed from the
|
||||
// ONE unified dose-history ledger (the same bands the % + the history view
|
||||
// read), so the per-day timing split (on-time / late) and the per-day
|
||||
// missed marks can never disagree with the headline rate. The legacy
|
||||
// per-day `classifyIntakeTiming` 3h-grace heuristic + `expectedSlotCount
|
||||
// ForDay` walk are retired here in favour of band membership: a take inside
|
||||
// a slot's on-time band reads on-time, inside its late tail reads late,
|
||||
// outside every band reads ad-hoc; an unfilled slot past its miss cutoff
|
||||
// reads missed; a future slot is upcoming (not yet due).
|
||||
const dailyCompliance: Record<string, DailyComplianceEntry> = {};
|
||||
|
||||
for (let d = 0; d < 90; d++) {
|
||||
// v1.7.0 code-correctness M1 — anchor each day's [start,end) on the
|
||||
// user-tz local-day boundary, not a UTC-midnight slice. The engine
|
||||
// applies `timesOfDay` in the user timezone, so for tz-distant users
|
||||
// a UTC slice could attach `due` / `expectedCount` to the adjacent
|
||||
// calendar cell. Anchor on a noon-local representative instant
|
||||
// (dodges the DST midnight-ambiguity edge) and key off the user-tz
|
||||
// day so the heatmap cell, the engine frame, and `dateKey` agree.
|
||||
const representative = new Date(
|
||||
now.getTime() - d * 24 * 60 * 60 * 1000 - 12 * 60 * 60 * 1000,
|
||||
);
|
||||
const { start: dayStart, end: dayEndInclusive } = getUserTodayBounds(
|
||||
representative,
|
||||
userTz,
|
||||
);
|
||||
// `getUserTodayBounds` returns an inclusive end (local 23:59:59.999);
|
||||
// the slicing loops below use a half-open `[dayStart, dayEnd)`.
|
||||
const dayEnd = new Date(dayEndInclusive.getTime() + 1);
|
||||
// Build the bands across the whole 90-day heatmap window once. The window
|
||||
// floor clamps to the medication's creation so pre-existence days never
|
||||
// mint phantom slots.
|
||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||
const windowFrom = new Date(
|
||||
Math.max(now.getTime() - 90 * DAY_MS, createdAt.getTime()),
|
||||
);
|
||||
const bandMedication: BandMinterMedication = {
|
||||
id: medication.id,
|
||||
startsOn: medication.startsOn,
|
||||
endsOn: medication.endsOn,
|
||||
oneShot: medication.oneShot,
|
||||
createdAt: medication.createdAt,
|
||||
};
|
||||
const bandCtx: RecurrenceContext = {
|
||||
medication: {
|
||||
id: medication.id,
|
||||
startsOn: medication.startsOn,
|
||||
endsOn: medication.endsOn,
|
||||
oneShot: medication.oneShot,
|
||||
createdAt: medication.createdAt,
|
||||
},
|
||||
timeZone: userTz,
|
||||
lastIntakeAt,
|
||||
};
|
||||
const canonicalSchedules: CanonicalSchedule[] = medication.schedules.map(
|
||||
(s) => toCanonicalForBands(s, medication.oneShot),
|
||||
);
|
||||
const intakeInstants = mapped
|
||||
.filter((e) => !e.skipped && e.takenAt !== null && e.takenAt <= now)
|
||||
.map((e) => e.takenAt as Date)
|
||||
.sort((a, b) => a.getTime() - b.getTime());
|
||||
const groups = buildBandsForSchedules({
|
||||
medication: bandMedication,
|
||||
schedules: canonicalSchedules,
|
||||
ctx: bandCtx,
|
||||
userTz,
|
||||
range: { from: windowFrom, to: now },
|
||||
now,
|
||||
intakeInstants,
|
||||
});
|
||||
const bands: SlotBand[] = [];
|
||||
for (const g of groups) {
|
||||
if (g.hasExpectedSlots) bands.push(...g.bands);
|
||||
}
|
||||
const historyIntakes: HistoryIntake[] = mapped
|
||||
.filter((e) => e.scheduledFor >= windowFrom && e.scheduledFor <= now)
|
||||
.map((e) => ({
|
||||
scheduledFor: e.scheduledFor,
|
||||
takenAt: e.takenAt,
|
||||
skipped: e.skipped,
|
||||
autoMissed: e.autoMissed,
|
||||
}));
|
||||
const ledgerRows = reconstructDoseHistory(bands, historyIntakes, now);
|
||||
|
||||
// Skip days before medication was created
|
||||
if (dayEnd <= createdAt) continue;
|
||||
|
||||
const dateKey = userDayKey(dayStart, userTz);
|
||||
|
||||
const dayEvents = mapped.filter(
|
||||
(e) => e.scheduledFor >= dayStart && e.scheduledFor < dayEnd,
|
||||
);
|
||||
|
||||
const takenEvents = dayEvents.filter(
|
||||
(e) => e.takenAt !== null && !e.skipped,
|
||||
);
|
||||
|
||||
// Classify timing for each taken event against the best-matching schedule
|
||||
let onTime = 0;
|
||||
let late = 0;
|
||||
let veryLate = 0;
|
||||
let early = 0;
|
||||
|
||||
// v1.8.1 B15 follow-on — match each taken event to the closest
|
||||
// effective time-of-day SLOT, not merely `windowStart`. A single
|
||||
// schedule row carrying `timesOfDay = ["07:00","19:00"]` is two
|
||||
// distinct dose slots; matching the evening dose against the lone
|
||||
// `windowStart` "07:00" mis-classified it as `very_late` (~12h late)
|
||||
// and painted the heatmap cell orange even when both doses were
|
||||
// taken on time. Candidate slots for a schedule are its `timesOfDay`
|
||||
// when non-empty, else `[windowStart]` — so single-time schedules
|
||||
// collapse to exactly one candidate at `windowStart` and stay
|
||||
// byte-identical to the pre-fix behaviour. The per-slot window
|
||||
// mirrors the projector / cadence anchor: effective windowStart is
|
||||
// the slot time, effective windowEnd is the slot time plus the
|
||||
// schedule's `windowEnd − windowStart` span (overnight spans where
|
||||
// `windowEnd ≤ windowStart` add a day).
|
||||
const slotCandidates = buildSlotCandidates(medication.schedules);
|
||||
|
||||
for (const evt of takenEvents) {
|
||||
if (slotCandidates.length === 0) {
|
||||
// No schedule info: treat all taken as on_time
|
||||
onTime++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Match event to the closest slot window by scheduledFor time.
|
||||
const evtHour = evt.scheduledFor.getUTCHours();
|
||||
const evtMin = evt.scheduledFor.getUTCMinutes();
|
||||
const evtMinutes = evtHour * 60 + evtMin;
|
||||
|
||||
let bestSlot = slotCandidates[0];
|
||||
let bestDist = Infinity;
|
||||
|
||||
for (const slot of slotCandidates) {
|
||||
const dist = Math.abs(evtMinutes - slot.startMinutes);
|
||||
if (dist < bestDist) {
|
||||
bestDist = dist;
|
||||
bestSlot = slot;
|
||||
}
|
||||
}
|
||||
|
||||
const timing = classifyIntakeTiming(
|
||||
evt.takenAt,
|
||||
bestSlot.windowStart,
|
||||
bestSlot.windowEnd,
|
||||
dayStart, // the scheduled date
|
||||
);
|
||||
|
||||
// v1.4.34 IW-C — `early` is the new compliant bucket; it counts
|
||||
// alongside `onTime` for the heatmap so a proactive logger reads
|
||||
// green. The classifier still emits a distinct `"early"` value
|
||||
// for downstream consumers that want to differentiate; the
|
||||
// separate counter is surfaced on the daily entry below.
|
||||
if (timing === "on_time") onTime++;
|
||||
else if (timing === "early") early++;
|
||||
else if (timing === "late") late++;
|
||||
else veryLate++;
|
||||
// Bucket each ledger row into its user-tz day. A slot row buckets on its
|
||||
// anchor; an ad-hoc row on its real take time.
|
||||
const byDay = new Map<string, DailyComplianceEntry>();
|
||||
const ensureDay = (key: string): DailyComplianceEntry => {
|
||||
let entry = byDay.get(key);
|
||||
if (!entry) {
|
||||
entry = {
|
||||
expected: 0,
|
||||
expectedCount: 0,
|
||||
due: false,
|
||||
taken: 0,
|
||||
skipped: 0,
|
||||
onTime: 0,
|
||||
late: 0,
|
||||
veryLate: 0,
|
||||
early: 0,
|
||||
};
|
||||
byDay.set(key, entry);
|
||||
}
|
||||
return entry;
|
||||
};
|
||||
|
||||
// v1.7.0 item 5 — the per-day expected count is the engine's actual
|
||||
// due-slot count for THIS day, not the static schedule count. iOS
|
||||
// history paints a "missed" mark only when `due === true`, so
|
||||
// off-weeks / non-matching weekdays / PRN days no longer show a
|
||||
// false miss. `expected` is kept populated (= expectedCount) for
|
||||
// existing web consumers that read it; `due` + `expectedCount` are
|
||||
// the explicit additive fields iOS keys off.
|
||||
const expectedCount = expectedSlotCountForDay(
|
||||
medication.schedules,
|
||||
dayStart,
|
||||
dayEnd,
|
||||
medicationContext,
|
||||
// v1.13.x — pass the intake history so a ROLLING schedule's per-day
|
||||
// `due` flags come from the retrospective grid (each logged dose is a
|
||||
// due day) instead of the engine's single forward slot — keeping the
|
||||
// heatmap, the denominator, and the displayed rate in agreement.
|
||||
mapped,
|
||||
);
|
||||
for (const row of ledgerRows) {
|
||||
const key = userDayKey(row.at, userTz);
|
||||
const entry = ensureDay(key);
|
||||
bucketLedgerRow(entry, row);
|
||||
}
|
||||
|
||||
dailyCompliance[dateKey] = {
|
||||
expected: expectedCount,
|
||||
expectedCount,
|
||||
due: expectedCount > 0,
|
||||
taken: takenEvents.length,
|
||||
skipped: dayEvents.filter((e) => e.skipped).length,
|
||||
onTime: onTime + early,
|
||||
late,
|
||||
veryLate,
|
||||
early,
|
||||
};
|
||||
for (const [key, entry] of byDay) {
|
||||
entry.due = entry.expectedCount > 0;
|
||||
dailyCompliance[key] = entry;
|
||||
}
|
||||
|
||||
annotate({
|
||||
|
||||
Reference in new issue
Block a user