mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-13 22:32:04 +00:00
v1.4.33 — polish and reliability
Quality-leap release between two HealthKit milestones. The headline is a P0 hotfix for the `/api/analytics` 500 that broke the Insights mother page for any account with more than a few thousand measurements: the six per-metric status helpers were spreading the entire numeric history into `Math.min` / `Math.max`, which trips a stack overflow once a single argument list exceeds the V8 spread cap. Both the surfaced symptom and the latent risk in five sibling helpers were folded onto a reduce-based min/max path. Around that hotfix landed a deep polish pass: the Insights mother page now defers three below-the-fold blocks behind `next/dynamic` to trim the cold mount cost, the analytics endpoint gained a `?slice=summaries` slim slice that the per-metric sub-pages now ride, the Coach snapshot builder picked up a 60 s LRU keyed on `(userId, scope)`, the Settings shell consolidated three duplicate copies of the about / notifications / about-dropdown surfaces, the navigation strip dropped a redundant "Home" group label and a colliding "Notifications" sibling, every icon-only button on the medications and admin surfaces gained an accessible name, every Progress bar gained one too, three pages had their heading hierarchy repaired to a sequential h1 → h2 → h3, the mobile Coach FAB now hides while a chart tooltip is open so it cannot occlude the read-out, and the auth shell normalised every authenticated route to a single `max-w-screen-xl` container. Also lands the Apple App Site Association handler at `/.well-known/apple-app-site-association` on every host that fronts the app, advertising the iOS bundle's App ID prefix (S8WDX4W5KX.dev.healthlog.app) under `webcredentials.apps` so the passkey ceremony shares cleanly between the web origin and the iOS app. The release closes nineteen issues from a five-surface audit + a runtime bug-hunt pass, plus six follow-up tracks of polish across the affected surfaces.
This commit is contained in:
1 parent
c7a0ff214b
commit
7bef199039
186 files changed
+9358
-946
No files matched your search
@@ -186,9 +186,13 @@ describe("<AiSection> — dropdown-driven UX (B2)", () => {
|
||||
const html = render("de");
|
||||
expect(html).toContain("Mit ChatGPT verbinden");
|
||||
expect(html).toContain("ChatGPT-Account (Codex)");
|
||||
// Heading uses the locale's full string ("Aktiver KI-Provider"); a
|
||||
// shorter substring would also match the "Aktiver Provider" line
|
||||
// in the chain summary.
|
||||
expect(html).toContain("Aktiver KI-Provider");
|
||||
// v1.4.33 IW7 — the active-provider heading and the parent section
|
||||
// both ship without the "KI"/"AI" prefix per the Marc-Voice rule
|
||||
// ("Aktiver KI-Provider" → "Aktiver Provider"). The heading and the
|
||||
// chain summary now share the same phrase, so we assert against the
|
||||
// explicit `activeProviderLabel` slot below it ("Primärer Provider")
|
||||
// to keep the test pinned to the right surface.
|
||||
expect(html).toContain("Aktiver Provider");
|
||||
expect(html).toContain("Primärer Provider");
|
||||
});
|
||||
});
|
||||
@@ -66,7 +66,7 @@ describe("<NotificationStatusCard />", () => {
|
||||
it("renders empty state when no channels exist", () => {
|
||||
setChannels([]);
|
||||
const html = render(<NotificationStatusCard />);
|
||||
expect(html).toContain("Channel reliability");
|
||||
expect(html).toContain("Delivery status");
|
||||
expect(html).toContain("No channels configured yet");
|
||||
expect(html).not.toContain('data-testid="notification-status-list"');
|
||||
});
|
||||
@@ -189,7 +189,7 @@ describe("<NotificationStatusCard />", () => {
|
||||
},
|
||||
]);
|
||||
const html = render(<NotificationStatusCard />, "de");
|
||||
expect(html).toContain("Kanalzuverlässigkeit");
|
||||
expect(html).toContain("Zustellstatus");
|
||||
expect(html).toContain("Automatisch deaktiviert");
|
||||
expect(html).toContain("Wieder aktivieren");
|
||||
});
|
||||
|
||||
@@ -137,9 +137,13 @@ describe("settings sections — SSR smoke", () => {
|
||||
expect(html).not.toContain("settings.about.");
|
||||
});
|
||||
|
||||
it("<AiSection> renders the AI Insights card", () => {
|
||||
it("<AiSection> renders the Insights card", () => {
|
||||
const html = render(<AiSection />);
|
||||
expect(html).toContain("AI Insights");
|
||||
// v1.4.33 IW7 — section renamed from "AI Insights" to "Insights"
|
||||
// per the Marc-Voice rule. The card title is replaced with a sparkles
|
||||
// icon + status badges, and the section H1 carries the only heading.
|
||||
expect(html).toContain("Insights");
|
||||
expect(html).toContain("settings-section-ai-title");
|
||||
});
|
||||
|
||||
it("<IntegrationsSection> renders Withings card title", () => {
|
||||
@@ -150,7 +154,10 @@ describe("settings sections — SSR smoke", () => {
|
||||
|
||||
it("<NotificationsSection> renders heading", () => {
|
||||
const html = render(<NotificationsSection />);
|
||||
expect(html).toContain("Notifications");
|
||||
// v1.4.33 IW7 — section renamed from "Notifications" to
|
||||
// "Notification channels" so it doesn't collide with the inbox at
|
||||
// `/notifications` ("Notification Center").
|
||||
expect(html).toContain("Notification channels");
|
||||
});
|
||||
|
||||
it("<DashboardSection> renders heading and embeds the layout customizer", () => {
|
||||
|
||||
@@ -80,9 +80,16 @@ describe("SETTINGS_SECTION_SLUGS", () => {
|
||||
});
|
||||
|
||||
describe("<SettingsShell>", () => {
|
||||
it("renders every section link — once for the mobile strip and once for the desktop sidebar", () => {
|
||||
it("renders every navigable section link — once for the mobile strip and once for the desktop sidebar", () => {
|
||||
const html = renderShell({ active: "account" });
|
||||
for (const slug of SETTINGS_SECTION_SLUGS) {
|
||||
// v1.4.33 IW7 — the `about` slug is alive as a route
|
||||
// (`/settings/about` still resolves and `generateStaticParams()`
|
||||
// still emits the page) but it is hidden from the in-shell nav.
|
||||
// The user-card dropdown owns the link now. Iterate the visible
|
||||
// sections list, not the slug list, when asserting on rendered
|
||||
// markup.
|
||||
const navigableSlugs = SETTINGS_SECTIONS.map((section) => section.slug);
|
||||
for (const slug of navigableSlugs) {
|
||||
const matches = html.match(new RegExp(`href="/settings/${slug}"`, "g"));
|
||||
// Two renders — mobile strip + desktop sidebar — guarantee the link
|
||||
// exists in both layouts. Tablet/desktop hide the strip with `md:`,
|
||||
@@ -90,6 +97,8 @@ describe("<SettingsShell>", () => {
|
||||
// before media queries resolve.
|
||||
expect(matches?.length ?? 0).toBe(2);
|
||||
}
|
||||
// The hidden `about` slug must NOT appear in the in-shell nav.
|
||||
expect(html).not.toContain('href="/settings/about"');
|
||||
});
|
||||
|
||||
it("links use the correct `/settings/<slug>` href — regression guard against typos", () => {
|
||||
@@ -136,9 +145,12 @@ describe("<SettingsShell>", () => {
|
||||
const html = renderShell({ active: "account", locale: "en" });
|
||||
expect(html).toContain("Account");
|
||||
expect(html).toContain("Integrations");
|
||||
expect(html).toContain("Notifications");
|
||||
// v1.4.33 IW7 — section renamed from "Notifications" to
|
||||
// "Notification channels" so it doesn't collide with the inbox at
|
||||
// `/notifications` ("Notification Center").
|
||||
expect(html).toContain("Notification channels");
|
||||
expect(html).toContain("Dashboard");
|
||||
expect(html).toContain("AI Insights");
|
||||
expect(html).toContain("Insights");
|
||||
// The ampersand is HTML-escaped by React SSR — assert on the encoded
|
||||
// form so we don't accidentally match a parser that double-escapes.
|
||||
expect(html).toContain("API & Tokens");
|
||||
@@ -146,25 +158,35 @@ describe("<SettingsShell>", () => {
|
||||
// entry in the sidebar; the link must be present in both locales.
|
||||
expect(html).toContain('href="/settings/export"');
|
||||
expect(html).toContain("Advanced");
|
||||
expect(html).toContain("About");
|
||||
// v1.4.33 IW7 — About is no longer in the settings nav; it lives
|
||||
// in the sidebar user-card dropdown. Route `/settings/about` is
|
||||
// still alive for direct links.
|
||||
expect(html).not.toContain('href="/settings/about"');
|
||||
});
|
||||
|
||||
it("resolves every section title via the i18n provider — German", () => {
|
||||
const html = renderShell({ active: "account", locale: "de" });
|
||||
expect(html).toContain("Konto");
|
||||
expect(html).toContain("Integrationen");
|
||||
expect(html).toContain("Benachrichtigungen");
|
||||
// v1.4.33 IW7 — section renamed from "Benachrichtigungen" to
|
||||
// "Benachrichtigungs-Kanäle" so it doesn't collide with the inbox at
|
||||
// `/notifications` ("Benachrichtigungs-Center").
|
||||
expect(html).toContain("Benachrichtigungs-Kanäle");
|
||||
// v1.4.3: the Settings sub-section formerly labelled "Übersicht" is now
|
||||
// "Dashboard" (matching the term users see in the main nav). The
|
||||
// per-metric overrides moved out into their own "Persönliche Zielwerte"
|
||||
// section, which is the new entry below the Dashboard one.
|
||||
expect(html).toContain("Dashboard");
|
||||
expect(html).toContain("Persönliche Zielwerte");
|
||||
expect(html).toContain("KI-Auswertungen");
|
||||
// v1.4.33 IW7 — "KI-Auswertungen" renamed to "Auswertungen" per
|
||||
// the Marc-Voice rule (no "KI"/"AI" prefix in user-facing copy).
|
||||
expect(html).toContain("Auswertungen");
|
||||
// API & Tokens is identical in both locales (proper noun + ampersand)
|
||||
expect(html).toContain("API & Tokens");
|
||||
expect(html).toContain("Erweitert");
|
||||
expect(html).toContain("Über");
|
||||
// v1.4.33 IW7 — "Über" (About) section removed from the in-shell
|
||||
// nav, folded into the sidebar user-card dropdown ("Über HealthLog").
|
||||
expect(html).not.toContain('href="/settings/about"');
|
||||
});
|
||||
|
||||
it("does NOT surface the raw key when a translation resolves — guards against missing JSON entries", () => {
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* `<SettingsCardHeader>` — v1.4.33 IW4 polish primitive.
|
||||
*
|
||||
* Every Settings card paints the same shape: a primary icon, a title,
|
||||
* an optional status surface (badge / pill / inline switch), and an
|
||||
* optional short description below the title. Before this primitive
|
||||
* the codebase carried five different `flex flex-wrap items-start
|
||||
* justify-between` permutations across `account-section.tsx`,
|
||||
* `integrations-section.tsx`, `notification-status-card.tsx`,
|
||||
* `telegram-card.tsx`, `ntfy-card.tsx`, `web-push-card.tsx`,
|
||||
* `api-section.tsx`, and `advanced-section.tsx`. The audit's mechanical
|
||||
* drift report (`.planning/round-v1433-audit-settings.md` §2.5) listed
|
||||
* one card with `mb-4 flex items-center gap-2`, the next with
|
||||
* `flex flex-col gap-3 sm:flex-row sm:justify-between`, another with
|
||||
* `flex flex-wrap items-start justify-between`, etc.
|
||||
*
|
||||
* The primitive consolidates the contract:
|
||||
* - Icon left, title right of it (`gap-2` between them).
|
||||
* - Optional status slot lives top-right; on `<sm` it falls below the
|
||||
* title block so the action surface keeps its 44 px tap target.
|
||||
* - Description is muted text, sits below the title row.
|
||||
*
|
||||
* Call sites can still wire their own action surfaces inside the body
|
||||
* of the card — the primitive only owns the *header* slice, not the
|
||||
* footer or the form rows.
|
||||
*/
|
||||
|
||||
import * as React from "react";
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export interface SettingsCardHeaderProps {
|
||||
/** Lucide icon component; rendered as `text-primary h-5 w-5`. */
|
||||
icon: LucideIcon;
|
||||
/** Card title — rendered as `<h2 class="text-lg font-semibold">`. */
|
||||
title: string;
|
||||
/** Optional id for the `<h2>` so an outer `aria-labelledby`
|
||||
* attribute on the card itself can reference it. */
|
||||
titleId?: string;
|
||||
/** Optional short description rendered as muted text below the
|
||||
* title row. Either a string (paragraph) or arbitrary node
|
||||
* (links, badges, etc.). */
|
||||
description?: React.ReactNode;
|
||||
/** Optional right-aligned status surface — typically an
|
||||
* `<IntegrationStatusPill>` or a wrapper around badges. */
|
||||
status?: React.ReactNode;
|
||||
/** Optional extra classes applied to the outer wrapper. */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function SettingsCardHeader({
|
||||
icon: Icon,
|
||||
title,
|
||||
titleId,
|
||||
description,
|
||||
status,
|
||||
className,
|
||||
}: SettingsCardHeaderProps) {
|
||||
return (
|
||||
<header className={cn("space-y-1", className)}>
|
||||
<div className="flex flex-wrap items-start justify-between gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Icon className="text-primary h-5 w-5 shrink-0" aria-hidden="true" />
|
||||
<h2 id={titleId} className="text-lg font-semibold">
|
||||
{title}
|
||||
</h2>
|
||||
</div>
|
||||
{status ? <div className="flex shrink-0 items-center gap-2">{status}</div> : null}
|
||||
</div>
|
||||
{description ? (
|
||||
<p className="text-muted-foreground text-xs">{description}</p>
|
||||
) : null}
|
||||
</header>
|
||||
);
|
||||
}
|
||||
@@ -351,10 +351,17 @@ export function AccountSection() {
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="username">{t("settings.username")}</Label>
|
||||
{/* v1.4.33 F13 — `disabled` adds `opacity-50` to the input
|
||||
primitive, which the maintainer's mobile pass read as
|
||||
"empty placeholder text" because the username then
|
||||
matches the muted-foreground colour exactly. Username
|
||||
changes still aren't allowed, but `readOnly` keeps the
|
||||
text crisp (full contrast) so it reads as a value, not
|
||||
a hint. */}
|
||||
<Input
|
||||
id="username"
|
||||
value={user.username}
|
||||
disabled
|
||||
readOnly
|
||||
autoComplete="username"
|
||||
/>
|
||||
</div>
|
||||
@@ -733,7 +740,13 @@ function PasskeyListSection({ isAuthenticated }: { isAuthenticated: boolean }) {
|
||||
table only at `≥ md`, and at `< md` paint a card-list where
|
||||
every passkey's name, device type, backup status, created
|
||||
date, and delete action are all visible without scrolling. */}
|
||||
<div className="border-border mt-3 hidden overflow-x-auto rounded-lg border md:block">
|
||||
{/* v1.4.33 — desktop table flips to card list at `lg` instead
|
||||
of `md` so iPad portrait (768 px = exactly the `md`
|
||||
inflection) lands on the card layout. The table needs
|
||||
~620 px of column width to read; below that it scrolls
|
||||
horizontally and the destructive delete column is the one
|
||||
that disappears. */}
|
||||
<div className="border-border mt-3 hidden overflow-x-auto rounded-lg border lg:block">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="bg-muted/40 text-muted-foreground border-b text-xs">
|
||||
@@ -817,12 +830,15 @@ function PasskeyListSection({ isAuthenticated }: { isAuthenticated: boolean }) {
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Mobile card list — < md only. Each passkey gets its own
|
||||
card so the delete action stays visible and tap-targetable
|
||||
(44px) without the user having to horizontally scroll
|
||||
through a wide table. */}
|
||||
{/* Mobile + tablet card list — < lg only. Each passkey gets
|
||||
its own card so the delete action stays visible and
|
||||
tap-targetable (44 px) without the user having to
|
||||
horizontally scroll through a wide table. v1.4.33 bumped
|
||||
the breakpoint from `md` to `lg` so iPad portrait (768 px)
|
||||
stays on the card layout instead of flipping between
|
||||
layouts on rotation. */}
|
||||
<ul
|
||||
className="mt-3 space-y-2 md:hidden"
|
||||
className="mt-3 space-y-2 lg:hidden"
|
||||
data-testid="passkeys-mobile-list"
|
||||
>
|
||||
{passkeys.map((pk) => (
|
||||
|
||||
@@ -251,11 +251,17 @@ function AiInsightsCard({ isAuthenticated }: { isAuthenticated: boolean }) {
|
||||
|
||||
return (
|
||||
<div className="bg-card border-border space-y-4 rounded-xl border p-6">
|
||||
<div className="flex flex-wrap items-start justify-between gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Sparkles className="text-primary h-5 w-5" />
|
||||
<h2 className="text-lg font-semibold">{t("settings.kiInsights")}</h2>
|
||||
</div>
|
||||
{/* v1.4.33 IW7 — the dedicated H2 ("KI-Insights") used to repeat
|
||||
the parent section title ("KI-Auswertungen"). Both are gone now:
|
||||
the section renders "Auswertungen" once at the top, and this
|
||||
card carries only the Sparkles icon + the live provider-status
|
||||
badges, mirroring the description that lives on the section
|
||||
header. The configuration description is moved one level up
|
||||
into the section subtitle so the visual hierarchy stays
|
||||
two-level (section -> form) instead of three-level
|
||||
(section -> card title -> form). */}
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<Sparkles className="text-primary h-5 w-5" aria-hidden="true" />
|
||||
<ProviderStatusBadges
|
||||
settings={insightsSettings}
|
||||
activeProvider={chainData?.activeProvider ?? null}
|
||||
|
||||
@@ -122,7 +122,7 @@ function ExportCardShell({
|
||||
return (
|
||||
<div
|
||||
data-testid={testId}
|
||||
className={`bg-card border-border flex h-full flex-col rounded-xl border p-5${outerClassName ? ` ${outerClassName}` : ""}`}
|
||||
className={`bg-card border-border flex h-full flex-col rounded-xl border p-6${outerClassName ? ` ${outerClassName}` : ""}`}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
|
||||
@@ -753,7 +753,16 @@ function MoodLogCard({
|
||||
enterKeyHint="done"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-start gap-2">
|
||||
{/* v1.4.33 — save-button placement contract:
|
||||
right-aligned primary `Speichern` with the secondary
|
||||
`Verbindung testen` immediately to its left. Mood Log
|
||||
used to left-align its row, which was the only
|
||||
integration form that did. */}
|
||||
<div className="flex flex-wrap items-center justify-end gap-2">
|
||||
<TestConnectionButton
|
||||
endpoint="/api/integrations/moodlog/test"
|
||||
disabled={!status?.configured}
|
||||
/>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={saving || (!url.trim() && !apiKey.trim())}
|
||||
@@ -763,10 +772,6 @@ function MoodLogCard({
|
||||
<Save className="mr-2 h-4 w-4" />
|
||||
{t("common.save")}
|
||||
</Button>
|
||||
<TestConnectionButton
|
||||
endpoint="/api/integrations/moodlog/test"
|
||||
disabled={!status?.configured}
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -777,12 +782,18 @@ function MoodLogCard({
|
||||
<Label htmlFor="moodlog-webhook-secret">
|
||||
{t("settings.moodLogWebhookSecret")}
|
||||
</Label>
|
||||
<div className="flex gap-2">
|
||||
{/* v1.4.33 — the webhook secret is a long hex string;
|
||||
on a 393 CSS px viewport the input + Copy button
|
||||
used to push past the card edge and trigger a body
|
||||
horizontal scroll. Stack vertically on `<sm` and
|
||||
break the input value so it wraps inside its own
|
||||
box. */}
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
|
||||
<Input
|
||||
id="moodlog-webhook-secret"
|
||||
value={status.webhookSecret}
|
||||
readOnly
|
||||
className="font-mono text-xs"
|
||||
className="font-mono text-xs break-all"
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
@@ -792,6 +803,7 @@ function MoodLogCard({
|
||||
setMsg(t("common.copied"));
|
||||
setMsgType("success");
|
||||
}}
|
||||
className="w-full sm:w-auto"
|
||||
>
|
||||
{/* v1.4.22 D / D-DSGN-M-04 — use the proper
|
||||
common.copy key instead of stripping the
|
||||
@@ -818,7 +830,14 @@ function MoodLogCard({
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
{/* v1.4.33 — the Sync / Voll-Sync / Trennen triplet
|
||||
overflowed the card edge on a 393 CSS px viewport
|
||||
(~376 px of buttons + gaps inside 345 px of inner
|
||||
width). Match the Withings card pattern: `flex-wrap`
|
||||
+ `min-w-[10rem]` on `<sm` so the triggers stack two
|
||||
per row and fill their column cleanly; tablet+ keeps
|
||||
the inline row. */}
|
||||
<div className="flex flex-wrap items-start gap-2 [&>*]:min-w-[10rem] sm:[&>*]:min-w-0">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
@@ -849,7 +868,7 @@ function MoodLogCard({
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>{t("common.cancel")}</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={() => handleSync(true)}>
|
||||
{t("settings.moodLogFullSync")}
|
||||
{t("settings.moodLogFullSyncConfirm")}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import Link from "next/link";
|
||||
import { ChevronRight } from "lucide-react";
|
||||
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
@@ -40,6 +42,32 @@ export function NotificationsSection() {
|
||||
aria-labelledby="settings-section-notifications-title"
|
||||
className="space-y-6"
|
||||
>
|
||||
{/* v1.4.33 IW7 — disambiguate the channel-config screen from the
|
||||
inbox at `/notifications`. Both surfaces used to be named plain
|
||||
"Notifications", so the crumb spells out where the user is and
|
||||
offers a one-tap jump to the inbox. */}
|
||||
<nav aria-label="Breadcrumb" className="text-muted-foreground text-xs">
|
||||
<ol className="flex items-center gap-1">
|
||||
<li>{t("notifications.breadcrumbSettings")}</li>
|
||||
<li aria-hidden="true">
|
||||
<ChevronRight className="h-3 w-3" />
|
||||
</li>
|
||||
<li className="text-foreground font-medium">
|
||||
{t("notifications.breadcrumbChannels")}
|
||||
</li>
|
||||
<li aria-hidden="true">
|
||||
<ChevronRight className="h-3 w-3" />
|
||||
</li>
|
||||
<li>
|
||||
<Link
|
||||
href="/notifications"
|
||||
className="hover:text-foreground underline-offset-2 hover:underline"
|
||||
>
|
||||
{t("notifications.breadcrumbInbox")}
|
||||
</Link>
|
||||
</li>
|
||||
</ol>
|
||||
</nav>
|
||||
<header className="space-y-1">
|
||||
<h1
|
||||
id="settings-section-notifications-title"
|
||||
|
||||
@@ -4,12 +4,12 @@ import { useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Bell, Loader2, Send } from "lucide-react";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { PasswordInput } from "@/components/ui/password-input";
|
||||
import { SettingsCardHeader } from "@/components/settings/_card-header";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
|
||||
interface NtfySettings {
|
||||
@@ -101,27 +101,11 @@ export function NtfyCard({ isAuthenticated }: { isAuthenticated: boolean }) {
|
||||
|
||||
return (
|
||||
<div className="bg-card border-border rounded-xl border p-6">
|
||||
<div className="flex flex-wrap items-start justify-between gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Bell className="text-primary h-5 w-5" />
|
||||
<h2 className="text-lg font-semibold">{t("settings.ntfy")}</h2>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{settings?.serverUrl && settings?.topic && (
|
||||
<Badge className="border-dracula-green/30 bg-dracula-green/15 text-dracula-green">
|
||||
{t("settings.configured")}
|
||||
</Badge>
|
||||
)}
|
||||
{settings?.enabled && (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{t("common.enabled")}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-muted-foreground mt-1 text-xs">
|
||||
{t("settings.ntfyDescription")}
|
||||
</p>
|
||||
<SettingsCardHeader
|
||||
icon={Bell}
|
||||
title={t("settings.ntfy")}
|
||||
description={t("settings.ntfyDescription")}
|
||||
/>
|
||||
|
||||
<div className="mt-4 space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
|
||||
@@ -54,6 +54,14 @@ export function SectionPlaceholder({ slug }: SectionPlaceholderProps) {
|
||||
const sectionTitle = t(`settings.sections.${slug}.title`);
|
||||
const sectionDescription = t(`settings.sections.${slug}.description`);
|
||||
|
||||
// v1.4.33 A4 — `<SectionPlaceholder>` is a defensive fallback for
|
||||
// slugs added to SETTINGS_SECTION_SLUGS without a matching component.
|
||||
// The type system blocks that path today, so the EmptyState body is
|
||||
// effectively unreachable. We retire the dedicated locale key
|
||||
// (`settings.sections.placeholder.coming_soon`) and inherit the
|
||||
// section's own description so the guard still renders meaningful
|
||||
// copy if it ever does fire — no more dead-string maintenance across
|
||||
// six locale files.
|
||||
return (
|
||||
<section
|
||||
aria-labelledby={`settings-section-${slug}-title`}
|
||||
@@ -72,7 +80,7 @@ export function SectionPlaceholder({ slug }: SectionPlaceholderProps) {
|
||||
<EmptyState
|
||||
icon={<Icon className="size-6" />}
|
||||
title={sectionTitle}
|
||||
description={t("settings.sections.placeholder.coming_soon")}
|
||||
description={sectionDescription}
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
|
||||
@@ -20,7 +20,6 @@ import { usePathname } from "next/navigation";
|
||||
import {
|
||||
Bell,
|
||||
Download,
|
||||
Info,
|
||||
KeyRound,
|
||||
Layers,
|
||||
LayoutDashboard,
|
||||
@@ -61,9 +60,19 @@ interface SettingsSection {
|
||||
}
|
||||
|
||||
/**
|
||||
* Source of truth for the section list. Order matches the in-app navigation
|
||||
* order so the sidebar, mobile strip, and `generateStaticParams()` all line
|
||||
* up. Don't reorder without updating tests.
|
||||
* Source of truth for the section list that the settings shell renders
|
||||
* in its sidebar + mobile chip-strip. Order matches the in-app
|
||||
* navigation order. Don't reorder without updating tests.
|
||||
*
|
||||
* v1.4.33 IW7 — "About" was folded into the sidebar user-card
|
||||
* dropdown (sidebar-nav.tsx -> "Über HealthLog" / "About HealthLog")
|
||||
* because its three small cards (identity / links / update check)
|
||||
* collectively read once or twice a year and didn't earn a top-level
|
||||
* settings slot. The route `/settings/about` is still alive — the
|
||||
* slug stays in `SETTINGS_SECTION_SLUGS` so `generateStaticParams()`
|
||||
* keeps emitting the page — but it no longer appears in the in-page
|
||||
* navigation. Direct links (the user-card dropdown, the public
|
||||
* `/about` permalink) continue to resolve to the same component.
|
||||
*/
|
||||
export const SETTINGS_SECTIONS: readonly SettingsSection[] = [
|
||||
{ slug: "account", titleKey: "settings.sections.account.title", icon: User },
|
||||
@@ -104,7 +113,6 @@ export const SETTINGS_SECTIONS: readonly SettingsSection[] = [
|
||||
titleKey: "settings.sections.advanced.title",
|
||||
icon: Settings2,
|
||||
},
|
||||
{ slug: "about", titleKey: "settings.sections.about.title", icon: Info },
|
||||
] as const;
|
||||
|
||||
export interface SettingsShellProps {
|
||||
@@ -135,6 +143,29 @@ export function SettingsShell({ active, children }: SettingsShellProps) {
|
||||
const { t } = useTranslations();
|
||||
const activeSlug = deriveActiveSlug(pathname, active);
|
||||
|
||||
// v1.4.33 IW4 — scroll the active chip into view inside the
|
||||
// horizontal mobile strip. On a 393 CSS px viewport the strip
|
||||
// is 11 chips wide and the rightmost four sit below the fold; a
|
||||
// user who tapped one of those chips lands on the new route
|
||||
// with the strip still scrolled to the leftmost chip, which
|
||||
// reads as if the navigation is broken. `inline: "center"`
|
||||
// anchors the active chip in the visible middle of its scroll
|
||||
// container so the user keeps spatial orientation between
|
||||
// sections.
|
||||
const mobileStripRef = React.useRef<HTMLElement | null>(null);
|
||||
React.useEffect(() => {
|
||||
if (typeof window === "undefined") return;
|
||||
const strip = mobileStripRef.current;
|
||||
if (!strip) return;
|
||||
const active = strip.querySelector<HTMLElement>('[aria-current="page"]');
|
||||
if (!active) return;
|
||||
active.scrollIntoView({
|
||||
block: "nearest",
|
||||
inline: "center",
|
||||
behavior: "smooth",
|
||||
});
|
||||
}, [activeSlug]);
|
||||
|
||||
// v1.4.25 W8 — AuthShell wraps the page in `px-4 py-6 md:px-6`
|
||||
// already, so this inner shell only carries the wider max-width.
|
||||
// Previously the duplicate `px-4 py-6 md:px-6 md:py-8` here was
|
||||
@@ -148,17 +179,25 @@ export function SettingsShell({ active, children }: SettingsShellProps) {
|
||||
scrolling still work. Without it the 10-section strip
|
||||
renders an always-on scrollbar at the top of every settings
|
||||
page, which makes the page feel like every section card has
|
||||
an overflow problem. */}
|
||||
an overflow problem.
|
||||
|
||||
v1.4.33 IW7 — `snap-x snap-mandatory` lets a swipe-flick land
|
||||
on the next chip's leading edge instead of the in-between
|
||||
dead zone. The auto `scrollIntoView({inline: "center"})`
|
||||
effect above stays the canonical positioner for the active
|
||||
chip; snap is the polish layer when the user manually flicks
|
||||
through the strip without tapping. */}
|
||||
<nav
|
||||
ref={mobileStripRef}
|
||||
aria-label={t("settings.shell.sectionsNav")}
|
||||
className="no-scrollbar -mx-4 mb-4 overflow-x-auto px-4 md:hidden"
|
||||
className="no-scrollbar -mx-4 mb-4 snap-x snap-mandatory overflow-x-auto px-4 md:hidden"
|
||||
>
|
||||
<ul className="flex min-w-max gap-2">
|
||||
{SETTINGS_SECTIONS.map((section) => {
|
||||
const isActive = section.slug === activeSlug;
|
||||
const Icon = section.icon;
|
||||
return (
|
||||
<li key={section.slug}>
|
||||
<li key={section.slug} className="snap-start">
|
||||
<Link
|
||||
href={`/settings/${section.slug}`}
|
||||
aria-current={isActive ? "page" : undefined}
|
||||
@@ -218,8 +257,17 @@ export function SettingsShell({ active, children }: SettingsShellProps) {
|
||||
`min-h-[calc(100dvh-12rem)]` reserve keeps the column tall
|
||||
enough that swapping a short loading state for a long
|
||||
section list (Thresholds, Sources) does not jump the page
|
||||
height under the sticky sidebar. */}
|
||||
<main className="min-h-[calc(100dvh-12rem)] min-w-0">{children}</main>
|
||||
height under the sticky sidebar.
|
||||
|
||||
v1.4.33 F14 — `pb-24 md:pb-0` reserves a 96 px bottom gutter
|
||||
on `<md` so the last form field on
|
||||
`/settings/account` (Geschlecht / Gender) and
|
||||
`/settings/ai` (Aktiver Provider) is not eaten by the
|
||||
floating mobile bottom-nav. Desktop reverts to the parent's
|
||||
own padding because the bottom-nav doesn't render on `md+`. */}
|
||||
<main className="min-h-[calc(100dvh-12rem)] min-w-0 pb-24 md:pb-0">
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -4,12 +4,12 @@ import { useState } from "react";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Loader2, MessageCircle, Save, Send } from "lucide-react";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { PasswordInput } from "@/components/ui/password-input";
|
||||
import { SettingsCardHeader } from "@/components/settings/_card-header";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
|
||||
interface TelegramSettings {
|
||||
@@ -106,32 +106,11 @@ export function TelegramCard({
|
||||
|
||||
return (
|
||||
<div className="bg-card border-border rounded-xl border p-6">
|
||||
<div className="flex flex-wrap items-start justify-between gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<MessageCircle className="text-primary h-5 w-5" />
|
||||
<h2 className="text-lg font-semibold">{t("settings.telegram")}</h2>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{/* F-16 (v1.4.19): show one unified status pill instead of
|
||||
the previous "Configured" + "Enabled" pair, which had no
|
||||
visual hierarchy and made it unclear how the two states
|
||||
differed. The new pill collapses both bits of info:
|
||||
green when active, neutral when configured-but-paused. */}
|
||||
{settings?.hasBotToken && settings?.chatId && settings?.enabled && (
|
||||
<Badge className="border-dracula-green/30 bg-dracula-green/15 text-dracula-green">
|
||||
{t("common.active")}
|
||||
</Badge>
|
||||
)}
|
||||
{settings?.hasBotToken && settings?.chatId && !settings?.enabled && (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{t("settings.configured")} · {t("common.disabled")}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-muted-foreground mt-1 text-xs">
|
||||
{t("settings.telegramDescription")}
|
||||
</p>
|
||||
<SettingsCardHeader
|
||||
icon={MessageCircle}
|
||||
title={t("settings.telegram")}
|
||||
description={t("settings.telegramDescription")}
|
||||
/>
|
||||
|
||||
<div className="mt-4 space-y-4">
|
||||
<form onSubmit={handleSave} className="space-y-4">
|
||||
|
||||
@@ -11,6 +11,7 @@ import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { useTranslations, useFormatters } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import {
|
||||
METRIC_BOUNDS,
|
||||
type ThresholdMetric,
|
||||
@@ -87,7 +88,7 @@ export function ThresholdsEditorSection({ id }: { id: string }) {
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["user", "thresholds"] });
|
||||
// Every chart/band depends on these thresholds — invalidate everything.
|
||||
queryClient.invalidateQueries({ queryKey: ["analytics"] });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.analytics() });
|
||||
queryClient.invalidateQueries({ queryKey: ["insights"] });
|
||||
toast.success(t("thresholds.saveSuccess"));
|
||||
},
|
||||
@@ -107,7 +108,7 @@ export function ThresholdsEditorSection({ id }: { id: string }) {
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["user", "thresholds"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["analytics"] });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.analytics() });
|
||||
queryClient.invalidateQueries({ queryKey: ["insights"] });
|
||||
toast.success(t("thresholds.resetSuccess"));
|
||||
},
|
||||
@@ -250,10 +251,18 @@ function MetricRow({
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
{/* v1.4.33 F17 — the switch label used to flip between
|
||||
"Auto" and "Überschrieben" with the current state. The
|
||||
maintainer's audit caught that as confusing: a user
|
||||
looking at a row with "Auto" + the toggle off reads it
|
||||
as "Auto is off, why are there no inputs?" instead of
|
||||
"flip the switch to enter a custom range". Anchor the
|
||||
label on the *action* ("Eigene Werte" / "Custom range")
|
||||
so the affordance is unambiguous; the
|
||||
`thresholds.sourceOverride` badge to the right still
|
||||
announces when the override is active. */}
|
||||
<Label htmlFor={`override-${metric}`} className="text-xs">
|
||||
{overrideMode
|
||||
? t("thresholds.overrideModeLabel")
|
||||
: t("thresholds.autoModeLabel")}
|
||||
{t("thresholds.overrideToggleLabel")}
|
||||
</Label>
|
||||
<Switch
|
||||
id={`override-${metric}`}
|
||||
|
||||
@@ -80,9 +80,14 @@ export function TimezonePicker({
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleUseBrowserTz}
|
||||
aria-label={
|
||||
detected
|
||||
? t("settings.timezoneDetectAria", { tz: detected })
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<Compass className="mr-2 h-4 w-4" />
|
||||
{t("settings.timezoneDetect", { tz: detected })}
|
||||
{t("settings.timezoneDetect")}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
@@ -109,15 +114,26 @@ export function TimezonePicker({
|
||||
</option>
|
||||
))}
|
||||
</NativeSelect>
|
||||
{/* v1.4.33 — visible label is "use browser zone"; the actual
|
||||
IANA zone is read out via aria-label so screen-reader users
|
||||
still hear which zone the click will apply. Previously the
|
||||
label inlined `(Europe/Berlin)`, which on a 393 CSS px
|
||||
viewport wrapped the button to two lines and broke height
|
||||
parity with the adjacent select. */}
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleUseBrowserTz}
|
||||
className="sm:w-auto"
|
||||
aria-label={
|
||||
detected
|
||||
? t("settings.timezoneDetectAria", { tz: detected })
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<Compass className="mr-2 h-4 w-4" />
|
||||
{t("settings.timezoneDetect", { tz: detected })}
|
||||
{t("settings.timezoneDetect")}
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-muted-foreground text-xs">{labelHint}</p>
|
||||
|
||||
@@ -3,9 +3,9 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { BellRing, Loader2 } from "lucide-react";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { TestConnectionButton } from "@/components/settings/test-connection-button";
|
||||
import { SettingsCardHeader } from "@/components/settings/_card-header";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
|
||||
/** Convert a URL-safe base64 string to an ArrayBuffer (for VAPID key). */
|
||||
@@ -157,27 +157,11 @@ export function WebPushCard() {
|
||||
|
||||
return (
|
||||
<div className="bg-card border-border rounded-xl border p-6">
|
||||
<div className="flex flex-wrap items-start justify-between gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<BellRing className="text-primary h-5 w-5" />
|
||||
<h2 className="text-lg font-semibold">{t("settings.webPush")}</h2>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{!loading && isSubscribed && (
|
||||
<Badge className="border-dracula-green/30 bg-dracula-green/15 text-dracula-green">
|
||||
{t("settings.configured")}
|
||||
</Badge>
|
||||
)}
|
||||
{!loading && isSubscribed && (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{t("settings.webPushActive")}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-muted-foreground mt-1 text-xs">
|
||||
{t("settings.webPushDescription")}
|
||||
</p>
|
||||
<SettingsCardHeader
|
||||
icon={BellRing}
|
||||
title={t("settings.webPush")}
|
||||
description={t("settings.webPushDescription")}
|
||||
/>
|
||||
|
||||
<div className="mt-4 space-y-3">
|
||||
{loading ? (
|
||||
|
||||
Reference in new issue
Block a user