mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-15 06:52:04 +00:00
feat(dashboard): snapshot carries the day's dose state, score and verdict inputs
The snapshot's fast phase gains a medsToday block — active count, today's scheduled/taken/skipped tally after the idempotent projection, and the earliest server-computed next due across active medications — and the warm phase derives a compact health score from the coverage map and graded blood-pressure windows the extras build already holds. A pure verdict resolver ranks the day's single highlighted item over nine rungs from an overdue dose down to a quiet-day fallback, with fixed crisis floors for the blood-pressure rung and timezone-correct day checks. The layout grows a heroVisible flag riding the stored widgets JSON, the iOS summary route adopts the shared dose-state builder with an unchanged wire shape, and the new sub-blocks report their build time alongside the existing snapshot timings.
This commit is contained in:
1 parent
2718930e8d
commit
ad33fa46ed
15 files changed
+2103
-113
No files matched your search
@@ -14,12 +14,19 @@ vi.mock("@/lib/db", () => ({
|
||||
// Dashboard compliance tile leaves "Heute nichts geplant" the
|
||||
// moment a daily med is configured.
|
||||
createMany: vi.fn(),
|
||||
// Shared meds-today builder — latest non-skipped intake per
|
||||
// medication (next-due re-anchor for rolling cadences).
|
||||
groupBy: vi.fn(),
|
||||
},
|
||||
medication: {
|
||||
// v1.4.39 W-SERVER-FIX-2 — projection source for the today's-
|
||||
// intakes backfill.
|
||||
findMany: vi.fn(),
|
||||
},
|
||||
// Shared meds-today builder — current-era floor per medication.
|
||||
medicationScheduleRevision: {
|
||||
groupBy: vi.fn(),
|
||||
},
|
||||
// v1.4.38 W-F — the route now uses `$queryRaw` for the per-type
|
||||
// latest reading, the 7-day rollup-bucket sparkline, and the
|
||||
// 365-day distinct-day streak set. Three raw calls per request,
|
||||
@@ -105,6 +112,14 @@ beforeEach(() => {
|
||||
vi.mocked(prisma.medicationIntakeEvent.createMany).mockResolvedValue({
|
||||
count: 0,
|
||||
} as never);
|
||||
// Shared meds-today builder feeder reads — default to empty so the
|
||||
// legacy tests keep their "no medications" branch.
|
||||
vi.mocked(prisma.medicationIntakeEvent.groupBy).mockResolvedValue(
|
||||
[] as never,
|
||||
);
|
||||
vi.mocked(prisma.medicationScheduleRevision.groupBy).mockResolvedValue(
|
||||
[] as never,
|
||||
);
|
||||
});
|
||||
|
||||
// v1.4.48 L13 — restore real timers no matter how a test exits. A failed
|
||||
@@ -620,25 +635,25 @@ describe("GET /api/dashboard/summary", () => {
|
||||
],
|
||||
},
|
||||
] as never);
|
||||
// The dashboard route now issues three findMany calls in the
|
||||
// today path:
|
||||
// 1. existence probe (no `OR`) → empty (no rows yet)
|
||||
// 2. final read after backfill (no `OR`) → one minted row
|
||||
// 3. streak fetch (with `OR`) → empty
|
||||
// The shared meds-today builder issues four findMany shapes:
|
||||
// 1. projector existence probe (no `OR`, selects medicationId +
|
||||
// scheduledFor) → empty (no rows yet)
|
||||
// 2. today tally read after backfill (no `OR`, selects takenAt +
|
||||
// skipped) → one minted row
|
||||
// 3. resolved-slot read + streak fetch (with `OR`) → empty
|
||||
vi.mocked(prisma.medicationIntakeEvent.findMany).mockImplementation(((
|
||||
args: unknown,
|
||||
) => {
|
||||
const a = args as {
|
||||
where: { OR?: unknown };
|
||||
select?: { id?: boolean };
|
||||
select?: { medicationId?: boolean };
|
||||
};
|
||||
if (a.where.OR) return Promise.resolve([]) as never;
|
||||
// No `id` field on the existence-probe `select` → the route
|
||||
// only asked for `(medicationId, scheduledFor)`.
|
||||
if (!a.select?.id) return Promise.resolve([]) as never;
|
||||
// The existence probe asks for `(medicationId, scheduledFor)`;
|
||||
// the tally read asks for `(takenAt, skipped)`.
|
||||
if (a.select?.medicationId) return Promise.resolve([]) as never;
|
||||
return Promise.resolve([
|
||||
{
|
||||
id: "e-new",
|
||||
takenAt: null,
|
||||
skipped: false,
|
||||
},
|
||||
@@ -712,14 +727,9 @@ describe("GET /api/dashboard/summary", () => {
|
||||
],
|
||||
},
|
||||
] as never);
|
||||
vi.mocked(prisma.medicationIntakeEvent.findMany).mockImplementation(((
|
||||
args: unknown,
|
||||
) => {
|
||||
const a = args as { where: { OR?: unknown }; select?: { id?: boolean } };
|
||||
if (a.where.OR) return Promise.resolve([]) as never;
|
||||
if (!a.select?.id) return Promise.resolve([]) as never;
|
||||
return Promise.resolve([]) as never;
|
||||
}) as never);
|
||||
vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue(
|
||||
[] as never,
|
||||
);
|
||||
vi.mocked(prisma.medicationIntakeEvent.createMany).mockResolvedValue({
|
||||
count: 2,
|
||||
} as never);
|
||||
@@ -783,18 +793,17 @@ describe("GET /api/dashboard/summary", () => {
|
||||
) => {
|
||||
const a = args as {
|
||||
where: { OR?: unknown };
|
||||
select?: { id?: boolean };
|
||||
select?: { medicationId?: boolean };
|
||||
};
|
||||
if (a.where.OR) return Promise.resolve([]) as never;
|
||||
if (!a.select?.id) {
|
||||
if (a.select?.medicationId) {
|
||||
// Existence probe — the projected slot already exists.
|
||||
return Promise.resolve([
|
||||
{ medicationId: "med-ramipril", scheduledFor },
|
||||
]) as never;
|
||||
}
|
||||
return Promise.resolve([
|
||||
{ id: "e-existing", takenAt: null, skipped: false },
|
||||
]) as never;
|
||||
// Today tally read — the pre-existing pending row.
|
||||
return Promise.resolve([{ takenAt: null, skipped: false }]) as never;
|
||||
}) as never);
|
||||
|
||||
const res = await callGet(makeReq());
|
||||
@@ -809,14 +818,16 @@ describe("GET /api/dashboard/summary", () => {
|
||||
|
||||
it("computes intake compliance for today", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
// No active medications (default) → the projector early-outs, so
|
||||
// the only no-`OR` read is the meds-today tally over the window.
|
||||
vi.mocked(prisma.medicationIntakeEvent.findMany).mockImplementation(((
|
||||
args: unknown,
|
||||
) => {
|
||||
const a = args as { where: { OR?: unknown } };
|
||||
if (!a.where.OR) {
|
||||
return Promise.resolve([
|
||||
{ id: "e1", takenAt: new Date(), skipped: false },
|
||||
{ id: "e2", takenAt: null, skipped: false },
|
||||
{ takenAt: new Date(), skipped: false },
|
||||
{ takenAt: null, skipped: false },
|
||||
]) as never;
|
||||
}
|
||||
return Promise.resolve([]) as never;
|
||||
|
||||
@@ -56,7 +56,7 @@ import { getServerTranslator } from "@/lib/i18n/server-translator";
|
||||
import { defaultLocale, locales, type Locale } from "@/lib/i18n/config";
|
||||
import { userDayKey, DEFAULT_TIMEZONE } from "@/lib/tz/resolver";
|
||||
import { cached, caches, type ServerCache } from "@/lib/cache/server-cache";
|
||||
import { projectTodayIntakesAndRecompute } from "@/lib/medications/scheduling/project-today-intakes";
|
||||
import { buildMedsTodayBlock } from "@/lib/dashboard/meds-today";
|
||||
import {
|
||||
summarizeSleepNights,
|
||||
reconstructSleepNights,
|
||||
@@ -211,20 +211,6 @@ function trendOf(values: number[]): MetricCard["trend"] {
|
||||
return delta > 0 ? "up" : "down";
|
||||
}
|
||||
|
||||
function startOfDayInTz(date: Date, tz: string): Date {
|
||||
// Compute midnight in the user's tz → UTC ms.
|
||||
const parts = new Intl.DateTimeFormat("en-US", {
|
||||
timeZone: tz,
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
}).formatToParts(date);
|
||||
const y = parts.find((p) => p.type === "year")?.value ?? "1970";
|
||||
const m = parts.find((p) => p.type === "month")?.value ?? "01";
|
||||
const d = parts.find((p) => p.type === "day")?.value ?? "01";
|
||||
return new Date(`${y}-${m}-${d}T00:00:00.000Z`);
|
||||
}
|
||||
|
||||
interface StreakInfo {
|
||||
currentDays: number;
|
||||
longest: number;
|
||||
@@ -373,41 +359,6 @@ function buildContext(
|
||||
return { greetingName, locale };
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.4.39 W-SERVER-FIX-2 — project today's active schedules, idempotently
|
||||
* backfill any missing `MedicationIntakeEvent` rows via the shared
|
||||
* helper, then re-read the today-window so the caller sees both
|
||||
* pre-existing rows + the freshly minted ones.
|
||||
*
|
||||
* Mirrors the behaviour of `/api/medications/intake?scope=today` (same
|
||||
* helper) so the iOS Dashboard tile (fed by this route) and the iOS
|
||||
* Erfassen sheet (fed by the intake route) converge on the same row
|
||||
* set the moment a daily med becomes active.
|
||||
*/
|
||||
async function projectAndReadTodaysIntakes(
|
||||
userId: string,
|
||||
userTz: string,
|
||||
todayStart: Date,
|
||||
todayEnd: Date,
|
||||
): Promise<Array<{ id: string; takenAt: Date | null; skipped: boolean }>> {
|
||||
await projectTodayIntakesAndRecompute({
|
||||
userId,
|
||||
userTz,
|
||||
todayStart,
|
||||
todayEnd,
|
||||
});
|
||||
|
||||
return prisma.medicationIntakeEvent.findMany({
|
||||
where: {
|
||||
userId,
|
||||
// v1.7.0 sync — exclude tombstoned rows from the today tile.
|
||||
deletedAt: null,
|
||||
scheduledFor: { gte: todayStart, lt: todayEnd },
|
||||
},
|
||||
select: { id: true, takenAt: true, skipped: true },
|
||||
});
|
||||
}
|
||||
|
||||
async function buildDashboardSummary(
|
||||
userId: string,
|
||||
userTz: string,
|
||||
@@ -421,8 +372,6 @@ async function buildDashboardSummary(
|
||||
const streakWindowStart = new Date(
|
||||
now.getTime() - STREAK_WINDOW_DAYS * 86_400_000,
|
||||
);
|
||||
const todayStart = startOfDayInTz(now, userTz);
|
||||
const todayEnd = new Date(todayStart.getTime() + 86_400_000);
|
||||
|
||||
// Derived from canonical enum so a new measurement type is auto-included
|
||||
// (V3 audit: enum drift cousins). Per-kind display blocks below decide
|
||||
@@ -453,14 +402,15 @@ async function buildDashboardSummary(
|
||||
// REG-11: taken via ROW_NUMBER window, no calendar
|
||||
// filter, so a 60-day-old metric still paints a chart)
|
||||
// - allTimeAggregate: ≤ N metric types (unchanged)
|
||||
// - todaysIntakes: ≤ daily intake schedule count (unchanged)
|
||||
// - medsToday: today-window tally via the shared projector
|
||||
// (≤ daily intake schedule count, unchanged semantics)
|
||||
// - streakActivity: ≤ daily intake count × 365 (unchanged)
|
||||
// - measurementStreakDays: ≤ 365 (was: every raw row in 365d)
|
||||
const [
|
||||
latestEver,
|
||||
sparkBuckets,
|
||||
allTimeAggregate,
|
||||
todaysIntakes,
|
||||
medsToday,
|
||||
streakActivity,
|
||||
measurementStreakDays,
|
||||
sleepStageRows,
|
||||
@@ -513,13 +463,12 @@ async function buildDashboardSummary(
|
||||
// reminder worker entered the RED phase at the end of the dose
|
||||
// window, so the iOS Dashboard tile fell to "Heute nichts geplant"
|
||||
// even when the intake route (post-`expandTodayIntakes` fix) was
|
||||
// already projecting + backfilling correctly. Re-uses the same
|
||||
// helper + idempotent `createMany` (with `skipDuplicates: true`
|
||||
// so a concurrent intake-route hit can't race a duplicate row in
|
||||
// before the existence probe converges).
|
||||
time("todaysIntakes", () =>
|
||||
projectAndReadTodaysIntakes(userId, userTz, todayStart, todayEnd),
|
||||
),
|
||||
// already projecting + backfilling correctly. The shared
|
||||
// `buildMedsTodayBlock` runs the same idempotent projection
|
||||
// (`skipDuplicates: true` so a concurrent intake-route hit can't
|
||||
// race a duplicate row in) before its today-window tally, so this
|
||||
// route and the dashboard snapshot count the identical row set.
|
||||
time("medsToday", () => buildMedsTodayBlock(prisma, userId, userTz, now)),
|
||||
time("streakIntakes", () =>
|
||||
prisma.medicationIntakeEvent.findMany({
|
||||
where: {
|
||||
@@ -887,10 +836,11 @@ async function buildDashboardSummary(
|
||||
});
|
||||
}
|
||||
|
||||
const scheduledToday = todaysIntakes.length;
|
||||
const takenToday = todaysIntakes.filter(
|
||||
(e) => e.takenAt !== null && !e.skipped,
|
||||
).length;
|
||||
// Shared builder carries the exact tally semantics this tile always
|
||||
// had (scheduled = every today-window row; taken = `takenAt` set and
|
||||
// not skipped). The wire shape of `compliance` is locked for iOS.
|
||||
const scheduledToday = medsToday.scheduledToday;
|
||||
const takenToday = medsToday.takenToday;
|
||||
|
||||
const t = getServerTranslator(ctx.locale).t;
|
||||
|
||||
|
||||
Reference in new issue
Block a user