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

@@ -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");
});