chore(release): v1.4.15

Bumps version to 1.4.15 and adds the corresponding CHANGELOG entry
covering backup completeness, achievements UI, onboarding tour,
doctor-report v2, auto-deploy on GHCR push, empty-states sweep,
notification + integration status UI, AI hallucination guardrails,
mobile UX fixes, and the BD-Zielbereich percentage fix.

Includes the formatting drift left over by the v1.4.15 development
phases (Prettier sweep on touched files) and a typecheck fix in
`dashboard-layout.test.ts` so `pnpm typecheck`, `pnpm lint`,
`pnpm format:check`, `pnpm test`, and `pnpm test:integration` all
pass green on the release tag.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Marc-André BombeckandClaude Opus 4.7 committed 2026-05-09 22:27:13 +02:00
1 parent 1b1214bcb3
commit 4dcaa08b42
32 files changed
+639 -295

No files matched your search

@@ -252,10 +252,7 @@ describe("POST /api/internal/deploy-webhook", () => {
it("tolerates malformed (non-string) status fields by treating them as unknown", async () => {
const res = await POST(
jsonRequest(
{ status: 42 },
{ "x-deploy-webhook-secret": "test-secret" },
),
jsonRequest({ status: 42 }, { "x-deploy-webhook-secret": "test-secret" }),
);
expect(res.status).toBe(200);
const body = (await res.json()) as { outcome: string };
@@ -291,18 +288,24 @@ describe("POST /api/internal/deploy-webhook", () => {
describe("GET /api/internal/deploy-webhook (reachability check)", () => {
it("returns 200 with valid secret header", async () => {
const req = new NextRequest("http://localhost/api/internal/deploy-webhook", {
method: "GET",
headers: { "x-deploy-webhook-secret": "test-secret" },
});
const req = new NextRequest(
"http://localhost/api/internal/deploy-webhook",
{
method: "GET",
headers: { "x-deploy-webhook-secret": "test-secret" },
},
);
const res = await GET(req);
expect(res.status).toBe(200);
});
it("returns 401 without a secret", async () => {
const req = new NextRequest("http://localhost/api/internal/deploy-webhook", {
method: "GET",
});
const req = new NextRequest(
"http://localhost/api/internal/deploy-webhook",
{
method: "GET",
},
);
const res = await GET(req);
expect(res.status).toBe(401);
});
+15 -4
View File
@@ -52,7 +52,10 @@ interface NormalizedEvent {
raw: CoolifyDeployPayload;
}
function asStringOr<T extends string | null>(value: unknown, fallback: T): string | T {
function asStringOr<T extends string | null>(
value: unknown,
fallback: T,
): string | T {
return typeof value === "string" && value.length > 0 ? value : fallback;
}
@@ -63,7 +66,8 @@ function classifyOutcome(status: unknown): NormalizedEvent["outcome"] {
// "failed" / "error" / "stopped" for unhappy. Anything else is
// logged but doesn't trigger a Telegram page (avoids alert fatigue
// on intermediate states like "queued" or "in_progress").
if (s === "success" || s === "finished" || s === "succeeded") return "success";
if (s === "success" || s === "finished" || s === "succeeded")
return "success";
if (s === "failed" || s === "error" || s === "stopped" || s === "failure") {
return "failure";
}
@@ -148,7 +152,11 @@ export const POST = apiHandler(async (request: NextRequest) => {
// Coolify's per-event burst (typically 1-2 per deploy) but well below
// a hostile actor flooding to fish for a working secret.
const ip = getClientIp(request);
const rl = await checkRateLimit(`deploy-webhook:${ip ?? "unknown"}`, 60, 60_000);
const rl = await checkRateLimit(
`deploy-webhook:${ip ?? "unknown"}`,
60,
60_000,
);
if (!rl.allowed) {
return NextResponse.json(
{ status: "rate_limited" },
@@ -204,7 +212,10 @@ export const POST = apiHandler(async (request: NextRequest) => {
await notifyAdminsOfFailure(event);
}
return NextResponse.json({ status: "ok", outcome: event.outcome }, { status: 200 });
return NextResponse.json(
{ status: "ok", outcome: event.outcome },
{ status: 200 },
);
});
/**
+3 -1
View File
@@ -822,7 +822,9 @@ export default function InsightsPage() {
description={t("insights.emptyDescription")}
action={
<Button size="sm" asChild>
<Link href="/measurements">{t("insights.emptyAddMeasurement")}</Link>
<Link href="/measurements">
{t("insights.emptyAddMeasurement")}
</Link>
</Button>
}
/>
@@ -64,9 +64,7 @@ describe("BackupsSection — empty state", () => {
it("includes the localized title and description", () => {
const html = render();
expect(html).toContain("No backups yet");
expect(html).toContain(
"Backups run automatically every Sunday at 03:00",
);
expect(html).toContain("Backups run automatically every Sunday at 03:00");
});
it("exposes the Backup-now CTA inside the empty card", () => {
@@ -75,9 +75,7 @@ describe("UserManagementSection — empty state", () => {
it("carries the localized title and description", () => {
const html = render();
expect(html).toContain("No users in this view");
expect(html).toContain(
"There are no users matching the current filter.",
);
expect(html).toContain("There are no users matching the current filter.");
});
it("does not render the reset CTA when the default filter is active", () => {
@@ -296,105 +296,109 @@ export function UserManagementSection() {
/>
</div>
) : (
<>
{/* Desktop table: kept verbatim — only the wrapping
<>
{/* Desktop table: kept verbatim — only the wrapping
`md:block hidden` swaps it out for the card-list below at
`< md`. Hiding the table-only wrapper (instead of just
the cells) saves DOM weight on mobile too. */}
<div className="mt-4 hidden overflow-x-auto md:block">
<table className="w-full text-sm">
<thead>
<tr className="text-muted-foreground border-b text-xs">
<th className="px-3 py-2 text-left font-medium">
{t("admin.users")}
</th>
<th className="px-3 py-2 text-left font-medium">
{t("admin.userEmail")}
</th>
<th className="px-3 py-2 text-center font-medium">
{t("admin.userRole")}
</th>
<th className="px-3 py-2 text-center font-medium">
{t("admin.userPasskeys")}
</th>
<th className="px-3 py-2 text-right font-medium">
{t("admin.userCreated")}
</th>
<th className="px-3 py-2 text-right font-medium">
{t("admin.userActions")}
</th>
</tr>
</thead>
<tbody className="divide-border divide-y">
{filteredUsers.map((u, i) => (
<tr key={u.id} className={i % 2 === 0 ? "bg-muted/30" : ""}>
<td className="px-3 py-2 font-medium">{u.username}</td>
<td className="text-muted-foreground px-3 py-2 text-xs">
{u.email || "—"}
</td>
<td className="px-3 py-2 text-center">
<Badge
variant={u.role === "ADMIN" ? "default" : "secondary"}
className="text-xs"
>
{u.role}
</Badge>
</td>
<td className="px-3 py-2 text-center">{u.passkeyCount}</td>
<td className="text-muted-foreground px-3 py-2 text-right text-xs whitespace-nowrap">
{formatDate(u.createdAt)}
</td>
<td className="px-3 py-2 text-right">
<div className="flex items-center justify-end gap-1">
{renderUserActions(u)}
</div>
</td>
<div className="mt-4 hidden overflow-x-auto md:block">
<table className="w-full text-sm">
<thead>
<tr className="text-muted-foreground border-b text-xs">
<th className="px-3 py-2 text-left font-medium">
{t("admin.users")}
</th>
<th className="px-3 py-2 text-left font-medium">
{t("admin.userEmail")}
</th>
<th className="px-3 py-2 text-center font-medium">
{t("admin.userRole")}
</th>
<th className="px-3 py-2 text-center font-medium">
{t("admin.userPasskeys")}
</th>
<th className="px-3 py-2 text-right font-medium">
{t("admin.userCreated")}
</th>
<th className="px-3 py-2 text-right font-medium">
{t("admin.userActions")}
</th>
</tr>
))}
</tbody>
</table>
</div>
</thead>
<tbody className="divide-border divide-y">
{filteredUsers.map((u, i) => (
<tr key={u.id} className={i % 2 === 0 ? "bg-muted/30" : ""}>
<td className="px-3 py-2 font-medium">{u.username}</td>
<td className="text-muted-foreground px-3 py-2 text-xs">
{u.email || "—"}
</td>
<td className="px-3 py-2 text-center">
<Badge
variant={u.role === "ADMIN" ? "default" : "secondary"}
className="text-xs"
>
{u.role}
</Badge>
</td>
<td className="px-3 py-2 text-center">
{u.passkeyCount}
</td>
<td className="text-muted-foreground px-3 py-2 text-right text-xs whitespace-nowrap">
{formatDate(u.createdAt)}
</td>
<td className="px-3 py-2 text-right">
<div className="flex items-center justify-end gap-1">
{renderUserActions(u)}
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
{/* Mobile card list: each user renders as a self-contained
{/* Mobile card list: each user renders as a self-contained
card with the meta line + a flex-wrapping action row.
All four actions stay visible and tap-targetable; nothing
is hidden behind a horizontal scroll. */}
<ul
className="mt-4 space-y-2 md:hidden"
data-testid="admin-users-mobile-list"
>
{filteredUsers.map((u) => (
<li
key={u.id}
className="bg-muted/30 border-border rounded-lg border p-3"
>
<div className="flex items-start justify-between gap-2">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="truncate font-medium">{u.username}</span>
<Badge
variant={u.role === "ADMIN" ? "default" : "secondary"}
className="text-[10px]"
>
{u.role}
</Badge>
<ul
className="mt-4 space-y-2 md:hidden"
data-testid="admin-users-mobile-list"
>
{filteredUsers.map((u) => (
<li
key={u.id}
className="bg-muted/30 border-border rounded-lg border p-3"
>
<div className="flex items-start justify-between gap-2">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="truncate font-medium">
{u.username}
</span>
<Badge
variant={u.role === "ADMIN" ? "default" : "secondary"}
className="text-[10px]"
>
{u.role}
</Badge>
</div>
<p className="text-muted-foreground truncate text-xs">
{u.email || "—"}
</p>
<p className="text-muted-foreground text-[11px]">
{t("admin.userPasskeys")}: {u.passkeyCount} ·{" "}
{formatDate(u.createdAt)}
</p>
</div>
<p className="text-muted-foreground truncate text-xs">
{u.email || "—"}
</p>
<p className="text-muted-foreground text-[11px]">
{t("admin.userPasskeys")}: {u.passkeyCount} ·{" "}
{formatDate(u.createdAt)}
</p>
</div>
</div>
<div className="mt-2 flex flex-wrap items-center gap-1">
{renderUserActions(u)}
</div>
</li>
))}
</ul>
</>
<div className="mt-2 flex flex-wrap items-center gap-1">
{renderUserActions(u)}
</div>
</li>
))}
</ul>
</>
)
) : (
<div className="mt-4 flex items-center gap-2">
@@ -9,13 +9,13 @@ import { renderToStaticMarkup } from "react-dom/server";
* exported so the contract can be locked in unit tests.
*/
import {
aggregateMoodEntries,
pickMoodBucket,
MoodChart,
} from "../mood-chart";
import { aggregateMoodEntries, pickMoodBucket, MoodChart } from "../mood-chart";
function dayEntries(start: string, count: number, score = 4): Array<{
function dayEntries(
start: string,
count: number,
score = 4,
): Array<{
date: string;
score: number;
}> {
@@ -53,7 +53,7 @@ describe("<TrendCard> 7-day trend label", () => {
expect(html).toContain("7d trend");
// Legacy avg7 short label must NOT appear in the trend variant —
// this is the whole point of the rename.
expect(html).not.toContain('>7d:<');
expect(html).not.toContain(">7d:<");
});
it("paints the trend-label in German", () => {
@@ -48,9 +48,15 @@ describe("doctor-report dialog — i18n contract", () => {
it(`exposes every doctor-report dialog key in ${locale}.json`, () => {
const messages = loadMessages(locale);
const root = messages.doctorReport as Record<string, unknown> | undefined;
expect(root, `doctorReport namespace missing in ${locale}.json`).toBeDefined();
expect(
root,
`doctorReport namespace missing in ${locale}.json`,
).toBeDefined();
const dialog = root!.dialog as Record<string, unknown> | undefined;
expect(dialog, `doctorReport.dialog missing in ${locale}.json`).toBeDefined();
expect(
dialog,
`doctorReport.dialog missing in ${locale}.json`,
).toBeDefined();
for (const key of REQUIRED_KEYS) {
expect(
dialog![key],
@@ -107,12 +107,18 @@ export function DoctorReportDialog({
const validation = useMemo(() => {
if (!startDate || !endDate) {
return { ok: false as const, key: "doctorReport.dialog.errorInvalidDate" };
return {
ok: false as const,
key: "doctorReport.dialog.errorInvalidDate",
};
}
const start = new Date(startDate);
const end = new Date(endDate);
if (Number.isNaN(start.getTime()) || Number.isNaN(end.getTime())) {
return { ok: false as const, key: "doctorReport.dialog.errorInvalidDate" };
return {
ok: false as const,
key: "doctorReport.dialog.errorInvalidDate",
};
}
if (end.getTime() < start.getTime()) {
return {
@@ -122,7 +128,10 @@ export function DoctorReportDialog({
}
const spanDays = Math.ceil((end.getTime() - start.getTime()) / ONE_DAY_MS);
if (spanDays > MAX_RANGE_DAYS) {
return { ok: false as const, key: "doctorReport.dialog.errorRangeTooLong" };
return {
ok: false as const,
key: "doctorReport.dialog.errorRangeTooLong",
};
}
return { ok: true as const };
}, [startDate, endDate]);
+1 -1
View File
@@ -124,7 +124,7 @@ export function AuthShell({ children }: { children: React.ReactNode }) {
*/}
<a
href="#main-content"
className="bg-primary text-primary-foreground fixed top-2 left-2 z-[100] -translate-y-full rounded-md px-4 py-2 text-sm font-medium opacity-0 transition-transform pointer-events-none focus:translate-y-0 focus:opacity-100 focus:pointer-events-auto"
className="bg-primary text-primary-foreground pointer-events-none fixed top-2 left-2 z-[100] -translate-y-full rounded-md px-4 py-2 text-sm font-medium opacity-0 transition-transform focus:pointer-events-auto focus:translate-y-0 focus:opacity-100"
>
{t("nav.skipToContent")}
</a>
+6 -1
View File
@@ -74,7 +74,12 @@ const navItems = [
icon: Lightbulb,
tourId: "nav-insights",
},
{ href: "/targets", tKey: "nav.targets", icon: Target, tourId: "nav-targets" },
{
href: "/targets",
tKey: "nav.targets",
icon: Target,
tourId: "nav-targets",
},
{
href: "/achievements",
tKey: "nav.achievements",
@@ -47,10 +47,7 @@ describe("<DashboardLayoutSection> — tile + chart split", () => {
});
it("renders both column headers in German", () => {
const html = render(
<DashboardLayoutSection id="dashboard-layout" />,
"de",
);
const html = render(<DashboardLayoutSection id="dashboard-layout" />, "de");
expect(html).toContain("Kachel");
expect(html).toContain("Chart");
});
+5 -6
View File
@@ -3,6 +3,7 @@ import {
DEFAULT_DASHBOARD_LAYOUT,
resolveDashboardLayout,
serializeDashboardLayout,
type DashboardLayout,
} from "@/lib/dashboard-layout";
/**
@@ -73,15 +74,13 @@ describe("resolveDashboardLayout() — tileVisible upgrade", () => {
it("defaults the layout when raw input is null / not an object", () => {
expect(resolveDashboardLayout(null)).toEqual(DEFAULT_DASHBOARD_LAYOUT);
expect(resolveDashboardLayout(undefined)).toEqual(
DEFAULT_DASHBOARD_LAYOUT,
);
expect(resolveDashboardLayout(undefined)).toEqual(DEFAULT_DASHBOARD_LAYOUT);
});
});
describe("serializeDashboardLayout() — tileVisible persistence", () => {
it("persists tileVisible explicitly so a re-read keeps the user's choice", () => {
const layout = {
const layout: DashboardLayout = {
version: 1,
widgets: [
// User toggled tileVisible to false but kept the chart visible.
@@ -94,7 +93,7 @@ describe("serializeDashboardLayout() — tileVisible persistence", () => {
});
it("derives tileVisible from visible when the field is missing on input", () => {
const layout = {
const layout: DashboardLayout = {
version: 1,
widgets: [
// Field omitted — serialize must not produce undefined; mirror.
@@ -106,7 +105,7 @@ describe("serializeDashboardLayout() — tileVisible persistence", () => {
});
it("normalizes order to 0-based dense", () => {
const layout = {
const layout: DashboardLayout = {
version: 1,
widgets: [
{ id: "weight", visible: true, tileVisible: true, order: 5 },
+22 -21
View File
@@ -214,7 +214,11 @@ describe("i18n locale file integrity", () => {
});
// Flat helper used by the value-quality checks below.
function flattenValues(obj: unknown, prefix: string, out: [string, string][]) {
function flattenValues(
obj: unknown,
prefix: string,
out: [string, string][],
) {
if (obj == null || typeof obj !== "object") return;
for (const [k, v] of Object.entries(obj as Record<string, unknown>)) {
const key = prefix ? `${prefix}.${k}` : k;
@@ -295,24 +299,21 @@ describe("i18n locale file integrity", () => {
it.each([
["en", EN_PATH],
["de", DE_PATH],
])(
"%s locale has no TODO/FIXME placeholders in values",
(_locale, path) => {
const data = JSON.parse(readFileSync(path, "utf8")) as Record<
string,
unknown
>;
const flat: [string, string][] = [];
flattenValues(data, "", flat);
const todoRe = /\b(TODO|FIXME|XXX|TBD)\b/;
const todos = flat
.filter(([, v]) => todoRe.test(v))
.map(([k, v]) => `${k} = ${JSON.stringify(v)}`);
expect(
todos,
`Found TODO/FIXME/XXX/TBD placeholders in translation values:\n` +
todos.map((s) => ` ${s}`).join("\n"),
).toEqual([]);
},
);
])("%s locale has no TODO/FIXME placeholders in values", (_locale, path) => {
const data = JSON.parse(readFileSync(path, "utf8")) as Record<
string,
unknown
>;
const flat: [string, string][] = [];
flattenValues(data, "", flat);
const todoRe = /\b(TODO|FIXME|XXX|TBD)\b/;
const todos = flat
.filter(([, v]) => todoRe.test(v))
.map(([k, v]) => `${k} = ${JSON.stringify(v)}`);
expect(
todos,
`Found TODO/FIXME/XXX/TBD placeholders in translation values:\n` +
todos.map((s) => ` ${s}`).join("\n"),
).toEqual([]);
});
});
@@ -83,7 +83,11 @@ describe("citation-from-data — schema-level enforcement", () => {
id: "rec-1",
text: "Walk more.",
severity: "suggestion",
metricSource: { type: "activity", timeRange: "last7days", summary: "" },
metricSource: {
type: "activity",
timeRange: "last7days",
summary: "",
},
},
],
};
@@ -158,7 +162,10 @@ describe("citation-from-data — cross-check enforcement", () => {
const missing = findUncitedRecommendations(orphan);
expect(missing).toHaveLength(1);
expect(missing[0].recommendationId).toBe("rec-orphan");
expect(missing[0].missing).toEqual({ type: "diet", timeRange: "last7days" });
expect(missing[0].missing).toEqual({
type: "diet",
timeRange: "last7days",
});
});
it("partial-key match on type alone is NOT enough — timeRange must also match", () => {
@@ -145,10 +145,7 @@ describe("generateInsight wrapper", () => {
it("retries once with correction message and succeeds on second attempt", async () => {
const provider = new MockAIProvider({
responses: [
"definitely not json",
JSON.stringify(validResponse),
],
responses: ["definitely not json", JSON.stringify(validResponse)],
});
const outcome = await generateInsight(provider, {
systemPrompt: "sys",
@@ -203,7 +200,10 @@ describe("generateInsight wrapper", () => {
citations: [], // empty — fails cross-check
};
const provider = new MockAIProvider({
responses: [JSON.stringify(orphanResponse), JSON.stringify(validResponse)],
responses: [
JSON.stringify(orphanResponse),
JSON.stringify(validResponse),
],
});
const outcome = await generateInsight(provider, {
systemPrompt: "sys",
@@ -5,10 +5,7 @@ import {
OUT_OF_SCOPE_REFUSAL_EN,
OUT_OF_SCOPE_REFUSAL_DE,
} from "../prompts/insight-generator";
import {
aiInsightResponseSchema,
findUncitedRecommendations,
} from "../schema";
import { aiInsightResponseSchema, findUncitedRecommendations } from "../schema";
import { generateInsight } from "../generate-insight";
import { MockAIProvider } from "../mock-client";
+2 -1
View File
@@ -252,7 +252,8 @@ export class CodexClient implements AIProvider {
upstream: "codex",
model: attempted[attempted.length - 1] ?? null,
bodyExcerpt: lastSlugRejectionError
? (lastSlugRejectionError as Error & { bodyExcerpt?: string }).bodyExcerpt
? (lastSlugRejectionError as Error & { bodyExcerpt?: string })
.bodyExcerpt
: null,
attempted,
});
+7 -5
View File
@@ -42,10 +42,7 @@ export interface GenerateInsightOutcome {
* what went wrong on the first attempt. Includes the violated zod
* issues verbatim (truncated to 1KB) so the model can self-correct.
*/
function buildRetryCorrectionMessage(
reason: string,
details: string,
): string {
function buildRetryCorrectionMessage(reason: string, details: string): string {
return `
Your previous response did not satisfy the required JSON schema.
Reason: ${reason}
@@ -144,7 +141,12 @@ export async function generateInsight(
): Promise<GenerateInsightOutcome> {
const first = await tryOnce(provider, params);
if (first.ok && first.parsed) {
return { parsed: first.parsed, raw: first.raw, attempts: 1, retried: false };
return {
parsed: first.parsed,
raw: first.raw,
attempts: 1,
retried: false,
};
}
// Retry-once with corrective context appended to the user prompt.
+7 -4
View File
@@ -59,7 +59,9 @@ export const metricSourceSchema = z.object({
* 12 readings" or "compliance 0.71 / 30d". Empty value rejected so
* the model cannot fake a citation.
*/
summary: z.string().min(1, "metricSource.summary required (zero-hallucination)"),
summary: z
.string()
.min(1, "metricSource.summary required (zero-hallucination)"),
/** Sample count behind the value, when known. */
n: z.number().int().nonnegative().optional(),
});
@@ -156,9 +158,10 @@ export class InsightSchemaError extends Error {
* Returns the list of missing-citation issues; an empty list means
* the response is internally consistent.
*/
export function findUncitedRecommendations(
parsed: AIInsightResponse,
): Array<{ recommendationId: string; missing: { type: string; timeRange: string } }> {
export function findUncitedRecommendations(parsed: AIInsightResponse): Array<{
recommendationId: string;
missing: { type: string; timeRange: string };
}> {
const cited = new Set(
parsed.citations.map((c) => `${c.type}::${c.timeRange}`),
);
Binary file not shown.
@@ -58,9 +58,7 @@ describe("formatAdminAlertPayload — moodLog transient", () => {
subjectLabel: "marc@example.com",
});
expect(out.title).toBe("moodLog sync failing for marc@example.com");
expect(out.message).toContain(
"Action: investigate the upstream service.",
);
expect(out.message).toContain("Action: investigate the upstream service.");
expect(out.message).toContain("(http_502)");
});
});