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:
Marc-André Bombeck authored and GitHub committed 2026-05-16 19:46:43 +02:00
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 &amp; 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 &amp; 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", () => {
+78
View File
@@ -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>
);
}
+23 -7
View File
@@ -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) => (
+11 -5
View File
@@ -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}
+1 -1
View File
@@ -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"
+6 -22
View File
@@ -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>
);
+58 -10
View File
@@ -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>
);
+6 -27
View File
@@ -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}`}
+18 -2
View File
@@ -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>
+6 -22
View File
@@ -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 ? (