From 9cef08919258cd32c8f9c9113a405f5484e263e7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 27 Jun 2026 17:53:05 +0200 Subject: [PATCH 01/16] fix(nav): even out the profile menu row spacing The Theme sub-trigger inherited the shared sub-trigger padding (py-1.5, no min-height) while every sibling row carries min-h-11 py-2, so the Theme row sat a few pixels shorter and the menu read as unevenly spaced. Match the item height on this instance only, leaving other dropdowns' sub-triggers untouched. --- src/components/layout/top-bar.tsx | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/components/layout/top-bar.tsx b/src/components/layout/top-bar.tsx index 956e27ee3..7f0fc898d 100644 --- a/src/components/layout/top-bar.tsx +++ b/src/components/layout/top-bar.tsx @@ -126,7 +126,13 @@ export function TopBar() { for the small audience that still reaches it (admins only, on the order of once or twice a year). */} - + {/* v1.22.1 — the shared sub-trigger ships `py-1.5` and no + min-height, while every sibling `DropdownMenuItem` carries + `min-h-11 py-2`. Left as-is the Theme row sits a few pixels + shorter than the rows around it and the menu reads as + unevenly spaced. Match the item height here (instance-level + override, so no other dropdown's sub-trigger is touched). */} + {themeIcon} {t("nav.theme")} From 3f19b13fab47c8b64b323e3b125c30b99d8f9e62 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 27 Jun 2026 17:53:12 +0200 Subject: [PATCH 02/16] fix(nav): keep the More-menu tile labels fully visible on narrow screens The long single-word labels (de "Benachrichtigungen") clipped their last character at the tile edge on a 320 px phone. Trim the grid gutters and the tile padding/gap to hand each label more horizontal room, and cap the bottom sheet at max-w-md, so the label wraps cleanly onto a second line instead of running off the tile. Tile height stays uniform. --- src/components/layout/bottom-nav.tsx | 20 +++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/src/components/layout/bottom-nav.tsx b/src/components/layout/bottom-nav.tsx index f7cbc7f19..300fa1cba 100644 --- a/src/components/layout/bottom-nav.tsx +++ b/src/components/layout/bottom-nav.tsx @@ -220,14 +220,19 @@ export function BottomNav() { // The sheet auto-sizes to its content; padding-bottom respects // the iOS safe area so the inner buttons stay reachable above // the home-indicator on iPhone-X-class devices. - className="rounded-t-xl pb-[calc(env(safe-area-inset-bottom)+1rem)] md:hidden" + className="mx-auto max-w-md rounded-t-xl pb-[calc(env(safe-area-inset-bottom)+1rem)] md:hidden" data-testid="bottom-nav-more-sheet" > {t("nav.moreSheetTitle")} {t("nav.moreSheetDescription")} -
+ {/* v1.22.1 — tighten the grid gutters (`px-3`) so each tile claims a + little more width on a 320 px phone; the long single-word labels + (de "Benachrichtigungen") then have room to break onto a clean + second line instead of clipping the last character at the tile + edge. */} +
{moreHub.map((item) => { const isActive = isActiveLink(item.href); return ( @@ -239,8 +244,11 @@ export function BottomNav() { // v1.12 — `min-h-14` matches the capture-picker tiles so // the equivalent tappable rows share one height at this // tier (and the larger row is a more comfortable target). + // v1.22.1 — trim the inner padding/gap (`px-3`, `gap-2.5`) + // to hand the label more horizontal room without changing + // the shared tile height. className={cn( - "border-border flex min-h-14 items-center gap-3 rounded-lg border px-4 py-3 transition-colors", + "border-border flex min-h-14 items-center gap-2.5 rounded-lg border px-3 py-3 transition-colors", isActive ? "text-primary bg-primary/5" : "text-foreground hover:bg-accent/40", @@ -252,11 +260,13 @@ export function BottomNav() { ("Benachrichtigungen" → "Benachrichtigun…"); the goal is a readable label, not a balanced tile. Shrink the type to `text-xs` so long single-word locales (de/es/fr/it) fit on - one line at ~110px, and allow a 2-line `line-clamp-2` + one line where they can, and allow a 2-line `line-clamp-2` fallback (with `[overflow-wrap:anywhere]` so a long word breaks rather than overflowing) for the rare label that still cannot fit one line. `min-w-0` lets the flex child - shrink below its content width. */} + shrink below its content width. v1.22.1 — with the wider + text area above, "Benachrichtigungen" wraps fully inside + two lines down to a 320 px viewport. */} Date: Sat, 27 Jun 2026 17:56:42 +0200 Subject: [PATCH 03/16] fix(insights): drop the duplicate average-per-night header in sleep The Sleep view led with an average-per-night headline card that repeated the same figure and label already shown in the average-sleep tile within the sleep-rhythm grid row below it. Remove the leading card and let the tile carry the readout; the stage-distribution bar and duration trend chart stay in place. --- src/components/insights/sleep-overview.tsx | 64 ++++------------------ 1 file changed, 10 insertions(+), 54 deletions(-) diff --git a/src/components/insights/sleep-overview.tsx b/src/components/insights/sleep-overview.tsx index fa561ca97..aaf123bcf 100644 --- a/src/components/insights/sleep-overview.tsx +++ b/src/components/insights/sleep-overview.tsx @@ -9,7 +9,7 @@ import { useTranslations } from "@/lib/i18n/context"; import { useInsightsLayoutPrefs } from "@/hooks/use-insights-layout-prefs"; import { useAnalyticsQuery } from "@/lib/queries/use-analytics-query"; import { Button } from "@/components/ui/button"; -import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Card, CardContent } from "@/components/ui/card"; import { EmptyState } from "@/components/ui/empty-state"; import dynamic from "next/dynamic"; import { ChartErrorBoundary } from "@/components/charts/chart-error-state"; @@ -45,13 +45,16 @@ function SleepStageStackedBar( * * Pulls the analytics `sleepStages` aggregate (added in v1.4.23 W2) * plus the SLEEP_DURATION trend series via `` and - * renders three blocks: + * renders two blocks: * - * 1. Headline card with the average nightly total (h / min) and the - * number of nights covered. - * 2. Stage composition stacked bar (REM / Deep / Core / Awake / In + * 1. Stage composition stacked bar (REM / Deep / Core / Awake / In * bed / Asleep) — the canonical sleep-phase distribution. - * 3. Duration trend chart with chart-cog parity (`chartKey="sleep"`). + * 2. Duration trend chart with chart-cog parity (`chartKey="sleep"`). + * + * v1.22.0 — the leading "average per night" headline card was dropped: + * the same figure + label already renders in `` inside + * the shared sleep-rhythm grid row directly below, so the top card only + * duplicated it. * * v1.18.7 W-D — the single-night "Last night" hypnogram card was removed: * its per-stage breakdown (time + %) duplicated the phase distribution the @@ -80,21 +83,9 @@ interface SleepAnalyticsResponse { sleepStages: SleepStageBreakdown | null; } -function formatHoursMinutes( - totalMinutes: number, - locale: string, -): { primary: string } { - const hours = Math.floor(totalMinutes / 60); - const mins = Math.round(totalMinutes - hours * 60); - if (locale === "de") { - return { primary: `${hours} Std. ${mins} Min.` }; - } - return { primary: `${hours}h ${mins}m` }; -} - export function SleepOverview() { const { isAuthenticated, user } = useAuth(); - const { t, locale } = useTranslations(); + const { t } = useTranslations(); const { compareBaseline } = useInsightsLayoutPrefs(isAuthenticated); // v1.4.33 IW2 — sleep overview reads `sleepStages` (a thick-only @@ -127,43 +118,8 @@ export function SleepOverview() { ); } - const averageMinutes = sleepSummary?.avg30 ?? sleepSummary?.avg7 ?? null; - const suffix = t("insights.sleep.headlineCaptionSuffix"); - const { primary } = - averageMinutes != null - ? formatHoursMinutes(averageMinutes, locale) - : { primary: "—" }; - return (
- {/* v1.12.0 — headline card tightened: the default Card - `gap-4 md:gap-6` + `py-4 md:py-6` left a tall empty band around a - two-line readout. `gap-2 py-4 md:py-4` pulls the caption up under the - number without crowding it. */} - - -
- - - {t("insights.sleep.headlineTitle")} - -
-
- -
-

{primary}

-

- {sleepSummary?.count - ? t("insights.sleep.headlineCaption", { - count: sleepSummary.count, - suffix, - }) - : suffix} -

-
-
-
- {sleepStages ? ( ) : ( From ba497f4ae8c614a50dd8807d52d945fc4733f39b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 27 Jun 2026 17:56:55 +0200 Subject: [PATCH 04/16] fix(insights): keep charts from crushing the layout in landscape Fixed-pixel chart bands ate roughly half the screen when a small phone was rotated to landscape. Swap the brittle heights for viewport-relative clamps so the chart envelope holds its previous size on desktop and portrait but shrinks toward a floor on short landscape viewports. Covers the overview trends-row slot and the mood distribution, weekday, and time-of-day charts (with matching skeleton heights). Chart series and styling are untouched; only the container heights flex. --- .../insights/__tests__/trends-row.test.tsx | 9 +++++++-- .../insights/mood/mood-distribution-chart.tsx | 2 +- .../insights/mood/mood-insights-sections.tsx | 12 +++++++++--- .../insights/mood/mood-time-of-day-chart.tsx | 2 +- src/components/insights/mood/mood-weekday-chart.tsx | 2 +- src/components/insights/trends-row.tsx | 9 ++++++++- 6 files changed, 27 insertions(+), 9 deletions(-) diff --git a/src/components/insights/__tests__/trends-row.test.tsx b/src/components/insights/__tests__/trends-row.test.tsx index b69b00b9e..c8b18b288 100644 --- a/src/components/insights/__tests__/trends-row.test.tsx +++ b/src/components/insights/__tests__/trends-row.test.tsx @@ -106,7 +106,7 @@ describe("", () => { expect(slots.length).toBe(3); }); - it("pins every chart slot to h-[180px] so Recharts mounts with a known size (CLS fix)", () => { + it("pins every chart slot to a known responsive height so Recharts mounts with a size (CLS fix)", () => { // v1.4.36 W2 — without an explicit height the Recharts // ResponsiveContainer mounts with width=-1 height=-1 and emits a // console warning per chart. The mood card also drifted taller @@ -119,12 +119,17 @@ describe("", () => { // TrendAnnotation row below — visible as text sitting on top of // the chart. 180 px absorbs the full envelope so the three tiles // share one chart-band baseline without overlap. + // + // v1.22.0 — the slot height became `clamp(140px,32vh,180px)` so a + // rotated phone doesn't lose half the screen to the fixed band. The + // 180 px envelope still holds on desktop + portrait (where 32vh + // clears 180 px); only short landscape viewports shrink to the floor. const html = render(); const slots = html.match(/data-slot="trends-row-chart-slot"[^>]*class="[^"]*"/g) ?? []; expect(slots.length).toBe(3); for (const slot of slots) { - expect(slot).toMatch(/h-\[180px\]/); + expect(slot).toMatch(/h-\[clamp\(140px,32vh,180px\)\]/); } }); diff --git a/src/components/insights/mood/mood-distribution-chart.tsx b/src/components/insights/mood/mood-distribution-chart.tsx index 9e1f64f8b..cd11da3cb 100644 --- a/src/components/insights/mood/mood-distribution-chart.tsx +++ b/src/components/insights/mood/mood-distribution-chart.tsx @@ -56,7 +56,7 @@ export function MoodDistributionChart({ // v1.15.14 — bounded compact height (was `aspect-[3/2] min-h-[180px]`, // which ballooned the card on a wide viewport). A fixed band keeps this a // tidy card rather than a dominant block, and matches the weekday sibling. -
+
, + loading: () => ( + + ), }, ); const MoodWeekdayChart = dynamic( @@ -56,7 +58,9 @@ const MoodWeekdayChart = dynamic( })), { ssr: false, - loading: () => , + loading: () => ( + + ), }, ); const MoodTimeOfDayChart = dynamic( @@ -66,7 +70,9 @@ const MoodTimeOfDayChart = dynamic( })), { ssr: false, - loading: () => , + loading: () => ( + + ), }, ); import { MoodTagBreakdown, type MoodTagRow } from "./mood-tag-breakdown"; diff --git a/src/components/insights/mood/mood-time-of-day-chart.tsx b/src/components/insights/mood/mood-time-of-day-chart.tsx index 5a5aa102b..2a8769fc0 100644 --- a/src/components/insights/mood/mood-time-of-day-chart.tsx +++ b/src/components/insights/mood/mood-time-of-day-chart.tsx @@ -78,7 +78,7 @@ export function MoodTimeOfDayChart({ width-constrained), so an aspect ratio derived the height off the full card width and ballooned the chart to ~800px on a wide viewport. A bounded height keeps it the same size as the sibling charts. */} -
+
+
From 89b8c0e6d722712f9c1588d1159eb38792cc69e1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 27 Jun 2026 17:57:45 +0200 Subject: [PATCH 05/16] refactor(admin): rename the availability section to Modules --- messages/de.json | 2 +- messages/en.json | 2 +- messages/es.json | 2 +- messages/fr.json | 2 +- messages/it.json | 2 +- messages/pl.json | 2 +- 6 files changed, 6 insertions(+), 6 deletions(-) diff --git a/messages/de.json b/messages/de.json index 8a3a5fb4c..221a0a141 100644 --- a/messages/de.json +++ b/messages/de.json @@ -5518,7 +5518,7 @@ "subtitle": "Version, Lizenz und Update-Status." }, "module-availability": { - "title": "Modulverfügbarkeit", + "title": "Module", "subtitle": "Module für jedes Konto auf diesem Server aktivieren oder deaktivieren." } }, diff --git a/messages/en.json b/messages/en.json index 06b38c3e8..8a5884a2f 100644 --- a/messages/en.json +++ b/messages/en.json @@ -5518,7 +5518,7 @@ "subtitle": "Version, license, and update status." }, "module-availability": { - "title": "Module availability", + "title": "Modules", "subtitle": "Turn modules on or off for every account on this server." } }, diff --git a/messages/es.json b/messages/es.json index 0b49cd985..c666fdab0 100644 --- a/messages/es.json +++ b/messages/es.json @@ -5518,7 +5518,7 @@ "subtitle": "Versión, licencia y estado de actualizaciones." }, "module-availability": { - "title": "Disponibilidad de módulos", + "title": "Módulos", "subtitle": "Activa o desactiva módulos para todas las cuentas de este servidor." } }, diff --git a/messages/fr.json b/messages/fr.json index 971a1bbd8..baa1a88cb 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -5518,7 +5518,7 @@ "subtitle": "Version, licence et état des mises à jour." }, "module-availability": { - "title": "Disponibilité des modules", + "title": "Modules", "subtitle": "Activez ou désactivez les modules pour tous les comptes de ce serveur." } }, diff --git a/messages/it.json b/messages/it.json index 4b762cd6b..c4dad1eda 100644 --- a/messages/it.json +++ b/messages/it.json @@ -5518,7 +5518,7 @@ "subtitle": "Versione, licenza e stato degli aggiornamenti." }, "module-availability": { - "title": "Disponibilità dei moduli", + "title": "Moduli", "subtitle": "Attiva o disattiva i moduli per tutti gli account su questo server." } }, diff --git a/messages/pl.json b/messages/pl.json index 664af0d8d..362f37151 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -5518,7 +5518,7 @@ "subtitle": "Wersja, licencja i stan aktualizacji." }, "module-availability": { - "title": "Dostępność modułów", + "title": "Moduły", "subtitle": "Włącz lub wyłącz moduły dla wszystkich kont na tym serwerze." } }, From 611de0f27423ce83f109b3c4b027c20d1d06ef4b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 27 Jun 2026 17:58:22 +0200 Subject: [PATCH 06/16] fix(settings): stop the mobile section selector animating a scroll from the top --- src/components/admin/admin-shell.tsx | 20 +++++++++----------- src/components/settings/settings-shell.tsx | 11 +++++------ 2 files changed, 14 insertions(+), 17 deletions(-) diff --git a/src/components/admin/admin-shell.tsx b/src/components/admin/admin-shell.tsx index 39f94bb37..1e4bd7ede 100644 --- a/src/components/admin/admin-shell.tsx +++ b/src/components/admin/admin-shell.tsx @@ -37,7 +37,6 @@ import { } from "lucide-react"; import { cn } from "@/lib/utils"; -import { scrollBehaviorForUser } from "@/lib/motion"; import { useAuth } from "@/hooks/use-auth"; import { useTranslations } from "@/lib/i18n/context"; import { isAdminSectionSlug, type AdminSectionSlug } from "./section-slugs"; @@ -188,16 +187,15 @@ export function AdminShell({ active, children }: AdminShellProps) { '[aria-current="page"]', ); if (!activeChip) return; - activeChip.scrollIntoView({ - block: "nearest", - // v1.4.36 W4b — `inline: "start"` pins the active chip to the - // left edge of the scroller. `inline: "center"` over-scrolled - // and the first one or two chips were unreachable on narrow - // viewports. - inline: "start", - // v1.4.43 W5-H5 — respect `prefers-reduced-motion`. - behavior: scrollBehaviorForUser(), - }); + // Adjust only the strip's own horizontal offset, instantly. The old + // `scrollIntoView` walked every scrollable ancestor and could nudge the + // document vertically, and a smooth behaviour animated the strip from a + // reset `scrollLeft: 0` to the target on every tap — an unwanted "scroll + // from the start" sweep. `scrollTo({ left, behavior: "auto" })` confines + // the motion to the strip's horizontal axis and jumps without animation. + const maxScroll = strip.scrollWidth - strip.clientWidth; + const target = Math.max(0, Math.min(activeChip.offsetLeft, maxScroll)); + strip.scrollTo({ left: target, behavior: "auto" }); }, [activeSlug]); // The section bodies are already role-gated, but the shell frame diff --git a/src/components/settings/settings-shell.tsx b/src/components/settings/settings-shell.tsx index e091cfc9c..865b52a57 100644 --- a/src/components/settings/settings-shell.tsx +++ b/src/components/settings/settings-shell.tsx @@ -43,7 +43,6 @@ import { } from "lucide-react"; import { cn } from "@/lib/utils"; -import { scrollBehaviorForUser } from "@/lib/motion"; import { useAuth } from "@/hooks/use-auth"; import { useTranslations } from "@/lib/i18n/context"; import type { ModuleKey } from "@/lib/modules/registry"; @@ -366,11 +365,11 @@ export function SettingsShell({ // box, which is the origin for `scrollLeft`. const maxScroll = strip.scrollWidth - strip.clientWidth; const target = Math.max(0, Math.min(active.offsetLeft, maxScroll)); - strip.scrollTo({ - left: target, - // v1.4.43 W5-H5 — respect `prefers-reduced-motion`. - behavior: scrollBehaviorForUser(), - }); + // Position the active chip instantly. A smooth behaviour here animates + // the strip from its reset `scrollLeft: 0` to the target on every tap, + // which reads as an unwanted "scroll from the start" sweep when swapping + // sections. An instant jump keeps the chip in view without the sweep. + strip.scrollTo({ left: target, behavior: "auto" }); }, [activeSlug]); // v1.18.6.1 — resolve the heading once. Pages pass an explicit `heading` From e5ee8f3b3e6b6e5ce526a510e1e5adec7a3bc925 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 27 Jun 2026 17:58:26 +0200 Subject: [PATCH 07/16] fix(settings): keep the section nav fixed while only the content pane scrolls --- src/components/admin/admin-shell.tsx | 93 ++++++++++++---------- src/components/settings/settings-shell.tsx | 63 ++++++++------- 2 files changed, 87 insertions(+), 69 deletions(-) diff --git a/src/components/admin/admin-shell.tsx b/src/components/admin/admin-shell.tsx index 1e4bd7ede..1a5ce6196 100644 --- a/src/components/admin/admin-shell.tsx +++ b/src/components/admin/admin-shell.tsx @@ -309,51 +309,60 @@ export function AdminShell({ active, children }: AdminShellProps) { {headingBlock}
- {/* Desktop sticky sidebar — starts at the cards row (row 2). */} + {/* Desktop sticky sidebar — starts at the cards row (row 2). + + The sticky lives on the `
) : null} - {/* Desktop sticky sidebar — starts at the cards row (row 2). */} + {/* Desktop sticky sidebar — starts at the cards row (row 2). + + The sticky lives on the `