mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-13 14:32:03 +00:00
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:
1 parent
0dc0e1645c
commit
ca03c6099f
59 files changed
+841
-206
No files matched your search
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user