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:
Marc-André Bombeck committed 2026-06-12 09:02:58 +02:00
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;
+15 -65
View File
@@ -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;
@@ -399,6 +399,132 @@ describe("PUT /api/dashboard/widgets — accept-and-ignore unknown ids (v1.7.0 #
const callGet = GET as unknown as () => Promise<Response>;
describe("PUT /api/dashboard/widgets — heroVisible thread-through", () => {
const knownId = DASHBOARD_WIDGET_IDS[0];
it("persists an explicit heroVisible: false WITHOUT dropping stored chartOverlayPrefs", async () => {
// The regression class this route already guards for
// `chartOverlayPrefs`: a layout PUT that omits a sibling field must
// not wipe it. heroVisible sent + prefs omitted → prefs merge from
// the stored blob, heroVisible persists from the body.
const storedPrefs = {
bp: {
showTrendIndicator: true,
showTrendArrow: false,
showTargetRange: true,
comparisonBaseline: "lastMonth" as const,
},
};
vi.mocked(prisma.user.findUnique).mockResolvedValue({
dashboardWidgetsJson: serializeDashboardLayout({
version: 1,
widgets: [{ id: knownId, visible: true, order: 0 }],
chartOverlayPrefs: storedPrefs,
}),
} as never);
vi.mocked(prisma.user.update).mockResolvedValue({} as never);
const res = await callPut(
makeReq({
version: 1,
widgets: [{ id: knownId, visible: true, order: 0 }],
heroVisible: false,
}),
);
expect(res.status).toBe(200);
const updateArg = vi.mocked(prisma.user.update).mock
.calls[0]?.[0] as unknown as {
data: {
dashboardWidgetsJson: {
heroVisible: boolean;
chartOverlayPrefs: Record<string, unknown>;
};
};
};
expect(updateArg.data.dashboardWidgetsJson.heroVisible).toBe(false);
expect(updateArg.data.dashboardWidgetsJson.chartOverlayPrefs).toEqual(
storedPrefs,
);
// The response echoes the persisted value.
const body = (await res.json()) as { data: { heroVisible: boolean } };
expect(body.data.heroVisible).toBe(false);
});
it("preserves a stored heroVisible: false when the PUT omits the field", async () => {
vi.mocked(prisma.user.findUnique).mockResolvedValue({
dashboardWidgetsJson: serializeDashboardLayout({
version: 1,
widgets: [{ id: knownId, visible: true, order: 0 }],
heroVisible: false,
}),
} as never);
vi.mocked(prisma.user.update).mockResolvedValue({} as never);
const res = await callPut(
makeReq({
version: 1,
widgets: [{ id: knownId, visible: true, order: 0 }],
}),
);
expect(res.status).toBe(200);
const updateArg = vi.mocked(prisma.user.update).mock
.calls[0]?.[0] as unknown as {
data: { dashboardWidgetsJson: { heroVisible: boolean } };
};
expect(updateArg.data.dashboardWidgetsJson.heroVisible).toBe(false);
});
it("defaults heroVisible to true for a first save with no stored layout", async () => {
vi.mocked(prisma.user.findUnique).mockResolvedValue({
dashboardWidgetsJson: null,
} as never);
vi.mocked(prisma.user.update).mockResolvedValue({} as never);
const res = await callPut(
makeReq({
version: 1,
widgets: [{ id: knownId, visible: true, order: 0 }],
}),
);
expect(res.status).toBe(200);
const updateArg = vi.mocked(prisma.user.update).mock
.calls[0]?.[0] as unknown as {
data: { dashboardWidgetsJson: { heroVisible: boolean } };
};
expect(updateArg.data.dashboardWidgetsJson.heroVisible).toBe(true);
});
it("422s on a non-boolean heroVisible", async () => {
const res = await callPut(
makeReq({
version: 1,
widgets: [{ id: knownId, visible: true, order: 0 }],
heroVisible: "yes",
}),
);
expect(res.status).toBe(422);
});
it("GET resolves heroVisible alongside the widget layout", async () => {
vi.mocked(prisma.user.findUnique).mockResolvedValue({
dashboardWidgetsJson: serializeDashboardLayout({
version: 1,
widgets: [{ id: knownId, visible: true, order: 0 }],
heroVisible: false,
}),
} as never);
const res = await callGet();
expect(res.status).toBe(200);
const body = (await res.json()) as { data: { heroVisible: boolean } };
expect(body.data.heroVisible).toBe(false);
});
});
describe("dashboard widgets — 27-id catalogue round-trip (v1.7.0 W1)", () => {
it("PUT of a full 27-id layout persists every id (iOS-only round-trip)", async () => {
vi.mocked(prisma.user.findUnique).mockResolvedValue({
+22 -5
View File
@@ -115,6 +115,12 @@ const layoutSchema = z.object({
}),
)
.optional(),
// Dashboard hero (daily verdict) visibility. Optional so clients
// that predate the field can still PUT; when omitted the handler
// preserves the stored value (same preserve-when-absent contract as
// `chartOverlayPrefs` — a Settings save that doesn't know the field
// must not silently reset the user's choice).
heroVisible: z.boolean().optional(),
});
async function buildDashboardLayout(userId: string): Promise<DashboardLayout> {
@@ -258,21 +264,32 @@ export const PUT = apiHandler(async (request: NextRequest) => {
// didn't send. The dashboard-layout PUT typically saves widget
// visibility / order; chart prefs are PUT through their own route
// (`/api/dashboard/chart-overlay-prefs`) and would otherwise be
// wiped here on a subsequent layout save.
// wiped here on a subsequent layout save. `heroVisible` rides the
// same preserve-when-absent contract — an older client's layout
// save must not reset the hero toggle. One stored-layout read
// covers both fallbacks.
let mergedChartOverlayPrefs: ChartOverlayPrefsMap | undefined = parsed.data
.chartOverlayPrefs as ChartOverlayPrefsMap | undefined;
if (mergedChartOverlayPrefs === undefined) {
let mergedHeroVisible: boolean | undefined = parsed.data.heroVisible;
if (mergedChartOverlayPrefs === undefined || mergedHeroVisible === undefined) {
const existing = await prisma.user.findUnique({
where: { id: user.id },
select: { dashboardWidgetsJson: true },
});
mergedChartOverlayPrefs =
resolveDashboardLayout(existing?.dashboardWidgetsJson)
.chartOverlayPrefs ?? {};
const existingLayout = resolveDashboardLayout(
existing?.dashboardWidgetsJson,
);
if (mergedChartOverlayPrefs === undefined) {
mergedChartOverlayPrefs = existingLayout.chartOverlayPrefs ?? {};
}
if (mergedHeroVisible === undefined) {
mergedHeroVisible = existingLayout.heroVisible !== false;
}
}
const normalized = serializeDashboardLayout({
...parsed.data,
chartOverlayPrefs: mergedChartOverlayPrefs,
heroVisible: mergedHeroVisible,
} as DashboardLayout);
await prisma.user.update({