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