mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 22:02:16 +00:00
style(prettier): sweep formatting across the tree
Pre-existing format drift accumulated across v1.4.x; running prettier brings the whole tree back to baseline so future diffs read clean. No behaviour change. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
c6e3ac69e8
commit
3048dd6637
72 files changed
+508
-606
No files matched your search
@@ -115,8 +115,7 @@ test.describe("achievements page renders all four card states", () => {
|
||||
metric: "totalTakenIntakes",
|
||||
category: "medication",
|
||||
titleKey: "achievements.badges.intakeTotal1.title",
|
||||
descriptionKey:
|
||||
"achievements.badges.intakeTotal1.description",
|
||||
descriptionKey: "achievements.badges.intakeTotal1.description",
|
||||
icon: "Pill",
|
||||
format: "count",
|
||||
target: 1,
|
||||
@@ -133,8 +132,7 @@ test.describe("achievements page renders all four card states", () => {
|
||||
metric: "moodDayStreak",
|
||||
category: "mood",
|
||||
titleKey: "achievements.badges.moodStreak7.title",
|
||||
descriptionKey:
|
||||
"achievements.badges.moodStreak7.description",
|
||||
descriptionKey: "achievements.badges.moodStreak7.description",
|
||||
icon: "Smile",
|
||||
format: "days",
|
||||
target: 7,
|
||||
@@ -209,9 +207,7 @@ test.describe("achievements page renders all four card states", () => {
|
||||
|
||||
// Hidden-locked — opaque placeholder visible. The real title /
|
||||
// description must NOT appear in the rendered HTML.
|
||||
await expect(
|
||||
page.getByText("Hidden achievement").first(),
|
||||
).toBeVisible();
|
||||
await expect(page.getByText("Hidden achievement").first()).toBeVisible();
|
||||
const html = await page.content();
|
||||
expect(html).not.toContain("Night owl");
|
||||
expect(html).not.toContain("between 02:00");
|
||||
|
||||
@@ -61,9 +61,9 @@ test.describe("/admin/api-tokens mobile no-overflow", () => {
|
||||
|
||||
// The mobile card-list must be visible and the desktop table must
|
||||
// be display:none at this viewport.
|
||||
await expect(
|
||||
page.getByTestId("admin-tokens-mobile-list"),
|
||||
).toBeVisible({ timeout: 10_000 });
|
||||
await expect(page.getByTestId("admin-tokens-mobile-list")).toBeVisible({
|
||||
timeout: 10_000,
|
||||
});
|
||||
|
||||
const dims = await page.evaluate(() => ({
|
||||
scrollWidth: document.documentElement.scrollWidth,
|
||||
|
||||
@@ -99,7 +99,9 @@ test.describe("Settings → AI provider dropdown UX (B2)", () => {
|
||||
|
||||
// 2c. Switch to Anthropic → Anthropic form rendered.
|
||||
await select.selectOption("anthropic");
|
||||
await expect(page.getByTestId("ai-provider-config-anthropic")).toBeVisible();
|
||||
await expect(
|
||||
page.getByTestId("ai-provider-config-anthropic"),
|
||||
).toBeVisible();
|
||||
|
||||
// 2d. Switch to Local → Local form rendered.
|
||||
await select.selectOption("local");
|
||||
@@ -108,9 +110,7 @@ test.describe("Settings → AI provider dropdown UX (B2)", () => {
|
||||
// 3. Fallback chain card surfaces with the four chain rows.
|
||||
await expect(page.getByTestId("ai-fallback-chain")).toBeVisible();
|
||||
for (const slug of ["codex", "openai", "anthropic", "local"]) {
|
||||
await expect(
|
||||
page.locator(`[data-chain-row="${slug}"]`),
|
||||
).toBeVisible();
|
||||
await expect(page.locator(`[data-chain-row="${slug}"]`)).toBeVisible();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -76,19 +76,16 @@ test.describe("chart overlay controls", () => {
|
||||
page,
|
||||
}) => {
|
||||
let putRequestCount = 0;
|
||||
await page.route(
|
||||
"**/api/dashboard/chart-overlay-prefs",
|
||||
async (route) => {
|
||||
if (route.request().method() === "PUT") {
|
||||
putRequestCount += 1;
|
||||
}
|
||||
return route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ data: { saved: true }, error: null }),
|
||||
});
|
||||
},
|
||||
);
|
||||
await page.route("**/api/dashboard/chart-overlay-prefs", async (route) => {
|
||||
if (route.request().method() === "PUT") {
|
||||
putRequestCount += 1;
|
||||
}
|
||||
return route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ data: { saved: true }, error: null }),
|
||||
});
|
||||
});
|
||||
|
||||
await page.goto("/", { waitUntil: "domcontentloaded" });
|
||||
|
||||
|
||||
@@ -42,9 +42,7 @@ test.describe("sidebar Admin entry no-expansion", () => {
|
||||
|
||||
// No sub-section anchors inside the sidebar — those belong to
|
||||
// `<AdminShell>`'s in-page nav, not the global sidebar.
|
||||
const subLinks = sidebar.locator(
|
||||
"a[href^='/admin/']:not([href='/admin'])",
|
||||
);
|
||||
const subLinks = sidebar.locator("a[href^='/admin/']:not([href='/admin'])");
|
||||
await expect(subLinks).toHaveCount(0);
|
||||
});
|
||||
|
||||
|
||||
@@ -83,12 +83,7 @@ export const GET = apiHandler(async () => {
|
||||
// for the dashboard tile in a single pair-and-count pass. The
|
||||
// headline (`bpInTargetPct`) reuses the 30-day result so we don't
|
||||
// re-pair the same data twice. See lib/analytics/bp-in-target.ts.
|
||||
const windows = computeBpInTargetWindows(
|
||||
sysData,
|
||||
diaData,
|
||||
bpTargets,
|
||||
now,
|
||||
);
|
||||
const windows = computeBpInTargetWindows(sysData, diaData, bpTargets, now);
|
||||
bpInTargetPct = windows.last30Days?.pct ?? null;
|
||||
bpInTargetPct7d = windows.last7Days?.pct ?? null;
|
||||
bpInTargetPct30d = windows.last30Days?.pct ?? null;
|
||||
|
||||
@@ -76,8 +76,7 @@ export const PUT = apiHandler(async (request: NextRequest) => {
|
||||
await tx.user.update({
|
||||
where: { id: user.id },
|
||||
data: {
|
||||
dashboardWidgetsJson:
|
||||
normalized as unknown as Prisma.InputJsonValue,
|
||||
dashboardWidgetsJson: normalized as unknown as Prisma.InputJsonValue,
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
@@ -98,8 +98,8 @@ export const PUT = apiHandler(async (request: NextRequest) => {
|
||||
// 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.
|
||||
let mergedChartOverlayPrefs: ChartOverlayPrefsMap | undefined =
|
||||
parsed.data.chartOverlayPrefs as ChartOverlayPrefsMap | undefined;
|
||||
let mergedChartOverlayPrefs: ChartOverlayPrefsMap | undefined = parsed.data
|
||||
.chartOverlayPrefs as ChartOverlayPrefsMap | undefined;
|
||||
if (mergedChartOverlayPrefs === undefined) {
|
||||
const existing = await prisma.user.findUnique({
|
||||
where: { id: user.id },
|
||||
|
||||
@@ -473,94 +473,94 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
auditEvents,
|
||||
moodEntries,
|
||||
] = await Promise.all([
|
||||
prisma.measurement.findMany({
|
||||
where: {
|
||||
userId,
|
||||
measuredAt: { gte: startDate, lte: now },
|
||||
source: { not: "IMPORT" },
|
||||
type: {
|
||||
in: ["WEIGHT", "BLOOD_PRESSURE_SYS", "BLOOD_PRESSURE_DIA", "PULSE"],
|
||||
prisma.measurement.findMany({
|
||||
where: {
|
||||
userId,
|
||||
measuredAt: { gte: startDate, lte: now },
|
||||
source: { not: "IMPORT" },
|
||||
type: {
|
||||
in: ["WEIGHT", "BLOOD_PRESSURE_SYS", "BLOOD_PRESSURE_DIA", "PULSE"],
|
||||
},
|
||||
},
|
||||
select: {
|
||||
type: true,
|
||||
value: true,
|
||||
measuredAt: true,
|
||||
},
|
||||
}),
|
||||
prisma.medicationIntakeEvent.findMany({
|
||||
where: {
|
||||
userId,
|
||||
source: { not: "IMPORT" },
|
||||
scheduledFor: { gte: startDate, lte: now },
|
||||
},
|
||||
select: {
|
||||
medicationId: true,
|
||||
scheduledFor: true,
|
||||
takenAt: true,
|
||||
skipped: true,
|
||||
},
|
||||
}),
|
||||
prisma.medication.findMany({
|
||||
where: { userId },
|
||||
select: {
|
||||
id: true,
|
||||
schedules: {
|
||||
select: {
|
||||
windowStart: true,
|
||||
windowEnd: true,
|
||||
daysOfWeek: true,
|
||||
},
|
||||
},
|
||||
select: {
|
||||
type: true,
|
||||
value: true,
|
||||
measuredAt: true,
|
||||
},
|
||||
}),
|
||||
prisma.passkey.findMany({
|
||||
where: {
|
||||
userId,
|
||||
createdAt: { gte: startDate, lte: now },
|
||||
},
|
||||
select: {
|
||||
createdAt: true,
|
||||
},
|
||||
}),
|
||||
prisma.auditLog.findMany({
|
||||
where: {
|
||||
userId,
|
||||
createdAt: { gte: startDate, lte: now },
|
||||
action: {
|
||||
in: [
|
||||
"auth.login.passkey",
|
||||
"auth.login.password",
|
||||
"bugreport.submit",
|
||||
// v1.4.18 — hidden Easter-egg triggers
|
||||
"doctor-report.generate",
|
||||
"doctor-report.pdf.generate",
|
||||
"settings.locale.update",
|
||||
],
|
||||
},
|
||||
}),
|
||||
prisma.medicationIntakeEvent.findMany({
|
||||
where: {
|
||||
userId,
|
||||
source: { not: "IMPORT" },
|
||||
scheduledFor: { gte: startDate, lte: now },
|
||||
},
|
||||
select: {
|
||||
medicationId: true,
|
||||
scheduledFor: true,
|
||||
takenAt: true,
|
||||
skipped: true,
|
||||
},
|
||||
}),
|
||||
prisma.medication.findMany({
|
||||
where: { userId },
|
||||
select: {
|
||||
id: true,
|
||||
schedules: {
|
||||
select: {
|
||||
windowStart: true,
|
||||
windowEnd: true,
|
||||
daysOfWeek: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
prisma.passkey.findMany({
|
||||
where: {
|
||||
userId,
|
||||
createdAt: { gte: startDate, lte: now },
|
||||
},
|
||||
select: {
|
||||
createdAt: true,
|
||||
},
|
||||
}),
|
||||
prisma.auditLog.findMany({
|
||||
where: {
|
||||
userId,
|
||||
createdAt: { gte: startDate, lte: now },
|
||||
action: {
|
||||
in: [
|
||||
"auth.login.passkey",
|
||||
"auth.login.password",
|
||||
"bugreport.submit",
|
||||
// v1.4.18 — hidden Easter-egg triggers
|
||||
"doctor-report.generate",
|
||||
"doctor-report.pdf.generate",
|
||||
"settings.locale.update",
|
||||
],
|
||||
},
|
||||
},
|
||||
select: {
|
||||
action: true,
|
||||
createdAt: true,
|
||||
},
|
||||
}),
|
||||
// v1.4.18 — mood entries feed the new mood badges + the
|
||||
// entry-streak/consistent-month engagement metrics. Synced from
|
||||
// moodLog.app or entered directly; we intentionally include all
|
||||
// sources because consistency-of-tracking is what the badge
|
||||
// rewards.
|
||||
prisma.moodEntry.findMany({
|
||||
where: {
|
||||
userId,
|
||||
moodLoggedAt: { gte: startDate, lte: now },
|
||||
},
|
||||
select: {
|
||||
date: true,
|
||||
score: true,
|
||||
moodLoggedAt: true,
|
||||
},
|
||||
}),
|
||||
]);
|
||||
},
|
||||
select: {
|
||||
action: true,
|
||||
createdAt: true,
|
||||
},
|
||||
}),
|
||||
// v1.4.18 — mood entries feed the new mood badges + the
|
||||
// entry-streak/consistent-month engagement metrics. Synced from
|
||||
// moodLog.app or entered directly; we intentionally include all
|
||||
// sources because consistency-of-tracking is what the badge
|
||||
// rewards.
|
||||
prisma.moodEntry.findMany({
|
||||
where: {
|
||||
userId,
|
||||
moodLoggedAt: { gte: startDate, lte: now },
|
||||
},
|
||||
select: {
|
||||
date: true,
|
||||
score: true,
|
||||
moodLoggedAt: true,
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
const schedulesByMedicationId = new Map(
|
||||
medications.map((med) => [med.id, med.schedules]),
|
||||
@@ -820,10 +820,7 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
// discovered set. Hidden achievements that are still locked are
|
||||
// counted toward `totalCount` so the user sees they exist.
|
||||
const visibleUnlocked = visibleAchievements.filter((a) => a.unlocked);
|
||||
const visibleEarned = visibleUnlocked.reduce(
|
||||
(acc, a) => acc + a.points,
|
||||
0,
|
||||
);
|
||||
const visibleEarned = visibleUnlocked.reduce((acc, a) => acc + a.points, 0);
|
||||
const visibleTotalPoints = visibleAchievements.reduce(
|
||||
(acc, a) => acc + a.points,
|
||||
0,
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import type { NextRequest } from "next/server";
|
||||
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { apiError, apiSuccess, getClientIp, safeJson } from "@/lib/api-response";
|
||||
import {
|
||||
apiError,
|
||||
apiSuccess,
|
||||
getClientIp,
|
||||
safeJson,
|
||||
} from "@/lib/api-response";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { Prisma } from "@/generated/prisma/client";
|
||||
|
||||
@@ -15,7 +15,10 @@ vi.mock("@/lib/api-handler", () => ({
|
||||
session: { id: "s-1" },
|
||||
})),
|
||||
HttpError: class HttpError extends Error {
|
||||
constructor(public status: number, message: string) {
|
||||
constructor(
|
||||
public status: number,
|
||||
message: string,
|
||||
) {
|
||||
super(message);
|
||||
}
|
||||
},
|
||||
|
||||
@@ -22,10 +22,7 @@ vi.mock("next/navigation", () => ({
|
||||
}));
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import {
|
||||
ADMIN_SECTIONS,
|
||||
AdminShell,
|
||||
} from "../admin-shell";
|
||||
import { ADMIN_SECTIONS, AdminShell } from "../admin-shell";
|
||||
|
||||
function renderShell(props: {
|
||||
active?: (typeof ADMIN_SECTIONS)[number]["slug"];
|
||||
|
||||
@@ -97,9 +97,7 @@ describe("ApiTokenOverviewSection — responsive", () => {
|
||||
// forcing a scroll container — that was the v1.4.15 bug Marc
|
||||
// re-reported. Confirm there's no overflow-x-auto INSIDE the
|
||||
// mobile <ul>.
|
||||
const mobileMatch = html.match(
|
||||
/<ul[^>]*md:hidden[^>]*>([\s\S]*?)<\/ul>/,
|
||||
);
|
||||
const mobileMatch = html.match(/<ul[^>]*md:hidden[^>]*>([\s\S]*?)<\/ul>/);
|
||||
expect(mobileMatch).not.toBeNull();
|
||||
expect(mobileMatch![1]).not.toContain("overflow-x-auto");
|
||||
});
|
||||
@@ -123,9 +121,7 @@ describe("ApiTokenOverviewSection — responsive", () => {
|
||||
|
||||
it("mobile timestamp lines wrap (no nowrap on the meta paragraphs)", () => {
|
||||
const html = render();
|
||||
const mobileMatch = html.match(
|
||||
/<ul[^>]*md:hidden[^>]*>([\s\S]*?)<\/ul>/,
|
||||
);
|
||||
const mobileMatch = html.match(/<ul[^>]*md:hidden[^>]*>([\s\S]*?)<\/ul>/);
|
||||
expect(mobileMatch).not.toBeNull();
|
||||
// The Last-used + Created lines use <p class="text-[11px]…"> — no
|
||||
// whitespace-nowrap so a long German date+time can flow onto a
|
||||
|
||||
@@ -132,12 +132,9 @@ export function AiQualitySection() {
|
||||
</div>
|
||||
|
||||
<div className="overflow-x-auto">
|
||||
<table
|
||||
className="w-full text-sm"
|
||||
data-slot="ai-quality-table"
|
||||
>
|
||||
<table className="w-full text-sm" data-slot="ai-quality-table">
|
||||
<thead>
|
||||
<tr className="text-muted-foreground border-border border-b text-left text-xs uppercase tracking-wider">
|
||||
<tr className="text-muted-foreground border-border border-b text-left text-xs tracking-wider uppercase">
|
||||
<th className="py-2 pr-3 font-medium">
|
||||
{t("admin.aiQuality.colSeverity")}
|
||||
</th>
|
||||
@@ -183,7 +180,9 @@ export function AiQualitySection() {
|
||||
{bucket.promptVersion}
|
||||
</td>
|
||||
<td className="py-2 pr-3 tabular-nums">{bucket.helpful}</td>
|
||||
<td className="py-2 pr-3 tabular-nums">{bucket.notHelpful}</td>
|
||||
<td className="py-2 pr-3 tabular-nums">
|
||||
{bucket.notHelpful}
|
||||
</td>
|
||||
<td
|
||||
className={`py-2 font-semibold tabular-nums ${helpfulRateColour(
|
||||
bucket.helpfulRate,
|
||||
|
||||
@@ -49,156 +49,158 @@ export function SystemStatusSection() {
|
||||
<div className="space-y-6">
|
||||
<HostMetricsChart />
|
||||
<div className="bg-card border-border rounded-xl border p-6">
|
||||
<div className="flex items-center gap-2">
|
||||
<Server className="text-primary h-5 w-5" />
|
||||
<div className="text-lg font-semibold">{t("admin.systemStatus")}</div>
|
||||
</div>
|
||||
{status ? (
|
||||
<div className="mt-4 grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<StatusItem
|
||||
icon={Database}
|
||||
label={t("admin.database")}
|
||||
value={
|
||||
status.database === "connected"
|
||||
? t("admin.databaseConnected")
|
||||
: t("admin.databaseError")
|
||||
}
|
||||
className={
|
||||
status.database === "connected"
|
||||
? "text-dracula-green"
|
||||
: "text-destructive"
|
||||
}
|
||||
/>
|
||||
<StatusItem
|
||||
icon={Clock}
|
||||
label={t("admin.startedAt")}
|
||||
value={formatDateTime(status.startTime)}
|
||||
/>
|
||||
<StatusItem
|
||||
icon={Users}
|
||||
label={t("admin.users")}
|
||||
value={String(status.counts.users)}
|
||||
/>
|
||||
<StatusItem
|
||||
icon={Activity}
|
||||
label={t("admin.measurementsCount")}
|
||||
value={fmt.integer(status.counts.measurements)}
|
||||
/>
|
||||
<StatusItem
|
||||
icon={Key}
|
||||
label={t("admin.activeTokens")}
|
||||
value={String(status.counts.activeTokens)}
|
||||
/>
|
||||
<StatusItem
|
||||
icon={Globe}
|
||||
label={t("admin.activeSessions")}
|
||||
value={String(status.counts.activeSessions)}
|
||||
/>
|
||||
<StatusItem
|
||||
icon={Cog}
|
||||
label={t("admin.workerStatus")}
|
||||
value={
|
||||
status.worker.running
|
||||
? t("admin.workerRunning")
|
||||
: t("admin.workerStopped")
|
||||
}
|
||||
className={
|
||||
status.worker.running ? "text-dracula-green" : "text-destructive"
|
||||
}
|
||||
/>
|
||||
{status.worker.lastReminderCheck && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Server className="text-primary h-5 w-5" />
|
||||
<div className="text-lg font-semibold">{t("admin.systemStatus")}</div>
|
||||
</div>
|
||||
{status ? (
|
||||
<div className="mt-4 grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<StatusItem
|
||||
icon={Bell}
|
||||
label={t("admin.lastReminderCheck")}
|
||||
value={formatDateTime(status.worker.lastReminderCheck)}
|
||||
icon={Database}
|
||||
label={t("admin.database")}
|
||||
value={
|
||||
status.database === "connected"
|
||||
? t("admin.databaseConnected")
|
||||
: t("admin.databaseError")
|
||||
}
|
||||
className={
|
||||
status.database === "connected"
|
||||
? "text-dracula-green"
|
||||
: "text-destructive"
|
||||
}
|
||||
/>
|
||||
<StatusItem
|
||||
icon={Clock}
|
||||
label={t("admin.startedAt")}
|
||||
value={formatDateTime(status.startTime)}
|
||||
/>
|
||||
<StatusItem
|
||||
icon={Users}
|
||||
label={t("admin.users")}
|
||||
value={String(status.counts.users)}
|
||||
/>
|
||||
)}
|
||||
{status.integrations.umami && (
|
||||
<StatusItem
|
||||
icon={Activity}
|
||||
label="Umami"
|
||||
label={t("admin.measurementsCount")}
|
||||
value={fmt.integer(status.counts.measurements)}
|
||||
/>
|
||||
<StatusItem
|
||||
icon={Key}
|
||||
label={t("admin.activeTokens")}
|
||||
value={String(status.counts.activeTokens)}
|
||||
/>
|
||||
<StatusItem
|
||||
icon={Globe}
|
||||
label={t("admin.activeSessions")}
|
||||
value={String(status.counts.activeSessions)}
|
||||
/>
|
||||
<StatusItem
|
||||
icon={Cog}
|
||||
label={t("admin.workerStatus")}
|
||||
value={
|
||||
status.integrations.umami.enabled
|
||||
? t("common.active")
|
||||
: t("common.disabled")
|
||||
status.worker.running
|
||||
? t("admin.workerRunning")
|
||||
: t("admin.workerStopped")
|
||||
}
|
||||
className={
|
||||
status.integrations.umami.enabled
|
||||
status.worker.running
|
||||
? "text-dracula-green"
|
||||
: "text-destructive"
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{status.integrations.glitchtip && (
|
||||
<StatusItem
|
||||
icon={AlertTriangle}
|
||||
label="GlitchTip"
|
||||
value={
|
||||
status.integrations.glitchtip.enabled
|
||||
? t("common.active")
|
||||
: t("common.disabled")
|
||||
}
|
||||
className={
|
||||
status.integrations.glitchtip.enabled
|
||||
? "text-dracula-green"
|
||||
: "text-destructive"
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{status.integrations.webPush && (
|
||||
<StatusItem
|
||||
icon={BellRing}
|
||||
label={t("admin.integrationWebPush")}
|
||||
value={t("admin.configured")}
|
||||
className="text-dracula-green"
|
||||
/>
|
||||
)}
|
||||
{status.integrations.bugReport && (
|
||||
<StatusItem
|
||||
icon={Bug}
|
||||
label={t("admin.integrationBugReport")}
|
||||
value={t("admin.configured")}
|
||||
className="text-dracula-green"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
) : isError ? (
|
||||
// P19: surface load failures inline instead of leaving the
|
||||
// section spinning forever. The hook throws on non-OK
|
||||
// responses, so isError is true on 500s, network errors, etc.
|
||||
// v1.4.16 Wave-C MED — pair the alert with a Retry button so a
|
||||
// transient 500 (rolling deploy, DB blip) doesn't require a
|
||||
// full page reload to recover.
|
||||
<div
|
||||
role="alert"
|
||||
className="text-destructive bg-destructive/10 border-destructive/30 mt-4 flex flex-col items-start gap-2 rounded-md border px-3 py-2 text-sm sm:flex-row sm:items-center sm:justify-between"
|
||||
>
|
||||
<span>{t("admin.systemStatusLoadError")}</span>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={isFetching}
|
||||
onClick={() => void refetch()}
|
||||
className="min-h-9"
|
||||
data-testid="system-status-retry"
|
||||
>
|
||||
{isFetching ? (
|
||||
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<RotateCw className="mr-1 h-3.5 w-3.5" />
|
||||
{status.worker.lastReminderCheck && (
|
||||
<StatusItem
|
||||
icon={Bell}
|
||||
label={t("admin.lastReminderCheck")}
|
||||
value={formatDateTime(status.worker.lastReminderCheck)}
|
||||
/>
|
||||
)}
|
||||
{t("admin.systemStatusRetry")}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-4 flex items-center gap-2">
|
||||
<Loader2 className="text-muted-foreground h-4 w-4 animate-spin" />
|
||||
<span className="text-muted-foreground text-sm">
|
||||
{t("admin.loadingStatus")}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{status.integrations.umami && (
|
||||
<StatusItem
|
||||
icon={Activity}
|
||||
label="Umami"
|
||||
value={
|
||||
status.integrations.umami.enabled
|
||||
? t("common.active")
|
||||
: t("common.disabled")
|
||||
}
|
||||
className={
|
||||
status.integrations.umami.enabled
|
||||
? "text-dracula-green"
|
||||
: "text-destructive"
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{status.integrations.glitchtip && (
|
||||
<StatusItem
|
||||
icon={AlertTriangle}
|
||||
label="GlitchTip"
|
||||
value={
|
||||
status.integrations.glitchtip.enabled
|
||||
? t("common.active")
|
||||
: t("common.disabled")
|
||||
}
|
||||
className={
|
||||
status.integrations.glitchtip.enabled
|
||||
? "text-dracula-green"
|
||||
: "text-destructive"
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{status.integrations.webPush && (
|
||||
<StatusItem
|
||||
icon={BellRing}
|
||||
label={t("admin.integrationWebPush")}
|
||||
value={t("admin.configured")}
|
||||
className="text-dracula-green"
|
||||
/>
|
||||
)}
|
||||
{status.integrations.bugReport && (
|
||||
<StatusItem
|
||||
icon={Bug}
|
||||
label={t("admin.integrationBugReport")}
|
||||
value={t("admin.configured")}
|
||||
className="text-dracula-green"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
) : isError ? (
|
||||
// P19: surface load failures inline instead of leaving the
|
||||
// section spinning forever. The hook throws on non-OK
|
||||
// responses, so isError is true on 500s, network errors, etc.
|
||||
// v1.4.16 Wave-C MED — pair the alert with a Retry button so a
|
||||
// transient 500 (rolling deploy, DB blip) doesn't require a
|
||||
// full page reload to recover.
|
||||
<div
|
||||
role="alert"
|
||||
className="text-destructive bg-destructive/10 border-destructive/30 mt-4 flex flex-col items-start gap-2 rounded-md border px-3 py-2 text-sm sm:flex-row sm:items-center sm:justify-between"
|
||||
>
|
||||
<span>{t("admin.systemStatusLoadError")}</span>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={isFetching}
|
||||
onClick={() => void refetch()}
|
||||
className="min-h-9"
|
||||
data-testid="system-status-retry"
|
||||
>
|
||||
{isFetching ? (
|
||||
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<RotateCw className="mr-1 h-3.5 w-3.5" />
|
||||
)}
|
||||
{t("admin.systemStatusRetry")}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-4 flex items-center gap-2">
|
||||
<Loader2 className="text-muted-foreground h-4 w-4 animate-spin" />
|
||||
<span className="text-muted-foreground text-sm">
|
||||
{t("admin.loadingStatus")}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -24,9 +24,7 @@ describe("<ChartEmptyState>", () => {
|
||||
/>,
|
||||
);
|
||||
expect(html).toContain("Not enough mood data");
|
||||
expect(html).toContain(
|
||||
"Log at least 3 entries to unlock the trend line.",
|
||||
);
|
||||
expect(html).toContain("Log at least 3 entries to unlock the trend line.");
|
||||
});
|
||||
|
||||
it("uses a custom height when supplied", () => {
|
||||
|
||||
@@ -64,9 +64,7 @@ describe("<ChartOverlayControls>", () => {
|
||||
const start: ChartOverlayPrefs = DEFAULT_CHART_OVERLAY_PREFS;
|
||||
// Render once — this binds the callback closure.
|
||||
renderToStaticMarkup(
|
||||
withProvider(
|
||||
<ChartOverlayControls prefs={start} onChange={onChange} />,
|
||||
),
|
||||
withProvider(<ChartOverlayControls prefs={start} onChange={onChange} />),
|
||||
);
|
||||
|
||||
// Simulate the merge the component performs: setKey("showTargetRange", true)
|
||||
|
||||
@@ -38,10 +38,7 @@ vi.mock("@/hooks/use-auth", () => ({
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import { HealthChart } from "../health-chart";
|
||||
import {
|
||||
resolveMiniRangePoints,
|
||||
type DataWindow,
|
||||
} from "../mini-window";
|
||||
import { resolveMiniRangePoints, type DataWindow } from "../mini-window";
|
||||
|
||||
/**
|
||||
* v1.4.16 phase B5c — chart mini-mode + windowOverride.
|
||||
@@ -85,9 +82,7 @@ describe("resolveMiniRangePoints()", () => {
|
||||
|
||||
describe("<HealthChart mini>", () => {
|
||||
it("does not render the range tabs / mode-switch row in mini mode", () => {
|
||||
const html = render(
|
||||
<HealthChart types={["WEIGHT"]} title="Weight" mini />,
|
||||
);
|
||||
const html = render(<HealthChart types={["WEIGHT"]} title="Weight" mini />);
|
||||
// No "7d" / "30d" / "90d" range tabs.
|
||||
expect(html).not.toMatch(/data-slot="chart-range-tab"/);
|
||||
// No "7-day trend" toggle.
|
||||
|
||||
@@ -141,7 +141,7 @@ describe("<HealthChart> v1.4.18 clean-line revert", () => {
|
||||
</I18nProvider>,
|
||||
);
|
||||
|
||||
expect(html).not.toContain("data-slot=\"chart-linear-gradient\"");
|
||||
expect(html).not.toContain('data-slot="chart-linear-gradient"');
|
||||
expect(html).not.toContain("chart-gradient-BLOOD_PRESSURE_SYS");
|
||||
expect(html).not.toContain("linearGradient");
|
||||
});
|
||||
|
||||
@@ -36,9 +36,8 @@ vi.mock("@/hooks/use-auth", () => ({
|
||||
describe("<MedicationComplianceChart> v1.4.18 clean-line revert", () => {
|
||||
it("does NOT paint a gradient fill under the line", async () => {
|
||||
const { I18nProvider } = await import("@/lib/i18n/context");
|
||||
const { MedicationComplianceChart } = await import(
|
||||
"../medication-compliance-chart"
|
||||
);
|
||||
const { MedicationComplianceChart } =
|
||||
await import("../medication-compliance-chart");
|
||||
|
||||
const html = renderToStaticMarkup(
|
||||
<I18nProvider initialLocale="en">
|
||||
@@ -46,7 +45,7 @@ describe("<MedicationComplianceChart> v1.4.18 clean-line revert", () => {
|
||||
</I18nProvider>,
|
||||
);
|
||||
|
||||
expect(html).not.toContain("data-slot=\"chart-linear-gradient\"");
|
||||
expect(html).not.toContain('data-slot="chart-linear-gradient"');
|
||||
expect(html).not.toContain("chart-gradient-medication");
|
||||
expect(html).not.toContain("linearGradient");
|
||||
});
|
||||
|
||||
@@ -60,9 +60,8 @@ vi.mock("@/hooks/use-chart-overlay-prefs", () => ({
|
||||
|
||||
async function renderChart(): Promise<string> {
|
||||
// Re-import to pick up the freshly-set mock value.
|
||||
const { MedicationComplianceChart } = await import(
|
||||
"../medication-compliance-chart"
|
||||
);
|
||||
const { MedicationComplianceChart } =
|
||||
await import("../medication-compliance-chart");
|
||||
return renderToStaticMarkup(
|
||||
<I18nProvider initialLocale="en">
|
||||
<MedicationComplianceChart />
|
||||
|
||||
@@ -61,7 +61,7 @@ describe("<MoodChart> v1.4.18 clean-line revert", () => {
|
||||
</I18nProvider>,
|
||||
);
|
||||
|
||||
expect(html).not.toContain("data-slot=\"chart-linear-gradient\"");
|
||||
expect(html).not.toContain('data-slot="chart-linear-gradient"');
|
||||
expect(html).not.toContain("chart-gradient-mood");
|
||||
expect(html).not.toContain("linearGradient");
|
||||
});
|
||||
|
||||
@@ -78,10 +78,7 @@ export function ChartOverlayControls({
|
||||
const trendArrowId = useId();
|
||||
const targetRangeId = useId();
|
||||
|
||||
const setKey = (
|
||||
key: keyof ChartOverlayPrefs,
|
||||
value: boolean,
|
||||
): void => {
|
||||
const setKey = (key: keyof ChartOverlayPrefs, value: boolean): void => {
|
||||
onChange({ ...prefs, [key]: value });
|
||||
};
|
||||
|
||||
@@ -163,4 +160,3 @@ export function ChartOverlayControls({
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -31,10 +31,7 @@ import {
|
||||
computeWindowTrend,
|
||||
SPLIT_HALF_THRESHOLD_DAYS,
|
||||
} from "@/lib/analytics/window-trend";
|
||||
import {
|
||||
resolveMiniRangePoints,
|
||||
type DataWindow,
|
||||
} from "./mini-window";
|
||||
import { resolveMiniRangePoints, type DataWindow } from "./mini-window";
|
||||
import { shiftDailySeriesForward } from "@/lib/charts/comparison-shift";
|
||||
import type {
|
||||
ChartOverlayKey,
|
||||
@@ -1229,11 +1226,7 @@ export function HealthChart({
|
||||
}
|
||||
if (rows.length === 0) return null;
|
||||
return (
|
||||
<RichChartTooltip
|
||||
active
|
||||
label={dateLabel}
|
||||
rows={rows}
|
||||
/>
|
||||
<RichChartTooltip active label={dateLabel} rows={rows} />
|
||||
);
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -16,11 +16,7 @@
|
||||
* pin the contract without depending on Recharts internals.
|
||||
*/
|
||||
|
||||
export type DataWindow =
|
||||
| "last7days"
|
||||
| "last30days"
|
||||
| "last90days"
|
||||
| "allTime";
|
||||
export type DataWindow = "last7days" | "last30days" | "last90days" | "allTime";
|
||||
|
||||
const POINTS_BY_WINDOW: Record<DataWindow, number> = {
|
||||
last7days: 7,
|
||||
|
||||
@@ -745,9 +745,9 @@ export function MoodChart({
|
||||
delta = t("charts.deltaUnchanged");
|
||||
} else {
|
||||
const sign = diff > 0 ? "+" : "−";
|
||||
const formatted = `${sign}${(
|
||||
Math.abs(diff)
|
||||
).toFixed(1)}`;
|
||||
const formatted = `${sign}${Math.abs(diff).toFixed(
|
||||
1,
|
||||
)}`;
|
||||
delta = t("charts.deltaVsBaseline").replace(
|
||||
"{delta}",
|
||||
formatted,
|
||||
@@ -776,11 +776,7 @@ export function MoodChart({
|
||||
}
|
||||
if (rows.length === 0) return null;
|
||||
return (
|
||||
<RichChartTooltip
|
||||
active
|
||||
label={dateLabel}
|
||||
rows={rows}
|
||||
/>
|
||||
<RichChartTooltip active label={dateLabel} rows={rows} />
|
||||
);
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -216,9 +216,7 @@ describe("<InsightAdvisorCard> — summary typography polish (B1b)", () => {
|
||||
// The summary's inline-chart wrapper must carry `data-mini="true"`
|
||||
// so the chart shrinks to sparkline form. Per-finding charts use
|
||||
// the default detail mode.
|
||||
expect(html).toMatch(
|
||||
/data-slot="insight-summary"[\s\S]*?data-mini="true"/,
|
||||
);
|
||||
expect(html).toMatch(/data-slot="insight-summary"[\s\S]*?data-mini="true"/);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -64,9 +64,7 @@ function rec(
|
||||
};
|
||||
}
|
||||
|
||||
function buildInsight(
|
||||
recommendations: InsightRecommendation[],
|
||||
): InsightResult {
|
||||
function buildInsight(recommendations: InsightRecommendation[]): InsightResult {
|
||||
return {
|
||||
insightType: "general",
|
||||
summary: "Test summary",
|
||||
|
||||
@@ -80,7 +80,9 @@ describe("<InsightsPageHero>", () => {
|
||||
const html = render(
|
||||
<InsightsPageHero onRegenerate={() => {}} regenerating />,
|
||||
);
|
||||
expect(html).toMatch(/data-slot="insights-page-hero-regenerate"[^>]*disabled/);
|
||||
expect(html).toMatch(
|
||||
/data-slot="insights-page-hero-regenerate"[^>]*disabled/,
|
||||
);
|
||||
});
|
||||
|
||||
it("German locale — heading + subtitle translate", () => {
|
||||
|
||||
+4
-3
@@ -130,8 +130,7 @@ describe("end-to-end — generateInsight() confidence override flows into the ca
|
||||
// Model still claims 99 — wrapper still discards.
|
||||
confidence: 99,
|
||||
metricSource: {
|
||||
...strictPayloadWithModelConfidence.recommendations[0]
|
||||
.metricSource,
|
||||
...strictPayloadWithModelConfidence.recommendations[0].metricSource,
|
||||
n: 1,
|
||||
},
|
||||
},
|
||||
@@ -145,7 +144,9 @@ describe("end-to-end — generateInsight() confidence override flows into the ca
|
||||
userPrompt: "u",
|
||||
});
|
||||
|
||||
expect(outcome.parsed.recommendations[0].confidence).toBeLessThanOrEqual(15);
|
||||
expect(outcome.parsed.recommendations[0].confidence).toBeLessThanOrEqual(
|
||||
15,
|
||||
);
|
||||
|
||||
const insight: InsightResult = {
|
||||
insightType: "general",
|
||||
|
||||
@@ -80,10 +80,7 @@ function render(node: React.ReactNode, locale: "en" | "de" = "en") {
|
||||
describe("<RecommendationCard> — confidence slot wiring", () => {
|
||||
it("renders ConfidenceMeter inside rec-confidence-slot when confidence is set", () => {
|
||||
const html = render(
|
||||
<RecommendationCard
|
||||
rec={{ ...recBase, confidence: 85 }}
|
||||
index={0}
|
||||
/>,
|
||||
<RecommendationCard rec={{ ...recBase, confidence: 85 }} index={0} />,
|
||||
);
|
||||
// Slot wraps the meter
|
||||
expect(html).toMatch(
|
||||
|
||||
@@ -230,7 +230,7 @@ describe("<RecommendationCard>", () => {
|
||||
const html = render(
|
||||
<RecommendationCard rec={recNoId} index={0} initiallyExpanded />,
|
||||
);
|
||||
expect(html).not.toContain('data-feedback-thumb=');
|
||||
expect(html).not.toContain("data-feedback-thumb=");
|
||||
});
|
||||
|
||||
it("does NOT render feedback thumbs when the metricSource.timeRange is out of vocabulary", () => {
|
||||
@@ -241,6 +241,6 @@ describe("<RecommendationCard>", () => {
|
||||
const html = render(
|
||||
<RecommendationCard rec={recBadWindow} index={0} initiallyExpanded />,
|
||||
);
|
||||
expect(html).not.toContain('data-feedback-thumb=');
|
||||
expect(html).not.toContain("data-feedback-thumb=");
|
||||
});
|
||||
});
|
||||
@@ -40,14 +40,14 @@ function render(node: React.ReactNode, locale: "en" | "de" = "en") {
|
||||
describe("<RecommendationFeedback>", () => {
|
||||
it("renders thumbs-up + thumbs-down buttons", () => {
|
||||
const html = render(<RecommendationFeedback {...baseProps} />);
|
||||
expect(html).toContain("data-feedback-thumb=\"up\"");
|
||||
expect(html).toContain("data-feedback-thumb=\"down\"");
|
||||
expect(html).toContain('data-feedback-thumb="up"');
|
||||
expect(html).toContain('data-feedback-thumb="down"');
|
||||
});
|
||||
|
||||
it("attaches accessible aria-label keys", () => {
|
||||
const html = render(<RecommendationFeedback {...baseProps} />);
|
||||
expect(html).toContain("aria-label=\"Helpful");
|
||||
expect(html).toContain("aria-label=\"Not helpful");
|
||||
expect(html).toContain('aria-label="Helpful');
|
||||
expect(html).toContain('aria-label="Not helpful');
|
||||
});
|
||||
|
||||
it("translates the labels to German when locale=de", () => {
|
||||
@@ -63,16 +63,19 @@ describe("<RecommendationFeedback>", () => {
|
||||
// Confirmation text replaces both buttons.
|
||||
expect(html).toContain("Thanks");
|
||||
// Highlighted thumb-up still rendered for context.
|
||||
expect(html).toContain("data-feedback-confirmed=\"up\"");
|
||||
expect(html).toContain('data-feedback-confirmed="up"');
|
||||
// The opposite button should NOT be re-clickable.
|
||||
expect(html).not.toContain('data-feedback-thumb="down"');
|
||||
});
|
||||
|
||||
it("renders the 'already rated' state with the prior verdict highlighted", () => {
|
||||
const html = render(
|
||||
<RecommendationFeedback {...baseProps} initialState="already-rated-down" />,
|
||||
<RecommendationFeedback
|
||||
{...baseProps}
|
||||
initialState="already-rated-down"
|
||||
/>,
|
||||
);
|
||||
expect(html).toContain("data-feedback-already-rated=\"down\"");
|
||||
expect(html).toContain('data-feedback-already-rated="down"');
|
||||
// Buttons are not interactive in this state.
|
||||
expect(html).not.toContain('data-feedback-thumb="up"');
|
||||
expect(html).not.toContain('data-feedback-thumb="down"');
|
||||
@@ -81,13 +84,11 @@ describe("<RecommendationFeedback>", () => {
|
||||
it("buttons carry type=button so they don't accidentally submit a parent form", () => {
|
||||
const html = render(<RecommendationFeedback {...baseProps} />);
|
||||
// Both up + down buttons must declare type=button.
|
||||
const upMatch = html.match(
|
||||
/<button[^>]*data-feedback-thumb="up"[^>]*>/,
|
||||
);
|
||||
const upMatch = html.match(/<button[^>]*data-feedback-thumb="up"[^>]*>/);
|
||||
const downMatch = html.match(
|
||||
/<button[^>]*data-feedback-thumb="down"[^>]*>/,
|
||||
);
|
||||
expect(upMatch?.[0]).toContain("type=\"button\"");
|
||||
expect(downMatch?.[0]).toContain("type=\"button\"");
|
||||
expect(upMatch?.[0]).toContain('type="button"');
|
||||
expect(downMatch?.[0]).toContain('type="button"');
|
||||
});
|
||||
});
|
||||
@@ -85,9 +85,7 @@ function rec(
|
||||
};
|
||||
}
|
||||
|
||||
function buildInsight(
|
||||
recommendations: InsightRecommendation[],
|
||||
): InsightResult {
|
||||
function buildInsight(recommendations: InsightRecommendation[]): InsightResult {
|
||||
return {
|
||||
insightType: "general",
|
||||
summary: "Test summary",
|
||||
@@ -145,10 +143,7 @@ describe("InsightAdvisorCard + RecommendationsGrid full flow (B1b)", () => {
|
||||
];
|
||||
const html = renderToStaticMarkup(
|
||||
<I18nProvider initialLocale="en">
|
||||
<InsightAdvisorCard
|
||||
title="draft"
|
||||
insight={buildInsight(recs)}
|
||||
/>
|
||||
<InsightAdvisorCard title="draft" insight={buildInsight(recs)} />
|
||||
</I18nProvider>,
|
||||
);
|
||||
|
||||
|
||||
@@ -120,10 +120,7 @@ describe("sortRecommendationsBySeverity", () => {
|
||||
|
||||
describe("<RecommendationsGrid>", () => {
|
||||
it("renders cards in a 2-col responsive grid", () => {
|
||||
const recs = [
|
||||
rec("a", "info", "alpha"),
|
||||
rec("b", "urgent", "bravo"),
|
||||
];
|
||||
const recs = [rec("a", "info", "alpha"), rec("b", "urgent", "bravo")];
|
||||
const html = render(<RecommendationsGrid recs={recs} />);
|
||||
expect(html).toMatch(/data-slot="rec-grid"/);
|
||||
expect(html).toMatch(/grid-cols-1/);
|
||||
@@ -131,10 +128,7 @@ describe("<RecommendationsGrid>", () => {
|
||||
});
|
||||
|
||||
it("renders cards in severity-ordered sequence (urgent first)", () => {
|
||||
const recs = [
|
||||
rec("a", "info", "alpha"),
|
||||
rec("b", "urgent", "bravo"),
|
||||
];
|
||||
const recs = [rec("a", "info", "alpha"), rec("b", "urgent", "bravo")];
|
||||
const html = render(<RecommendationsGrid recs={recs} />);
|
||||
// bravo (urgent) must appear before alpha (info) in the output.
|
||||
expect(html.indexOf("bravo")).toBeLessThan(html.indexOf("alpha"));
|
||||
|
||||
@@ -47,12 +47,14 @@ function litBarsFor(value: number): number {
|
||||
return lit;
|
||||
}
|
||||
|
||||
const BAR_LIT_COLOUR_BY_BAND: Record<Exclude<ConfidenceBand, "draft">, string> =
|
||||
{
|
||||
high: "bg-dracula-green",
|
||||
medium: "bg-dracula-yellow",
|
||||
low: "bg-dracula-orange",
|
||||
};
|
||||
const BAR_LIT_COLOUR_BY_BAND: Record<
|
||||
Exclude<ConfidenceBand, "draft">,
|
||||
string
|
||||
> = {
|
||||
high: "bg-dracula-green",
|
||||
medium: "bg-dracula-yellow",
|
||||
low: "bg-dracula-orange",
|
||||
};
|
||||
|
||||
const RING_STROKE_COLOUR_BY_BAND: Record<
|
||||
Exclude<ConfidenceBand, "draft">,
|
||||
|
||||
@@ -178,7 +178,9 @@ function RationaleCard({
|
||||
feedbackProps,
|
||||
}: {
|
||||
rationale: InsightRecommendationRationale;
|
||||
metricSource: { type: string; timeRange: string; summary: string } | undefined;
|
||||
metricSource:
|
||||
| { type: string; timeRange: string; summary: string }
|
||||
| undefined;
|
||||
referenceId: string | undefined;
|
||||
confidence: number | undefined;
|
||||
locale: Locale;
|
||||
@@ -295,10 +297,7 @@ export function RecommendationCard({
|
||||
// empty so a thumbs-click can never produce a 422.
|
||||
const feedbackTimeRange = asFeedbackTimeRange(norm.metricSource?.timeRange);
|
||||
const feedbackProps =
|
||||
norm.id &&
|
||||
norm.severity &&
|
||||
norm.metricSource?.type &&
|
||||
feedbackTimeRange
|
||||
norm.id && norm.severity && norm.metricSource?.type && feedbackTimeRange
|
||||
? {
|
||||
recId: norm.id,
|
||||
recText: norm.text,
|
||||
|
||||
@@ -113,10 +113,7 @@ function writeLocalState(
|
||||
): void {
|
||||
if (typeof window === "undefined") return;
|
||||
try {
|
||||
window.localStorage.setItem(
|
||||
localCacheKey(userId, recId, recText),
|
||||
verdict,
|
||||
);
|
||||
window.localStorage.setItem(localCacheKey(userId, recId, recText), verdict);
|
||||
} catch {
|
||||
// Best-effort — server dedup is the source of truth.
|
||||
}
|
||||
@@ -259,10 +256,7 @@ export function RecommendationFeedback({
|
||||
const disabled = submittingUp || submittingDown;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex items-center gap-1.5"
|
||||
data-slot="rec-feedback-buttons"
|
||||
>
|
||||
<div className="flex items-center gap-1.5" data-slot="rec-feedback-buttons">
|
||||
<button
|
||||
type="button"
|
||||
data-feedback-thumb="up"
|
||||
|
||||
@@ -96,7 +96,9 @@ export function RecommendationsGrid({ recs }: RecommendationsGridProps) {
|
||||
SEVERITY_BORDER_FALLBACK;
|
||||
return (
|
||||
<div
|
||||
key={typeof rec === "string" ? `${index}-${rec}` : (rec.id ?? index)}
|
||||
key={
|
||||
typeof rec === "string" ? `${index}-${rec}` : (rec.id ?? index)
|
||||
}
|
||||
data-stagger-index={index}
|
||||
role="listitem"
|
||||
className={`animate-insight-in transition-all md:hover:-translate-y-0.5 md:hover:shadow-lg ${borderClass} rounded-lg border-l-2`}
|
||||
|
||||
@@ -64,12 +64,10 @@ describe("<BottomNav> 5+More layout", () => {
|
||||
const html = render();
|
||||
// Attribute order isn't guaranteed by React's renderer, so match
|
||||
// both possible directions instead of pinning the order.
|
||||
const hasLabelFirst = /aria-label="More"[^>]*data-testid="bottom-nav-more"/.test(
|
||||
html,
|
||||
);
|
||||
const hasTestidFirst = /data-testid="bottom-nav-more"[^>]*aria-label="More"/.test(
|
||||
html,
|
||||
);
|
||||
const hasLabelFirst =
|
||||
/aria-label="More"[^>]*data-testid="bottom-nav-more"/.test(html);
|
||||
const hasTestidFirst =
|
||||
/data-testid="bottom-nav-more"[^>]*aria-label="More"/.test(html);
|
||||
expect(hasLabelFirst || hasTestidFirst).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -13,10 +13,7 @@
|
||||
* correctness.
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
tourReferrerKey,
|
||||
tourSessionDismissedKey,
|
||||
} from "../tour-launcher";
|
||||
import { tourReferrerKey, tourSessionDismissedKey } from "../tour-launcher";
|
||||
|
||||
describe("tour-launcher sessionStorage key scoping", () => {
|
||||
it("scopes the dismiss key by user id", () => {
|
||||
|
||||
@@ -28,8 +28,18 @@ function lookup(bag: Bag, path: string[]): string | null {
|
||||
describe("settings sections — i18n parity (B6)", () => {
|
||||
for (const slug of SETTINGS_SECTION_SLUGS) {
|
||||
it(`every locale resolves settings.sections.${slug}.title`, () => {
|
||||
const enValue = lookup(en as Bag, ["settings", "sections", slug, "title"]);
|
||||
const deValue = lookup(de as Bag, ["settings", "sections", slug, "title"]);
|
||||
const enValue = lookup(en as Bag, [
|
||||
"settings",
|
||||
"sections",
|
||||
slug,
|
||||
"title",
|
||||
]);
|
||||
const deValue = lookup(de as Bag, [
|
||||
"settings",
|
||||
"sections",
|
||||
slug,
|
||||
"title",
|
||||
]);
|
||||
expect(enValue, `EN missing title for ${slug}`).toBeTypeOf("string");
|
||||
expect(enValue?.length ?? 0).toBeGreaterThan(0);
|
||||
expect(deValue, `DE missing title for ${slug}`).toBeTypeOf("string");
|
||||
@@ -49,9 +59,13 @@ describe("settings sections — i18n parity (B6)", () => {
|
||||
slug,
|
||||
"description",
|
||||
]);
|
||||
expect(enValue, `EN missing description for ${slug}`).toBeTypeOf("string");
|
||||
expect(enValue, `EN missing description for ${slug}`).toBeTypeOf(
|
||||
"string",
|
||||
);
|
||||
expect(enValue?.length ?? 0).toBeGreaterThan(0);
|
||||
expect(deValue, `DE missing description for ${slug}`).toBeTypeOf("string");
|
||||
expect(deValue, `DE missing description for ${slug}`).toBeTypeOf(
|
||||
"string",
|
||||
);
|
||||
expect(deValue?.length ?? 0).toBeGreaterThan(0);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -105,11 +105,7 @@ const DEFAULT_CHAIN: readonly ChainEntry[] = [
|
||||
* fine-tunes (e.g. `gpt-5`, which is admitted via the env override on
|
||||
* the Codex backend but not yet a documented OpenAI model id).
|
||||
*/
|
||||
const OPENAI_MODEL_PRESETS = [
|
||||
"gpt-4o",
|
||||
"gpt-4o-mini",
|
||||
"gpt-4-turbo",
|
||||
] as const;
|
||||
const OPENAI_MODEL_PRESETS = ["gpt-4o", "gpt-4o-mini", "gpt-4-turbo"] as const;
|
||||
const ANTHROPIC_MODEL_PRESETS = [
|
||||
"claude-sonnet-4-6",
|
||||
"claude-opus-4-7",
|
||||
@@ -268,10 +264,7 @@ function AiInsightsCard({ isAuthenticated }: { isAuthenticated: boolean }) {
|
||||
{t("settings.kiInsightsDescription")}
|
||||
</p>
|
||||
|
||||
<ActiveProviderSelect
|
||||
value={selectedProvider}
|
||||
onChange={pickProvider}
|
||||
/>
|
||||
<ActiveProviderSelect value={selectedProvider} onChange={pickProvider} />
|
||||
|
||||
<ProviderConfigCard
|
||||
provider={selectedProvider}
|
||||
@@ -1096,10 +1089,7 @@ function LocalProviderForm({
|
||||
if (userProvider?.provider === "LOCAL") {
|
||||
setBaseUrl(userProvider.baseUrl ?? "");
|
||||
const saved = userProvider.model ?? "";
|
||||
if (
|
||||
saved &&
|
||||
(LOCAL_MODEL_PRESETS as readonly string[]).includes(saved)
|
||||
) {
|
||||
if (saved && (LOCAL_MODEL_PRESETS as readonly string[]).includes(saved)) {
|
||||
setModelChoice(saved);
|
||||
setCustomModel("");
|
||||
} else if (saved) {
|
||||
|
||||
@@ -54,9 +54,7 @@ export function useChartOverlayPrefs(
|
||||
|
||||
const prefs = useMemo<ChartOverlayPrefs>(() => {
|
||||
if (!chartKey) return DEFAULT_CHART_OVERLAY_PREFS;
|
||||
return (
|
||||
layout?.chartOverlayPrefs?.[chartKey] ?? DEFAULT_CHART_OVERLAY_PREFS
|
||||
);
|
||||
return layout?.chartOverlayPrefs?.[chartKey] ?? DEFAULT_CHART_OVERLAY_PREFS;
|
||||
}, [layout, chartKey]);
|
||||
|
||||
const mutation = useMutation({
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
aiInsightResponseSchema,
|
||||
aiRecommendationSchema,
|
||||
} from "../schema";
|
||||
import { aiInsightResponseSchema, aiRecommendationSchema } from "../schema";
|
||||
|
||||
/**
|
||||
* v1.4.16 phase B5d — `recommendation.confidence` schema field.
|
||||
@@ -75,7 +72,8 @@ describe("aiRecommendationSchema — confidence", () => {
|
||||
|
||||
it("rejects non-integer confidence", () => {
|
||||
expect(
|
||||
aiRecommendationSchema.safeParse({ ...baseRec, confidence: 67.5 }).success,
|
||||
aiRecommendationSchema.safeParse({ ...baseRec, confidence: 67.5 })
|
||||
.success,
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
|
||||
@@ -102,8 +102,7 @@ describe("generateInsight() — confidence override", () => {
|
||||
{
|
||||
...validResponseWithModelConfidence.recommendations[0],
|
||||
metricSource: {
|
||||
...validResponseWithModelConfidence.recommendations[0]
|
||||
.metricSource,
|
||||
...validResponseWithModelConfidence.recommendations[0].metricSource,
|
||||
n: 14,
|
||||
},
|
||||
},
|
||||
@@ -127,8 +126,7 @@ describe("generateInsight() — confidence override", () => {
|
||||
{
|
||||
...validResponseWithModelConfidence.recommendations[0],
|
||||
metricSource: {
|
||||
...validResponseWithModelConfidence.recommendations[0]
|
||||
.metricSource,
|
||||
...validResponseWithModelConfidence.recommendations[0].metricSource,
|
||||
n: 1,
|
||||
},
|
||||
},
|
||||
@@ -182,8 +180,7 @@ describe("generateInsight() — confidence override", () => {
|
||||
id: "rec-b",
|
||||
confidence: 1,
|
||||
metricSource: {
|
||||
...validResponseWithModelConfidence.recommendations[0]
|
||||
.metricSource,
|
||||
...validResponseWithModelConfidence.recommendations[0].metricSource,
|
||||
n: 5,
|
||||
},
|
||||
},
|
||||
|
||||
@@ -46,10 +46,9 @@ describe("MEDICAL_REFERENCES bundle", () => {
|
||||
expect(ref.publishedYear, `year missing on ${ref.id}`).toBeGreaterThan(
|
||||
1990,
|
||||
);
|
||||
expect(
|
||||
Array.isArray(ref.scope),
|
||||
`scope must be array on ${ref.id}`,
|
||||
).toBe(true);
|
||||
expect(Array.isArray(ref.scope), `scope must be array on ${ref.id}`).toBe(
|
||||
true,
|
||||
);
|
||||
expect(
|
||||
ref.scope.length,
|
||||
`scope cannot be empty on ${ref.id}`,
|
||||
@@ -67,7 +66,10 @@ describe("MEDICAL_REFERENCES bundle", () => {
|
||||
|
||||
it("every URL parses as a well-formed https:// URL", () => {
|
||||
for (const ref of MEDICAL_REFERENCES) {
|
||||
expect(() => new URL(ref.url), `URL malformed on ${ref.id}`).not.toThrow();
|
||||
expect(
|
||||
() => new URL(ref.url),
|
||||
`URL malformed on ${ref.id}`,
|
||||
).not.toThrow();
|
||||
const parsed = new URL(ref.url);
|
||||
expect(parsed.protocol, `non-https URL on ${ref.id}`).toBe("https:");
|
||||
expect(parsed.hostname, `empty hostname on ${ref.id}`).toBeTruthy();
|
||||
|
||||
@@ -101,8 +101,7 @@ describe("PROVIDER_CHAIN_DEFAULT", () => {
|
||||
});
|
||||
|
||||
it("includes admin OpenAI as final fallback so misconfigured users still see insights", () => {
|
||||
const last =
|
||||
PROVIDER_CHAIN_DEFAULT[PROVIDER_CHAIN_DEFAULT.length - 1];
|
||||
const last = PROVIDER_CHAIN_DEFAULT[PROVIDER_CHAIN_DEFAULT.length - 1];
|
||||
expect(last.providerType).toBe("admin-openai");
|
||||
});
|
||||
|
||||
|
||||
@@ -113,9 +113,7 @@ describe("runWithFallback — happy path", () => {
|
||||
});
|
||||
const result = await runWithFallback({
|
||||
userId: "u1",
|
||||
providers: [
|
||||
{ providerType: "codex", instance: codex },
|
||||
],
|
||||
providers: [{ providerType: "codex", instance: codex }],
|
||||
params: { systemPrompt: "s", userPrompt: "u" },
|
||||
});
|
||||
expect(result.parsed.summary).toBe("ok");
|
||||
@@ -131,9 +129,7 @@ describe("runWithFallback — happy path", () => {
|
||||
});
|
||||
const result = await runWithFallback({
|
||||
userId: "u1",
|
||||
providers: [
|
||||
{ providerType: "openai", instance: openai },
|
||||
],
|
||||
providers: [{ providerType: "openai", instance: openai }],
|
||||
params: { systemPrompt: "s", userPrompt: "u" },
|
||||
});
|
||||
expect(result.workingProvider.providerType).toBe("openai");
|
||||
@@ -197,10 +193,7 @@ describe("runWithFallback — all-fail cascade", () => {
|
||||
const e = caught as AllProvidersFailedError;
|
||||
expect(e.httpStatus).toBe(503);
|
||||
expect(e.attempts).toHaveLength(2);
|
||||
expect(e.attempts.map((a) => a.providerType)).toEqual([
|
||||
"codex",
|
||||
"openai",
|
||||
]);
|
||||
expect(e.attempts.map((a) => a.providerType)).toEqual(["codex", "openai"]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -215,7 +208,10 @@ describe("runWithFallback — non-hard errors bubble unchanged", () => {
|
||||
// Both wrapper attempts return invalid JSON → wrapper raises
|
||||
// InsightSchemaError(422). The runner does not walk to provider
|
||||
// 2 because that error is not a "hard provider failure".
|
||||
script: [{ ok: true, content: "not-json" }, { ok: true, content: "still-not-json" }],
|
||||
script: [
|
||||
{ ok: true, content: "not-json" },
|
||||
{ ok: true, content: "still-not-json" },
|
||||
],
|
||||
});
|
||||
const openai = new ScriptedProvider({
|
||||
type: "admin-key",
|
||||
@@ -348,7 +344,7 @@ describe("runRawCompletionWithFallback — legacy route shim", () => {
|
||||
});
|
||||
const openai = new ScriptedProvider({
|
||||
type: "admin-key",
|
||||
script: [{ ok: true, content: "{ \"legacy\": true }" }],
|
||||
script: [{ ok: true, content: '{ "legacy": true }' }],
|
||||
});
|
||||
const result = await runRawCompletionWithFallback({
|
||||
userId: "u-raw",
|
||||
|
||||
@@ -70,9 +70,9 @@ const baseValid: AIInsightResponse = {
|
||||
|
||||
describe("aiRecommendationRationaleSchema", () => {
|
||||
it("accepts a well-formed rationale", () => {
|
||||
expect(aiRecommendationRationaleSchema.safeParse(baseRationale).success).toBe(
|
||||
true,
|
||||
);
|
||||
expect(
|
||||
aiRecommendationRationaleSchema.safeParse(baseRationale).success,
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it.each(["last7days", "last30days", "last90days", "allTime"] as const)(
|
||||
@@ -185,10 +185,7 @@ describe("aiInsightResponseSchema — rationale integration", () => {
|
||||
void _r;
|
||||
const result = aiInsightResponseSchema.safeParse({
|
||||
...baseValid,
|
||||
recommendations: [
|
||||
baseRec,
|
||||
{ ...recWithoutRationale, id: "rec-2" },
|
||||
],
|
||||
recommendations: [baseRec, { ...recWithoutRationale, id: "rec-2" }],
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
@@ -69,21 +69,20 @@ describe("resolveProviderChain", () => {
|
||||
});
|
||||
|
||||
it("respects user-defined chain priority order", async () => {
|
||||
vi.mocked(prisma.user.findUnique)
|
||||
.mockResolvedValueOnce({
|
||||
aiProvider: null,
|
||||
aiModel: null,
|
||||
aiBaseUrl: null,
|
||||
aiAnthropicKeyEncrypted: "enc-anth",
|
||||
aiLocalKeyEncrypted: null,
|
||||
aiOpenaiKeyEncrypted: "enc-openai",
|
||||
// user puts openai first, anthropic second; admin disabled
|
||||
aiProviderChain: [
|
||||
{ providerType: "openai", priority: 1, enabled: true },
|
||||
{ providerType: "anthropic", priority: 2, enabled: true },
|
||||
{ providerType: "admin-openai", priority: 3, enabled: false },
|
||||
],
|
||||
} as never);
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValueOnce({
|
||||
aiProvider: null,
|
||||
aiModel: null,
|
||||
aiBaseUrl: null,
|
||||
aiAnthropicKeyEncrypted: "enc-anth",
|
||||
aiLocalKeyEncrypted: null,
|
||||
aiOpenaiKeyEncrypted: "enc-openai",
|
||||
// user puts openai first, anthropic second; admin disabled
|
||||
aiProviderChain: [
|
||||
{ providerType: "openai", priority: 1, enabled: true },
|
||||
{ providerType: "anthropic", priority: 2, enabled: true },
|
||||
{ providerType: "admin-openai", priority: 3, enabled: false },
|
||||
],
|
||||
} as never);
|
||||
vi.mocked(prisma.appSettings.findUnique).mockResolvedValue({
|
||||
adminAiKeyEncrypted: "enc-admin",
|
||||
adminAiModel: "gpt-4o-mini",
|
||||
@@ -96,19 +95,18 @@ describe("resolveProviderChain", () => {
|
||||
});
|
||||
|
||||
it("filters out chain entries the user has not credentialed", async () => {
|
||||
vi.mocked(prisma.user.findUnique)
|
||||
.mockResolvedValueOnce({
|
||||
aiProvider: null,
|
||||
aiModel: null,
|
||||
aiBaseUrl: null,
|
||||
aiAnthropicKeyEncrypted: null, // no anthropic key
|
||||
aiLocalKeyEncrypted: null,
|
||||
aiOpenaiKeyEncrypted: "enc-openai",
|
||||
aiProviderChain: [
|
||||
{ providerType: "anthropic", priority: 1, enabled: true },
|
||||
{ providerType: "openai", priority: 2, enabled: true },
|
||||
],
|
||||
} as never);
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValueOnce({
|
||||
aiProvider: null,
|
||||
aiModel: null,
|
||||
aiBaseUrl: null,
|
||||
aiAnthropicKeyEncrypted: null, // no anthropic key
|
||||
aiLocalKeyEncrypted: null,
|
||||
aiOpenaiKeyEncrypted: "enc-openai",
|
||||
aiProviderChain: [
|
||||
{ providerType: "anthropic", priority: 1, enabled: true },
|
||||
{ providerType: "openai", priority: 2, enabled: true },
|
||||
],
|
||||
} as never);
|
||||
vi.mocked(prisma.appSettings.findUnique).mockResolvedValue({
|
||||
adminAiKeyEncrypted: null,
|
||||
adminAiModel: "gpt-4o-mini",
|
||||
@@ -121,19 +119,18 @@ describe("resolveProviderChain", () => {
|
||||
});
|
||||
|
||||
it("skips disabled entries even if the credential is present", async () => {
|
||||
vi.mocked(prisma.user.findUnique)
|
||||
.mockResolvedValueOnce({
|
||||
aiProvider: null,
|
||||
aiModel: null,
|
||||
aiBaseUrl: null,
|
||||
aiAnthropicKeyEncrypted: null,
|
||||
aiLocalKeyEncrypted: null,
|
||||
aiOpenaiKeyEncrypted: "enc-openai",
|
||||
aiProviderChain: [
|
||||
{ providerType: "openai", priority: 1, enabled: false }, // disabled
|
||||
{ providerType: "admin-openai", priority: 2, enabled: true },
|
||||
],
|
||||
} as never);
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValueOnce({
|
||||
aiProvider: null,
|
||||
aiModel: null,
|
||||
aiBaseUrl: null,
|
||||
aiAnthropicKeyEncrypted: null,
|
||||
aiLocalKeyEncrypted: null,
|
||||
aiOpenaiKeyEncrypted: "enc-openai",
|
||||
aiProviderChain: [
|
||||
{ providerType: "openai", priority: 1, enabled: false }, // disabled
|
||||
{ providerType: "admin-openai", priority: 2, enabled: true },
|
||||
],
|
||||
} as never);
|
||||
vi.mocked(prisma.appSettings.findUnique).mockResolvedValue({
|
||||
adminAiKeyEncrypted: "enc-admin",
|
||||
adminAiModel: "gpt-4o-mini",
|
||||
|
||||
@@ -38,7 +38,8 @@ function parseAuditDetails(raw: string | null): AuditDetailsShape {
|
||||
if (!raw) return {};
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
if (parsed && typeof parsed === "object") return parsed as AuditDetailsShape;
|
||||
if (parsed && typeof parsed === "object")
|
||||
return parsed as AuditDetailsShape;
|
||||
} catch {
|
||||
// Treat unparseable details as absent — the fallback applies.
|
||||
}
|
||||
|
||||
@@ -28,13 +28,7 @@
|
||||
* model emits an id, the UI renders a labelled link.
|
||||
*/
|
||||
|
||||
export type MedicalReferenceOrg =
|
||||
| "AHA"
|
||||
| "ESH"
|
||||
| "ESC"
|
||||
| "WHO"
|
||||
| "DGE"
|
||||
| "ADA";
|
||||
export type MedicalReferenceOrg = "AHA" | "ESH" | "ESC" | "WHO" | "DGE" | "ADA";
|
||||
|
||||
export type MedicalReferenceMetric =
|
||||
| "bp"
|
||||
@@ -107,7 +101,8 @@ export const MEDICAL_REFERENCES: MedicalReference[] = [
|
||||
{
|
||||
id: "who-2021-hypertension",
|
||||
org: "WHO",
|
||||
title: "WHO Guideline for the Pharmacological Treatment of Hypertension in Adults",
|
||||
title:
|
||||
"WHO Guideline for the Pharmacological Treatment of Hypertension in Adults",
|
||||
titleDe:
|
||||
"WHO-Leitlinie zur pharmakologischen Behandlung der Hypertonie bei Erwachsenen",
|
||||
url: "https://www.who.int/publications/i/item/9789240033986",
|
||||
@@ -153,9 +148,8 @@ export const MEDICAL_REFERENCE_IDS: readonly string[] = MEDICAL_REFERENCES.map(
|
||||
);
|
||||
|
||||
/** Stable list of all distinct issuing bodies. */
|
||||
export const MEDICAL_REFERENCE_ORGS: readonly MedicalReferenceOrg[] = Array.from(
|
||||
new Set(MEDICAL_REFERENCES.map((r) => r.org)),
|
||||
);
|
||||
export const MEDICAL_REFERENCE_ORGS: readonly MedicalReferenceOrg[] =
|
||||
Array.from(new Set(MEDICAL_REFERENCES.map((r) => r.org)));
|
||||
|
||||
/**
|
||||
* Look up a reference by id. Returns `undefined` for unknown ids — the
|
||||
|
||||
@@ -93,9 +93,7 @@ export function parseProviderChain(
|
||||
};
|
||||
const providerType = candidate.providerType;
|
||||
if (typeof providerType !== "string") continue;
|
||||
if (
|
||||
!PROVIDER_CHAIN_TYPES.includes(providerType as ProviderChainType)
|
||||
) {
|
||||
if (!PROVIDER_CHAIN_TYPES.includes(providerType as ProviderChainType)) {
|
||||
continue;
|
||||
}
|
||||
if (seen.has(providerType as ProviderChainType)) continue;
|
||||
|
||||
@@ -1,8 +1,4 @@
|
||||
import type {
|
||||
AIProvider,
|
||||
CompletionParams,
|
||||
CompletionResult,
|
||||
} from "./types";
|
||||
import type { AIProvider, CompletionParams, CompletionResult } from "./types";
|
||||
import {
|
||||
generateInsight,
|
||||
type GenerateInsightOutcome,
|
||||
|
||||
@@ -11,10 +11,7 @@ import {
|
||||
decryptCodexCreds,
|
||||
} from "./codex-oauth";
|
||||
import { isPublicUrl } from "@/lib/validations/notifications";
|
||||
import {
|
||||
parseProviderChain,
|
||||
type ProviderChainType,
|
||||
} from "./provider-chain";
|
||||
import { parseProviderChain, type ProviderChainType } from "./provider-chain";
|
||||
import type { ProviderChainResolved } from "./provider-runner";
|
||||
|
||||
const TOKEN_REFRESH_BUFFER_MS = 5 * 60 * 1000; // 5 minutes
|
||||
|
||||
+1
-3
@@ -65,9 +65,7 @@ export const insightRecommendationSchema = z.union([
|
||||
n: z.number().optional(),
|
||||
})
|
||||
.optional(),
|
||||
severity: z
|
||||
.enum(["info", "suggestion", "important", "urgent"])
|
||||
.optional(),
|
||||
severity: z.enum(["info", "suggestion", "important", "urgent"]).optional(),
|
||||
id: z.string().optional(),
|
||||
/**
|
||||
* v1.4.16 phase B5d — deterministic confidence score (0-100).
|
||||
|
||||
@@ -93,12 +93,12 @@ export async function applyProfileUpdate(
|
||||
// touch other fields.
|
||||
const priorLocale =
|
||||
data.locale !== undefined
|
||||
? (
|
||||
? ((
|
||||
await prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: { locale: true },
|
||||
})
|
||||
)?.locale ?? null
|
||||
)?.locale ?? null)
|
||||
: null;
|
||||
|
||||
const updatedUser = await prisma.user.update({
|
||||
|
||||
@@ -60,7 +60,9 @@ export function shiftDailySeriesForward<T extends { timestamp: number }>(
|
||||
* average and the prior-period average so the difference is what the
|
||||
* user sees in the chart, not a single-day cherry-pick.
|
||||
*/
|
||||
export function averageValue(values: Array<number | null | undefined>): number | null {
|
||||
export function averageValue(
|
||||
values: Array<number | null | undefined>,
|
||||
): number | null {
|
||||
let sum = 0;
|
||||
let n = 0;
|
||||
for (const v of values) {
|
||||
|
||||
@@ -12,7 +12,10 @@
|
||||
* vitest "node" environment.
|
||||
*/
|
||||
export function prefersReducedMotion(): boolean {
|
||||
if (typeof window === "undefined" || typeof window.matchMedia !== "function") {
|
||||
if (
|
||||
typeof window === "undefined" ||
|
||||
typeof window.matchMedia !== "function"
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
|
||||
@@ -130,7 +130,9 @@ export const DEFAULT_CHART_OVERLAY_PREFS: ChartOverlayPrefs = {
|
||||
showTargetRange: false,
|
||||
};
|
||||
|
||||
export type ChartOverlayPrefsMap = Partial<Record<ChartOverlayKey, ChartOverlayPrefs>>;
|
||||
export type ChartOverlayPrefsMap = Partial<
|
||||
Record<ChartOverlayKey, ChartOverlayPrefs>
|
||||
>;
|
||||
|
||||
function isChartOverlayKey(value: string): value is ChartOverlayKey {
|
||||
return (CHART_OVERLAY_KEYS as readonly string[]).includes(value);
|
||||
|
||||
@@ -169,7 +169,10 @@ describe("applyDiscoveryFilter", () => {
|
||||
});
|
||||
|
||||
it("keeps engagement / security achievements unconditionally", () => {
|
||||
const items = [progressFor("login-streak-7"), progressFor("passkey-created-1")];
|
||||
const items = [
|
||||
progressFor("login-streak-7"),
|
||||
progressFor("passkey-created-1"),
|
||||
];
|
||||
const filtered = applyDiscoveryFilter(items, NONE_EARNABLE);
|
||||
expect(filtered).toHaveLength(2);
|
||||
});
|
||||
|
||||
@@ -150,8 +150,16 @@ describe("getEngagementMetrics", () => {
|
||||
{ type: "WEIGHT", measuredAt: new Date("2026-04-01T08:00:00Z") },
|
||||
],
|
||||
moodEntries: [
|
||||
{ date: "2026-04-02", score: 3, moodLoggedAt: new Date("2026-04-02T08:00:00Z") },
|
||||
{ date: "2026-04-03", score: 3, moodLoggedAt: new Date("2026-04-03T08:00:00Z") },
|
||||
{
|
||||
date: "2026-04-02",
|
||||
score: 3,
|
||||
moodLoggedAt: new Date("2026-04-02T08:00:00Z"),
|
||||
},
|
||||
{
|
||||
date: "2026-04-03",
|
||||
score: 3,
|
||||
moodLoggedAt: new Date("2026-04-03T08:00:00Z"),
|
||||
},
|
||||
],
|
||||
intakeEvents: [
|
||||
{
|
||||
|
||||
@@ -88,9 +88,7 @@ export interface EarnabilityFlags {
|
||||
hasPulse: boolean;
|
||||
}
|
||||
|
||||
function categoryForMetric(
|
||||
metric: AchievementMetricKey,
|
||||
): AchievementCategory {
|
||||
function categoryForMetric(metric: AchievementMetricKey): AchievementCategory {
|
||||
switch (metric) {
|
||||
case "totalTakenIntakes":
|
||||
case "overIntakeCount":
|
||||
|
||||
@@ -38,14 +38,14 @@ const monthlySnapshot: ComparisonSnapshot = {
|
||||
|
||||
describe("buildUserPrompt() — comparison block (B8)", () => {
|
||||
it("appends NO comparison block when comparison is undefined", () => {
|
||||
const prompt = buildUserPrompt("{\"weight\":[]}", "agg", "en");
|
||||
const prompt = buildUserPrompt('{"weight":[]}', "agg", "en");
|
||||
expect(prompt).not.toMatch(/COMPARISON MODE/i);
|
||||
expect(prompt).not.toMatch(/VERGLEICHSMODUS/i);
|
||||
});
|
||||
|
||||
it("appends an English comparison block when comparison is supplied with locale=en", () => {
|
||||
const prompt = buildUserPrompt(
|
||||
"{\"weight\":[]}",
|
||||
'{"weight":[]}',
|
||||
"agg",
|
||||
"en",
|
||||
monthlySnapshot,
|
||||
@@ -60,7 +60,7 @@ describe("buildUserPrompt() — comparison block (B8)", () => {
|
||||
|
||||
it("appends a German comparison block when locale=de", () => {
|
||||
const prompt = buildUserPrompt(
|
||||
"{\"gewicht\":[]}",
|
||||
'{"gewicht":[]}',
|
||||
"agg",
|
||||
"de",
|
||||
monthlySnapshot,
|
||||
|
||||
@@ -120,7 +120,9 @@ export function buildUserPrompt(
|
||||
locale: Locale,
|
||||
comparison?: ComparisonSnapshot,
|
||||
): string {
|
||||
const comparisonBlock = comparison ? buildComparisonBlock(locale, comparison) : "";
|
||||
const comparisonBlock = comparison
|
||||
? buildComparisonBlock(locale, comparison)
|
||||
: "";
|
||||
|
||||
if (locale === "en") {
|
||||
const modeLabel =
|
||||
@@ -172,9 +174,7 @@ export function buildComparisonBlock(
|
||||
? "den gleichen Zeitraum 30 Tage zuvor"
|
||||
: "den gleichen Zeitraum 365 Tage zuvor";
|
||||
|
||||
const renderRow = (
|
||||
metric: ComparisonSnapshot["metrics"][number],
|
||||
): string => {
|
||||
const renderRow = (metric: ComparisonSnapshot["metrics"][number]): string => {
|
||||
if (metric.delta === null || metric.baselineAvg === null) {
|
||||
return `- ${metric.type}: no prior-period data available`;
|
||||
}
|
||||
|
||||
@@ -100,11 +100,7 @@ describe("buildFeedbackBuckets", () => {
|
||||
},
|
||||
];
|
||||
const buckets = buildFeedbackBuckets(rows);
|
||||
expect(buckets.map((b) => b.severity)).toEqual([
|
||||
"info",
|
||||
"info",
|
||||
"urgent",
|
||||
]);
|
||||
expect(buckets.map((b) => b.severity)).toEqual(["info", "info", "urgent"]);
|
||||
// Within "info" the metricSourceType is the secondary key.
|
||||
expect(buckets[0].metricSourceType).toBe("bloodPressure");
|
||||
expect(buckets[1].metricSourceType).toBe("mood");
|
||||
@@ -131,17 +127,17 @@ describe("aggregateRecommendationFeedback", () => {
|
||||
await aggregateRecommendationFeedback(prisma, { now });
|
||||
|
||||
expect(prisma.recommendationFeedback.findMany).toHaveBeenCalledTimes(1);
|
||||
const call = (prisma.recommendationFeedback.findMany as ReturnType<typeof vi.fn>).mock
|
||||
.calls[0][0];
|
||||
const call = (
|
||||
prisma.recommendationFeedback.findMany as ReturnType<typeof vi.fn>
|
||||
).mock.calls[0][0];
|
||||
const expectedSince = new Date(
|
||||
now.getTime() - DEFAULT_FEEDBACK_AGGREGATION_WINDOW_DAYS * 86_400_000,
|
||||
);
|
||||
expect(call.where.createdAt.gte).toEqual(expectedSince);
|
||||
|
||||
expect(prisma.appSettings.upsert).toHaveBeenCalledTimes(1);
|
||||
const upsertCall = (
|
||||
prisma.appSettings.upsert as ReturnType<typeof vi.fn>
|
||||
).mock.calls[0][0];
|
||||
const upsertCall = (prisma.appSettings.upsert as ReturnType<typeof vi.fn>)
|
||||
.mock.calls[0][0];
|
||||
expect(upsertCall.where).toEqual({ id: "singleton" });
|
||||
const summary = upsertCall.update.adminAiInsightsFeedbackSummary;
|
||||
expect(summary.windowDays).toBe(DEFAULT_FEEDBACK_AGGREGATION_WINDOW_DAYS);
|
||||
@@ -156,18 +152,18 @@ describe("aggregateRecommendationFeedback", () => {
|
||||
|
||||
await aggregateRecommendationFeedback(prisma, { now, windowDays: 7 });
|
||||
|
||||
const call = (prisma.recommendationFeedback.findMany as ReturnType<typeof vi.fn>).mock
|
||||
.calls[0][0];
|
||||
const call = (
|
||||
prisma.recommendationFeedback.findMany as ReturnType<typeof vi.fn>
|
||||
).mock.calls[0][0];
|
||||
const expectedSince = new Date(now.getTime() - 7 * 86_400_000);
|
||||
expect(call.where.createdAt.gte).toEqual(expectedSince);
|
||||
|
||||
const upsertCall = (
|
||||
prisma.appSettings.upsert as ReturnType<typeof vi.fn>
|
||||
).mock.calls[0][0];
|
||||
const upsertCall = (prisma.appSettings.upsert as ReturnType<typeof vi.fn>)
|
||||
.mock.calls[0][0];
|
||||
expect(upsertCall.update.adminAiInsightsFeedbackSummary.windowDays).toBe(7);
|
||||
// Empty buckets when no rows.
|
||||
expect(
|
||||
upsertCall.update.adminAiInsightsFeedbackSummary.buckets,
|
||||
).toEqual([]);
|
||||
expect(upsertCall.update.adminAiInsightsFeedbackSummary.buckets).toEqual(
|
||||
[],
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -88,9 +88,7 @@ async function seedNonAdminSession() {
|
||||
async function callRoute(query = "") {
|
||||
const { GET } = await import("@/app/api/admin/host-metrics/route");
|
||||
const url = `http://localhost/api/admin/host-metrics${query ? `?${query}` : ""}`;
|
||||
return GET(
|
||||
new Request(url) as unknown as Parameters<typeof GET>[0],
|
||||
);
|
||||
return GET(new Request(url) as unknown as Parameters<typeof GET>[0]);
|
||||
}
|
||||
|
||||
describe("GET /api/admin/host-metrics", () => {
|
||||
|
||||
@@ -292,9 +292,7 @@ describe("BP-in-target % — windowed (7-day + 30-day) — production data shape
|
||||
];
|
||||
|
||||
for (const r of seed) {
|
||||
const at = new Date(
|
||||
now.getTime() - r.daysAgo * 24 * 60 * 60 * 1000,
|
||||
);
|
||||
const at = new Date(now.getTime() - r.daysAgo * 24 * 60 * 60 * 1000);
|
||||
await prisma.measurement.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
@@ -360,9 +358,7 @@ describe("BP-in-target % — windowed (7-day + 30-day) — production data shape
|
||||
});
|
||||
|
||||
const now = new Date();
|
||||
const fourteenDaysAgo = new Date(
|
||||
now.getTime() - 14 * 24 * 60 * 60 * 1000,
|
||||
);
|
||||
const fourteenDaysAgo = new Date(now.getTime() - 14 * 24 * 60 * 60 * 1000);
|
||||
|
||||
await prisma.measurement.create({
|
||||
data: {
|
||||
|
||||
@@ -76,12 +76,10 @@ async function seedUser(): Promise<{ userId: string }> {
|
||||
describe("PUT /api/dashboard/chart-overlay-prefs — integration", () => {
|
||||
it("persists per-chart prefs and the follow-up GET /api/dashboard/widgets surfaces them", async () => {
|
||||
const { userId } = await seedUser();
|
||||
const { PUT } = await import(
|
||||
"@/app/api/dashboard/chart-overlay-prefs/route"
|
||||
);
|
||||
const { GET: getWidgets } = await import(
|
||||
"@/app/api/dashboard/widgets/route"
|
||||
);
|
||||
const { PUT } =
|
||||
await import("@/app/api/dashboard/chart-overlay-prefs/route");
|
||||
const { GET: getWidgets } =
|
||||
await import("@/app/api/dashboard/widgets/route");
|
||||
|
||||
const putRes = await (PUT as (req: Request) => Promise<Response>)(
|
||||
new Request("http://localhost/api/dashboard/chart-overlay-prefs", {
|
||||
@@ -130,9 +128,9 @@ describe("PUT /api/dashboard/chart-overlay-prefs — integration", () => {
|
||||
}),
|
||||
);
|
||||
|
||||
const finalRes = await (
|
||||
getWidgets as (req: Request) => Promise<Response>
|
||||
)(new Request("http://localhost/api/dashboard/widgets"));
|
||||
const finalRes = await (getWidgets as (req: Request) => Promise<Response>)(
|
||||
new Request("http://localhost/api/dashboard/widgets"),
|
||||
);
|
||||
const finalBody = (await finalRes.json()) as {
|
||||
data: { chartOverlayPrefs?: Record<string, unknown> };
|
||||
};
|
||||
@@ -159,9 +157,8 @@ describe("PUT /api/dashboard/chart-overlay-prefs — integration", () => {
|
||||
|
||||
it("rejects an unknown chart key with 422", async () => {
|
||||
await seedUser();
|
||||
const { PUT } = await import(
|
||||
"@/app/api/dashboard/chart-overlay-prefs/route"
|
||||
);
|
||||
const { PUT } =
|
||||
await import("@/app/api/dashboard/chart-overlay-prefs/route");
|
||||
|
||||
const res = await (PUT as (req: Request) => Promise<Response>)(
|
||||
new Request("http://localhost/api/dashboard/chart-overlay-prefs", {
|
||||
|
||||
@@ -84,9 +84,7 @@ interface PutEnvelope {
|
||||
describe("PUT /api/insights/provider-chain — integration", () => {
|
||||
it("persists a reordered chain and the follow-up GET surfaces it", async () => {
|
||||
const { userId } = await seedUser();
|
||||
const { PUT } = await import(
|
||||
"@/app/api/insights/provider-chain/route"
|
||||
);
|
||||
const { PUT } = await import("@/app/api/insights/provider-chain/route");
|
||||
|
||||
const res = await (PUT as (req: Request) => Promise<Response>)(
|
||||
new Request("http://localhost/api/insights/provider-chain", {
|
||||
@@ -118,9 +116,7 @@ describe("PUT /api/insights/provider-chain — integration", () => {
|
||||
|
||||
it("rejects an empty chain with 422 and leaves the column untouched", async () => {
|
||||
const { userId } = await seedUser();
|
||||
const { PUT } = await import(
|
||||
"@/app/api/insights/provider-chain/route"
|
||||
);
|
||||
const { PUT } = await import("@/app/api/insights/provider-chain/route");
|
||||
|
||||
const res = await (PUT as (req: Request) => Promise<Response>)(
|
||||
new Request("http://localhost/api/insights/provider-chain", {
|
||||
|
||||
@@ -74,9 +74,8 @@ beforeEach(async () => {
|
||||
headerJar.clear();
|
||||
// Wipe the in-process last-working cache so test order doesn't
|
||||
// leak the fallback decision from one case into the next.
|
||||
const { clearLastWorkingProviderCache } = await import(
|
||||
"@/lib/ai/provider-runner"
|
||||
);
|
||||
const { clearLastWorkingProviderCache } =
|
||||
await import("@/lib/ai/provider-runner");
|
||||
clearLastWorkingProviderCache();
|
||||
});
|
||||
|
||||
@@ -187,8 +186,9 @@ describe("POST /api/insights/generate — chain fallback", () => {
|
||||
it("falls back from broken OpenAI primary to Anthropic secondary when the primary fails", async () => {
|
||||
await seedUserWithKey();
|
||||
|
||||
const fetchSpy = vi.spyOn(globalThis, "fetch").mockImplementation(
|
||||
async (input: RequestInfo | URL) => {
|
||||
const fetchSpy = vi
|
||||
.spyOn(globalThis, "fetch")
|
||||
.mockImplementation(async (input: RequestInfo | URL) => {
|
||||
if (isOpenAICall(input)) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: { code: "invalid_api_key" } }),
|
||||
@@ -199,8 +199,7 @@ describe("POST /api/insights/generate — chain fallback", () => {
|
||||
return anthropicSuccess();
|
||||
}
|
||||
throw new Error(`unexpected URL: ${String(input)}`);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
const { POST } = await import("@/app/api/insights/generate/route");
|
||||
const res = await POST(jsonRequest() as never);
|
||||
@@ -241,8 +240,9 @@ describe("POST /api/insights/generate — chain fallback", () => {
|
||||
it("caches the working provider so the next call skips the broken primary", async () => {
|
||||
await seedUserWithKey();
|
||||
|
||||
const fetchSpy = vi.spyOn(globalThis, "fetch").mockImplementation(
|
||||
async (input: RequestInfo | URL) => {
|
||||
const fetchSpy = vi
|
||||
.spyOn(globalThis, "fetch")
|
||||
.mockImplementation(async (input: RequestInfo | URL) => {
|
||||
if (isOpenAICall(input)) {
|
||||
return new Response("nope", { status: 401 });
|
||||
}
|
||||
@@ -250,8 +250,7 @@ describe("POST /api/insights/generate — chain fallback", () => {
|
||||
return anthropicSuccess();
|
||||
}
|
||||
throw new Error(`unexpected URL: ${String(input)}`);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
const { POST } = await import("@/app/api/insights/generate/route");
|
||||
|
||||
|
||||
Reference in new issue
Block a user