chore(release): v1.4.24

Security + accessibility hardening release. Closes the highest-priority
findings from a focused security audit of the v1.4.23 release:

- Bearer auth fail-closed when a route declares no scope (api-handler)
- Public /api/send Umami proxy now rate-limits 120 req/min/IP
- Proxy nonce generation switched to btoa(...) for Edge portability
- Session-expiry delete race uses deleteMany + catch (no more 500s)
- POST /api/import returns the standard apiError envelope on 422
- Runtime Docker image pins Prisma 7.8 + worker deps to minors
- Notifications page renders a real load-error alert
- Accessibility: switch aria-labels, htmlFor pairing, mobile admin
  Overview link, deep-link anchors on notification settings
- Chart overlays: per-chart comparisonBaseline pref, BMI/bodyFat/
  sleep/steps overlay keys, TrendCard layout tolerates long labels

Verification: pnpm typecheck && pnpm lint && pnpm test (2244/2244)
&& pnpm format:check all green.
This commit is contained in:
Marc-André Bombeck committed 2026-05-12 22:33:35 +02:00
1 parent 0dc0e1645c
commit ca03c6099f
59 files changed
+841 -206

No files matched your search

+15
View File
@@ -165,6 +165,21 @@ export function AdminShell({ active, children }: AdminShellProps) {
className="no-scrollbar -mx-4 mb-4 overflow-x-auto px-4 md:hidden"
>
<ul className="flex min-w-max gap-2">
<li>
<Link
href="/admin"
aria-current={activeSlug === null ? "page" : undefined}
className={cn(
"flex items-center gap-2 rounded-full border px-3 py-1.5 text-sm font-medium whitespace-nowrap transition-colors",
activeSlug === null
? "border-primary/40 bg-primary/10 text-primary"
: "border-border text-foreground hover:bg-accent",
)}
>
<ScrollText className="h-4 w-4" aria-hidden="true" />
{t("admin.shell.overview")}
</Link>
</li>
{ADMIN_SECTIONS.map((section) => {
const isActive = section.slug === activeSlug;
const Icon = section.icon;
+1 -8
View File
@@ -2,14 +2,7 @@
import { useState } from "react";
import { useMutation } from "@tanstack/react-query";
import {
Activity,
Bell,
CheckCircle2,
Clock,
Loader2,
XCircle,
} from "lucide-react";
import { Activity, Bell, CheckCircle2, Loader2, XCircle } from "lucide-react";
import { toast } from "sonner";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
@@ -52,6 +52,7 @@ describe("<ChartOverlayControls>", () => {
showTrendIndicator: false,
showTrendArrow: false,
showTargetRange: false,
comparisonBaseline: "none",
});
});
@@ -78,6 +79,7 @@ describe("<ChartOverlayControls>", () => {
showTrendIndicator: false,
showTrendArrow: false,
showTargetRange: true,
comparisonBaseline: "none",
});
});
@@ -105,6 +105,18 @@ describe("<HealthChart mini>", () => {
expect(html).toContain("chart-overlay-controls-trigger");
});
it("renders the overlay-controls trigger for BMI charts", () => {
const html = render(
<HealthChart
types={["WEIGHT"]}
title="BMI"
chartKey="bmi"
valueMode="bmi"
/>,
);
expect(html).toContain("chart-overlay-controls-trigger");
});
it("accepts windowOverride and applies the matching range-points", () => {
const html = render(
<HealthChart
@@ -47,6 +47,7 @@ const mockPrefs = vi.hoisted(() => ({
showTrendIndicator: false,
showTrendArrow: false,
showTargetRange: false,
comparisonBaseline: "none",
},
}));
@@ -75,6 +76,7 @@ describe("MedicationComplianceChart overlay toggles", () => {
showTrendIndicator: false,
showTrendArrow: false,
showTargetRange: false,
comparisonBaseline: "none",
};
const html = await renderChart();
expect(html).not.toContain("medication-trend-chip");
@@ -85,6 +87,7 @@ describe("MedicationComplianceChart overlay toggles", () => {
showTrendIndicator: true,
showTrendArrow: false,
showTargetRange: false,
comparisonBaseline: "none",
};
const html = await renderChart();
expect(html).toContain("medication-trend-chip");
@@ -122,3 +122,29 @@ describe("<TrendCard> directionSentiment", () => {
expect(html).not.toContain("text-dracula-orange");
});
});
describe("<TrendCard> responsive layout", () => {
it("keeps long BP target tile content wrappable inside the card", () => {
const html = render(
<TrendCard
label="BP in target"
latest={50}
unit="%"
avg7={100}
avg30={50}
avgAllTime={11}
slope30={RISING}
trend7Delta={50}
icon={Activity}
directionSentiment="up-good"
compareBaseline="lastMonth"
compareDelta={36}
/>,
);
expect(html).toContain("min-w-0");
expect(html).toContain("[overflow-wrap:anywhere]");
expect(html).toContain('data-slot="tile-compare-delta"');
expect(html).toContain('data-slot="trend-card-all-time"');
});
});
@@ -14,7 +14,11 @@ import { Switch } from "@/components/ui/switch";
import { Label } from "@/components/ui/label";
import { Button } from "@/components/ui/button";
import { useTranslations } from "@/lib/i18n/context";
import { type ChartOverlayPrefs } from "@/lib/dashboard-layout";
import {
COMPARISON_BASELINES,
type ChartOverlayPrefs,
type ComparisonBaseline,
} from "@/lib/dashboard-layout";
/**
* v1.4.18 — per-chart overlay-controls popover.
@@ -78,10 +82,17 @@ export function ChartOverlayControls({
const trendArrowId = useId();
const targetRangeId = useId();
const setKey = (key: keyof ChartOverlayPrefs, value: boolean): void => {
const setToggle = (
key: "showTrendIndicator" | "showTrendArrow" | "showTargetRange",
value: boolean,
): void => {
onChange({ ...prefs, [key]: value });
};
const setComparisonBaseline = (value: ComparisonBaseline): void => {
onChange({ ...prefs, comparisonBaseline: value });
};
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
@@ -118,7 +129,7 @@ export function ChartOverlayControls({
id={trendIndicatorId}
checked={prefs.showTrendIndicator}
onCheckedChange={(value) =>
setKey("showTrendIndicator", Boolean(value))
setToggle("showTrendIndicator", Boolean(value))
}
data-slot="chart-overlay-toggle-trend-indicator"
/>
@@ -134,7 +145,7 @@ export function ChartOverlayControls({
id={trendArrowId}
checked={prefs.showTrendArrow}
onCheckedChange={(value) =>
setKey("showTrendArrow", Boolean(value))
setToggle("showTrendArrow", Boolean(value))
}
data-slot="chart-overlay-toggle-trend-arrow"
/>
@@ -150,12 +161,39 @@ export function ChartOverlayControls({
id={targetRangeId}
checked={prefs.showTargetRange}
onCheckedChange={(value) =>
setKey("showTargetRange", Boolean(value))
setToggle("showTargetRange", Boolean(value))
}
data-slot="chart-overlay-toggle-target-range"
/>
</div>
</div>
<DropdownMenuSeparator />
<div className="flex flex-col gap-1.5 pt-1">
<Label className="text-muted-foreground text-xs font-medium">
{t("chart.overlay.controls.comparisonBaseline")}
</Label>
<div
className="grid grid-cols-3 gap-1"
data-slot="chart-overlay-comparison-baseline"
>
{COMPARISON_BASELINES.map((value) => (
<Button
key={value}
type="button"
variant={
prefs.comparisonBaseline === value ? "default" : "outline"
}
size="sm"
className="h-9 px-1 text-[11px]"
onClick={() => setComparisonBaseline(value)}
aria-pressed={prefs.comparisonBaseline === value}
data-slot={`chart-overlay-comparison-${value}`}
>
{t(`comparison.baseline.${value}`)}
</Button>
))}
</div>
</div>
</DropdownMenuContent>
</DropdownMenu>
);
+27 -17
View File
@@ -388,6 +388,9 @@ export function HealthChart({
const showMA = overlayPrefs.prefs.showTrendIndicator;
const showTrend = overlayPrefs.prefs.showTrendArrow;
const showBands = overlayPrefs.prefs.showTargetRange;
const effectiveCompareBaseline = chartKey
? overlayPrefs.prefs.comparisonBaseline
: compareBaseline;
// v1.4.19 A2 — viewport-aware tick density. Recharts' default is one
// tick per data point; the helper caps that at 4-10 visible labels
@@ -605,7 +608,7 @@ export function HealthChart({
// "Comparison unavailable — no data from last month yet" via the
// hasComparisonData flag below).
const chartDataWithCompare = useMemo(() => {
if (!chartData || compareBaseline === "none") return chartData;
if (!chartData || effectiveCompareBaseline === "none") return chartData;
if (!data?.length) return chartData;
const shifted = shiftDailySeriesForward(
@@ -615,7 +618,7 @@ export function HealthChart({
types.map((type) => [type, row[type] as number | undefined]),
),
})),
compareBaseline,
effectiveCompareBaseline,
);
// Index shifted rows by the same day-key the chart already uses.
@@ -643,7 +646,7 @@ export function HealthChart({
}
return merged;
});
}, [chartData, compareBaseline, data, types]);
}, [chartData, effectiveCompareBaseline, data, types]);
/**
* v1.4.16 phase B8 — true when at least one visible day has a prior-
@@ -654,7 +657,8 @@ export function HealthChart({
* when they can clearly see SOME dimmed history.
*/
const hasComparisonData = useMemo(() => {
if (compareBaseline === "none" || !chartDataWithCompare) return false;
if (effectiveCompareBaseline === "none" || !chartDataWithCompare)
return false;
return chartDataWithCompare.some((point) =>
types.some(
(type) =>
@@ -662,7 +666,7 @@ export function HealthChart({
Number.isFinite(point[`${type}_compare`] as number),
),
);
}, [chartDataWithCompare, compareBaseline, types]);
}, [chartDataWithCompare, effectiveCompareBaseline, types]);
const activeBucket: ChartBucketType = useMemo(() => {
if (!data?.length) return "day";
@@ -684,7 +688,7 @@ export function HealthChart({
// v1.4.16 phase B8 — extend the y-domain to fit the prior-period
// overlay. Without this the dimmed line can clip outside the
// visible range when last-month / last-year had a different scale.
if (compareBaseline !== "none") {
if (effectiveCompareBaseline !== "none") {
keys.push(...types.map((type) => `${type}_compare`));
}
@@ -707,7 +711,13 @@ export function HealthChart({
const paddingBottom = Math.max(span * 0.08, 0.5);
const paddingTop = Math.max(span * 0.16, 1);
return [min - paddingBottom, max + paddingTop];
}, [chartDataWithCompare, compareBaseline, showMA, showTrend, types]);
}, [
chartDataWithCompare,
effectiveCompareBaseline,
showMA,
showTrend,
types,
]);
const visibleBands = useMemo(() => {
if (!showBands || !valueBands?.length || !yDomain) return [];
@@ -959,25 +969,25 @@ export function HealthChart({
neutral muted styling so it doesn't read as an error.
v1.4.19 A2 — hidden on mobile to free up the title row. */}
{compareBaseline !== "none" && hasComparisonData && (
{effectiveCompareBaseline !== "none" && hasComparisonData && (
<span
className="text-dracula-purple bg-dracula-purple/10 hidden rounded-md border border-current/30 px-1.5 py-0.5 text-[10px] font-medium tracking-wide uppercase sm:inline-flex"
data-slot="chart-compare-caption"
>
{t(
compareBaseline === "lastMonth"
effectiveCompareBaseline === "lastMonth"
? "comparison.captionLastMonth"
: "comparison.captionLastYear",
)}
</span>
)}
{compareBaseline !== "none" && !hasComparisonData && (
{effectiveCompareBaseline !== "none" && !hasComparisonData && (
<span
className="text-muted-foreground bg-muted/40 hidden rounded-md px-1.5 py-0.5 text-[10px] font-medium tracking-wide sm:inline-flex"
data-slot="chart-compare-unavailable"
>
{t(
compareBaseline === "lastMonth"
effectiveCompareBaseline === "lastMonth"
? "comparison.unavailable.lastMonth"
: "comparison.unavailable.lastYear",
)}
@@ -1291,13 +1301,13 @@ export function HealthChart({
// have a prior value for this day. Otherwise fall
// back to the existing baseline-delta path.
const compareValue =
compareBaseline !== "none" && hoverPoint
effectiveCompareBaseline !== "none" && hoverPoint
? (hoverPoint[`${dataKey}_compare`] as
| number
| undefined)
: undefined;
if (
compareBaseline !== "none" &&
effectiveCompareBaseline !== "none" &&
typeof compareValue === "number" &&
Number.isFinite(compareValue)
) {
@@ -1311,7 +1321,7 @@ export function HealthChart({
1,
)}${unit ? ` ${unit}` : ""}`;
delta = t(
compareBaseline === "lastMonth"
effectiveCompareBaseline === "lastMonth"
? "comparison.deltaVs.lastMonth"
: "comparison.deltaVs.lastYear",
).replace("{delta}", formatted);
@@ -1345,7 +1355,7 @@ export function HealthChart({
// numbers (current AND last-month / last-year)
// alongside the delta.
if (
compareBaseline !== "none" &&
effectiveCompareBaseline !== "none" &&
typeof compareValue === "number" &&
Number.isFinite(compareValue)
) {
@@ -1428,7 +1438,7 @@ export function HealthChart({
stroke opacity (45%) and a thinner stroke (1.25)
so the user reads the current line first and the
overlay as orientation. */}
{compareBaseline !== "none" &&
{effectiveCompareBaseline !== "none" &&
hasComparisonData &&
types.map((type, i) => (
<Line
@@ -1436,7 +1446,7 @@ export function HealthChart({
type="monotone"
dataKey={`${type}_compare`}
name={`${getTypeLabel(type, valueMode, t)} (${t(
compareBaseline === "lastMonth"
effectiveCompareBaseline === "lastMonth"
? "comparison.captionLastMonth"
: "comparison.captionLastYear",
)})`}
@@ -169,7 +169,6 @@ interface MedicationComplianceChartProps {
export function MedicationComplianceChart({
title,
compareBaseline = "none",
}: MedicationComplianceChartProps) {
const { isAuthenticated } = useAuth();
const { t } = useTranslations();
@@ -182,6 +181,7 @@ export function MedicationComplianceChart({
const overlayPrefs = useChartOverlayPrefs("medications");
const showTrendChip = overlayPrefs.prefs.showTrendIndicator;
const showTargetRange = overlayPrefs.prefs.showTargetRange;
const effectiveCompareBaseline = overlayPrefs.prefs.comparisonBaseline;
// v1.4.19 A2 — universal tick-density helper. Pre-fix the medication
// chart drew ONE tick per day, so a 30-day Pixel-5 window painted 30
@@ -320,11 +320,11 @@ export function MedicationComplianceChart({
v1.4.17 when the underlying intake-event window comparison
ships). Without this caption the user toggles Vormonat and
this card just stays static, which reads as a bug. */}
{compareBaseline !== "none" && (
{effectiveCompareBaseline !== "none" && (
<p
className="text-muted-foreground -mt-2 mb-3 text-xs"
data-slot="medication-comparison-na"
data-compare-baseline={compareBaseline}
data-compare-baseline={effectiveCompareBaseline}
>
{t("comparison.notAvailableForCompliance")}
</p>
+41 -34
View File
@@ -289,6 +289,8 @@ export function MoodChart({
const showMA = !mini && overlayPrefs.prefs.showTrendIndicator;
const showTrend = !mini && overlayPrefs.prefs.showTrendArrow;
const showBands = !mini && overlayPrefs.prefs.showTargetRange;
const effectiveCompareBaseline =
!mini && chartKey ? overlayPrefs.prefs.comparisonBaseline : compareBaseline;
// v1.4.19 A2 — viewport-aware tick density helper.
const viewportWidth = useViewportWidth();
@@ -411,7 +413,7 @@ export function MoodChart({
* below renders the dimmed dashed overlay.
*/
const chartDataWithCompare = useMemo<ChartDataPoint[] | undefined>(() => {
if (!chartData || compareBaseline === "none") return chartData;
if (!chartData || effectiveCompareBaseline === "none") return chartData;
if (!data?.entries?.length) return chartData;
const shifted = shiftDailySeriesForward(
@@ -419,7 +421,7 @@ export function MoodChart({
timestamp: dayKeyToTimestamp(entry.date),
score: entry.score,
})),
compareBaseline,
effectiveCompareBaseline,
);
const shiftedByDay = new Map<string, number>();
@@ -437,16 +439,17 @@ export function MoodChart({
}
return point;
});
}, [chartData, compareBaseline, data]);
}, [chartData, effectiveCompareBaseline, data]);
const hasComparisonData = useMemo(() => {
if (compareBaseline === "none" || !chartDataWithCompare) return false;
if (effectiveCompareBaseline === "none" || !chartDataWithCompare)
return false;
return chartDataWithCompare.some((point) =>
typeof point.scoreCompare === "number"
? Number.isFinite(point.scoreCompare)
: false,
);
}, [chartDataWithCompare, compareBaseline]);
}, [chartDataWithCompare, effectiveCompareBaseline]);
// Mirror the activeBucket calculation from health-chart so the chip
// in the header reflects the *same* aggregation chartData used.
@@ -533,30 +536,34 @@ export function MoodChart({
)}
{/* v1.4.16 phase B8 — comparison caption (mood).
v1.4.19 A2 — hidden on mobile to free up the title row. */}
{!mini && compareBaseline !== "none" && hasComparisonData && (
<span
className="text-dracula-purple bg-dracula-purple/10 hidden rounded-md border border-current/30 px-1.5 py-0.5 text-[10px] font-medium tracking-wide uppercase sm:inline-flex"
data-slot="chart-compare-caption"
>
{t(
compareBaseline === "lastMonth"
? "comparison.captionLastMonth"
: "comparison.captionLastYear",
)}
</span>
)}
{!mini && compareBaseline !== "none" && !hasComparisonData && (
<span
className="text-muted-foreground bg-muted/40 hidden rounded-md px-1.5 py-0.5 text-[10px] font-medium tracking-wide sm:inline-flex"
data-slot="chart-compare-unavailable"
>
{t(
compareBaseline === "lastMonth"
? "comparison.unavailable.lastMonth"
: "comparison.unavailable.lastYear",
)}
</span>
)}
{!mini &&
effectiveCompareBaseline !== "none" &&
hasComparisonData && (
<span
className="text-dracula-purple bg-dracula-purple/10 hidden rounded-md border border-current/30 px-1.5 py-0.5 text-[10px] font-medium tracking-wide uppercase sm:inline-flex"
data-slot="chart-compare-caption"
>
{t(
effectiveCompareBaseline === "lastMonth"
? "comparison.captionLastMonth"
: "comparison.captionLastYear",
)}
</span>
)}
{!mini &&
effectiveCompareBaseline !== "none" &&
!hasComparisonData && (
<span
className="text-muted-foreground bg-muted/40 hidden rounded-md px-1.5 py-0.5 text-[10px] font-medium tracking-wide sm:inline-flex"
data-slot="chart-compare-unavailable"
>
{t(
effectiveCompareBaseline === "lastMonth"
? "comparison.unavailable.lastMonth"
: "comparison.unavailable.lastYear",
)}
</span>
)}
</div>
{!mini && (
<div
@@ -733,11 +740,11 @@ export function MoodChart({
continue;
let delta: string | undefined;
const compareValue =
compareBaseline !== "none" && hoverPoint
effectiveCompareBaseline !== "none" && hoverPoint
? hoverPoint.scoreCompare
: undefined;
if (
compareBaseline !== "none" &&
effectiveCompareBaseline !== "none" &&
typeof compareValue === "number" &&
Number.isFinite(compareValue)
) {
@@ -750,7 +757,7 @@ export function MoodChart({
1,
)}`;
delta = t(
compareBaseline === "lastMonth"
effectiveCompareBaseline === "lastMonth"
? "comparison.deltaVs.lastMonth"
: "comparison.deltaVs.lastYear",
).replace("{delta}", formatted);
@@ -777,7 +784,7 @@ export function MoodChart({
delta,
});
if (
compareBaseline !== "none" &&
effectiveCompareBaseline !== "none" &&
typeof compareValue === "number" &&
Number.isFinite(compareValue)
) {
@@ -838,7 +845,7 @@ export function MoodChart({
uses; mood is a single metric so we only render
one overlay line. Suppressed when there's no prior
data via hasComparisonData. */}
{compareBaseline !== "none" && hasComparisonData && (
{effectiveCompareBaseline !== "none" && hasComparisonData && (
<Line
type="monotone"
dataKey="scoreCompare"
+50 -22
View File
@@ -210,21 +210,21 @@ export function TrendCard({
};
return (
<div className="bg-card border-border flex h-full w-full flex-col rounded-xl border p-4 md:p-6">
<div className="flex items-center justify-between">
<span className="text-muted-foreground text-xs font-medium tracking-wide uppercase">
<div className="bg-card border-border flex h-full w-full min-w-0 flex-col rounded-xl border p-4 md:p-6">
<div className="flex min-w-0 items-start justify-between gap-2">
<span className="text-muted-foreground min-w-0 text-xs leading-snug font-medium tracking-wide break-words uppercase">
{label}
</span>
<Icon className="text-muted-foreground h-4 w-4" />
<Icon className="text-muted-foreground h-4 w-4 shrink-0" />
</div>
<div className="mt-2 flex items-baseline gap-2">
<span className="text-3xl font-bold tracking-tight tabular-nums">
<div className="mt-2 flex min-w-0 flex-wrap items-baseline gap-x-2 gap-y-1">
<span className="min-w-0 text-3xl leading-none font-bold tracking-tight [overflow-wrap:anywhere] tabular-nums">
{latest !== null ? renderPair(latest, secondary?.latest) : "—"}
</span>
<span className="text-muted-foreground text-sm tabular-nums">
<span className="text-muted-foreground text-sm [overflow-wrap:anywhere] tabular-nums">
{unit}
</span>
{slope30 && <TrendIcon className={`h-4 w-4 ${trendColor}`} />}
{slope30 && <TrendIcon className={`h-4 w-4 shrink-0 ${trendColor}`} />}
</div>
{/* v1.4.16 phase B8 — comparison delta callout. Sits on its own
line below the latest value so the tile stays scannable on
@@ -246,7 +246,7 @@ export function TrendCard({
>
<span
className={cn(
"text-xs font-medium tabular-nums",
"inline-block max-w-full text-xs leading-snug font-medium [overflow-wrap:anywhere] tabular-nums",
comparisonDeltaColor,
)}
data-slot="tile-compare-delta"
@@ -266,14 +266,17 @@ export function TrendCard({
</div>
)}
<TooltipProvider>
<div className="text-muted-foreground mt-auto flex gap-3 pt-1 text-xs">
<span>
<div className="text-muted-foreground mt-auto flex min-w-0 flex-wrap gap-x-3 gap-y-1 pt-1 text-xs leading-snug">
<span className="max-w-full min-w-0 [overflow-wrap:anywhere]">
{t(avg7LabelKey)}:{" "}
{avg7Hint ? (
<Tooltip>
<TooltipTrigger asChild>
<span
className={cn("font-medium tabular-nums", avg7ColorClass)}
className={cn(
"font-medium [overflow-wrap:anywhere] tabular-nums",
avg7ColorClass,
)}
>
{avg7 !== null ? renderPair(avg7, secondary?.avg7) : "—"}
</span>
@@ -283,13 +286,21 @@ export function TrendCard({
</TooltipContent>
</Tooltip>
) : (
<span className={cn("font-medium tabular-nums", avg7ColorClass)}>
<span
className={cn(
"font-medium [overflow-wrap:anywhere] tabular-nums",
avg7ColorClass,
)}
>
{avg7 !== null ? renderPair(avg7, secondary?.avg7) : "—"}
</span>
)}
{trend7Delta != null && (
<span
className={cn("ml-1 font-medium tabular-nums", deltaColor)}
className={cn(
"ml-1 font-medium [overflow-wrap:anywhere] tabular-nums",
deltaColor,
)}
data-slot="trend7-delta"
aria-label={`7-day trend ${formatDelta(trend7Delta)}`}
>
@@ -297,13 +308,16 @@ export function TrendCard({
</span>
)}
</span>
<span>
<span className="max-w-full min-w-0 [overflow-wrap:anywhere]">
{t("charts.avg30dShort")}:{" "}
{avg30Hint ? (
<Tooltip>
<TooltipTrigger asChild>
<span
className={cn("font-medium tabular-nums", avg30ColorClass)}
className={cn(
"font-medium [overflow-wrap:anywhere] tabular-nums",
avg30ColorClass,
)}
>
{avg30 !== null ? renderPair(avg30, secondary?.avg30) : "—"}
</span>
@@ -313,7 +327,12 @@ export function TrendCard({
</TooltipContent>
</Tooltip>
) : (
<span className={cn("font-medium tabular-nums", avg30ColorClass)}>
<span
className={cn(
"font-medium [overflow-wrap:anywhere] tabular-nums",
avg30ColorClass,
)}
>
{avg30 !== null ? renderPair(avg30, secondary?.avg30) : "—"}
</span>
)}
@@ -328,10 +347,16 @@ export function TrendCard({
combined secondary row below so the tile keeps the same
vertical density as every other trend card. */}
{avgAllTime !== undefined && (
<span data-slot="trend-card-all-time" className="hidden sm:inline">
<span
data-slot="trend-card-all-time"
className="hidden max-w-full min-w-0 [overflow-wrap:anywhere] sm:inline"
>
{t(avgAllTimeLabelKey)}:{" "}
<span
className={cn("font-medium tabular-nums", avgAllTimeColorClass)}
className={cn(
"font-medium [overflow-wrap:anywhere] tabular-nums",
avgAllTimeColorClass,
)}
>
{avgAllTime !== null ? formatValue(avgAllTime) : "—"}
</span>
@@ -346,12 +371,15 @@ export function TrendCard({
{avgAllTime !== undefined && (
<div
data-slot="trend-card-secondary-mobile"
className="text-muted-foreground mt-1 text-xs sm:hidden"
className="text-muted-foreground mt-1 min-w-0 text-xs leading-snug [overflow-wrap:anywhere] sm:hidden"
>
<span>
{t(avgAllTimeLabelKey)}:{" "}
<span
className={cn("font-medium tabular-nums", avgAllTimeColorClass)}
className={cn(
"font-medium [overflow-wrap:anywhere] tabular-nums",
avgAllTimeColorClass,
)}
>
{avgAllTime !== null ? formatValue(avgAllTime) : "—"}
</span>
@@ -361,7 +389,7 @@ export function TrendCard({
<span aria-hidden="true"> · </span>
<span
className={cn(
"font-medium tabular-nums",
"font-medium [overflow-wrap:anywhere] tabular-nums",
comparisonDeltaColor,
)}
data-slot="tile-compare-delta-mobile"
@@ -172,7 +172,7 @@ export function CoachDrawer({
}
onOpenChange(next);
},
[onOpenChange],
[onOpenChange, setInputValue],
);
const { data: conversation } = useCoachConversation(currentConversationId);
@@ -1,6 +1,6 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import { useMutation } from "@tanstack/react-query";
import {
Bot,
@@ -69,7 +69,10 @@ export function MessageThread({
const { data: coachPrefs } = useCoachPrefs();
const evidenceDefaultOpen = coachPrefs?.showEvidenceByDefault ?? false;
const messages: CoachMessageDTO[] = conversation?.messages ?? [];
const messages: CoachMessageDTO[] = useMemo(
() => conversation?.messages ?? [],
[conversation?.messages],
);
// v1.4.20.1 — once the SSE stream emits `done`, the route's
// invalidate-then-refetch pulls the persisted assistant message into
// `conversation.messages`. The streaming bubble was still rendering
@@ -459,7 +459,7 @@ export function MeasurementList({ onEdit, onAddFirst }: MeasurementListProps) {
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
className="h-10 w-10"
onClick={() => startEdit(m)}
aria-label={t("common.edit")}
>
@@ -536,6 +536,7 @@ export function MeasurementList({ onEdit, onAddFirst }: MeasurementListProps) {
<Pencil className="h-3.5 w-3.5" />
</Button>
<DeleteButton
className="h-10 w-10"
onConfirm={() => deleteMutation.mutate(m.id)}
/>
</div>
@@ -774,7 +775,13 @@ function SortableHead({
);
}
function DeleteButton({ onConfirm }: { onConfirm: () => void }) {
function DeleteButton({
onConfirm,
className = "h-8 w-8",
}: {
onConfirm: () => void;
className?: string;
}) {
const { t } = useTranslations();
return (
<AlertDialog>
@@ -782,7 +789,7 @@ function DeleteButton({ onConfirm }: { onConfirm: () => void }) {
<Button
variant="ghost"
size="icon"
className="text-destructive h-8 w-8"
className={`text-destructive ${className}`}
aria-label={t("common.delete")}
>
<Trash2 className="h-3.5 w-3.5" />
@@ -664,8 +664,9 @@ function MoodLogCard({
<form onSubmit={handleSave} className="space-y-3">
<div>
<Label>{t("settings.moodLogUrl")}</Label>
<Label htmlFor="moodlog-url">{t("settings.moodLogUrl")}</Label>
<Input
id="moodlog-url"
type="url"
placeholder={t("settings.moodLogUrlPlaceholder")}
value={url}
@@ -673,8 +674,11 @@ function MoodLogCard({
/>
</div>
<div>
<Label>{t("settings.moodLogApiKey")}</Label>
<Label htmlFor="moodlog-api-key">
{t("settings.moodLogApiKey")}
</Label>
<PasswordInput
id="moodlog-api-key"
placeholder={
status?.configured
? t("settings.withingsCredentialsSavedPlaceholder")
@@ -705,9 +709,12 @@ function MoodLogCard({
<div className="space-y-3 border-t pt-3">
{status.webhookSecret && (
<div>
<Label>{t("settings.moodLogWebhookSecret")}</Label>
<Label htmlFor="moodlog-webhook-secret">
{t("settings.moodLogWebhookSecret")}
</Label>
<div className="flex gap-2">
<Input
id="moodlog-webhook-secret"
value={status.webhookSecret}
readOnly
className="font-mono text-xs"
@@ -54,9 +54,21 @@ export function NotificationsSection() {
<NotificationStatusCard />
{showTelegram && <TelegramCard isAuthenticated={isAuthenticated} />}
{showNtfy && <NtfyCard isAuthenticated={isAuthenticated} />}
{showWebPush && <WebPushCard />}
{showTelegram && (
<div id="telegram">
<TelegramCard isAuthenticated={isAuthenticated} />
</div>
)}
{showNtfy && (
<div id="ntfy">
<NtfyCard isAuthenticated={isAuthenticated} />
</div>
)}
{showWebPush && (
<div id="web-push">
<WebPushCard />
</div>
)}
</section>
);
}