mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 13:01:00 +00:00
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:
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]);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
Reference in new issue
Block a user