mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-14 22:50:51 +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
@@ -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({
|
||||
|
||||
@@ -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({
|
||||
|
||||
Reference in new issue
Block a user