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

@@ -57,7 +57,9 @@ describe("<AiQualitySection>", () => {
mockQueryState.isLoading = false;
mockQueryState.isError = false;
const html = render(<AiQualitySection />);
expect(html).toContain("AI Quality");
// v1.4.33 IW7 — admin section renamed from "AI Quality" to
// "Insights Quality" per the Marc-Voice rule.
expect(html).toContain("Insights Quality");
expect(html).toContain("No feedback yet");
});
@@ -118,6 +120,6 @@ describe("<AiQualitySection>", () => {
mockQueryState.isLoading = false;
mockQueryState.isError = true;
const html = render(<AiQualitySection />);
expect(html).toContain("Failed to load AI quality summary");
expect(html).toContain("Failed to load Insights quality summary");
});
});
@@ -148,7 +148,10 @@ describe("admin sections — SSR smoke", () => {
// ai-quality-section test.
it("<AiQualitySection> renders the empty-state when no summary exists", () => {
const html = render(<AiQualitySection />);
expect(html).toContain("AI Quality");
// v1.4.33 IW7 — section renamed from "AI Quality" to
// "Insights Quality" per the Marc-Voice rule (no "KI"/"AI" prefix
// in user-facing copy).
expect(html).toContain("Insights Quality");
expect(html).toContain("No feedback yet");
});
@@ -434,9 +434,11 @@ function FeedbackDetailDialog({
{(url || locale || userAgent || appVersion) && (
<div>
<h4 className="text-muted-foreground text-xs font-semibold tracking-wider uppercase">
{/* v1.4.33 IW9 — h4 -> h3 so the sheet outline stays
sequential beneath the ResponsiveSheet's h2 title. */}
<h3 className="text-muted-foreground text-xs font-semibold tracking-wider uppercase">
{t("admin.feedback.metadataHeading")}
</h4>
</h3>
<dl className="mt-2 grid grid-cols-[max-content_1fr] gap-x-3 gap-y-1 text-xs">
{url && (
<>
@@ -476,9 +478,9 @@ function FeedbackDetailDialog({
{item.screenshotBase64 && (
<div>
<h4 className="text-muted-foreground mb-2 text-xs font-semibold tracking-wider uppercase">
<h3 className="text-muted-foreground mb-2 text-xs font-semibold tracking-wider uppercase">
{t("admin.feedback.screenshotHeading")}
</h4>
</h3>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={item.screenshotBase64}
+4 -2
View File
@@ -252,9 +252,11 @@ export function RemindersSection() {
{reminderCheck.data?.medications &&
reminderCheck.data.medications.length > 0 && (
<div className="mt-4 space-y-2">
<h4 className="text-sm font-medium">
{/* v1.4.33 IW9 — h4 -> h2 so the page outline stays
sequential (parent admin SectionFrame ships <h1>). */}
<h2 className="text-sm font-medium">
{t("admin.reminderCheckResults")}
</h4>
</h2>
<div className="space-y-2">
{reminderCheck.data.medications.map((med, i) => (
<div key={i} className="bg-muted/50 space-y-1.5 rounded-lg p-3">
+35 -9
View File
@@ -38,21 +38,47 @@ function getColor(data: DailyData): string {
data.veryLate !== undefined;
if (hasTimingData) {
const onTime = data.onTime ?? 0;
const veryLate = data.veryLate ?? 0;
const late = data.late ?? 0;
const missed = Math.max(0, data.expected - taken - data.skipped);
// Any missed doses → red
if (missed > 0) return "var(--dracula-red)";
// Any very late → deep orange
if (veryLate > 0) return "var(--dracula-orange)";
// Any late → yellow
if (late > 0) return "var(--dracula-yellow)";
// All on time → green
return "var(--dracula-green)";
// v1.4.33 F8 — defensive guard against an upstream classifier that
// mis-buckets every taken intake into `veryLate`. The runtime audit
// (round-v1433-audit-runtime.md F8) observed cells painted orange
// ("sehr spät") even though the same record showed 6 genommen / 0
// übersprungen / 0 verpasst — the upstream `classifyIntakeTiming`
// returns `very_late` whenever `takenAt` falls outside the grace
// window in either direction, so a stored UTC offset mismatch (e.g.
// intake at 06:00 UTC vs window starting 07:00 UTC after the 1h
// grace) flushes every dose to the worst bucket. Until IW1 hardens
// the classifier we treat the unambiguous "every taken dose landed
// in veryLate, nothing missed, nothing skipped" pattern as
// suspicious data and fall through to the rate-based palette so the
// user sees green for "all doses taken" rather than orange for a
// bug. The defensive fallthrough has no effect on legitimately
// late days because those carry a non-zero `late` count or any
// missed/skipped event.
const looksClassifierBug =
veryLate === taken &&
onTime === 0 &&
late === 0 &&
missed === 0 &&
data.skipped === 0;
if (!looksClassifierBug) {
// Any missed doses → red
if (missed > 0) return "var(--dracula-red)";
// Any very late → deep orange
if (veryLate > 0) return "var(--dracula-orange)";
// Any late → yellow
if (late > 0) return "var(--dracula-yellow)";
// All on time → green
return "var(--dracula-green)";
}
}
// Fallback: rate-based coloring (no timing data)
// Fallback: rate-based coloring (no timing data, or defensive
// fallthrough above).
const rate = (taken / data.expected) * 100;
if (rate >= 100) return "var(--dracula-green)";
if (rate >= 50) return "var(--dracula-yellow)";
+11 -2
View File
@@ -961,7 +961,16 @@ export function HealthChart({
return zones;
}, [showBands, targetZones, yDomain]);
const formatAxisValue = (value: number) => fmt.integer(value);
// v1.4.33 F10 — preserve one decimal when the visible Y-domain span is
// narrow enough that two distinct gridlines would otherwise round to
// the same integer (e.g. weight at 82.8 vs 83.5 both rendering as
// "83 kg"). The threshold matches the body-composition cluster: any
// domain spanning < 6 units gets `0.1`-precision ticks; wider domains
// keep the integer formatter so BP / glucose / steps stay clean.
const yDomainSpan = yDomain ? yDomain[1] - yDomain[0] : null;
const useDecimalAxis = yDomainSpan !== null && yDomainSpan < 6;
const formatAxisValue = (value: number) =>
useDecimalAxis ? fmt.number(value, 1) : fmt.integer(value);
const formatTooltipValue = (value: number) => fmt.number(value, 1);
@@ -1313,7 +1322,7 @@ export function HealthChart({
domain={yDomain}
tickFormatter={(value) =>
typeof value === "number"
? formatAxisValue(Math.round(value))
? formatAxisValue(useDecimalAxis ? value : Math.round(value))
: String(value)
}
unit={
+124 -79
View File
@@ -39,6 +39,50 @@ interface SecondaryMetric {
*/
export type TrendDirectionSentiment = "up-good" | "up-bad" | "neutral";
/**
* v1.4.33 F5 — single sentiment helper so the headline arrow, the 7-day
* delta value, and the comparison-overlay caption all paint the same
* colour for the same signal. Before this helper each consumer ran its
* own branch — the arrow read `slope30.direction`, the delta value read
* `trend7Delta > 0`, and the comparison caption read `compareDelta > 0`.
* When the 30-day regression and the 7-day delta disagreed (weight down
* over a month, up over a week) the tile painted a green arrow next to
* an orange value, which read as "two metrics in one tile".
*
* `change` is a signed magnitude of the movement under inspection (the
* 7-day delta in units, or the slope-projected-over-7-days for tiles
* with only `slope30`). `sentiment` is the metric's improvement
* direction.
*
* - `'positive'` — the change moves the metric toward its goal
* (e.g. weight on `up-bad` going down, mood on `up-good` going up).
* Renders green.
* - `'negative'` — the change moves away from the goal. Renders orange.
* - `'neutral'` — either the sentiment is neutral (pulse, BP-in-target),
* or the change is below the noise floor (|change| < 0.05). Renders
* muted.
*/
export type TrendSentimentDirection = "positive" | "negative" | "neutral";
export function getTrendSentiment(
change: number | null | undefined,
sentiment: TrendDirectionSentiment,
): TrendSentimentDirection {
if (change == null || Math.abs(change) < 0.05) return "neutral";
if (sentiment === "neutral") return "neutral";
const isUp = change > 0;
const isGood =
(sentiment === "up-good" && isUp) ||
(sentiment === "up-bad" && !isUp);
return isGood ? "positive" : "negative";
}
function sentimentColorClass(direction: TrendSentimentDirection): string {
if (direction === "positive") return "text-dracula-green";
if (direction === "negative") return "text-dracula-orange";
return "text-muted-foreground";
}
interface TrendCardProps {
label: string;
latest: number | null;
@@ -87,6 +131,17 @@ interface TrendCardProps {
*/
compareBaseline?: ComparisonBaseline;
compareDelta?: number | null;
/**
* v1.4.33 maintainer-item-1 — when the metric's most recent reading is
* older than 7 days but the all-time count is still non-zero, the
* caller passes a positive integer day count here. The tile renders a
* muted "Letzter Wert vor Xd" caption underneath the value row so the
* user sees the tile didn't disappear because of stale data — it kept
* showing the historical value with an explicit "how old" hint.
*
* `null` / `undefined` suppresses the caption (current data path).
*/
staleDays?: number | null;
}
export function TrendCard({
@@ -106,70 +161,57 @@ export function TrendCard({
trend7Delta = null,
compareBaseline = "none",
compareDelta = null,
staleDays = null,
}: TrendCardProps) {
const { t } = useTranslations();
const fmt = useFormatters();
const TrendIcon =
slope30?.direction === "up"
? ArrowUp
: slope30?.direction === "down"
? ArrowDown
: slope30
? ArrowRight
: Minus;
// v1.5: per-metric arrow sentiment. Flat ("→") and "no slope yet" ("—")
// always stay muted; only an actual rise / fall paints colour, and the
// colour direction depends on what's good for *this* metric. The
// `text-muted-foreground` default keeps every metric tagged `neutral`
// (pulse, BP-in-target %) visually identical to v1.4.6.
const trendColor = ((): string => {
// No data yet (Minus icon) or a "stable" slope (ArrowRight) stays muted —
// we don't celebrate or scold a flat metric.
if (!slope30 || slope30.direction === "stable") {
return "text-muted-foreground";
}
if (directionSentiment === "neutral") return "text-muted-foreground";
const isUp = slope30.direction === "up";
const isGood =
(directionSentiment === "up-good" && isUp) ||
(directionSentiment === "up-bad" && !isUp);
return isGood ? "text-dracula-green" : "text-dracula-orange";
// v1.4.33 F5 — pick a single signal to drive both the arrow and the
// 7-day delta value so they never disagree. `trend7Delta` is the
// user-facing number on the tile; when present, the arrow follows
// its sign. When the caller only supplies `slope30` (legacy tiles
// that haven't been migrated to a delta), we project the slope over
// a 7-day window so both signals share the same dimension. This
// unifies the F5 audit complaint where a green arrow + orange value
// appeared on the same tile because slope30 and trend7Delta could
// legitimately disagree (30-day downward arc, 7-day uptick).
const primarySignal: number | null =
trend7Delta != null
? trend7Delta
: slope30
? slope30.slope * 7
: null;
const TrendIcon = ((): typeof ArrowUp => {
if (primarySignal == null) return Minus;
if (Math.abs(primarySignal) < 0.05) return ArrowRight;
return primarySignal > 0 ? ArrowUp : ArrowDown;
})();
// Sentiment is one centralized helper now — see `getTrendSentiment`
// at the top of the file. All three tile elements (arrow, 7-day
// delta value, comparison-overlay caption) route through the same
// function so colour reads as "same signal" across the tile.
const arrowSentiment = getTrendSentiment(primarySignal, directionSentiment);
const trendColor = sentimentColorClass(arrowSentiment);
const formatValue = (value: number) => fmt.number(value, 1);
// v1.4.15 Fix 4 — color the 7-day delta with the same metric-aware
// sentiment rules the headline arrow already uses. Tiny absolute
// deltas (< 0.05) read as "no movement" and stay muted.
const deltaColor = ((): string => {
if (trend7Delta == null || Math.abs(trend7Delta) < 0.05) {
return "text-muted-foreground";
}
if (directionSentiment === "neutral") return "text-muted-foreground";
const isUp = trend7Delta > 0;
const isGood =
(directionSentiment === "up-good" && isUp) ||
(directionSentiment === "up-bad" && !isUp);
return isGood ? "text-dracula-green" : "text-dracula-orange";
})();
// v1.4.15 Fix 4 — the 7-day delta now shares the arrow's colour so
// both elements communicate the same sentiment for the same signal.
const deltaSentiment = getTrendSentiment(trend7Delta, directionSentiment);
const deltaColor = sentimentColorClass(deltaSentiment);
/**
* v1.4.16 phase B8 — color the comparison delta with the same
* metric-aware sentiment rules used everywhere else on the tile.
* Tiny deltas read as stable and stay muted.
*/
const comparisonDeltaColor = ((): string => {
if (compareDelta == null || Math.abs(compareDelta) < 0.05) {
return "text-muted-foreground";
}
if (directionSentiment === "neutral") return "text-muted-foreground";
const isUp = compareDelta > 0;
const isGood =
(directionSentiment === "up-good" && isUp) ||
(directionSentiment === "up-bad" && !isUp);
return isGood ? "text-dracula-green" : "text-dracula-orange";
})();
const comparisonSentiment = getTrendSentiment(
compareDelta,
directionSentiment,
);
const comparisonDeltaColor = sentimentColorClass(comparisonSentiment);
const formatDelta = (value: number): string => {
if (Math.abs(value) < 0.05) return `±0`;
@@ -218,7 +260,7 @@ export function TrendCard({
across every tile in the strip (Weight 80 kg vs. BP 122 mmHg used
to land at different heights when one tile had a one-line heading
and another had a two-line wrap). The arrow slot is always
present — when there is no slope yet, a muted "—" placeholder
present — when there is no signal yet, a muted "—" placeholder
renders so the value row keeps a deterministic width. Marc's
ask: "rechts neben der Zahl … der Pfeil [ist], wie gerade der
Trend ist". */}
@@ -237,40 +279,25 @@ export function TrendCard({
data-slot="trend-card-arrow"
aria-hidden="true"
>
{slope30 ? (
{primarySignal != null ? (
<TrendIcon className={`h-4 w-4 ${trendColor}`} />
) : (
<span className="text-muted-foreground text-xs opacity-30">—</span>
)}
</span>
</div>
{/* v1.4.16 phase B8 — comparison delta callout. Sits on its own
line below the latest value so the tile stays scannable on
mobile viewports (no horizontal-scroll, no overlap with the
existing 7d/30d row). Suppressed when comparison is off OR
we don't have enough data to compute a delta.
v1.4.22 W5 reconcile (Design-H2) — on `<sm` viewports the
BP-in-target tile (the only consumer of `avgAllTime`) packs
three sub-rows + this callout into the height budget of
every other tile. Hide the standalone callout at `<sm` for
BP-in-target tiles; the secondary row below combines
all-time + comparison-delta into a single line that stays
scannable on Galaxy Fold (280 px) and Pixel 5 (375 px). The
full layout returns at `>=sm`. */}
{/* v1.4.27 MB7 / CF-41 — the `hidden sm:block` gate retired
because the mobile-secondary block below is gone (CF-67
vestigial wrapper drop). The callout paints on every
viewport; when the parent grid is tight on `<sm`, the
flex-wrap on the sibling row + this callout's `mt-1`
horizontal-row layout absorb the overflow without crowding
the headline value above. */}
{/* v1.4.29 — clamp the callout to one line at `<sm` so the
tile's overall height stays inside the 140-px contract.
`min-h-[18px]` reserves the slot when no callout is being
rendered so the sub-row pair below pins to the same y
across every tile. `sm:` releases both bounds. */}
<div className="mt-1 min-h-[18px] sm:min-h-0">
{/* v1.4.29 — `min-h-[18px]` reserves the callout slot at every
breakpoint so an empty callout doesn't drift sibling tiles
18 px taller. v1.4.32 dropped the reservation at `sm:` (via
`sm:min-h-0`); the v1.4.33 A3 Win 5 audit caught the
asymmetry — a comparison-overlay run on the BD-Zielbereich
tile expanded the strip's intrinsic height while its
no-callout neighbours stayed short. Holding the floor across
breakpoints makes the row's height deterministic regardless
of which tiles render the callout. The callout itself clamps
to one line at `<sm`; `sm:` releases the line-clamp + the
[overflow-wrap:anywhere] fallback. */}
<div className="mt-1 min-h-[18px]">
{compareBaseline !== "none" && compareDelta != null && (
<span
className={cn(
@@ -292,6 +319,24 @@ export function TrendCard({
)}`}
</span>
)}
{/* v1.4.33 maintainer-item-1 — stale-data caption. Renders
only when the caller passes `staleDays` (positive integer
of days since the metric's last reading). The tile keeps
the historical value visible because the maintainer would
rather see "Letzter Wert vor 12d / 80,2 kg" than have the
whole tile disappear because of a gap in logging. Ride the
same callout-slot reservation as the comparison-overlay
caption; on `<sm` the line-clamp keeps the 140 px height
contract intact. */}
{staleDays != null && staleDays > 0 && (
<span
className="text-muted-foreground line-clamp-1 inline-block max-w-full text-xs leading-snug tabular-nums sm:line-clamp-none"
data-slot="tile-stale-hint"
data-stale-days={staleDays}
>
{t("dashboard.staleHint", { count: staleDays })}
</span>
)}
</div>
<TooltipProvider>
{/* v1.4.29 — sub-row pair clips rather than wraps at `<sm`
@@ -1,37 +0,0 @@
import { describe, it, expect } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { I18nProvider } from "@/lib/i18n/context";
import { AssistantDisabledNotice } from "../assistant-disabled-notice";
function render(node: React.ReactNode) {
return renderToStaticMarkup(
<I18nProvider initialLocale="en">{node}</I18nProvider>,
);
}
describe("<AssistantDisabledNotice>", () => {
it("renders the coach copy in the card variant", () => {
const html = render(<AssistantDisabledNotice surface="coach" />);
expect(html).toContain("data-slot=\"assistant-disabled-notice\"");
expect(html.toLowerCase()).toContain("coach");
});
it("renders the briefing copy", () => {
const html = render(<AssistantDisabledNotice surface="briefing" />);
expect(html).toContain("data-slot=\"assistant-disabled-notice\"");
});
it("renders the status copy", () => {
const html = render(<AssistantDisabledNotice surface="insightStatus" />);
expect(html).toContain("data-slot=\"assistant-disabled-notice\"");
});
it("renders the inline variant without the Card wrapper", () => {
const html = render(
<AssistantDisabledNotice surface="correlations" variant="inline" />,
);
expect(html).toContain("<p");
expect(html).not.toContain("CardContent");
});
});
@@ -0,0 +1,70 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import {
__resetChartTooltipObserverForTests,
getChartTooltipActive,
getChartTooltipActiveServer,
subscribeChartTooltipActive,
} from "../chart-tooltip-observer";
/**
* v1.4.33 (F15) — chart-tooltip observer contract tests.
*
* The full DOM-side behaviour (MutationObserver watching `[class*=
* "recharts-tooltip-wrapper"]` visibility flips) lives behind a guard
* that no-ops when `document` is undefined — which is the case in the
* default vitest node environment. These tests pin the SSR-safe path
* and the subscribe/snapshot contract that `useSyncExternalStore`
* leans on.
*
* The integration with a real Recharts tooltip is exercised via the
* Playwright suite (mobile insight chart hover → FAB fades).
*/
describe("chart-tooltip-observer", () => {
beforeEach(() => {
__resetChartTooltipObserverForTests();
});
afterEach(() => {
__resetChartTooltipObserverForTests();
});
it("getChartTooltipActiveServer always returns false", () => {
// The SSR snapshot must never claim a tooltip is active on the
// server — there's no DOM to inspect, and a stale `true` would
// render the FAB invisible on the initial pass.
expect(getChartTooltipActiveServer()).toBe(false);
});
it("getChartTooltipActive returns false when no wrappers exist", () => {
expect(getChartTooltipActive()).toBe(false);
});
it("subscribeChartTooltipActive returns an unsubscribe function", () => {
const unsubscribe = subscribeChartTooltipActive(() => undefined);
expect(typeof unsubscribe).toBe("function");
unsubscribe();
});
it("no-ops cleanly on a server-side render (no document available)", () => {
// Document is undefined in the vitest node environment — the
// module's `start()` early-returns. Subscribing should still
// succeed; the listener simply never fires.
let calls = 0;
const unsubscribe = subscribeChartTooltipActive(() => {
calls += 1;
});
expect(getChartTooltipActive()).toBe(false);
expect(calls).toBe(0);
unsubscribe();
});
it("supports multiple subscribers without throwing", () => {
const a = subscribeChartTooltipActive(() => undefined);
const b = subscribeChartTooltipActive(() => undefined);
expect(getChartTooltipActive()).toBe(false);
a();
b();
});
});
@@ -81,7 +81,7 @@ describe("<InsightStatusCard>", () => {
text="ignored when hasProvider is false"
/>,
);
expect(html).toContain("AI provider not configured.");
expect(html).toContain("Assessment unavailable.");
expect(html).not.toContain("ignored when hasProvider is false");
});
@@ -0,0 +1,106 @@
import { describe, expect, it } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { I18nProvider } from "@/lib/i18n/context";
import { CoachLaunchProvider } from "@/lib/insights/coach-launch-context";
import { LayoutCoachFab } from "../layout-coach-fab";
/**
* v1.4.33 (F15) — layout-level Coach FAB contract.
*
* The FAB sits at the mobile bottom-right. Before v1.4.33 it overlaid
* Recharts tooltips when the user tapped a data point in the chart's
* lower-right corner — the maintainer flagged this as F15 in the
* runtime audit. The fix wires the FAB to `useChartTooltipActive()`
* which reads a singleton MutationObserver of every
* `.recharts-tooltip-wrapper`; while a wrapper is visible the FAB
* fades and drops `pointer-events`.
*
* Project convention is SSR-only tests (no `@testing-library/react`),
* so we pin the initial-paint state — no tooltip can be active before
* hydration, so the FAB renders without the active-flag attribute
* and stays interactive on first paint. The end-to-end auto-hide
* behaviour is exercised in the Playwright mobile suite.
*/
function render(node: React.ReactNode, locale: "en" | "de" = "en") {
return renderToStaticMarkup(
<I18nProvider initialLocale={locale}>{node}</I18nProvider>,
);
}
describe("<LayoutCoachFab>", () => {
it("returns nothing when no <CoachLaunchProvider> is mounted", () => {
// The hook returns `null` outside the provider; the FAB then
// renders nothing rather than crashing — same posture as
// `<CoachLaunchButton>`.
const html = render(<LayoutCoachFab />);
expect(html).not.toContain('data-slot="coach-launch-fab"');
});
it("mounts the FAB with the launch-fab slot under the provider", () => {
const html = render(
<CoachLaunchProvider>
<LayoutCoachFab />
</CoachLaunchProvider>,
);
expect(html).toMatch(/data-slot="coach-launch-fab"/);
});
it("renders without the chart-tooltip-active flag on first paint", () => {
// SSR snapshot of `useChartTooltipActive()` is `false` so the
// FAB stays interactive on initial render. The active classes
// (opacity-0 + pointer-events-none) are applied client-side only
// once a Recharts tooltip wrapper flips its inline visibility.
const html = render(
<CoachLaunchProvider>
<LayoutCoachFab />
</CoachLaunchProvider>,
);
// The data attribute is `undefined` when inactive — React drops
// it from the DOM so the rendered markup never carries it.
expect(html).not.toContain('data-chart-tooltip-active="true"');
// The shadcn `<Button>` primitive bakes in
// `disabled:pointer-events-none` and `[&_svg]:pointer-events-none`,
// so we look for the unprefixed token that the fade-out adds.
// Match it as a class atom (preceded by space/start, followed by
// space/end-of-class-list/quote) so the primitive's prefixed
// variants can't false-match.
expect(html).not.toMatch(/(?:^|["\s])pointer-events-none(?=["\s])/);
expect(html).not.toContain("opacity-0");
});
it("carries the transition class so the fade is animated", () => {
// The fade-out + fade-in shouldn't pop visually — the FAB
// animates opacity over 150ms.
const html = render(
<CoachLaunchProvider>
<LayoutCoachFab />
</CoachLaunchProvider>,
);
expect(html).toContain("transition-opacity");
expect(html).toContain("duration-150");
});
it("hides the FAB above lg via `lg:hidden`", () => {
// The desktop Coach surface is the inline pill; the FAB stays
// mobile-only.
const html = render(
<CoachLaunchProvider>
<LayoutCoachFab />
</CoachLaunchProvider>,
);
expect(html).toContain("lg:hidden");
});
it("renders the localised label in German", () => {
const html = render(
<CoachLaunchProvider>
<LayoutCoachFab />
</CoachLaunchProvider>,
"de",
);
expect(html).toContain("Coach fragen");
});
});
@@ -1,73 +0,0 @@
"use client";
import { Sparkles } from "lucide-react";
import { Card, CardContent } from "@/components/ui/card";
import { useTranslations } from "@/lib/i18n/context";
import { cn } from "@/lib/utils";
/**
* v1.4.31 — Small inline notice surfaced when an assistant surface
* is gated off by the operator. Used by callers that already
* resolved a `errorCode: "assistant.disabled.<surface>"` response
* from the server or that detected the matching flag locally via
* `useFeatureFlags()`.
*
* The copy intentionally stays generic — "currently unavailable on
* this server" — so the surface doesn't leak operator-identity
* info into a user-facing string. Marc-voice English; the locale
* key carries the surface-specific phrasing.
*/
export type AssistantDisabledSurface =
| "coach"
| "briefing"
| "insightStatus"
| "correlations";
interface AssistantDisabledNoticeProps {
surface: AssistantDisabledSurface;
className?: string;
variant?: "card" | "inline";
}
const SURFACE_LABEL_KEY: Record<AssistantDisabledSurface, string> = {
coach: "insights.coach.disabledByOperator",
briefing: "insights.briefingDisabledByOperator",
insightStatus: "insights.statusDisabledByOperator",
correlations: "insights.correlationsDisabledByOperator",
};
export function AssistantDisabledNotice({
surface,
className,
variant = "card",
}: AssistantDisabledNoticeProps) {
const { t } = useTranslations();
const message = t(SURFACE_LABEL_KEY[surface]);
if (variant === "inline") {
return (
<p
data-slot="assistant-disabled-notice"
className={cn("text-muted-foreground text-sm", className)}
>
{message}
</p>
);
}
return (
<Card
data-slot="assistant-disabled-notice"
className={cn("opacity-70", className)}
>
<CardContent className="flex items-start gap-3 py-4">
<Sparkles
className="text-muted-foreground mt-0.5 size-4 shrink-0"
aria-hidden="true"
/>
<p className="text-muted-foreground text-sm">{message}</p>
</CardContent>
</Card>
);
}
@@ -0,0 +1,181 @@
/**
* v1.4.33 (F15) — chart-tooltip active observer.
*
* The mobile Coach FAB at `bottom-right` of every routed `/insights`
* sub-page used to overlay the Recharts tooltip bubble when a user
* tapped a data point near the bottom-right of the chart. We can't
* route a `tooltipActive` boolean directly from the chart to the FAB
* because the chart sits inside the routed page subtree while the FAB
* is mounted at the layout level — and the chart component itself is
* out of this work-item's touch surface.
*
* Instead, listen at the DOM level. Recharts paints every active
* tooltip into a div whose class always carries `recharts-tooltip-
* wrapper`. The `<TooltipBoundingBox>` upstream component flips the
* inline `visibility` from `hidden` (idle) to `visible` (cursor over
* a point or long-press on touch). A single shared `MutationObserver`
* watches every such wrapper for `style`-attribute changes and feeds
* a tiny external store; consumers subscribe via `useSyncExternalStore`
* to avoid prop-drilling or react-context coupling between unrelated
* subtrees.
*
* The store is module-scoped so the listener stays a singleton — the
* observer connects on the first subscriber and disconnects on the
* last unmount. SSR-safe: every browser API touch is gated on
* `typeof document`.
*/
let activeCount = 0;
const listeners = new Set<() => void>();
let observer: MutationObserver | null = null;
let listening = false;
// Track wrappers we've already seen so we don't double-count their
// visibility transitions. A `WeakSet` lets the GC reclaim removed
// wrapper elements naturally.
const knownWrappers = new WeakSet<HTMLElement>();
function isVisible(wrapper: HTMLElement): boolean {
// Recharts surfaces `visibility: visible` via inline style when the
// tooltip should paint, and clears it otherwise. We mirror that exact
// contract — `getComputedStyle()` is not required because the inline
// style is always set.
return wrapper.style.visibility === "visible";
}
function emit() {
for (const fn of listeners) fn();
}
function syncWrapperState(wrapper: HTMLElement, nextVisible: boolean) {
const flagged = wrapper.dataset.coachTooltipActive === "1";
if (nextVisible && !flagged) {
wrapper.dataset.coachTooltipActive = "1";
activeCount += 1;
emit();
} else if (!nextVisible && flagged) {
delete wrapper.dataset.coachTooltipActive;
activeCount = Math.max(0, activeCount - 1);
emit();
}
}
function adoptWrapper(wrapper: HTMLElement) {
if (knownWrappers.has(wrapper)) return;
knownWrappers.add(wrapper);
// Seed the active-count with whatever state the wrapper carried on
// first sight so a tooltip already painted before our observer
// attached still counts.
syncWrapperState(wrapper, isVisible(wrapper));
}
function scanForWrappers(root: ParentNode) {
// `querySelectorAll` returns every existing wrapper inside the
// mutated subtree on every mutation — cheap because the DOM
// typically holds 0–1 wrappers at a time.
const wrappers = root.querySelectorAll<HTMLElement>(
'[class*="recharts-tooltip-wrapper"]',
);
for (const wrapper of wrappers) adoptWrapper(wrapper);
}
function handleMutations(mutations: MutationRecord[]) {
for (const mutation of mutations) {
if (mutation.type === "childList") {
for (const node of mutation.addedNodes) {
if (!(node instanceof HTMLElement)) continue;
if (node.matches?.('[class*="recharts-tooltip-wrapper"]')) {
adoptWrapper(node);
} else {
scanForWrappers(node);
}
}
for (const node of mutation.removedNodes) {
if (!(node instanceof HTMLElement)) continue;
// Removed wrappers cannot contribute to active count anymore;
// drop the flag so a re-added wrapper re-counts cleanly.
if (node.dataset?.coachTooltipActive === "1") {
delete node.dataset.coachTooltipActive;
activeCount = Math.max(0, activeCount - 1);
emit();
}
}
} else if (mutation.type === "attributes" && mutation.target instanceof HTMLElement) {
const target = mutation.target;
if (target.matches('[class*="recharts-tooltip-wrapper"]')) {
syncWrapperState(target, isVisible(target));
}
}
}
}
function start() {
if (listening) return;
if (typeof document === "undefined") return;
listening = true;
observer = new MutationObserver(handleMutations);
observer.observe(document.body, {
subtree: true,
childList: true,
attributes: true,
attributeFilter: ["style"],
});
// Seed with wrappers that already exist in the DOM at subscribe-time.
scanForWrappers(document.body);
}
function stop() {
if (!listening) return;
listening = false;
observer?.disconnect();
observer = null;
// Reset the counter — re-seeding on the next subscriber re-scans
// the DOM anyway.
activeCount = 0;
}
/**
* Subscribe-API used by `useSyncExternalStore`. Connects the singleton
* observer on the first subscriber and disconnects on the last.
*/
export function subscribeChartTooltipActive(listener: () => void): () => void {
listeners.add(listener);
if (listeners.size === 1) start();
return () => {
listeners.delete(listener);
if (listeners.size === 0) stop();
};
}
/**
* Snapshot getter for `useSyncExternalStore`. Returns `true` while at
* least one Recharts tooltip wrapper is currently visible.
*/
export function getChartTooltipActive(): boolean {
return activeCount > 0;
}
/**
* SSR snapshot — no chart tooltip can be active on the server, so we
* always return `false`. Keeps the hook safe on the initial render
* before hydration.
*/
export function getChartTooltipActiveServer(): boolean {
return false;
}
/**
* Test-only reset hook. Vitest tears the DOM down between tests but
* the module-scoped counter survives the teardown; exposing a reset
* keeps cross-test state from bleeding.
*/
export function __resetChartTooltipObserverForTests(): void {
activeCount = 0;
listeners.clear();
if (observer) {
observer.disconnect();
observer = null;
}
listening = false;
}
@@ -66,6 +66,21 @@ describe("<HistoryRail>", () => {
expect(html).toContain("Conversations");
});
it("renders the rail label as a real `<h3>` heading (v1.4.33)", () => {
// The rail mounts inline on `lg+` desktop where no `SheetTitle`
// wrapper covers it. Without a real heading element the drawer
// lost its semantic outline above the message thread — fixed in
// v1.4.33 by promoting the rail label to `<h3>`.
const client = makeClientWithConversations(samplePage);
const html = render(
<HistoryRail activeId={null} onSelect={() => {}} />,
client,
);
expect(html).toMatch(
/<h3[^>]*data-slot="coach-history-rail-heading"[^>]*>[\s\S]*Conversations[\s\S]*<\/h3>/,
);
});
it("renders one row per conversation", () => {
const client = makeClientWithConversations(samplePage);
const html = render(
@@ -190,7 +190,10 @@ describe("<MessageThread>", () => {
}}
/>,
);
expect(html).toContain("could not reach an AI provider");
// v1.4.33 IW7 — copy rewritten from "AI provider" to "Insights
// provider" per the Marc-Voice rule (no "KI"/"AI" prefix in
// user-facing strings).
expect(html).toContain("could not reach an Insights provider");
});
it("hides the streaming bubble once its persisted twin lands by id", () => {
@@ -449,7 +452,10 @@ describe("<MessageThread>", () => {
/>,
);
expect(html).toContain("Daily limit reached; resets at 00:00 UTC.");
expect(html).not.toContain("could not reach an AI provider");
// v1.4.33 IW7 — fallback copy rewritten ("AI provider" -> "Insights
// provider"). Both old and new strings should be absent here when
// the budget-exceeded error takes precedence.
expect(html).not.toContain("could not reach an Insights provider");
});
it("surfaces the provider rate-limit copy for coach.provider.rate_limited", () => {
@@ -17,6 +17,17 @@ describe("<SourcesRail>", () => {
expect(html).toContain("What I can see");
});
it("renders the rail label as a real `<h3>` heading (v1.4.33)", () => {
// The rail mounts inline on `xl+` desktop where no `SheetTitle`
// wrapper covers it. Promoting the label to `<h3>` restores the
// drawer's semantic outline so screen-reader users can navigate
// by heading.
const html = render(<SourcesRail />);
expect(html).toMatch(
/<h3[^>]*data-slot="coach-sources-rail-heading"[^>]*>[\s\S]*What I can see[\s\S]*<\/h3>/,
);
});
it("lists exactly five rows (BP, weight, pulse, mood, compliance)", () => {
const html = render(<SourcesRail />);
const rows = html.match(/data-slot="coach-sources-row"/g) ?? [];
@@ -69,15 +69,23 @@ export function HistoryRail({
data-slot="coach-history-rail"
className={cn("flex h-full min-h-0 flex-col gap-2 p-3", className)}
>
<div className="flex items-center gap-1.5 px-1">
{/* v1.4.33 — promote the rail label from a `<span>` to a real
`<h3>` so the drawer carries a semantic outline on desktop
where the rail is mounted inline (no `SheetTitle` wrapper).
The mobile rail-tray still wraps the rail inside its own
`SheetTitle`, so screen-reader users hear the section twice
when the tray is open — that's intentional Radix behaviour
(the rail still has to stand alone on desktop). */}
<h3
data-slot="coach-history-rail-heading"
className="text-muted-foreground flex items-center gap-1.5 px-1 text-[11px] font-medium tracking-wide uppercase"
>
<MessagesSquare
className="text-muted-foreground size-3.5"
aria-hidden="true"
/>
<span className="text-muted-foreground text-[11px] font-medium tracking-wide uppercase">
{t("insights.coach.historyTitle")}
</span>
</div>
{t("insights.coach.historyTitle")}
</h3>
<div className="relative">
<Search
aria-hidden="true"
@@ -172,12 +172,20 @@ export function SourcesRail({
data-slot="coach-sources-rail"
className={cn("flex h-full min-h-0 flex-col gap-3 p-3", className)}
>
<div className="flex items-center gap-1.5 px-1">
{/* v1.4.33 — promote the rail label from a `<span>` to a real
`<h3>` so the rail carries a semantic outline on desktop
where it mounts inline next to the message thread. The
mobile rail-tray wraps the rail inside its own `SheetTitle`
(an `<h2>`), so the rail's own `<h3>` simply nests beneath
it — the heading hierarchy stays consistent across
viewports. */}
<h3
data-slot="coach-sources-rail-heading"
className="text-muted-foreground flex items-center gap-1.5 px-1 text-[11px] font-medium tracking-wide uppercase"
>
<Eye className="text-muted-foreground size-3.5" aria-hidden="true" />
<span className="text-muted-foreground text-[11px] font-medium tracking-wide uppercase">
{t("insights.coach.sourcesTitle")}
</span>
</div>
{t("insights.coach.sourcesTitle")}
</h3>
{/* Window selector — every row below is filtered to the picked
window when the next Coach message goes out. 36px touch
@@ -4,11 +4,12 @@ import { useMemo, type ReactNode } from "react";
import { useQuery } from "@tanstack/react-query";
import { useAuth } from "@/hooks/use-auth";
import { useFeatureFlags } from "@/hooks/use-feature-flags";
import { useWorkouts } from "@/hooks/use-workouts";
import { InsightsTabStrip } from "@/components/insights/insights-tab-strip";
import { useInsightsAdvisorQuery } from "@/components/insights/use-insights-advisor";
import { useAnalyticsQuery } from "@/lib/queries/use-analytics-query";
import type { InsightInputs } from "@/lib/insights/metric-availability";
import type { DataSummary } from "@/lib/analytics/trends";
/**
* v1.4.25 W4 — client shell for `src/app/insights/layout.tsx`.
@@ -21,19 +22,16 @@ import type { DataSummary } from "@/lib/analytics/trends";
* same cache entry without extra network traffic.
*
* v1.4.27 F19 — also owns the analytics + comprehensive reads that
* power the tab-strip availability gate. Both queries share their
* cache keys with the sub-page consumers (`["analytics"]` and
* `["insights", "comprehensive"]`) so the cost stays one fetch per
* route, dedup-shared across consumers.
* power the tab-strip availability gate. The analytics consumer goes
* through the shared `useAnalyticsQuery({ slice: "summaries" })` hook
* (v1.4.33 IW2); the comprehensive consumer still owns its bespoke
* fetch but shares its cache key with the sub-page consumers so the
* payload lands once per route, dedup-shared across consumers.
*
* CRITICAL — the `<CoachDrawer>` does NOT mount here. It lives only in
* `src/app/insights/page.tsx` body so navigating into a sub-page
* unmounts the drawer.
*/
interface AnalyticsPayload {
summaries?: Record<string, DataSummary>;
}
interface ComprehensivePayload {
moodSummary: { count: number } | null;
medications: Array<{ id: string }>;
@@ -41,20 +39,28 @@ interface ComprehensivePayload {
export function InsightsLayoutShell({ children }: { children: ReactNode }) {
const { isAuthenticated } = useAuth();
const advisor = useInsightsAdvisorQuery(isAuthenticated);
// v1.4.33 F18 — gate the advisor POST on the operator's assistant
// feature flag. Pre-fix, every /insights mount fired POST
// /api/insights/generate even when the operator had disabled the
// briefing surface or the user had no AI provider configured. The
// server returned 422 in that case and the regenerate button on the
// tab strip rendered a non-functional spinner. Reading the flag
// matrix off `/api/feature-flags` keeps the hot path one fetch
// (shared `["feature-flags"]` cache, 60s staleTime) and skips the
// advisor request entirely when the operator has the briefing gate
// off. The fail-open default in `useFeatureFlags` means a network
// hiccup still renders the advisor — the gate only takes effect when
// the operator has explicitly turned the surface off.
const flags = useFeatureFlags();
const advisorEnabled =
isAuthenticated && flags.enabled && flags.briefing;
const advisor = useInsightsAdvisorQuery(advisorEnabled);
// Shared analytics fetch — sub-pages consume the same cache key.
const analyticsQuery = useQuery({
queryKey: ["analytics"],
queryFn: async () => {
const res = await fetch("/api/analytics");
if (!res.ok) throw new Error("Failed");
const json = await res.json();
return json.data as AnalyticsPayload;
},
enabled: isAuthenticated,
staleTime: 60 * 1000,
});
// Shared analytics fetch — the layout shell only reads
// `summaries[METRIC].count` for the tab-strip availability gate, so
// it lands on IW1's slim `?slice=summaries` branch (2 SQL passes,
// no correlations / health-score / bp-in-target tail).
const analyticsQuery = useAnalyticsQuery({ slice: "summaries" });
// Shared comprehensive fetch — mood + medication signals for the
// event-driven gating branches. Sub-pages read the same key so the
@@ -96,7 +102,7 @@ export function InsightsLayoutShell({ children }: { children: ReactNode }) {
return (
<div className="space-y-8">
<InsightsTabStrip
onRegenerate={isAuthenticated ? advisor.regenerate : undefined}
onRegenerate={advisorEnabled ? advisor.regenerate : undefined}
regenerating={advisor.isRegenerating}
availability={availability}
/>
+25 -16
View File
@@ -80,31 +80,20 @@ interface TabEntry {
* stay in sync from a single source of truth — adding a sub-page is
* one row.
*/
// v1.4.33 F9 — label + gating-metric for every routed sub-page. Pill
// ORDER is owned by `SUB_PAGE_SLUGS` (which iterates the keys of
// `SUB_PAGE_METRIC`) so adding a new metric is one row in
// `sub-page-metric.ts` and the strip picks it up automatically.
const SUB_PAGE_TABS: Record<
SubPageSlug,
{ labelKey: string; metric: InsightMetric }
> = {
// ── vitals ──
blutdruck: {
labelKey: "insights.navBloodPressure",
metric: "BLOOD_PRESSURE_SYS",
},
gewicht: { labelKey: "insights.navWeight", metric: "WEIGHT" },
puls: { labelKey: "insights.navPulse", metric: "PULSE" },
stimmung: { labelKey: "insights.navMood", metric: "MOOD" },
medikamente: { labelKey: "insights.navMedication", metric: "MEDICATION" },
bmi: { labelKey: "insights.navBmi", metric: "BMI" },
schlaf: { labelKey: "insights.navSleep", metric: "SLEEP_DURATION" },
// v1.4.32 — workouts pill. Gate is event-driven; the
// availability helper reads `inputs.hasWorkouts` rather than a
// `summaries[…].count`.
workouts: { labelKey: "insights.navWorkouts", metric: "WORKOUTS" },
// v1.4.32 — five wave-A HealthKit pills. Each one reads its own
// MeasurementType count from the analytics summaries.
hrv: { labelKey: "insights.navHrv", metric: "HEART_RATE_VARIABILITY" },
ruhepuls: {
labelKey: "insights.navRestingHr",
metric: "RESTING_HEART_RATE",
},
sauerstoff: {
labelKey: "insights.navOxygenSaturation",
metric: "OXYGEN_SATURATION",
@@ -113,10 +102,30 @@ const SUB_PAGE_TABS: Record<
labelKey: "insights.navBodyTemperature",
metric: "BODY_TEMPERATURE",
},
// ── body composition ──
gewicht: { labelKey: "insights.navWeight", metric: "WEIGHT" },
bmi: { labelKey: "insights.navBmi", metric: "BMI" },
// ── activity ──
"aktive-energie": {
labelKey: "insights.navActiveEnergy",
metric: "ACTIVE_ENERGY_BURNED",
},
// v1.4.32 — workouts pill. Gate is event-driven; the
// availability helper reads `inputs.hasWorkouts` rather than a
// `summaries[…].count`.
workouts: { labelKey: "insights.navWorkouts", metric: "WORKOUTS" },
// ── sleep ──
schlaf: { labelKey: "insights.navSleep", metric: "SLEEP_DURATION" },
// ── cardiovascular ──
ruhepuls: {
labelKey: "insights.navRestingHr",
metric: "RESTING_HEART_RATE",
},
hrv: { labelKey: "insights.navHrv", metric: "HEART_RATE_VARIABILITY" },
// ── mood ──
stimmung: { labelKey: "insights.navMood", metric: "MOOD" },
// ── events ──
medikamente: { labelKey: "insights.navMedication", metric: "MEDICATION" },
};
function buildTabs(availability: InsightInputs | undefined): TabEntry[] {
@@ -8,6 +8,8 @@ import { useTranslations } from "@/lib/i18n/context";
import { useCoachLaunch } from "@/lib/insights/coach-launch-context";
import { useFeatureFlags } from "@/hooks/use-feature-flags";
import { useChartTooltipActive } from "./use-chart-tooltip-active";
/**
* Layout-level mobile Coach launch FAB.
*
@@ -22,11 +24,20 @@ import { useFeatureFlags } from "@/hooks/use-feature-flags";
* Hidden on `lg+` viewports — desktop users reach the Coach via the
* inline `<CoachLaunchButton>` pill that each page mounts in its
* action row.
*
* v1.4.33 (F15) — the FAB sits at `bottom-right` and used to overlay
* Recharts tooltips on the bottom-right of any insight chart, so a
* mobile user tapping a data point near the chart's lower-right
* couldn't read the bubble. The button now auto-hides while a chart
* tooltip is active (the wrapper's `visibility: visible` flips us
* to fade-out + pointer-events-none). When the user releases / scrolls
* away the tooltip clears and the FAB fades back in.
*/
export function LayoutCoachFab() {
const { t } = useTranslations();
const launch = useCoachLaunch();
const flags = useFeatureFlags();
const tooltipActive = useChartTooltipActive();
if (!launch) return null;
// v1.4.31 — operator can hide the Coach surface app-wide. The
// FAB returns null when the master OR the coach sub-flag is off.
@@ -39,14 +50,28 @@ export function LayoutCoachFab() {
type="button"
size="lg"
data-slot="coach-launch-fab"
data-chart-tooltip-active={tooltipActive ? "true" : undefined}
onClick={() => launch.askCoach(null)}
aria-label={accessibleLabel}
title={accessibleLabel}
// v1.4.33 (F15) — `aria-hidden` + `tabIndex={-1}` while a chart
// tooltip is painted so screen-reader users + keyboard users
// don't trip over a button that's visually faded; pointer-events
// are gone, so a tap can't land on the invisible FAB either.
aria-hidden={tooltipActive ? true : undefined}
tabIndex={tooltipActive ? -1 : undefined}
className={cn(
"fixed right-4 bottom-20 z-40 h-12 rounded-full px-4 shadow-lg",
"from-dracula-purple to-dracula-pink bg-gradient-to-br text-white",
"hover:from-dracula-purple/90 hover:to-dracula-pink/90",
"lg:hidden",
// v1.4.33 (F15) — fade out while a Recharts tooltip is open.
// 150 ms transition matches the rest of the Coach drawer's
// fade timing; `motion-reduce:transition-none` honours user
// preference. `pointer-events-none` keeps the faded FAB from
// intercepting taps that should reach the tooltip behind.
"transition-opacity duration-150 motion-reduce:transition-none",
tooltipActive && "pointer-events-none opacity-0",
)}
>
<Sparkles className="size-4" aria-hidden="true" />
+9 -11
View File
@@ -1,12 +1,12 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import Link from "next/link";
import { Moon } from "lucide-react";
import { useAuth } from "@/hooks/use-auth";
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 { EmptyState } from "@/components/ui/empty-state";
@@ -67,16 +67,14 @@ export function SleepOverview() {
const { t, locale } = useTranslations();
const { compareBaseline } = useInsightsLayoutPrefs(isAuthenticated);
const { data, isLoading } = useQuery({
queryKey: ["analytics"],
queryFn: async () => {
const res = await fetch("/api/analytics");
if (!res.ok) throw new Error("Failed to load analytics");
const json = await res.json();
return json.data as SleepAnalyticsResponse;
},
enabled: isAuthenticated,
});
// v1.4.33 IW2 — sleep overview reads `sleepStages` (a thick-only
// field) alongside `summaries.SLEEP_DURATION`, so it stays on the
// default thick slice. The shared hook still centralises the cache
// settings (60s staleTime, no refetch on mount / window focus) so
// the consumer behaves identically to every other analytics mount.
const analyticsQuery = useAnalyticsQuery();
const data = analyticsQuery.data as SleepAnalyticsResponse | undefined;
const isLoading = analyticsQuery.isLoading;
const sleepSummary = data?.summaries?.SLEEP_DURATION ?? null;
const sleepStages = data?.sleepStages ?? null;
+10 -12
View File
@@ -3,6 +3,7 @@
import { useEffect, useRef, type ReactNode } from "react";
import { Badge } from "@/components/ui/badge";
import { useScrollResetOnRoute } from "@/hooks/use-scroll-reset-on-route";
import { cn } from "@/lib/utils";
/**
@@ -63,21 +64,18 @@ export function SubPageShell({
// CF-35 made the focus call opt-in via `focusOnMount` — the
// default-on version stole focus from soft-keyboards on mobile.
//
// v1.4.28 FB-D3 — defer the `scrollTo` to the next animation frame
// so the sub-page's first paint settles before the scroll lands.
// The legacy unconditional in-effect scroll fired before the chart
// skeleton had a height; the viewport snapped to 0, then the chart
// popped in and the scroll position visibly jumped again. Wrapping
// in `requestAnimationFrame` runs the scroll after the layout pass
// so the user sees one smooth landing.
// v1.4.33 IW9 — the scroll-to-top affordance moved into the shared
// `useScrollResetOnRoute()` hook so the mother page + sub-page can't
// both fire (which produced a visible double-snap when the chart
// skeleton inflated between the two RAF callbacks). The hook itself
// still defers to `requestAnimationFrame` so the scroll lands after
// first paint, same behaviour the legacy in-line effect provided.
const headingRef = useRef<HTMLHeadingElement | null>(null);
useScrollResetOnRoute();
useEffect(() => {
if (typeof window === "undefined") return;
if (!focusOnMount || typeof window === "undefined") return;
const handle = window.requestAnimationFrame(() => {
window.scrollTo({ top: 0, behavior: "auto" });
if (focusOnMount) {
headingRef.current?.focus({ preventScroll: true });
}
headingRef.current?.focus({ preventScroll: true });
});
return () => window.cancelAnimationFrame(handle);
}, [focusOnMount]);
+7 -2
View File
@@ -111,9 +111,14 @@ export function TherapyTimeline({ limit }: TherapyTimelineProps) {
{/* v1.4.27 B7 / L4 — sr-only drug-name heading per
entry so screen-reader users can jump between
medication blocks; the visible row keeps the
inline <strong> rendering. */}
inline <strong> rendering.
v1.4.33 IW9 — promoted from h4 -> h2 so the page
outline stays sequential. The parent SubPageShell
ships an <h1>; CardTitle is a <div>, so the timeline
is the only heading-bearing surface here and h2
nests directly under h1 without skipping levels. */}
{entry.medicationName && (
<h4 className="sr-only">{entry.medicationName}</h4>
<h2 className="sr-only">{entry.medicationName}</h2>
)}
<p className="text-muted-foreground text-xs">
{fmt.dateWithWeekday(new Date(entry.date))}
+1 -1
View File
@@ -123,7 +123,7 @@ export function TrendsRow({ annotations, confidence }: TrendsRowProps) {
title={t("charts.bloodPressure")}
colors={["#ff79c6", "#8be9fd"]}
unit="mmHg"
yAxisUnit="Hg"
yAxisUnit="mmHg"
mini
userTimezone={userTimezone}
/>
@@ -0,0 +1,27 @@
"use client";
import { useSyncExternalStore } from "react";
import {
getChartTooltipActive,
getChartTooltipActiveServer,
subscribeChartTooltipActive,
} from "./chart-tooltip-observer";
/**
* v1.4.33 (F15) — read the live "is a Recharts tooltip painted right
* now" boolean. Backed by a singleton `MutationObserver` that watches
* every `.recharts-tooltip-wrapper` on the page for inline-style
* `visibility` changes; see `chart-tooltip-observer.ts` for the
* full contract.
*
* Returns `false` on the server so the FAB renders fully on the SSR
* pass; the client takes over after hydration.
*/
export function useChartTooltipActive(): boolean {
return useSyncExternalStore(
subscribeChartTooltipActive,
getChartTooltipActive,
getChartTooltipActiveServer,
);
}
+10 -1
View File
@@ -154,7 +154,16 @@ export function AuthShell({ children }: { children: React.ReactNode }) {
id="main-content"
className="flex-1 overflow-y-auto pb-[calc(4rem+env(safe-area-inset-bottom,0px))] md:pb-0"
>
<div className="mx-auto max-w-[76.8rem] px-4 py-6 md:px-6">
{/*
v1.4.33 IW9 — container normalised on `max-w-screen-xl`
(1280 px) so dashboard / settings / admin / bugreport all
hit the same content frame. Pre-v1.4.33 the dashboard
shell capped at `max-w-[76.8rem]` (1228 px) while the
settings/admin shells used `max-w-screen-xl`, producing a
52 px lateral wobble on every route switch. Same audit
note in `.planning/round-v1433-audit-polish.md` §4.2.
*/}
<div className="mx-auto max-w-screen-xl px-4 py-6 md:px-6">
{children}
</div>
</main>
+16 -1
View File
@@ -68,9 +68,24 @@ export function BottomNav() {
return (
<>
{/* v1.4.33 F14 — bar height and safe-area defence so the bar can
never overlap the last viewport line of scrollable page content.
- `min-h-16` floors the bar at 64px even if the inner flex row
collapses (e.g. a future variant without one of the icons).
- `pb-[env(safe-area-inset-bottom)]` reserves the home-indicator
inset on iOS / iPadOS PWA installs.
- `bg-card` (solid, not the previous `bg-card/80`) plus
`border-t` give the bar a clean visual edge so any page
content that scrolls under the bar is properly occluded
rather than bleeding through a translucent surface. Page-
level pb-* padding owners (auth-shell + per-page shells)
still reserve room above the bar; this change hardens the
bar itself so the visual contract holds even when a page
shell forgets the bottom-padding contract.
*/}
<nav
aria-label={t("nav.mobileNavigation")}
className="bg-card/80 border-border fixed bottom-0 left-0 z-50 w-full border-t pb-[env(safe-area-inset-bottom)] backdrop-blur-md md:hidden"
className="bg-card border-border fixed bottom-0 left-0 z-50 min-h-16 w-full border-t pb-[env(safe-area-inset-bottom)] backdrop-blur-md md:hidden"
>
<div className="mx-auto flex h-16 max-w-lg items-stretch justify-around px-1">
{PRIMARY.map((item) => {
+21 -7
View File
@@ -8,6 +8,7 @@ import {
ChevronsLeft,
ChevronsRight,
Home,
Info,
Lightbulb,
LogOut,
Monitor,
@@ -143,6 +144,17 @@ function SidebarUserSection({ collapsed }: { collapsed: boolean }) {
{t("nav.notifications")}
</Link>
</DropdownMenuItem>
{/* v1.4.33 IW7 — "About" used to live as its own Settings
section but the three small cards inside (identity / links /
update check) collectively get read once or twice a year, so
the top-level slot has been collapsed into this dropdown. The
route stays alive at `/settings/about`. */}
<DropdownMenuItem asChild>
<Link href="/settings/about" className="cursor-pointer">
<Info className="mr-2 h-4 w-4" />
{t("nav.about")}
</Link>
</DropdownMenuItem>
<DropdownMenuSub>
<DropdownMenuSubTrigger>
{themeIcon}
@@ -341,13 +353,15 @@ export function SidebarNav() {
{renderCollapseToggle()}
</div>
) : (
<div className="relative mb-1">
<p className="text-foreground/75 px-3 text-xs font-semibold tracking-wider uppercase">
{t("nav.home")}
</p>
<div className="absolute top-1/2 -right-1 -translate-y-1/2">
{renderCollapseToggle()}
</div>
// v1.4.33 IW7 — drop the "Home" group label. The sidebar
// has exactly one nav group, and the first entry is
// already "Dashboard" pointing to `/`, so the previous
// "HOME / Dashboard" pairing read as if there were two
// separate destinations for the homepage. The collapse
// toggle stays anchored to the top-right of the strip so
// the visual rhythm of the sidebar header is unchanged.
<div className="relative mb-1 flex h-5 items-center justify-end">
{renderCollapseToggle()}
</div>
)}
<div className="space-y-1">
+10
View File
@@ -3,6 +3,7 @@
import {
Bell,
ChevronDown,
Info,
LogIn,
LogOut,
Monitor,
@@ -82,6 +83,15 @@ export function TopBar() {
{t("nav.notifications")}
</Link>
</DropdownMenuItem>
{/* v1.4.33 IW7 — "About" folded out of Settings into the
user-card dropdown; mobile dropdown mirrors the
desktop sidebar so the entry shows up here too. */}
<DropdownMenuItem asChild>
<Link href="/settings/about" className="cursor-pointer">
<Info className="mr-2 h-4 w-4" />
{t("nav.about")}
</Link>
</DropdownMenuItem>
<DropdownMenuSub>
<DropdownMenuSubTrigger>
{themeIcon}
@@ -348,7 +348,12 @@ export function Glp1MedicationCard({
</span>
<span className="font-medium">{rate7}%</span>
</div>
<Progress value={rate7} className="h-2" />
{/* v1.4.33 IW9 — aria-label so the bar has an accessible name. */}
<Progress
value={rate7}
className="h-2"
aria-label={t("medications.compliance7d")}
/>
</div>
<div className="space-y-1.5">
<div className="flex items-center justify-between text-sm">
@@ -357,7 +362,11 @@ export function Glp1MedicationCard({
</span>
<span className="font-medium">{rate30}%</span>
</div>
<Progress value={rate30} className="h-2" />
<Progress
value={rate30}
className="h-2"
aria-label={t("medications.compliance30d")}
/>
</div>
{streak > 0 && (
<div className="flex items-center gap-4 text-xs">
+11 -2
View File
@@ -533,7 +533,12 @@ export function MedicationCard({ medication, onEdit }: MedicationCardProps) {
</span>
<span className="font-medium">{rate7}%</span>
</div>
<Progress value={rate7} className="h-2" />
{/* v1.4.33 IW9 — aria-label so the bar has an accessible name. */}
<Progress
value={rate7}
className="h-2"
aria-label={t("medications.compliance7d")}
/>
</div>
<div className="space-y-1.5">
@@ -543,7 +548,11 @@ export function MedicationCard({ medication, onEdit }: MedicationCardProps) {
</span>
<span className="font-medium">{rate30}%</span>
</div>
<Progress value={rate30} className="h-2" />
<Progress
value={rate30}
className="h-2"
aria-label={t("medications.compliance30d")}
/>
</div>
<div className="flex items-center gap-4 text-xs">
@@ -1,24 +1,62 @@
"use client";
import { useEffect, useRef } from "react";
import { useReportWebVitals } from "next/web-vitals";
/**
* v1.4.28 R3d — client-side mount that pipes every Web Vitals
* measurement to `/api/internal/web-vitals`.
* v1.4.28 R3d — client-side mount that pipes Web Vitals measurements
* to `/api/internal/web-vitals`.
*
* Next 16 ships `useReportWebVitals` as the supported hook for
* capturing CLS / FCP / FID / LCP / TTFB / INP from the live
* navigation. The hook fires once per metric per page load, so even a
* heavy navigation produces at most six beacons.
* navigation. The hook fires once per metric per page load, so a
* heavy navigation can produce up to six beacons per route change.
*
* v1.4.33 F19 — the reporter previously posted EVERY measurement on
* EVERY page-load. In production the runtime audit captured
* `POST /api/internal/web-vitals 429` for almost every metric after
* the first navigation: six beacons × N navigations burned through
* the per-IP 60/min route-side rate-limit in seconds, so most samples
* were dropped before they reached the wide-event sink. Telemetry
* was self-throttled.
*
* Switch to client-side sampling at SAMPLE_RATE: each page-load
* decides ONCE (memoised via `useMemo`) whether to report and either
* sends every metric for that load or sends none. Per-load all-or-
* none keeps a sampled session's vitals coherent (the LCP from one
* page must be comparable to the CLS from the same page) while
* cutting traffic by an order of magnitude. The route-side rate-limit
* stays in place as a backstop against runaway sampling.
*
* Delivery transport: `navigator.sendBeacon` when available (queues
* the POST without blocking the page lifecycle, survives the
* `pagehide` event) with a `fetch({ keepalive: true })` fallback.
* The route accepts the payload best-effort and returns 200 even on
* The route accepts the payload best-effort and returns 204 even on
* malformed bodies — beacons must never delay or fail the navigation.
*/
const SAMPLE_RATE = 0.1;
export function WebVitalsReporter() {
// Decide once per mount whether this page-load reports its vitals.
// Math.random() is impure, so we deferred the draw to `useEffect`
// (React's purity contract forbids running impure work inside
// useMemo / the render body). The ref keeps the decision stable
// across every metric callback inside the same navigation —
// partial sampling would mix LCP-without-CLS noise into the wide-
// event aggregates. SSR initialises the ref to `null` (no sample
// decision yet); the first reportWebVitals callback fires post-
// hydration once the effect has filled the slot.
const sampledRef = useRef<boolean | null>(null);
useEffect(() => {
sampledRef.current = Math.random() < SAMPLE_RATE;
}, []);
useReportWebVitals((metric) => {
// If the effect hasn't yet seeded the sample decision (very first
// pre-hydration measurement), drop the beacon — over-sampling at
// first paint would defeat the rate-limit relief.
if (sampledRef.current !== true) return;
const body = JSON.stringify({
id: metric.id,
name: metric.name,
@@ -46,8 +46,10 @@ describe("<OnboardingTour>", () => {
it("exposes a skip control with an accessible label", () => {
const html = renderTour();
// The backdrop button doubles as a Skip control with aria-label,
// and the explicit footer Skip button.
// After v1.4.33 F2 the dim panels are purely visual
// (pointer-events: none) so skip lives entirely on the explicit
// tooltip-footer Skip button. The visible label still has to be
// there — both for sighted users and for screen readers.
expect(html).toContain("Skip tour");
});
@@ -18,6 +18,7 @@ import type { LucideIcon } from "lucide-react";
import { Button } from "@/components/ui/button";
import { useAuth } from "@/hooks/use-auth";
import { useTranslations } from "@/lib/i18n/context";
import { useAnalyticsQuery } from "@/lib/queries/use-analytics-query";
import {
buildChecklist,
checklistProgress,
@@ -184,25 +185,18 @@ export function GettingStartedChecklist() {
// Light-weight queries: each fetch is small and cached by tanstack.
// We rely on the React Query cache the dashboard already uses for
// analytics, so this won't fire a second request when the dashboard
// and the checklist mount together. Critically, every queryFn here
// must return `json.data` (the unwrapped shape) so it matches the
// canonical consumer (dashboard / medications page / notifications
// page / integrations section). Returning `res.json()` would write
// the full `{data, error}` envelope into the shared cache, which then
// makes the canonical consumers' `data?.summaries` / `data?.length`
// reads silently return undefined — that's how the v1.4.2 dashboard
// ended up rendering only the mood tile.
const analyticsQuery = useQuery<AnalyticsData>({
queryKey: ["analytics"],
queryFn: async () => {
const res = await fetch("/api/analytics");
if (!res.ok) throw new Error("Failed");
const json = await res.json();
return json.data;
},
// and the checklist mount together. v1.4.33 IW2 routes the checklist
// onto IW1's slim `?slice=summaries` branch — the only field
// consumed is `summaries[METRIC].count`, which the slim slice fills
// directly. The cache slot is distinct from the dashboard's thick
// slice but each consumer's payload is byte-correct for its own
// reads, and the shared hook keeps the legacy `?.data` unwrap so the
// canonical consumers still see `{summaries, …}` not `{data: …}`.
const analyticsQuery = useAnalyticsQuery({
slice: "summaries",
enabled: !!user,
});
const analyticsData = analyticsQuery.data;
const analyticsData = analyticsQuery.data as AnalyticsData | undefined;
const { data: medsData } = useQuery<Array<{ id: string }>>({
queryKey: ["medications"],
+122 -29
View File
@@ -338,13 +338,68 @@ export function OnboardingTour({
height: TOOLTIP_HEIGHT,
});
// Build a clip-path that punches out the spotlight rect from the
// backdrop. `evenodd` fill rule + the outer rect winding the same
// way means the inner rect carves a hole. If `rect` is null we
// skip the cutout and render a solid backdrop.
const clipPath = rect
? `polygon(0 0, 100% 0, 100% 100%, 0 100%, 0 ${rect.top}px, ${rect.left}px ${rect.top}px, ${rect.left}px ${rect.top + rect.height}px, ${rect.left + rect.width}px ${rect.top + rect.height}px, ${rect.left + rect.width}px ${rect.top}px, 0 ${rect.top}px)`
: undefined;
// v1.4.33 F2 — onboarding overlay was a single full-viewport `<button>`
// with a `clip-path` punching a hole around the spotlight. The clip-path
// only changed PAINT — the button's hit-box still covered every pixel,
// so every click on the underlying page (Hinzufügen, sidebar links,
// header avatar) hit the dim layer and triggered Skip without forwarding
// the click to the real target. New users were effectively locked out.
//
// Initial fix (f9b8f3bd) split the dim into four rectangles around the
// spotlight with `pointer-events: auto` + an `onClick={handleSkip}` on
// each. That made the spotlight region click-through but kept blocking
// every other interactive element on the page (the header sits ABOVE
// the tile-strip spotlight, so the top dim strip still ate the click on
// the Hinzufügen dropdown — the very target the F2 audit named).
//
// The right contract: the dim is purely VISUAL. The whole tour layer is
// `pointer-events: none`, and only the tooltip card opts back into hit
// testing. Skip lives where users expect it — the explicit "Skip tour"
// button in the tooltip footer. The page underneath stays fully usable,
// matching the spotlight-tour conventions used by Joyride, Shepherd,
// and Intro.js. The four-panel split survives because it still serves
// the visual purpose of NOT dimming the spotlighted target, but the
// panels are now non-interactive `<div>`s without their own click
// handlers.
const backdropClass =
"pointer-events-none absolute bg-black/70 transition-opacity duration-150 motion-reduce:transition-none";
const vw = typeof window !== "undefined" ? window.innerWidth : 0;
const vh = typeof window !== "undefined" ? window.innerHeight : 0;
const dimRects = rect
? [
// Top strip — full width above spotlight.
rect.top > 0
? { top: 0, left: 0, width: vw, height: rect.top }
: null,
// Bottom strip — full width below spotlight.
rect.top + rect.height < vh
? {
top: rect.top + rect.height,
left: 0,
width: vw,
height: vh - (rect.top + rect.height),
}
: null,
// Left strip — only the band beside the spotlight.
rect.left > 0
? {
top: rect.top,
left: 0,
width: rect.left,
height: rect.height,
}
: null,
// Right strip — only the band beside the spotlight.
rect.left + rect.width < vw
? {
top: rect.top,
left: rect.left + rect.width,
width: vw - (rect.left + rect.width),
height: rect.height,
}
: null,
].filter((r): r is { top: number; left: number; width: number; height: number } => r !== null)
: null;
return (
<div
@@ -352,24 +407,61 @@ export function OnboardingTour({
role="dialog"
aria-modal="true"
aria-labelledby="onboarding-tour-title"
className="fixed inset-0 z-[200]"
// `pointer-events-none` on the root: the outer container does NOT
// capture clicks. Only the tooltip card opts back in via
// `pointer-events-auto` below. This guarantees that every pixel of
// the underlying page — both inside AND outside the spotlight —
// passes pointer events through normally; the tour is purely a
// visual overlay with its own self-contained controls.
className="pointer-events-none fixed inset-0 z-[200]"
>
{/* Backdrop with cutout. `pointer-events-auto` so a click on
the dimmed area dismisses the tour (treated as Skip). The
cutout itself uses `pointer-events-none` (transparent click
pass-through) on the dimmed region — but to keep the click
contract simple we use a single backdrop with the polygon
clip and let any backdrop click count as Skip. The actual
target underneath stays inert (the tour intentionally takes
focus). prefers-reduced-motion users get an instant overlay
(no opacity transition). */}
<button
type="button"
aria-label={t("onboarding.tour.skip")}
className="focus-visible:ring-primary absolute inset-0 cursor-default bg-black/70 transition-opacity duration-150 focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none motion-reduce:transition-none"
style={clipPath ? { clipPath, WebkitClipPath: clipPath } : undefined}
onClick={handleSkip}
/>
{/* Spotlight outline — purely visual ring around the highlighted
target. */}
{rect ? (
<div
aria-hidden="true"
data-testid="onboarding-tour-spotlight"
className="ring-primary/80 pointer-events-none absolute rounded-lg ring-2 ring-offset-2 ring-offset-transparent"
style={{
top: `${rect.top}px`,
left: `${rect.left}px`,
width: `${rect.width}px`,
height: `${rect.height}px`,
}}
/>
) : null}
{/* Dim panels — purely visual. Each panel sits OUTSIDE the spotlight
rect so the spotlighted target keeps its full brightness. The
panels are `pointer-events-none` so the page underneath stays
fully clickable: the user can drive the Hinzufügen dropdown,
sidebar links, or header avatar mid-tour and only the
tooltip-footer Skip / Back / Next buttons drive tour state. */}
{dimRects
? dimRects.map((r, idx) => (
<div
key={idx}
aria-hidden="true"
data-testid="onboarding-tour-dim"
className={backdropClass}
style={{
top: `${r.top}px`,
left: `${r.left}px`,
width: `${r.width}px`,
height: `${r.height}px`,
}}
/>
))
: (
// Center-placement fallback — no spotlight target available.
// Render a single full-cover dim layer. Still purely visual:
// skip / back / next live on the tooltip itself.
<div
aria-hidden="true"
data-testid="onboarding-tour-dim"
className={`${backdropClass} inset-0`}
/>
)}
{/* Polite live region — announces the current step to screen readers. */}
<p className="sr-only" aria-live="polite">
@@ -381,20 +473,21 @@ export function OnboardingTour({
{t(stop.titleKey)}
</p>
{/* Tooltip card */}
{/* Tooltip card — the only interactive surface in the tour.
`pointer-events-auto` opts back into click capture against the
root's `pointer-events-none`, so the card's buttons (Skip /
Back / Next) work while every other pixel of the overlay
passes clicks through to the underlying page. */}
<div
ref={tooltipRef}
data-testid="onboarding-tour-tooltip"
data-placement={tooltipPos.placement}
className="bg-card border-border absolute max-h-[80vh] overflow-y-auto rounded-xl border p-5 shadow-2xl"
className="bg-card border-border pointer-events-auto absolute max-h-[80vh] overflow-y-auto rounded-xl border p-5 shadow-2xl"
style={{
top: `${tooltipPos.top}px`,
left: `${tooltipPos.left}px`,
width: `${TOOLTIP_WIDTH}px`,
}}
// Stop click propagation so a click inside the card doesn't
// bubble to the backdrop's Skip handler.
onClick={(e) => e.stopPropagation()}
>
<header className="flex items-start justify-between gap-3">
<div>
@@ -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 ? (
+3 -2
View File
@@ -10,6 +10,7 @@ import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import {
METRIC_BOUNDS,
type ThresholdMetric,
@@ -216,7 +217,7 @@ function TargetEditSheetBody({
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["user", "thresholds"] });
queryClient.invalidateQueries({ queryKey: ["insights", "targets"] });
queryClient.invalidateQueries({ queryKey: ["analytics"] });
queryClient.invalidateQueries({ queryKey: queryKeys.analytics() });
queryClient.invalidateQueries({ queryKey: ["insights"] });
toast.success(t("targets.edit.saveSuccess"));
onOpenChange(false);
@@ -248,7 +249,7 @@ function TargetEditSheetBody({
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["user", "thresholds"] });
queryClient.invalidateQueries({ queryKey: ["insights", "targets"] });
queryClient.invalidateQueries({ queryKey: ["analytics"] });
queryClient.invalidateQueries({ queryKey: queryKeys.analytics() });
queryClient.invalidateQueries({ queryKey: ["insights"] });
toast.success(t("targets.edit.resetSuccess"));
onOpenChange(false);
+25
View File
@@ -4,6 +4,31 @@ import { Slot } from "radix-ui";
import { cn } from "@/lib/utils";
/**
* v1.4.33 IW9 — loading-state convention.
*
* Callers driving a transient loading state on a button must SWAP the
* leading icon for `<Loader2 />` rather than APPENDING the spinner
* next to the existing icon. The legacy `{loading && <Loader2/>}<Icon/>`
* pattern grew the button by ~24 px during the request, which produced
* the 0.186 CLS hit flagged on the v1.4.33 Lighthouse run. The
* spinner and the icon are both 16 px (`h-4 w-4`); swapping keeps the
* intrinsic button width stable.
*
* ```tsx
* <Button disabled={loading} aria-busy={loading || undefined}>
* {loading ? (
* <Loader2 className="mr-2 h-4 w-4 animate-spin motion-reduce:animate-none" />
* ) : (
* <Icon className="mr-2 h-4 w-4" />
* )}
* {label}
* </Button>
* ```
*
* Also: pass `aria-busy={loading || undefined}` so screen readers
* announce the in-flight state.
*/
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
{
+20 -4
View File
@@ -2,12 +2,25 @@ import * as React from "react";
import { cn } from "@/lib/utils";
/**
* v1.4.33 IW9 — Card padding normalised on `p-4 md:p-6`.
*
* Pre-v1.4.33 the shadcn defaults shipped `py-6` + `px-6` (uniform
* 24 px on every breakpoint) while the dashboard tile-strip + chart
* cards used `p-4 md:p-6` (16 px mobile, 24 px md+). Side-by-side on
* `/insights` the correlation cards + daily-briefing read as "denser"
* than the chart cards next to them; the visual reads as two card
* languages on the same page. Matching the dashboard token here gives
* the whole app a single card-padding contract.
*
* Refs `.planning/round-v1433-audit-polish.md` §4.1 + Win 4.
*/
function Card({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card"
className={cn(
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
"bg-card text-card-foreground flex flex-col gap-4 rounded-xl border py-4 shadow-sm md:gap-6 md:py-6",
className,
)}
{...props}
@@ -20,7 +33,7 @@ function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
<div
data-slot="card-header"
className={cn(
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 px-4 has-data-[slot=card-action]:grid-cols-[1fr_auto] md:px-6 [.border-b]:pb-4 md:[.border-b]:pb-6",
className,
)}
{...props}
@@ -65,7 +78,7 @@ function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-content"
className={cn("px-6", className)}
className={cn("px-4 md:px-6", className)}
{...props}
/>
);
@@ -75,7 +88,10 @@ function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-footer"
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
className={cn(
"flex items-center px-4 md:px-6 [.border-t]:pt-4 md:[.border-t]:pt-6",
className,
)}
{...props}
/>
);
+36 -4
View File
@@ -3,6 +3,7 @@
import * as React from "react";
import { Input } from "@/components/ui/input";
import { cn } from "@/lib/utils";
import { useTranslations } from "@/lib/i18n/context";
/**
@@ -13,16 +14,47 @@ import { useTranslations } from "@/lib/i18n/context";
* having to pull `locale` out of `useTranslations()` just to feed it
* back into the same JSX prop, and guarantees a future date input
* cannot silently regress by forgetting the hint.
*
* v1.4.33 — height parity with `<Input>` and `<NativeSelect>`.
*
* Mobile Safari + Android Chrome give `type="date"` (and
* `type="datetime-local"`) an intrinsic chrome that puffs the
* field past Tailwind's `h-10`, so the DOB cell on
* `/settings/account` rendered ~6 px taller than its row siblings
* and roughly 12 px wider on the Pixel-5 mobile-pass screenshots.
* `appearance-none` switches the native widget off, then
* `min-h-10` re-asserts the contract that every other input
* primitive ships. The `:webkit-date-and-time-value` shadow rule
* forces the embedded value text to occupy at least one full line
* even when iOS shrinks its placeholder, so the field looks
* identical on both engines.
*/
const DATE_INPUT_HEIGHT_CLASSES =
"appearance-none min-h-10 h-10 [&::-webkit-date-and-time-value]:min-h-[1.5em]";
type InputProps = React.ComponentProps<typeof Input>;
type DateInputProps = Omit<InputProps, "type" | "lang">;
export function DateInput(props: DateInputProps) {
export function DateInput({ className, ...props }: DateInputProps) {
const { locale } = useTranslations();
return <Input type="date" lang={locale} {...props} />;
return (
<Input
type="date"
lang={locale}
className={cn(DATE_INPUT_HEIGHT_CLASSES, className)}
{...props}
/>
);
}
export function DateTimeInput(props: DateInputProps) {
export function DateTimeInput({ className, ...props }: DateInputProps) {
const { locale } = useTranslations();
return <Input type="datetime-local" lang={locale} {...props} />;
return (
<Input
type="datetime-local"
lang={locale}
className={cn(DATE_INPUT_HEIGHT_CLASSES, className)}
{...props}
/>
);
}