mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-14 06:40:50 +00:00
chore(release): v1.6.0
Medication editor overhaul, route of administration, today-tile read-flip. - Route-of-administration column (ORAL | INJECTION | OTHER) decoupled from treatmentClass; injection-site picker surfaces for any injection. Migration 0088 backfills ORAL via a constant column default. - One-time injection (oneShot + deliveryForm = INJECTION) editor path. - Profile-photo upload card on Settings → Account. - Today-tile read-flip: /api/medications/intake?scope=today and /api/dashboard/summary gate through the canonical recurrence engine, matching the reminder worker for bi-weekly, rolling, RRULE, and one-time cadences. - Measurement note cap 25 → 200; dashboard range colors on semantic tokens; admin sidebar icons + shared-helper consolidation; API-tokens multi-language snippets restored. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
d01d1d1dc9
commit
8ee7aaac44
53 files changed
+1049
-1017
No files matched your search
@@ -39,8 +39,8 @@ export const GET = apiHandler(async () => {
|
||||
|
||||
const payload: BackupsList = {
|
||||
rows,
|
||||
// Mirrors the value surfaced by `/api/admin/status-overview` so the
|
||||
// backups page and the status card grid agree.
|
||||
// Matches the retention window the backup-prune job enforces so the
|
||||
// backups page states the same number the worker acts on.
|
||||
retentionDays: 90,
|
||||
};
|
||||
|
||||
|
||||
@@ -135,9 +135,9 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
meta: {
|
||||
since: parsed.since,
|
||||
count: samples.length,
|
||||
// Memory total in bytes is useful so the chart can render an
|
||||
// absolute label ("4.2 GiB / 8 GiB") in the tooltip without a
|
||||
// second round-trip. Latest row wins; falls back to 0 on empty.
|
||||
// Memory total in bytes feeds the tooltip's absolute label
|
||||
// ("X.X / Y.Y GiB") so the percentage isn't read in a vacuum.
|
||||
// Latest row wins; falls back to 0 on empty.
|
||||
memTotalBytes: rows.length
|
||||
? Number(rows[rows.length - 1].memTotalBytes)
|
||||
: 0,
|
||||
|
||||
@@ -1,191 +0,0 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
user: { count: vi.fn() },
|
||||
withingsConnection: { count: vi.fn() },
|
||||
notificationChannel: { count: vi.fn() },
|
||||
pushSubscription: { count: vi.fn() },
|
||||
appSettings: { findUnique: vi.fn() },
|
||||
auditLog: { findFirst: vi.fn(), count: vi.fn() },
|
||||
dataBackup: { findFirst: vi.fn(), findMany: vi.fn() },
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/api-handler", async () => {
|
||||
const actual =
|
||||
await vi.importActual<typeof import("@/lib/api-handler")>(
|
||||
"@/lib/api-handler",
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
apiHandler: <T extends (...args: unknown[]) => Promise<Response>>(
|
||||
h: T,
|
||||
): T => h,
|
||||
requireAdmin: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/lib/logging/context", () => ({
|
||||
annotate: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/jobs/worker-status", () => ({
|
||||
getWorkerStatus: vi.fn(() => ({
|
||||
running: true,
|
||||
startedAt: new Date(Date.now() - 3_600_000).toISOString(),
|
||||
lastHeartbeat: null,
|
||||
lastReminderCheck: null,
|
||||
lastWithingsSync: null,
|
||||
lastInsightsRun: null,
|
||||
jobsProcessed: 0,
|
||||
errors: 0,
|
||||
})),
|
||||
}));
|
||||
|
||||
import { GET } from "../route";
|
||||
import { prisma } from "@/lib/db";
|
||||
|
||||
const mocks = prisma as unknown as {
|
||||
user: { count: ReturnType<typeof vi.fn> };
|
||||
withingsConnection: { count: ReturnType<typeof vi.fn> };
|
||||
notificationChannel: { count: ReturnType<typeof vi.fn> };
|
||||
pushSubscription: { count: ReturnType<typeof vi.fn> };
|
||||
appSettings: { findUnique: ReturnType<typeof vi.fn> };
|
||||
auditLog: {
|
||||
findFirst: ReturnType<typeof vi.fn>;
|
||||
count: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
dataBackup: {
|
||||
findFirst: ReturnType<typeof vi.fn>;
|
||||
findMany: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
};
|
||||
|
||||
describe("GET /api/admin/status-overview", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
// default returns
|
||||
mocks.user.count.mockResolvedValue(0);
|
||||
mocks.withingsConnection.count.mockResolvedValue(0);
|
||||
mocks.notificationChannel.count.mockResolvedValue(0);
|
||||
mocks.pushSubscription.count.mockResolvedValue(0);
|
||||
mocks.appSettings.findUnique.mockResolvedValue(null);
|
||||
mocks.auditLog.findFirst.mockResolvedValue(null);
|
||||
mocks.auditLog.count.mockResolvedValue(0);
|
||||
mocks.dataBackup.findFirst.mockResolvedValue(null);
|
||||
mocks.dataBackup.findMany.mockResolvedValue([]);
|
||||
});
|
||||
|
||||
it("returns the 6-section overview shape", async () => {
|
||||
const res = await GET();
|
||||
const body = (await res.json()) as { data: Record<string, unknown> };
|
||||
expect(body.data).toHaveProperty("users");
|
||||
expect(body.data).toHaveProperty("integrations");
|
||||
expect(body.data).toHaveProperty("monitoring");
|
||||
expect(body.data).toHaveProperty("backups");
|
||||
expect(body.data).toHaveProperty("maintenance");
|
||||
expect(body.data).toHaveProperty("auditLog");
|
||||
});
|
||||
|
||||
it("batches DB calls in a single Promise.allSettled (no N+1)", async () => {
|
||||
await GET();
|
||||
// 3 user.count + 1 withings + 1 ntfy notifications + 1 webPush + 3
|
||||
// moodLog/telegram done via user.count → expect user.count called 4 times
|
||||
// (total, admins, newThisWeek, moodLog, telegram)
|
||||
expect(mocks.user.count).toHaveBeenCalledTimes(5);
|
||||
expect(mocks.withingsConnection.count).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.notificationChannel.count).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.pushSubscription.count).toHaveBeenCalledTimes(1);
|
||||
// The route does not loop — every aggregate is a single query.
|
||||
expect(mocks.dataBackup.findMany).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("P20: a single failed probe still returns the rest of the grid", async () => {
|
||||
// Simulate a transient DB failure on the withings count probe only.
|
||||
// Without Promise.allSettled the whole route would 500 and the entire
|
||||
// admin overview grid would go blank — with allSettled the failure
|
||||
// is isolated to its card (severity → "alert", value → 0) and the
|
||||
// other 5 cards still render.
|
||||
mocks.withingsConnection.count.mockRejectedValueOnce(
|
||||
new Error("simulated db timeout"),
|
||||
);
|
||||
const res = await GET();
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as {
|
||||
data: {
|
||||
users: { severity: string };
|
||||
integrations: { severity: string; withings: number };
|
||||
backups: { severity: string };
|
||||
};
|
||||
};
|
||||
expect(body.data.integrations.severity).toBe("alert");
|
||||
expect(body.data.integrations.withings).toBe(0);
|
||||
// Untouched cards keep their normal severity.
|
||||
expect(body.data.users.severity).toBe("good");
|
||||
});
|
||||
|
||||
it("flags backups as alert when none exist", async () => {
|
||||
mocks.dataBackup.findFirst.mockResolvedValue(null);
|
||||
const res = await GET();
|
||||
const body = (await res.json()) as {
|
||||
data: { backups: { severity: string } };
|
||||
};
|
||||
expect(body.data.backups.severity).toBe("alert");
|
||||
});
|
||||
|
||||
it("flags backups as good when fresh (<8d)", async () => {
|
||||
mocks.dataBackup.findFirst.mockResolvedValue({
|
||||
createdAt: new Date(Date.now() - 24 * 60 * 60 * 1000),
|
||||
});
|
||||
const res = await GET();
|
||||
const body = (await res.json()) as {
|
||||
data: { backups: { severity: string } };
|
||||
};
|
||||
expect(body.data.backups.severity).toBe("good");
|
||||
});
|
||||
|
||||
it("flags monitoring as alert on a recent error (<24h)", async () => {
|
||||
mocks.auditLog.findFirst.mockImplementation(
|
||||
({ where }: { where: { action: unknown } }) => {
|
||||
const action = where.action as { startsWith?: string } | string;
|
||||
if (
|
||||
typeof action === "object" &&
|
||||
action.startsWith === "system.error"
|
||||
) {
|
||||
return Promise.resolve({ createdAt: new Date(Date.now() - 60_000) });
|
||||
}
|
||||
return Promise.resolve(null);
|
||||
},
|
||||
);
|
||||
mocks.appSettings.findUnique.mockResolvedValue({
|
||||
glitchtipEnabled: true,
|
||||
umamiEnabled: true,
|
||||
});
|
||||
const res = await GET();
|
||||
const body = (await res.json()) as {
|
||||
data: { monitoring: { severity: string } };
|
||||
};
|
||||
expect(body.data.monitoring.severity).toBe("alert");
|
||||
});
|
||||
|
||||
it("flags maintenance as alert when worker is stopped", async () => {
|
||||
const wsModule = await import("@/lib/jobs/worker-status");
|
||||
vi.mocked(wsModule.getWorkerStatus).mockReturnValue({
|
||||
running: false,
|
||||
startedAt: null,
|
||||
lastHeartbeat: null,
|
||||
lastReminderCheck: null,
|
||||
lastWithingsSync: null,
|
||||
lastInsightsRun: null,
|
||||
jobsProcessed: 0,
|
||||
errors: 0,
|
||||
});
|
||||
const res = await GET();
|
||||
const body = (await res.json()) as {
|
||||
data: { maintenance: { severity: string; workerRunning: boolean } };
|
||||
};
|
||||
expect(body.data.maintenance.severity).toBe("alert");
|
||||
expect(body.data.maintenance.workerRunning).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,318 +0,0 @@
|
||||
import { prisma } from "@/lib/db";
|
||||
import { apiHandler, requireAdmin } from "@/lib/api-handler";
|
||||
import { apiSuccess } from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { getWorkerStatus } from "@/lib/jobs/worker-status";
|
||||
|
||||
/**
|
||||
* Severity tokens map onto §2.6 status taxonomy of `docs/ui-guidelines.md`.
|
||||
* Returned as plain strings so the client renders dots + labels — not RAW
|
||||
* colours — and never leans on color alone for state.
|
||||
*/
|
||||
export type StatusSeverity = "good" | "info" | "caution" | "alert" | "pending";
|
||||
|
||||
export interface StatusOverview {
|
||||
users: {
|
||||
severity: StatusSeverity;
|
||||
total: number;
|
||||
admins: number;
|
||||
newThisWeek: number;
|
||||
};
|
||||
integrations: {
|
||||
severity: StatusSeverity;
|
||||
withings: number;
|
||||
moodLog: number;
|
||||
telegram: number;
|
||||
ntfy: number;
|
||||
webPush: number;
|
||||
};
|
||||
monitoring: {
|
||||
severity: StatusSeverity;
|
||||
glitchtipEnabled: boolean;
|
||||
umamiEnabled: boolean;
|
||||
wideEventsEnabled: boolean;
|
||||
lastErrorAt: string | null;
|
||||
};
|
||||
backups: {
|
||||
severity: StatusSeverity;
|
||||
lastBackupAt: string | null;
|
||||
backedUpUsers: number;
|
||||
retentionDays: number;
|
||||
};
|
||||
maintenance: {
|
||||
severity: StatusSeverity;
|
||||
workerRunning: boolean;
|
||||
workerUptimeSeconds: number | null;
|
||||
lastIdempotencyCleanup: string | null;
|
||||
lastAuditLogCleanup: string | null;
|
||||
};
|
||||
auditLog: {
|
||||
severity: StatusSeverity;
|
||||
eventsLast30d: number;
|
||||
lastLoginAt: string | null;
|
||||
};
|
||||
}
|
||||
|
||||
const ONE_DAY_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
function severityForBackup(lastBackupAt: Date | null): StatusSeverity {
|
||||
if (!lastBackupAt) return "alert";
|
||||
const ageMs = Date.now() - lastBackupAt.getTime();
|
||||
if (ageMs > 14 * ONE_DAY_MS) return "alert";
|
||||
if (ageMs > 8 * ONE_DAY_MS) return "caution";
|
||||
return "good";
|
||||
}
|
||||
|
||||
function severityForMaintenance(workerRunning: boolean): StatusSeverity {
|
||||
return workerRunning ? "good" : "alert";
|
||||
}
|
||||
|
||||
function severityForMonitoring(
|
||||
glitchtipEnabled: boolean,
|
||||
umamiEnabled: boolean,
|
||||
recentErrorAt: Date | null,
|
||||
): StatusSeverity {
|
||||
if (recentErrorAt && Date.now() - recentErrorAt.getTime() < ONE_DAY_MS) {
|
||||
return "alert";
|
||||
}
|
||||
if (glitchtipEnabled && umamiEnabled) return "good";
|
||||
if (glitchtipEnabled || umamiEnabled) return "caution";
|
||||
return "info";
|
||||
}
|
||||
|
||||
function severityForIntegrations(totalConfigured: number): StatusSeverity {
|
||||
if (totalConfigured === 0) return "info";
|
||||
return "good";
|
||||
}
|
||||
|
||||
export const GET = apiHandler(async () => {
|
||||
await requireAdmin();
|
||||
annotate({ action: { name: "admin.status-overview" } });
|
||||
|
||||
const now = new Date();
|
||||
const sevenDaysAgo = new Date(now.getTime() - 7 * ONE_DAY_MS);
|
||||
const thirtyDaysAgo = new Date(now.getTime() - 30 * ONE_DAY_MS);
|
||||
|
||||
// P20: switch from Promise.all to Promise.allSettled so a single
|
||||
// failed probe (e.g. a missing index, a transient timeout, an
|
||||
// unavailable model) doesn't blank the whole admin overview grid.
|
||||
// Each probe is labelled so we can map a rejection back to the
|
||||
// overview field it was meant to populate, then surface the failed
|
||||
// labels in a Wide Event annotation for ops triage.
|
||||
const probes = [
|
||||
["userTotal", () => prisma.user.count()],
|
||||
["userAdmins", () => prisma.user.count({ where: { role: "ADMIN" } })],
|
||||
[
|
||||
"userNewThisWeek",
|
||||
() => prisma.user.count({ where: { createdAt: { gte: sevenDaysAgo } } }),
|
||||
],
|
||||
["withingsCount", () => prisma.withingsConnection.count()],
|
||||
[
|
||||
"moodLogCount",
|
||||
() => prisma.user.count({ where: { moodLogEnabled: true } }),
|
||||
],
|
||||
[
|
||||
"telegramCount",
|
||||
() => prisma.user.count({ where: { telegramEnabled: true } }),
|
||||
],
|
||||
[
|
||||
"ntfyCount",
|
||||
() =>
|
||||
prisma.notificationChannel.count({
|
||||
where: { type: "NTFY", enabled: true },
|
||||
}),
|
||||
],
|
||||
["webPushCount", () => prisma.pushSubscription.count()],
|
||||
[
|
||||
"appSettings",
|
||||
() => prisma.appSettings.findUnique({ where: { id: "singleton" } }),
|
||||
],
|
||||
[
|
||||
"lastErrorEntry",
|
||||
() =>
|
||||
prisma.auditLog.findFirst({
|
||||
where: { action: { startsWith: "system.error" } },
|
||||
orderBy: { createdAt: "desc" },
|
||||
select: { createdAt: true },
|
||||
}),
|
||||
],
|
||||
[
|
||||
"latestBackup",
|
||||
() =>
|
||||
prisma.dataBackup.findFirst({
|
||||
orderBy: { createdAt: "desc" },
|
||||
select: { createdAt: true },
|
||||
}),
|
||||
],
|
||||
[
|
||||
"backedUpUsers",
|
||||
() =>
|
||||
prisma.dataBackup
|
||||
.findMany({ select: { userId: true }, distinct: ["userId"] })
|
||||
.then((rows) => rows.length),
|
||||
],
|
||||
[
|
||||
"last30dEvents",
|
||||
() =>
|
||||
prisma.auditLog.count({ where: { createdAt: { gte: thirtyDaysAgo } } }),
|
||||
],
|
||||
[
|
||||
"lastLoginEntry",
|
||||
() =>
|
||||
prisma.auditLog.findFirst({
|
||||
where: { action: "auth.login" },
|
||||
orderBy: { createdAt: "desc" },
|
||||
select: { createdAt: true },
|
||||
}),
|
||||
],
|
||||
[
|
||||
"lastIdempotencyCleanup",
|
||||
() =>
|
||||
prisma.auditLog.findFirst({
|
||||
where: { action: "system.cleanup.idempotency" },
|
||||
orderBy: { createdAt: "desc" },
|
||||
select: { createdAt: true },
|
||||
}),
|
||||
],
|
||||
[
|
||||
"lastAuditLogCleanup",
|
||||
() =>
|
||||
prisma.auditLog.findFirst({
|
||||
where: { action: "system.cleanup.audit-log" },
|
||||
orderBy: { createdAt: "desc" },
|
||||
select: { createdAt: true },
|
||||
}),
|
||||
],
|
||||
] as const;
|
||||
|
||||
const settled = await Promise.allSettled(probes.map(([, run]) => run()));
|
||||
|
||||
const failed: string[] = [];
|
||||
const results: Record<string, unknown> = {};
|
||||
settled.forEach((r, i) => {
|
||||
const label = probes[i][0];
|
||||
if (r.status === "fulfilled") {
|
||||
results[label] = r.value;
|
||||
} else {
|
||||
failed.push(label);
|
||||
results[label] = null;
|
||||
}
|
||||
});
|
||||
|
||||
// Pull each probe out in the same shape the overview expects. `?? 0`
|
||||
// turns a null (failed-count probe) into a renderable zero so the
|
||||
// grid tiles still draw something instead of going blank.
|
||||
const userTotal = (results.userTotal as number | null) ?? 0;
|
||||
const userAdmins = (results.userAdmins as number | null) ?? 0;
|
||||
const userNewThisWeek = (results.userNewThisWeek as number | null) ?? 0;
|
||||
const withingsCount = (results.withingsCount as number | null) ?? 0;
|
||||
const moodLogCount = (results.moodLogCount as number | null) ?? 0;
|
||||
const telegramCount = (results.telegramCount as number | null) ?? 0;
|
||||
const ntfyCount = (results.ntfyCount as number | null) ?? 0;
|
||||
const webPushCount = (results.webPushCount as number | null) ?? 0;
|
||||
const appSettings = results.appSettings as {
|
||||
glitchtipEnabled: boolean;
|
||||
umamiEnabled: boolean;
|
||||
} | null;
|
||||
const lastErrorEntry = results.lastErrorEntry as { createdAt: Date } | null;
|
||||
const latestBackup = results.latestBackup as { createdAt: Date } | null;
|
||||
const backedUpUsers = (results.backedUpUsers as number | null) ?? 0;
|
||||
const last30dEvents = (results.last30dEvents as number | null) ?? 0;
|
||||
const lastLoginEntry = results.lastLoginEntry as { createdAt: Date } | null;
|
||||
const lastIdempotencyCleanup = results.lastIdempotencyCleanup as {
|
||||
createdAt: Date;
|
||||
} | null;
|
||||
const lastAuditLogCleanup = results.lastAuditLogCleanup as {
|
||||
createdAt: Date;
|
||||
} | null;
|
||||
|
||||
if (failed.length > 0) {
|
||||
annotate({ meta: { statusOverviewProbeFailures: failed } });
|
||||
}
|
||||
|
||||
const worker = getWorkerStatus();
|
||||
const workerUptimeSeconds = worker.startedAt
|
||||
? Math.max(
|
||||
0,
|
||||
Math.floor(
|
||||
(now.getTime() - new Date(worker.startedAt).getTime()) / 1000,
|
||||
),
|
||||
)
|
||||
: null;
|
||||
|
||||
const integrationsTotal =
|
||||
withingsCount + moodLogCount + telegramCount + ntfyCount + webPushCount;
|
||||
|
||||
// Map probe-name → which overview card depends on it. If any probe
|
||||
// for a card failed, force the card's severity to "alert" so the UI
|
||||
// surfaces the failure instead of showing zeros as if they were real.
|
||||
const failedSet = new Set(failed);
|
||||
const anyFailed = (...labels: string[]) =>
|
||||
labels.some((l) => failedSet.has(l));
|
||||
|
||||
const overview: StatusOverview = {
|
||||
users: {
|
||||
severity: anyFailed("userTotal", "userAdmins", "userNewThisWeek")
|
||||
? "alert"
|
||||
: "good",
|
||||
total: userTotal,
|
||||
admins: userAdmins,
|
||||
newThisWeek: userNewThisWeek,
|
||||
},
|
||||
integrations: {
|
||||
severity: anyFailed(
|
||||
"withingsCount",
|
||||
"moodLogCount",
|
||||
"telegramCount",
|
||||
"ntfyCount",
|
||||
"webPushCount",
|
||||
)
|
||||
? "alert"
|
||||
: severityForIntegrations(integrationsTotal),
|
||||
withings: withingsCount,
|
||||
moodLog: moodLogCount,
|
||||
telegram: telegramCount,
|
||||
ntfy: ntfyCount,
|
||||
webPush: webPushCount,
|
||||
},
|
||||
monitoring: {
|
||||
severity: anyFailed("appSettings", "lastErrorEntry")
|
||||
? "alert"
|
||||
: severityForMonitoring(
|
||||
Boolean(appSettings?.glitchtipEnabled),
|
||||
Boolean(appSettings?.umamiEnabled),
|
||||
lastErrorEntry?.createdAt ?? null,
|
||||
),
|
||||
glitchtipEnabled: Boolean(appSettings?.glitchtipEnabled),
|
||||
umamiEnabled: Boolean(appSettings?.umamiEnabled),
|
||||
wideEventsEnabled: true,
|
||||
lastErrorAt: lastErrorEntry?.createdAt?.toISOString() ?? null,
|
||||
},
|
||||
backups: {
|
||||
severity: anyFailed("latestBackup", "backedUpUsers")
|
||||
? "alert"
|
||||
: severityForBackup(latestBackup?.createdAt ?? null),
|
||||
lastBackupAt: latestBackup?.createdAt?.toISOString() ?? null,
|
||||
backedUpUsers,
|
||||
retentionDays: 90,
|
||||
},
|
||||
maintenance: {
|
||||
severity: anyFailed("lastIdempotencyCleanup", "lastAuditLogCleanup")
|
||||
? "alert"
|
||||
: severityForMaintenance(worker.running),
|
||||
workerRunning: worker.running,
|
||||
workerUptimeSeconds,
|
||||
lastIdempotencyCleanup:
|
||||
lastIdempotencyCleanup?.createdAt?.toISOString() ?? null,
|
||||
lastAuditLogCleanup:
|
||||
lastAuditLogCleanup?.createdAt?.toISOString() ?? null,
|
||||
},
|
||||
auditLog: {
|
||||
severity: anyFailed("last30dEvents", "lastLoginEntry") ? "alert" : "info",
|
||||
eventsLast30d: last30dEvents,
|
||||
lastLoginAt: lastLoginEntry?.createdAt?.toISOString() ?? null,
|
||||
},
|
||||
};
|
||||
|
||||
return apiSuccess(overview);
|
||||
});
|
||||
@@ -544,6 +544,10 @@ describe("GET /api/dashboard/summary", () => {
|
||||
vi.mocked(prisma.medication.findMany).mockResolvedValue([
|
||||
{
|
||||
id: "med-ramipril",
|
||||
startsOn: null,
|
||||
endsOn: null,
|
||||
oneShot: false,
|
||||
createdAt: new Date("2026-01-01T00:00:00Z"),
|
||||
schedules: [
|
||||
{
|
||||
id: "sched-morgens",
|
||||
@@ -551,6 +555,10 @@ describe("GET /api/dashboard/summary", () => {
|
||||
windowStart: "07:00",
|
||||
windowEnd: "09:00",
|
||||
daysOfWeek: null,
|
||||
timesOfDay: [],
|
||||
reminderGraceMinutes: null,
|
||||
rrule: null,
|
||||
rollingIntervalDays: null,
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -632,6 +640,10 @@ describe("GET /api/dashboard/summary", () => {
|
||||
vi.mocked(prisma.medication.findMany).mockResolvedValue([
|
||||
{
|
||||
id: "med-ramipril",
|
||||
startsOn: null,
|
||||
endsOn: null,
|
||||
oneShot: false,
|
||||
createdAt: new Date("2026-01-01T00:00:00Z"),
|
||||
schedules: [
|
||||
{
|
||||
id: "sched-morgens",
|
||||
@@ -639,6 +651,10 @@ describe("GET /api/dashboard/summary", () => {
|
||||
windowStart: "07:00",
|
||||
windowEnd: "09:00",
|
||||
daysOfWeek: null,
|
||||
timesOfDay: [],
|
||||
reminderGraceMinutes: null,
|
||||
rrule: null,
|
||||
rollingIntervalDays: null,
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
@@ -95,7 +95,7 @@ describe("PUT /api/measurements/[id] — 422 multi-issue (v1.4.43 W6)", () => {
|
||||
putReq({
|
||||
value: "string",
|
||||
measuredAt: "not-iso",
|
||||
notes: "a".repeat(200),
|
||||
notes: "a".repeat(201),
|
||||
}),
|
||||
ROUTE_CTX,
|
||||
);
|
||||
|
||||
@@ -91,6 +91,7 @@ export const PUT = apiHandler(
|
||||
category,
|
||||
treatmentClass,
|
||||
dosesPerUnit,
|
||||
deliveryForm,
|
||||
active,
|
||||
notificationsEnabled,
|
||||
schedules,
|
||||
@@ -180,6 +181,7 @@ export const PUT = apiHandler(
|
||||
...(dose !== undefined && { dose }),
|
||||
...(treatmentClass !== undefined && { treatmentClass }),
|
||||
...(dosesPerUnit !== undefined && { dosesPerUnit }),
|
||||
...(deliveryForm !== undefined && { deliveryForm }),
|
||||
...(active !== undefined && { active }),
|
||||
...(notificationsEnabled !== undefined && { notificationsEnabled }),
|
||||
// v1.5 scheduling primitives — pass-through when supplied.
|
||||
|
||||
@@ -162,6 +162,10 @@ describe("GET /api/medications/intake", () => {
|
||||
vi.mocked(prisma.medication.findMany).mockResolvedValue([
|
||||
{
|
||||
id: "med-ramipril",
|
||||
startsOn: null,
|
||||
endsOn: null,
|
||||
oneShot: false,
|
||||
createdAt: new Date("2026-01-01T00:00:00Z"),
|
||||
schedules: [
|
||||
{
|
||||
id: "sched-morgens",
|
||||
@@ -169,6 +173,10 @@ describe("GET /api/medications/intake", () => {
|
||||
windowStart: "07:00",
|
||||
windowEnd: "09:00",
|
||||
daysOfWeek: null,
|
||||
timesOfDay: [],
|
||||
reminderGraceMinutes: null,
|
||||
rrule: null,
|
||||
rollingIntervalDays: null,
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -237,6 +245,10 @@ describe("GET /api/medications/intake", () => {
|
||||
vi.mocked(prisma.medication.findMany).mockResolvedValue([
|
||||
{
|
||||
id: "med-ramipril",
|
||||
startsOn: null,
|
||||
endsOn: null,
|
||||
oneShot: false,
|
||||
createdAt: new Date("2026-01-01T00:00:00Z"),
|
||||
schedules: [
|
||||
{
|
||||
id: "sched-morgens",
|
||||
@@ -244,6 +256,10 @@ describe("GET /api/medications/intake", () => {
|
||||
windowStart: "07:00",
|
||||
windowEnd: "09:00",
|
||||
daysOfWeek: null,
|
||||
timesOfDay: [],
|
||||
reminderGraceMinutes: null,
|
||||
rrule: null,
|
||||
rollingIntervalDays: null,
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
@@ -121,6 +121,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
category,
|
||||
treatmentClass,
|
||||
dosesPerUnit,
|
||||
deliveryForm,
|
||||
notificationsEnabled,
|
||||
schedules,
|
||||
startsOn,
|
||||
@@ -181,6 +182,8 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
// wire schema; Prisma fills the default GENERIC when omitted.
|
||||
...(treatmentClass !== undefined && { treatmentClass }),
|
||||
...(dosesPerUnit !== undefined && { dosesPerUnit }),
|
||||
// v1.6.0 — route of administration; Prisma defaults to ORAL when omitted.
|
||||
...(deliveryForm !== undefined && { deliveryForm }),
|
||||
// v1.5 — wizard's reminders toggle now ships through the create
|
||||
// payload (was orphaned in the initial diff). Prisma defaults to
|
||||
// true when omitted, matching the legacy form's behaviour.
|
||||
|
||||
@@ -43,6 +43,8 @@ import { TitrationSection } from "@/components/medications/TitrationSection";
|
||||
import { MedicationDetailHeader } from "@/components/medications/medication-detail-header";
|
||||
import { CadenceSummaryRow } from "@/components/medications/cadence-summary-row";
|
||||
import { IntakeHistoryPreview } from "@/components/medications/sections/intake-history-preview";
|
||||
import { SideEffectsSection } from "@/components/medications/SideEffectsSection";
|
||||
import { SchedulingSection } from "@/components/medications/SchedulingSection";
|
||||
import { AdvancedSettingsSheet } from "@/components/medications/advanced-settings-sheet";
|
||||
import { PhaseConfigSheet } from "@/components/medications/sections/phase-config-sheet";
|
||||
import { MedicationWizardDialog } from "@/components/medications/wizard/MedicationWizardDialog";
|
||||
@@ -67,6 +69,8 @@ interface MedicationDetailSnapshot {
|
||||
dose: string;
|
||||
category: string;
|
||||
treatmentClass?: string;
|
||||
deliveryForm?: string;
|
||||
dosesPerUnit?: number | null;
|
||||
active: boolean;
|
||||
notificationsEnabled: boolean;
|
||||
pausedAt: string | null;
|
||||
@@ -103,6 +107,8 @@ function snapshotToWizardPayload(
|
||||
dose: med.dose,
|
||||
category: med.category,
|
||||
treatmentClass: med.treatmentClass,
|
||||
deliveryForm: med.deliveryForm,
|
||||
dosesPerUnit: med.dosesPerUnit ?? null,
|
||||
notificationsEnabled: med.notificationsEnabled,
|
||||
startsOn: med.startsOn ? new Date(med.startsOn) : null,
|
||||
endsOn: med.endsOn ? new Date(med.endsOn) : null,
|
||||
@@ -269,9 +275,9 @@ export default function MedicationDetailPage({
|
||||
onOpenAdvanced={() => setAdvancedOpen(true)}
|
||||
/>
|
||||
|
||||
{/* G-1 §3.3 — Static cadence line. One-shot collapses to the
|
||||
`Einmalig am …` card; recurring renders the summary row with
|
||||
`hideEdit` (cadence editing lives in the wizard). */}
|
||||
{/* G-1 §3.3 — Cadence line. One-shot collapses to the
|
||||
`Einmalig am …` card; recurring renders the summary row whose
|
||||
edit affordance opens the editor directly. */}
|
||||
{oneShot ? (
|
||||
<Card
|
||||
className="p-5 sm:p-6"
|
||||
@@ -288,8 +294,7 @@ export default function MedicationDetailPage({
|
||||
) : (
|
||||
<CadenceSummaryRow
|
||||
medication={payload}
|
||||
hideEdit
|
||||
onEdit={() => {}}
|
||||
onEdit={() => setWizardOpen(true)}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -304,6 +309,14 @@ export default function MedicationDetailPage({
|
||||
dose: medication.dose,
|
||||
}}
|
||||
/>
|
||||
{/* v1.6.0 — side-effect logbook + cadence/compliance section
|
||||
restored onto the detail page (they previously lived only
|
||||
on the legacy `/history` route). */}
|
||||
<SideEffectsSection medicationId={id} />
|
||||
<SchedulingSection
|
||||
medicationId={id}
|
||||
reminderEnabled={medication.notificationsEnabled}
|
||||
/>
|
||||
<TitrationSection medicationId={id} />
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -11,8 +11,9 @@
|
||||
* - no `landingIntent` / `wizardIntent` / `openWizardWithIntent`
|
||||
* plumbing (the header dropdown is the only wizard entry, landing
|
||||
* on Step 1);
|
||||
* - the page hosts the `AdvancedSettingsSheet` and renders the
|
||||
* cadence row with `hideEdit`.
|
||||
* - the page hosts the `AdvancedSettingsSheet` and renders an
|
||||
* editable cadence row (the v1.6.0 editor opens directly from the
|
||||
* row, so `hideEdit` is gone).
|
||||
*/
|
||||
|
||||
import { readFileSync } from "node:fs";
|
||||
@@ -41,8 +42,10 @@ describe("medication detail page is a pure history surface (G-1 §3)", () => {
|
||||
expect(source).not.toContain("openWizardWithIntent");
|
||||
});
|
||||
|
||||
it("hosts the AdvancedSettingsSheet and the static cadence row", () => {
|
||||
it("hosts the AdvancedSettingsSheet and an editable cadence row", () => {
|
||||
expect(source).toContain("AdvancedSettingsSheet");
|
||||
expect(source).toContain("hideEdit");
|
||||
// v1.6.0 — the cadence row opens the editor directly; the dead
|
||||
// `hideEdit` prop is gone.
|
||||
expect(source).not.toContain("hideEdit");
|
||||
});
|
||||
});
|
||||
@@ -41,6 +41,8 @@ interface Medication {
|
||||
treatmentClass?: string;
|
||||
/** v1.4.25 W4d — doses per pen/vial for inventory math. */
|
||||
dosesPerUnit?: number | null;
|
||||
/** v1.6.0 — route of administration (ORAL | INJECTION | OTHER). */
|
||||
deliveryForm?: string;
|
||||
active: boolean;
|
||||
notificationsEnabled: boolean;
|
||||
pausedAt: string | null;
|
||||
@@ -284,6 +286,8 @@ function medicationToPayload(med: Medication): MedicationPayload {
|
||||
dose: med.dose,
|
||||
category: med.category,
|
||||
treatmentClass: med.treatmentClass,
|
||||
deliveryForm: med.deliveryForm,
|
||||
dosesPerUnit: med.dosesPerUnit ?? null,
|
||||
notificationsEnabled: med.notificationsEnabled,
|
||||
startsOn: med.startsOn ? new Date(med.startsOn) : null,
|
||||
endsOn: med.endsOn ? new Date(med.endsOn) : null,
|
||||
|
||||
+8
-6
@@ -123,9 +123,9 @@ function getRangeColorClass(
|
||||
const inOrange =
|
||||
!inGreen && value >= range.orangeMin && value <= range.orangeMax;
|
||||
|
||||
if (inGreen) return "text-green-400";
|
||||
if (inOrange) return "text-orange-400";
|
||||
return "text-red-400";
|
||||
if (inGreen) return "text-success";
|
||||
if (inOrange) return "text-warning";
|
||||
return "text-destructive";
|
||||
}
|
||||
|
||||
function getRangeHint(
|
||||
@@ -142,20 +142,22 @@ function getRangeHint(
|
||||
return (
|
||||
<>
|
||||
<p>
|
||||
<span className="font-bold text-green-400">
|
||||
<span className="text-success font-bold">
|
||||
{t("charts.colorGreen")}
|
||||
</span>{" "}
|
||||
{format(range.greenMin)}-{format(range.greenMax)} {unit}
|
||||
</p>
|
||||
<p>
|
||||
<span className="font-bold text-orange-400">
|
||||
<span className="text-warning font-bold">
|
||||
{t("charts.colorOrange")}
|
||||
</span>{" "}
|
||||
{format(range.orangeMin)}-{format(range.greenMin)} {t("common.or")}{" "}
|
||||
{format(range.greenMax)}-{format(range.orangeMax)} {unit}
|
||||
</p>
|
||||
<p>
|
||||
<span className="font-bold text-red-400">{t("charts.colorRed")}</span>{" "}
|
||||
<span className="text-destructive font-bold">
|
||||
{t("charts.colorRed")}
|
||||
</span>{" "}
|
||||
{"< "}
|
||||
{format(range.orangeMin)} {t("common.or")} {"> "}
|
||||
{format(range.orangeMax)} {unit}
|
||||
|
||||
@@ -236,9 +236,9 @@ export default function TargetsPage() {
|
||||
|
||||
{/* Profile incomplete hint */}
|
||||
{profileIncomplete && (
|
||||
<Card className="border-l-4 border-l-orange-500">
|
||||
<Card className="border-warning border-l-4">
|
||||
<CardContent className="flex gap-3 py-3">
|
||||
<AlertCircle className="mt-0.5 h-4 w-4 shrink-0 text-orange-500" />
|
||||
<AlertCircle className="text-warning mt-0.5 h-4 w-4 shrink-0" />
|
||||
<div>
|
||||
<p className="text-sm font-medium">
|
||||
{t("targets.profileIncomplete")}
|
||||
|
||||
Reference in new issue
Block a user