style: one spacing, title and skeleton language across the app

Icons inside buttons drop their manual margins in favour of the
button's built-in gap (two hundred call sites), form field stacks settle
on one spacing step, card titles on exactly two sizes, inline field
errors on one text size, and a dozen hand-rolled loading pulses move
onto the shared skeleton primitive while keeping their silhouettes. The
intake dialog wording joins the rest of the app on 'übersprungen', and
the UI guidelines now document the two deliberate footer and
touch-target systems.
This commit is contained in:
Marc-André Bombeck committed 2026-06-10 16:54:15 +02:00
1 parent 4ca3de5ff3
commit 04765ea8ac
92 files changed
+1433 -1445

No files matched your search

+18 -4
View File
@@ -48,7 +48,7 @@ Tailwind's default 4-pixel scale. Reach for these stops first:
| ------------------------------------ | ------------- | ------ |
| Inline gap between icon and label | `gap-1` | 4 |
| Tight stack inside a card | `gap-2` | 8 |
| Default form-field stack | `space-y-3` | 12 |
| Label-to-field stack (form fields) | `space-y-2` | 8 |
| Section block (heading + body) | `space-y-4` | 16 |
| Page section separator | `space-y-6` | 24 |
| Dashboard tile-row gap, hero spacing | `gap-4` | 16 |
@@ -65,13 +65,18 @@ these classes first:
| Use | Class |
| ---------------------------------- | --------------------------------------- |
| Page title (h1) | `text-2xl font-semibold tracking-tight` |
| Section heading (h2) | `text-lg font-semibold` |
| Card title | `text-base font-medium` |
| Section heading / section card | `text-lg font-semibold` |
| Tile / inner card title | `text-base font-semibold` |
| Body text | `text-sm` |
| Muted helper / metadata | `text-sm text-muted-foreground` |
| Microcopy / footer | `text-xs text-muted-foreground` |
| Tabular data (latency, large nums) | `font-mono tabular-nums` |
Card titles come in exactly two sizes: `text-lg font-semibold` for a
section-level card (one per page region) and `text-base font-semibold`
for tiles and inner cards. Don't introduce a third level — a smaller
heading is a sign the content wants a list row, not a card.
Never use `font-bold` for emphasis — bump to `font-semibold` instead and
let the design system breathe.
@@ -381,7 +386,16 @@ We aim for **WCAG 2.1 AA**, with the following non-negotiables:
- Color contrast ≥ 4.5:1 for normal text, 3:1 for large text and UI
components. Verified by `@axe-core/playwright` in CI on every page.
- Touch targets ≥ 44×44 CSS px on bottom-nav and primary action buttons
(WCAG 2.5.5).
(WCAG 2.5.5). Two idioms implement the floor, and they are deliberate:
- **Form controls** (`Input`, `NativeSelect`, `SelectTrigger` default
size): `h-11 sm:h-10` is baked into the primitives — 44 px on
mobile, back to the 40 px desktop tier. Never re-add `min-h-11` on
top of these; it forces 44 px on desktop and breaks the row rhythm.
- **Buttons on dense touch surfaces** (medication detail, dashboard
quick actions): `min-h-11 sm:min-h-9` — 44 px mobile floor,
compact `sm` tier on desktop. Plain `min-h-11` without the `sm:`
reset is legacy spelling; don't introduce new ones, and align a
button to its direct neighbours when editing one.
- Focus is always visible: shadcn's `focus-visible:ring` is preserved on
every customized component.
- Every interactive element has an accessible name. Icon-only buttons
+1 -1
View File
@@ -643,7 +643,7 @@
"slotNone": "Kein bestimmter Slot",
"takenAtLabel": "Datum und Uhrzeit",
"takenAtHint": "Standard ist jetzt; wähle ein früheres Datum und eine frühere Uhrzeit, um eine vergangene Dosis zu erfassen.",
"skippedLabel": "Als ausgelassen erfassen",
"skippedLabel": "Als übersprungen erfassen",
"noMedications": "Lege zuerst ein Medikament an, dann kannst du Einnahmen dafür erfassen.",
"cancel": "Abbrechen",
"submit": "Einnahme erfassen",
+5 -5
View File
@@ -151,9 +151,9 @@ export default function LoginPage() {
disabled={loading}
>
{loading && mode === "passkey" ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin motion-reduce:animate-none" />
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
) : (
<KeyRound className="mr-2 h-4 w-4" />
<KeyRound className="h-4 w-4" />
)}
{t("auth.loginWithPasskey")}
</Button>
@@ -173,7 +173,7 @@ export default function LoginPage() {
size="lg"
onClick={() => setMode("password")}
>
<Lock className="mr-2 h-4 w-4" />
<Lock className="h-4 w-4" />
{t("auth.loginWithPassword")}
</Button>
) : (
@@ -220,9 +220,9 @@ export default function LoginPage() {
disabled={loading}
>
{loading && mode === "password" ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin motion-reduce:animate-none" />
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
) : (
<Lock className="mr-2 h-4 w-4" />
<Lock className="h-4 w-4" />
)}
{t("auth.login")}
</Button>
+2 -2
View File
@@ -191,9 +191,9 @@ export default function RegisterPage() {
disabled={loading}
>
{loading ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin motion-reduce:animate-none" />
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
) : (
<UserPlus className="mr-2 h-4 w-4" />
<UserPlus className="h-4 w-4" />
)}
{t("auth.register")}
</Button>
+2 -2
View File
@@ -243,9 +243,9 @@ export default function BugReportPage() {
`div.space-y-2 > Button > Loader-Circle`).
*/}
{loading ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin motion-reduce:animate-none" />
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
) : (
<Bug className="mr-2 h-4 w-4" />
<Bug className="h-4 w-4" />
)}
{t("bugreport.submit")}
</Button>
+10 -9
View File
@@ -8,29 +8,30 @@
* by less than a row. Pure server component — no client hooks, no data
* fetch; Next.js renders it instantly while the segment streams.
*/
import { Skeleton } from "@/components/ui/skeleton";
export default function InsightsLoading() {
return (
<div data-slot="insights-subpage-loading" className="space-y-6">
<header className="space-y-2">
<div className="bg-muted h-7 w-48 animate-pulse rounded motion-reduce:animate-none" />
<div className="bg-muted/70 h-4 w-72 animate-pulse rounded motion-reduce:animate-none" />
<Skeleton className="bg-muted h-7 w-48 rounded" />
<Skeleton className="bg-muted/70 h-4 w-72 rounded" />
</header>
{/* Chart card */}
<div className="bg-card rounded-xl border p-4">
<div className="bg-muted mb-4 h-4 w-32 animate-pulse rounded motion-reduce:animate-none" />
<div className="bg-muted/60 h-56 w-full animate-pulse rounded-lg motion-reduce:animate-none" />
<Skeleton className="bg-muted mb-4 h-4 w-32 rounded" />
<Skeleton className="h-56 w-full rounded-lg" />
</div>
{/* Assessment card */}
<div className="bg-card space-y-2 rounded-xl border p-4">
<div className="flex items-center gap-2">
<div className="bg-muted h-5 w-5 animate-pulse rounded motion-reduce:animate-none" />
<div className="bg-muted h-4 w-40 animate-pulse rounded motion-reduce:animate-none" />
<Skeleton className="bg-muted h-5 w-5 rounded" />
<Skeleton className="bg-muted h-4 w-40 rounded" />
</div>
<div className="bg-muted h-3.5 w-full animate-pulse rounded motion-reduce:animate-none" />
<div className="bg-muted h-3.5 w-11/12 animate-pulse rounded motion-reduce:animate-none" />
<div className="bg-muted h-3.5 w-9/12 animate-pulse rounded motion-reduce:animate-none" />
<Skeleton className="bg-muted h-3.5 w-full rounded" />
<Skeleton className="bg-muted h-3.5 w-11/12 rounded" />
<Skeleton className="bg-muted h-3.5 w-9/12 rounded" />
</div>
</div>
);
+1 -1
View File
@@ -96,7 +96,7 @@ export default function CompositeScorePage() {
className="-ml-2 w-fit"
>
<Link href="/insights">
<ArrowLeft className="mr-1 size-4" aria-hidden="true" />
<ArrowLeft className="size-4" aria-hidden="true" />
{t("insights.subPage.scoresBack")}
</Link>
</Button>
+4 -2
View File
@@ -71,9 +71,11 @@ export default function InsightsMetricValuesPage({
className="-ml-2 w-fit"
>
<Link href={backHref}>
<ArrowLeft className="mr-1 size-4" aria-hidden="true" />
<ArrowLeft className="size-4" aria-hidden="true" />
{backToOrigin
? t("insights.subPage.valuesBackToMetric", { metric: metricLabel })
? t("insights.subPage.valuesBackToMetric", {
metric: metricLabel,
})
: t("insights.subPage.valuesBack")}
</Link>
</Button>
+1 -1
View File
@@ -49,7 +49,7 @@ export default function InsightsWorkoutDetailPage({
className="-ml-2 w-fit"
>
<Link href="/insights/workouts">
<ArrowLeft className="mr-1 size-4" aria-hidden="true" />
<ArrowLeft className="size-4" aria-hidden="true" />
{t("insights.workouts.detail.backToList")}
</Link>
</Button>
+4 -3
View File
@@ -7,15 +7,16 @@
* component — no client hooks, no data fetch. More specific segments (e.g.
* `/insights`) provide their own skeleton and take precedence.
*/
import { Skeleton } from "@/components/ui/skeleton";
export default function RootLoading() {
return (
<div data-slot="dashboard-loading" className="space-y-6 p-4">
<div className="bg-muted h-7 w-44 animate-pulse rounded motion-reduce:animate-none" />
<Skeleton className="bg-muted h-7 w-44 rounded" />
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
{Array.from({ length: 6 }).map((_, i) => (
<div key={i} className="bg-card rounded-xl border p-4">
<div className="bg-muted mb-3 h-4 w-24 animate-pulse rounded motion-reduce:animate-none" />
<div className="bg-muted/60 h-24 w-full animate-pulse rounded-lg motion-reduce:animate-none" />
<Skeleton className="bg-muted mb-3 h-4 w-24 rounded" />
<Skeleton className="h-24 w-full rounded-lg" />
</div>
))}
</div>
+2 -4
View File
@@ -28,9 +28,7 @@ export default function MeasurementsPage() {
// effect, so the open-on-param work is render-driven instead.
const addParam = searchParams.get("add");
const [dialogOpen, setDialogOpen] = useState(false);
const [defaultType, setDefaultType] = useState<string | undefined>(
undefined,
);
const [defaultType, setDefaultType] = useState<string | undefined>(undefined);
const [consumedAddParam, setConsumedAddParam] = useState<string | null>(null);
// v1.4.27 R4 RC2 — DOM handle the form portals its action row into so
// the Sheet branch can sticky-pin Save / Cancel.
@@ -81,7 +79,7 @@ export default function MeasurementsPage() {
</p>
</div>
<Button onClick={() => setDialogOpen(true)}>
<Plus className="mr-2 h-4 w-4" />
<Plus className="h-4 w-4" />
{t("measurements.addMeasurement")}
</Button>
</div>
+1 -1
View File
@@ -90,7 +90,7 @@ export default function MedicationDetailPage({
<div className="space-y-6">
<Button variant="ghost" size="sm" className="-ml-2 w-fit" asChild>
<Link href="/medications">
<ArrowLeft className="mr-1 size-4" aria-hidden="true" />
<ArrowLeft className="size-4" aria-hidden="true" />
{t("medications.back")}
</Link>
</Button>
+2 -2
View File
@@ -245,7 +245,7 @@ export default function MedicationsPage() {
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button className="min-h-11 sm:min-h-9">
<Plus className="mr-2 h-4 w-4" />
<Plus className="h-4 w-4" />
{t("medications.addMedication")}
</Button>
</DropdownMenuTrigger>
@@ -305,7 +305,7 @@ export default function MedicationsPage() {
description={t("medications.emptyDescription")}
action={
<Button size="sm" onClick={openCreate}>
<Plus className="mr-1 h-4 w-4" />
<Plus className="h-4 w-4" />
{t("medications.firstMedication")}
</Button>
}
+1 -1
View File
@@ -49,7 +49,7 @@ export default function MoodPage() {
</p>
</div>
<Button onClick={() => setDialogOpen(true)}>
<Plus className="mr-2 h-4 w-4" />
<Plus className="h-4 w-4" />
{t("mood.addEntry")}
</Button>
</div>
+9 -7
View File
@@ -7,7 +7,13 @@ import { Switch } from "@/components/ui/switch";
import { Label } from "@/components/ui/label";
import { Button } from "@/components/ui/button";
import Link from "next/link";
import { Bell, ChevronRight, Loader2, Settings, AlertCircle } from "lucide-react";
import {
Bell,
ChevronRight,
Loader2,
Settings,
AlertCircle,
} from "lucide-react";
import {
EVENT_DEFAULT_ENABLED,
type EventType,
@@ -43,11 +49,7 @@ interface PreferencesData {
* unauthenticated, error, empty, populated) so the user always sees the
* disambiguation, even when the body content is a fallback state.
*/
function NotificationsBreadcrumb({
t,
}: {
t: (key: string) => string;
}) {
function NotificationsBreadcrumb({ t }: { t: (key: string) => string }) {
return (
<nav aria-label="Breadcrumb" className="text-muted-foreground text-xs">
<ol className="flex items-center gap-1">
@@ -231,7 +233,7 @@ export default function NotificationsPage() {
</p>
<Button asChild variant="outline">
<Link href="/settings/notifications">
<Settings className="mr-2 h-4 w-4" />
<Settings className="h-4 w-4" />
{t("notifications.goToSettings")}
</Link>
</Button>
+13 -21
View File
@@ -101,9 +101,9 @@ export function sheetBodyHasUnsavedInput(): boolean {
'[data-slot="responsive-sheet-body"]',
);
if (!body) return false;
const fields = body.querySelectorAll<
HTMLInputElement | HTMLTextAreaElement
>("input, textarea");
const fields = body.querySelectorAll<HTMLInputElement | HTMLTextAreaElement>(
"input, textarea",
);
for (const field of fields) {
if (field.disabled || field.type === "hidden") continue;
// Skip pickers the form prefills with "now" — an untouched default
@@ -124,9 +124,7 @@ export function sheetBodyHasUnsavedInput(): boolean {
//
// Mood radios start with nothing selected (`mood = ""`), so ANY
// `aria-checked="true"` is a deliberate user choice — flag it.
if (
body.querySelector('[role="radio"][aria-checked="true"]') !== null
) {
if (body.querySelector('[role="radio"][aria-checked="true"]') !== null) {
return true;
}
// Radix Select trigger renders `role="combobox"`. A trigger with
@@ -135,9 +133,7 @@ export function sheetBodyHasUnsavedInput(): boolean {
// We deliberately do NOT treat a merely non-placeholder value as dirty: the
// type / medication selects mount with a non-placeholder DEFAULT, and
// flagging that would over-trigger the confirm on a pristine sheet.
if (
body.querySelector('[role="combobox"][data-state="open"]') !== null
) {
if (body.querySelector('[role="combobox"][data-state="open"]') !== null) {
return true;
}
return false;
@@ -290,9 +286,7 @@ function getRangeHint(
return (
<>
<p>
<span className="text-success font-bold">
{t("charts.colorGreen")}
</span>{" "}
<span className="text-success font-bold">{t("charts.colorGreen")}</span>{" "}
{format(range.greenMin)}-{format(range.greenMax)} {unit}
</p>
<p>
@@ -572,9 +566,7 @@ export default function DashboardPage() {
* undefined-safe and tiles with recent data paint byte-identical
* with the pre-v1.4.34 contract.
*/
const tileStaleDays = (
type: string | null | undefined,
): number | null => {
const tileStaleDays = (type: string | null | undefined): number | null => {
if (!type) return null;
const entry = data?.lastSeenByType?.[type];
if (!entry) return null;
@@ -892,7 +884,7 @@ export default function DashboardPage() {
className="min-h-11 sm:min-h-9"
data-tour-id="dashboard-quick-add"
>
<Plus className="mr-1 h-4 w-4" />
<Plus className="h-4 w-4" />
{t("common.add")}
</Button>
</DropdownMenuTrigger>
@@ -954,9 +946,7 @@ export default function DashboardPage() {
open={quickEntryDialog === "measurement"}
onOpenChange={handleQuickEntryOpenChange}
title={t("measurements.addMeasurement")}
footer={
<div ref={setMeasurementFooterEl} className="flex w-full" />
}
footer={<div ref={setMeasurementFooterEl} className="flex w-full" />}
>
<MeasurementForm
onSuccess={() => setQuickEntryDialog(null)}
@@ -1196,7 +1186,9 @@ export default function DashboardPage() {
icon={TrendingUp}
compareBaseline={compareBaseline}
compareDelta={tileCompareDelta(pulseTileSummary)}
staleDays={tileStaleDays(hasRestingHr ? "RESTING_HEART_RATE" : "PULSE")}
staleDays={tileStaleDays(
hasRestingHr ? "RESTING_HEART_RATE" : "PULSE",
)}
/>
),
});
@@ -1736,7 +1728,7 @@ export default function DashboardPage() {
size="sm"
onClick={() => setQuickEntryDialog("measurement")}
>
<Plus className="mr-1 h-4 w-4" />
<Plus className="h-4 w-4" />
{t("dashboard.emptyAddMeasurement")}
</Button>
}
+10 -10
View File
@@ -91,9 +91,9 @@ function RestoreRowDialog({
className="min-h-11"
>
{pending ? (
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
) : (
<History className="mr-1 h-3.5 w-3.5" />
<History className="h-3.5 w-3.5" />
)}
{t("admin.section.backups.restore")}
</Button>
@@ -366,9 +366,9 @@ export function BackupsSection() {
className="min-h-11"
>
{runBackup.isPending ? (
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
) : (
<PlayCircle className="mr-1 h-3.5 w-3.5" />
<PlayCircle className="h-3.5 w-3.5" />
)}
{t("admin.section.backups.runNow")}
</Button>
@@ -422,9 +422,9 @@ export function BackupsSection() {
className="min-h-11"
>
{upload.isPending ? (
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
) : (
<Upload className="mr-1 h-3.5 w-3.5" />
<Upload className="h-3.5 w-3.5" />
)}
{t("admin.section.backups.uploadButton")}
</Button>
@@ -463,9 +463,9 @@ export function BackupsSection() {
className="min-h-11"
>
{runBackup.isPending ? (
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
) : (
<PlayCircle className="mr-1 h-3.5 w-3.5" />
<PlayCircle className="h-3.5 w-3.5" />
)}
{t("admin.section.backups.runNow")}
</Button>
@@ -522,9 +522,9 @@ export function BackupsSection() {
className="min-h-11"
>
{downloadingId === row.id ? (
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
) : (
<Download className="mr-1 h-3.5 w-3.5" />
<Download className="h-3.5 w-3.5" />
)}
{t("admin.section.backups.download")}
</Button>
+1 -1
View File
@@ -119,7 +119,7 @@ export function BugReportSection() {
disabled={updateSettings.isPending}
>
{updateSettings.isPending && (
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
)}
{t("common.save")}
</Button>
+2 -2
View File
@@ -85,9 +85,9 @@ export function DangerZoneSection() {
disabled={wipeAllData.isPending}
>
{wipeAllData.isPending ? (
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
) : (
<Trash2 className="mr-1 h-3.5 w-3.5" />
<Trash2 className="h-3.5 w-3.5" />
)}
{t("admin.deleteButton")}
</Button>
@@ -366,7 +366,7 @@ function FeedbackDetailDialog({
disabled={update.isPending || item.status === "ACKNOWLEDGED"}
onClick={() => update.mutate({ status: "ACKNOWLEDGED" })}
>
<CheckCircle2 className="mr-1.5 h-3.5 w-3.5" />
<CheckCircle2 className="h-3.5 w-3.5" />
{t("admin.feedback.actionAcknowledge")}
</Button>
<Button
@@ -374,7 +374,7 @@ function FeedbackDetailDialog({
disabled={update.isPending || item.status === "RESOLVED"}
onClick={() => update.mutate({ status: "RESOLVED" })}
>
<CheckCircle2 className="mr-1.5 h-3.5 w-3.5" />
<CheckCircle2 className="h-3.5 w-3.5" />
{t("admin.feedback.actionResolve")}
</Button>
<AlertDialog>
@@ -384,7 +384,7 @@ function FeedbackDetailDialog({
variant="destructive"
disabled={archive.isPending || item.status === "ARCHIVED"}
>
<Trash2 className="mr-1.5 h-3.5 w-3.5" />
<Trash2 className="h-3.5 w-3.5" />
{t("admin.feedback.actionArchive")}
</Button>
</AlertDialogTrigger>
@@ -419,9 +419,9 @@ function FeedbackDetailDialog({
className="ml-auto"
>
{publish.isPending ? (
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
) : (
<GitPullRequest className="mr-1.5 h-3.5 w-3.5" />
<GitPullRequest className="h-3.5 w-3.5" />
)}
{t("admin.feedback.actionPublishGithub")}
</Button>
@@ -517,7 +517,7 @@ function FeedbackDetailDialog({
onClick={() => update.mutate({ adminNote: note || null })}
>
{update.isPending && (
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
)}
{t("admin.feedback.saveNote")}
</Button>
+2 -2
View File
@@ -148,7 +148,7 @@ export function GlitchtipSection() {
disabled={testGlitchtip.isPending || updateSettings.isPending}
>
{testGlitchtip.isPending && (
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
)}
{t("common.test")}
</Button>
@@ -158,7 +158,7 @@ export function GlitchtipSection() {
disabled={updateSettings.isPending}
>
{updateSettings.isPending && (
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
)}
{t("common.save")}
</Button>
+2 -1
View File
@@ -38,6 +38,7 @@ import {
import { useMemo } from "react";
import { useFormatters, useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import { Skeleton } from "@/components/ui/skeleton";
interface HostMetricsApiSample {
capturedAt: string;
@@ -168,7 +169,7 @@ export function HostMetricsChart() {
{t("admin.hostMetrics.last2hours")}
</span>
</div>
<div className="bg-muted/40 h-[180px] w-full animate-pulse rounded-md motion-reduce:animate-none" />
<Skeleton className="bg-muted/40 h-[180px] w-full" />
</section>
);
}
@@ -331,7 +331,7 @@ export function LoginOverviewSection() {
onClick={downloadCsv}
disabled={entries.length === 0}
>
<Download className="mr-1 h-3.5 w-3.5" />
<Download className="h-3.5 w-3.5" />
{t("admin.section.auditLog.export")}
</Button>
</div>
+8 -8
View File
@@ -181,9 +181,9 @@ export function RemindersSection() {
disabled={testNotification.isPending}
>
{testNotification.isPending && (
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
)}
<Bell className="mr-1.5 h-3.5 w-3.5" />
<Bell className="h-3.5 w-3.5" />
{t("admin.notificationTestSend")}
</Button>
<Button
@@ -193,9 +193,9 @@ export function RemindersSection() {
disabled={reminderCheck.isPending}
>
{reminderCheck.isPending && (
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
)}
<Activity className="mr-1.5 h-3.5 w-3.5" />
<Activity className="h-3.5 w-3.5" />
{t("admin.reminderCheckRun")}
</Button>
<Button
@@ -224,7 +224,7 @@ export function RemindersSection() {
}
>
{updateSettings.isPending && (
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
)}
{t("common.save")}
</Button>
@@ -236,9 +236,9 @@ export function RemindersSection() {
{testNotification.data.results.map((r, i) => (
<div key={i} className="flex items-center gap-1.5 text-xs">
{r.success ? (
<CheckCircle2 className="text-success dark:text-green-400 h-3.5 w-3.5 shrink-0" />
<CheckCircle2 className="text-success h-3.5 w-3.5 shrink-0 dark:text-green-400" />
) : (
<XCircle className="text-destructive dark:text-red-400 h-3.5 w-3.5 shrink-0" />
<XCircle className="text-destructive h-3.5 w-3.5 shrink-0 dark:text-red-400" />
)}
<span className="font-medium">{r.channel}</span>
{r.error && (
@@ -299,7 +299,7 @@ export function RemindersSection() {
</span>
<span className={statusColor}>{sched.label}</span>
{sched.notificationSent && (
<span className="text-success dark:text-green-400 flex shrink-0 items-center gap-0.5">
<span className="text-success flex shrink-0 items-center gap-0.5 dark:text-green-400">
<Bell className="h-3 w-3" />
{t("admin.reminderCheckNotifSent")}
</span>
@@ -19,6 +19,7 @@ import {
Users,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
import { formatDateTime } from "@/lib/format";
import { useFormatters, useTranslations } from "@/lib/i18n/context";
import { StatusItem, usePublicVersion, useSystemStatus } from "./_shared";
@@ -36,7 +37,7 @@ const HostMetricsChart = dynamic(
{
ssr: false,
loading: () => (
<div className="bg-muted/40 h-[200px] w-full animate-pulse rounded-xl motion-reduce:animate-none" />
<Skeleton className="bg-muted/40 h-[200px] w-full rounded-xl" />
),
},
);
@@ -207,9 +208,9 @@ export function SystemStatusSection() {
data-testid="system-status-retry"
>
{isFetching ? (
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
) : (
<RotateCw className="mr-1 h-3.5 w-3.5" />
<RotateCw className="h-3.5 w-3.5" />
)}
{t("admin.systemStatusRetry")}
</Button>
+2 -2
View File
@@ -149,7 +149,7 @@ export function UmamiSection() {
disabled={testUmami.isPending || updateSettings.isPending}
>
{testUmami.isPending && (
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
)}
{t("common.test")}
</Button>
@@ -159,7 +159,7 @@ export function UmamiSection() {
disabled={updateSettings.isPending}
>
{updateSettings.isPending && (
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
)}
{t("common.save")}
</Button>
@@ -189,7 +189,7 @@ export function UserManagementSection() {
: t("admin.promoteToAdmin")
}
>
<Shield className="mr-1 h-3 w-3" aria-hidden="true" />
<Shield className="h-3 w-3" aria-hidden="true" />
{u.role === "ADMIN" ? t("admin.toUser") : t("admin.toAdmin")}
</Button>
<Button
@@ -461,7 +461,7 @@ export function UserManagementSection() {
}
>
{updateUser.isPending && (
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
)}
{t("common.save")}
</Button>
@@ -511,7 +511,7 @@ export function UserManagementSection() {
}
>
{resetPw.isPending && (
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
)}
{t("admin.reset")}
</Button>
@@ -135,7 +135,7 @@ export function WebPushVapidSection() {
disabled={updateSettings.isPending}
>
{updateSettings.isPending && (
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
)}
{t("common.save")}
</Button>
+1 -1
View File
@@ -244,7 +244,7 @@ export function CycleSettings({ profile }: { profile: CycleProfileDTO }) {
) : null}
<Button onClick={handleSave} disabled={update.isPending}>
{update.isPending ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin motion-reduce:animate-none" />
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
) : null}
{t("cycle.settings.save")}
</Button>
+6 -10
View File
@@ -423,9 +423,9 @@ export function LogDaySheet({
aria-label={t("cycle.sheet.delete")}
>
{deleteDay.isPending ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin motion-reduce:animate-none" />
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
) : (
<Trash2 className="mr-2 h-4 w-4" />
<Trash2 className="h-4 w-4" />
)}
{t("cycle.sheet.delete")}
</Button>
@@ -439,7 +439,7 @@ export function LogDaySheet({
</Button>
<Button onClick={handleSave} disabled={busy}>
{saving ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin motion-reduce:animate-none" />
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
) : null}
{saving ? t("cycle.sheet.saving") : t("cycle.sheet.save")}
</Button>
@@ -815,11 +815,7 @@ function SymptomChip({
* popover to mint a new custom symptom. Matches the chip grid (same rounded-
* full pill, same size) so it reads as part of the grid, not a foreign button.
*/
function AddSymptomChip({
onCreated,
}: {
onCreated: (key: string) => void;
}) {
function AddSymptomChip({ onCreated }: { onCreated: (key: string) => void }) {
const { t } = useTranslations();
const create = useCreateCustomSymptom();
const [openForm, setOpenForm] = useState(false);
@@ -924,7 +920,7 @@ function AddSymptomChip({
</div>
</div>
{limitReached ? (
<p className="text-destructive text-xs" role="alert">
<p className="text-destructive text-sm" role="alert">
{t("cycle.symptom.custom.limitReached")}
</p>
) : null}
@@ -945,7 +941,7 @@ function AddSymptomChip({
disabled={!label.trim() || create.isPending}
>
{create.isPending ? (
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
) : null}
{t("cycle.symptom.custom.add")}
</Button>
@@ -121,10 +121,7 @@ describe("pickDefaultMedicationId — auto-select heuristic", () => {
// alphabetically sort and pick the leading id, "ramipril".
const now = new Date("2026-05-12T01:00:00Z"); // 03:00 Berlin
const result = pickDefaultMedicationId(
[
makeMed("z", { name: "Zolpidem" }),
makeMed("r", { name: "Ramipril" }),
],
[makeMed("z", { name: "Zolpidem" }), makeMed("r", { name: "Ramipril" })],
now,
);
expect(result).toBe("r");
@@ -169,10 +166,15 @@ describe("<MedicationIntakeQuickAdd> — SSR contract", () => {
expect(html).toContain(
'data-testid="medication-intake-quick-add-taken-at"',
);
// 44 px touch floor on every interactive field + the footer Save
// button (WCAG 2.5.5). v1.4.37 W10 added the floor to the footer
// buttons; the form fields already carried it.
expect(html.match(/min-h-11/g)?.length ?? 0).toBeGreaterThanOrEqual(3);
// 44 px touch floor (WCAG 2.5.5). The form controls inherit it from
// the Input / SelectTrigger primitives' `h-11 sm:h-10` base — no
// per-call-site `min-h-11` override (which would force 44 px on
// desktop too). The footer buttons keep the explicit
// `min-h-11 sm:min-h-9` floor.
expect(html.match(/h-11/g)?.length ?? 0).toBeGreaterThanOrEqual(3);
expect(
html.match(/min-h-11 sm:min-h-9/g)?.length ?? 0,
).toBeGreaterThanOrEqual(1);
});
it("does not leak raw i18n keys in either locale", () => {
@@ -298,9 +298,9 @@ export function MedicationIntakeQuickAdd({
className="min-h-11 sm:min-h-9"
>
{loading ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin motion-reduce:animate-none" />
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
) : (
<Plus className="mr-2 h-4 w-4" />
<Plus className="h-4 w-4" />
)}
{t("common.save")}
</Button>
@@ -382,7 +382,6 @@ export function MedicationIntakeQuickAdd({
<SelectTrigger
id="medication-intake-medication"
data-testid="medication-intake-quick-add-medication"
className="min-h-11"
>
<SelectValue
placeholder={t(
@@ -427,7 +426,7 @@ export function MedicationIntakeQuickAdd({
t("dashboard.medicationIntakeQuickAdd.dosePlaceholder")
}
autoComplete="off"
className="min-h-11 bg-muted/40"
className="bg-muted/40"
/>
<p className="text-muted-foreground text-xs">
{t("dashboard.medicationIntakeQuickAdd.doseHint")}
@@ -88,9 +88,9 @@ export function SelectionActionBar({
disabled={isDeleting}
>
{isDeleting ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin motion-reduce:animate-none" />
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
) : (
<Trash2 className="mr-2 h-4 w-4" />
<Trash2 className="h-4 w-4" />
)}
{t("dataList.deleteN", { count: String(count) })}
</Button>
@@ -46,6 +46,7 @@ import { Input } from "@/components/ui/input";
import { DateInput } from "@/components/ui/date-input";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import { Skeleton } from "@/components/ui/skeleton";
import {
DEFAULT_DOCTOR_REPORT_PREFS,
type DoctorReportPrefs,
@@ -497,7 +498,7 @@ export function DoctorReportDialog({
</Button>
<Button type="submit" disabled={submitting || !validation.ok}>
{submitting && (
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
)}
{t("doctorReport.dialog.submit")}
</Button>
@@ -579,8 +580,8 @@ export function SectionToggles({
{t("doctorReport.sections.title")}
</p>
<div className="space-y-2">
<div className="bg-muted h-9 animate-pulse motion-reduce:animate-none rounded-md" />
<div className="bg-muted h-9 animate-pulse motion-reduce:animate-none rounded-md" />
<Skeleton className="bg-muted h-9" />
<Skeleton className="bg-muted h-9" />
</div>
</div>
);
+4 -4
View File
@@ -90,22 +90,22 @@ export function ErrorDetails({
<div className="flex flex-wrap items-center gap-2">
{reset && (
<Button onClick={reset} variant="outline">
<RotateCcw className="mr-2 h-4 w-4" />
<RotateCcw className="h-4 w-4" />
{t("common.retry")}
</Button>
)}
<Button onClick={handleCopy} variant="outline">
{copied ? (
<CheckCircle2 className="text-success mr-2 h-4 w-4" />
<CheckCircle2 className="text-success h-4 w-4" />
) : (
<Copy className="mr-2 h-4 w-4" />
<Copy className="h-4 w-4" />
)}
{copied ? t("common.copied") : t("common.copyDetails")}
</Button>
{bugReportEnabled && (
<Button asChild variant="outline">
<Link href={reportHref}>
<Bug className="mr-2 h-4 w-4" />
<Bug className="h-4 w-4" />
{t("common.reportIssue")}
</Link>
</Button>
@@ -23,6 +23,7 @@ import { useTranslations } from "@/lib/i18n/context";
import { formatDate } from "@/lib/format";
import { useAchievementsQuery } from "@/lib/queries/use-achievements-query";
import type { AchievementProgress } from "@/lib/gamification/achievements";
import { Skeleton } from "@/components/ui/skeleton";
const iconMap: Record<string, LucideIcon> = {
Activity,
@@ -123,10 +124,10 @@ export function RecentAchievementsCard() {
key={`achievement-skeleton-${idx}`}
className="border-border bg-background/40 flex items-center gap-3 rounded-md border p-2"
>
<div className="bg-muted/60 h-7 w-7 shrink-0 animate-pulse rounded-md motion-reduce:animate-none" />
<Skeleton className="h-7 w-7 shrink-0" />
<div className="min-w-0 flex-1 space-y-1.5">
<div className="bg-muted/60 h-3 w-1/2 animate-pulse rounded motion-reduce:animate-none" />
<div className="bg-muted/60 h-2.5 w-3/4 animate-pulse rounded motion-reduce:animate-none" />
<Skeleton className="h-3 w-1/2 rounded" />
<Skeleton className="h-2.5 w-3/4 rounded" />
</div>
</li>
))}
@@ -18,10 +18,7 @@ describe("correlation-card scatter skeleton mirrors loaded chart", () => {
"utf8",
);
const chartSrc = readFileSync(
join(
process.cwd(),
"src/components/charts/scatter-correlation-chart.tsx",
),
join(process.cwd(), "src/components/charts/scatter-correlation-chart.tsx"),
"utf8",
);
@@ -35,7 +32,10 @@ describe("correlation-card scatter skeleton mirrors loaded chart", () => {
expect(block).toContain("min-h-[180px]");
expect(block).toContain("sm:aspect-[3/2]");
expect(block).toContain("sm:h-auto");
expect(block).toContain("motion-reduce:animate-none");
// Reduced-motion handling comes from the shared <Skeleton>
// primitive (its base classes carry `motion-reduce:animate-none`),
// so the loader block must render it rather than a raw pulse div.
expect(block).toContain("<Skeleton");
});
it("loaded scatter chart still owns the same aspect-ratio quartet", () => {
+2 -1
View File
@@ -6,6 +6,7 @@ import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { EmptyState } from "@/components/ui/empty-state";
import { Skeleton } from "@/components/ui/skeleton";
import { useTranslations } from "@/lib/i18n/context";
import { cn } from "@/lib/utils";
import type { CorrelationResult } from "@/lib/insights/correlations";
@@ -36,7 +37,7 @@ const ScatterCorrelationChart = dynamic(
// skeleton reserves the same space across breakpoints. The
// legacy `h-[180px]` was ~60 px shorter than the painted chart
// at `sm+` and caused a visible CLS shift on insights mount.
<div className="bg-muted/40 aspect-square min-h-[180px] w-full animate-pulse rounded-md motion-reduce:animate-none sm:aspect-[3/2] sm:h-auto" />
<Skeleton className="bg-muted/40 aspect-square min-h-[180px] w-full sm:aspect-[3/2] sm:h-auto" />
),
},
);
+6 -5
View File
@@ -22,6 +22,7 @@ import {
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { EmptyState } from "@/components/ui/empty-state";
import { Skeleton } from "@/components/ui/skeleton";
import { SectionHeading } from "@/components/insights/section-heading";
import { useTranslations } from "@/lib/i18n/context";
import { formatRelativeTime } from "@/lib/i18n/relative-time";
@@ -250,10 +251,10 @@ function BriefingSkeleton() {
aria-hidden="true"
>
<div className="space-y-2">
<div className="bg-muted/60 h-3 w-11/12 animate-pulse rounded motion-reduce:animate-none" />
<div className="bg-muted/60 h-3 w-10/12 animate-pulse rounded motion-reduce:animate-none" />
<div className="bg-muted/60 h-3 w-9/12 animate-pulse rounded motion-reduce:animate-none" />
<div className="bg-muted/60 h-3 w-8/12 animate-pulse rounded motion-reduce:animate-none" />
<Skeleton className="h-3 w-11/12 rounded" />
<Skeleton className="h-3 w-10/12 rounded" />
<Skeleton className="h-3 w-9/12 rounded" />
<Skeleton className="h-3 w-8/12 rounded" />
</div>
<div className="space-y-2">
{[0, 1, 2].map((i) => (
@@ -261,7 +262,7 @@ function BriefingSkeleton() {
key={i}
className="border-border/40 bg-card/30 flex h-16 items-center rounded-md border p-3"
>
<div className="bg-muted/60 h-3 w-1/3 animate-pulse rounded motion-reduce:animate-none" />
<Skeleton className="h-3 w-1/3 rounded" />
</div>
))}
</div>
@@ -3,6 +3,7 @@
import { useEffect, useRef, useState } from "react";
import { Card, CardContent, CardHeader } from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";
import { TileHeader } from "@/components/insights/tile-header";
import { useTranslations } from "@/lib/i18n/context";
import { formatRelativeTime } from "@/lib/i18n/relative-time";
@@ -91,9 +92,9 @@ export function InsightStatusCard({
<TileHeader icon={nodeIcon(icon)} title={title} />
</CardHeader>
<CardContent className="space-y-2">
<div className="bg-muted h-3.5 w-full animate-pulse rounded motion-reduce:animate-none" />
<div className="bg-muted h-3.5 w-11/12 animate-pulse rounded motion-reduce:animate-none" />
<div className="bg-muted h-3.5 w-9/12 animate-pulse rounded motion-reduce:animate-none" />
<Skeleton className="bg-muted h-3.5 w-full rounded" />
<Skeleton className="bg-muted h-3.5 w-11/12 rounded" />
<Skeleton className="bg-muted h-3.5 w-9/12 rounded" />
<p className="text-muted-foreground text-xs">
{t("insights.assessmentPreparing")}
</p>
@@ -126,15 +127,15 @@ export function InsightStatusCard({
>
<CardHeader className="pb-1">
<div className="flex items-center gap-2">
<div className="bg-muted h-5 w-5 animate-pulse rounded motion-reduce:animate-none" />
<div className="bg-muted h-4 w-32 animate-pulse rounded motion-reduce:animate-none" />
<Skeleton className="bg-muted h-5 w-5 rounded" />
<Skeleton className="bg-muted h-4 w-32 rounded" />
</div>
</CardHeader>
<CardContent className="space-y-2">
<div className="bg-muted h-3.5 w-full animate-pulse rounded motion-reduce:animate-none" />
<div className="bg-muted h-3.5 w-11/12 animate-pulse rounded motion-reduce:animate-none" />
<div className="bg-muted h-3.5 w-9/12 animate-pulse rounded motion-reduce:animate-none" />
<div className="bg-muted/70 h-3 w-1/3 animate-pulse rounded motion-reduce:animate-none" />
<Skeleton className="bg-muted h-3.5 w-full rounded" />
<Skeleton className="bg-muted h-3.5 w-11/12 rounded" />
<Skeleton className="bg-muted h-3.5 w-9/12 rounded" />
<Skeleton className="bg-muted/70 h-3 w-1/3 rounded" />
<span className="sr-only">{t("common.loading")}</span>
</CardContent>
</Card>
+13 -18
View File
@@ -4,13 +4,7 @@ import { useEffect, useMemo, useRef, useState } from "react";
import Link from "next/link";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import {
Eye,
EyeOff,
GripVertical,
Loader2,
RotateCcw,
} from "lucide-react";
import { Eye, EyeOff, GripVertical, Loader2, RotateCcw } from "lucide-react";
import {
DndContext,
KeyboardSensor,
@@ -222,9 +216,9 @@ export function InsightsEditMode({
data-slot="insights-edit-reset"
>
{resetMutation.isPending ? (
<Loader2 className="mr-2 h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
) : (
<RotateCcw className="mr-2 h-3.5 w-3.5" />
<RotateCcw className="h-3.5 w-3.5" />
)}
{t("insights.editMode.reset")}
</Button>
@@ -235,7 +229,7 @@ export function InsightsEditMode({
data-slot="insights-edit-done"
>
{saveMutation.isPending && (
<Loader2 className="mr-2 h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
)}
{t("insights.editMode.done")}
</Button>
@@ -335,11 +329,7 @@ function EyeToggle({
data-visible={visible ? "true" : "false"}
className="text-muted-foreground hover:text-foreground focus-visible:ring-ring focus-visible:ring-offset-background inline-flex h-11 w-11 shrink-0 items-center justify-center rounded transition-colors focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50 motion-reduce:transition-none sm:h-9 sm:w-9"
>
{visible ? (
<Eye className="h-4 w-4" />
) : (
<EyeOff className="h-4 w-4" />
)}
{visible ? <Eye className="h-4 w-4" /> : <EyeOff className="h-4 w-4" />}
</button>
);
}
@@ -361,8 +351,14 @@ function SortableSectionRow({
labels,
onToggle,
}: SortableSectionRowProps) {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } =
useSortable({ id: section.id });
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id: section.id });
const style: React.CSSProperties = {
transform: CSS.Transform.toString(transform),
@@ -427,4 +423,3 @@ function SortableSectionRow({
</div>
);
}
@@ -6,6 +6,7 @@ import { Activity } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { EmptyState } from "@/components/ui/empty-state";
import { Skeleton } from "@/components/ui/skeleton";
import { useTranslations } from "@/lib/i18n/context";
import type { CorrelationResult } from "@/lib/analytics/correlations";
import { MoodExplainerIcon } from "./mood-explainer-icon";
@@ -29,7 +30,7 @@ const ScatterCorrelationChart = dynamic(
{
ssr: false,
loading: () => (
<div className="bg-muted/40 aspect-square min-h-[180px] w-full animate-pulse rounded-md motion-reduce:animate-none sm:aspect-[3/2] sm:h-auto" />
<Skeleton className="bg-muted/40 aspect-square min-h-[180px] w-full sm:aspect-[3/2] sm:h-auto" />
),
},
);
+16 -9
View File
@@ -68,7 +68,14 @@ export interface SleepHypnogramProps {
* Lane order top → bottom. Awake highest, Deep lowest — depth increases
* downward, matching Apple Health / Oura's "cityscape" convention.
*/
const LANE_ORDER = ["AWAKE", "IN_BED", "ASLEEP", "REM", "CORE", "DEEP"] as const;
const LANE_ORDER = [
"AWAKE",
"IN_BED",
"ASLEEP",
"REM",
"CORE",
"DEEP",
] as const;
const LANE_OF: Record<string, number> = Object.fromEntries(
// Reverse-index so AWAKE (first) sits at the highest y value.
LANE_ORDER.map((s, i) => [s, LANE_ORDER.length - 1 - i]),
@@ -126,10 +133,7 @@ export function SleepHypnogram({ session }: SleepHypnogramProps) {
}, [session.segments]);
const domain = useMemo<[number, number]>(
() => [
new Date(session.start).getTime(),
new Date(session.end).getTime(),
],
() => [new Date(session.start).getTime(), new Date(session.end).getTime()],
[session.start, session.end],
);
@@ -148,9 +152,10 @@ export function SleepHypnogram({ session }: SleepHypnogramProps) {
// First whole hour at or after `from`, aligned to the step grid.
const firstHour = new Date(from);
firstHour.setMinutes(0, 0, 0);
if (firstHour.getTime() < from) firstHour.setTime(firstHour.getTime() + 3_600_000);
if (firstHour.getTime() < from)
firstHour.setTime(firstHour.getTime() + 3_600_000);
// Align the first tick to a multiple of the step (relative to midnight).
while ((firstHour.getHours() % stepHours) !== 0) {
while (firstHour.getHours() % stepHours !== 0) {
firstHour.setTime(firstHour.getTime() + 3_600_000);
}
const ticks: number[] = [];
@@ -203,7 +208,7 @@ export function SleepHypnogram({ session }: SleepHypnogramProps) {
<Card data-slot="sleep-hypnogram" className="gap-3 md:gap-3">
<CardHeader className="pb-0">
<div className="flex flex-col gap-0.5">
<CardTitle className="text-sm font-medium">
<CardTitle className="text-base font-semibold">
{t("insights.sleep.hypnogram.title")}
</CardTitle>
<span className="text-muted-foreground text-xs">
@@ -249,7 +254,9 @@ export function SleepHypnogram({ session }: SleepHypnogramProps) {
type="number"
dataKey="lane"
domain={[-0.5, LANE_ORDER.length - 0.5]}
ticks={LANE_ORDER.map((_, i) => LANE_ORDER.length - 1 - i)}
ticks={LANE_ORDER.map(
(_, i) => LANE_ORDER.length - 1 - i,
)}
stroke="var(--muted-foreground)"
fontSize={10}
width={56}
+2 -5
View File
@@ -16,10 +16,7 @@ import {
SleepStageStackedBar,
type SleepStageBreakdown,
} from "./sleep-stage-stacked-bar";
import {
SleepHypnogram,
type SleepHypnogramSession,
} from "./sleep-hypnogram";
import { SleepHypnogram, type SleepHypnogramSession } from "./sleep-hypnogram";
import { SleepDurationChart } from "./sleep-duration-chart";
/**
@@ -138,7 +135,7 @@ export function SleepOverview() {
<CardHeader className="pb-0">
<div className="flex items-center gap-2">
<Moon className="text-dracula-cyan h-4 w-4" />
<CardTitle className="text-sm font-medium">
<CardTitle className="text-base font-semibold">
{t("insights.sleep.headlineTitle")}
</CardTitle>
</div>
@@ -81,13 +81,7 @@ type WindowSize = (typeof WINDOW_DAYS)[number];
* token + the `insights.sleep.stages.inBed` label survive for the
* last-night hypnogram, which still renders the in-bed span.
*/
export const STAGE_ORDER = [
"DEEP",
"REM",
"CORE",
"ASLEEP",
"AWAKE",
] as const;
export const STAGE_ORDER = ["DEEP", "REM", "CORE", "ASLEEP", "AWAKE"] as const;
/**
* Dracula stage palette. Exported so the last-night hypnogram
@@ -218,7 +212,7 @@ export function SleepStageStackedBar({ breakdown }: SleepStageStackedBarProps) {
<CardHeader className="pb-2">
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<div className="flex flex-col gap-0.5">
<CardTitle className="text-sm font-medium">
<CardTitle className="text-base font-semibold">
{t("insights.sleep.compositionTitle")}
</CardTitle>
<span className="text-muted-foreground text-xs">
+1 -1
View File
@@ -290,7 +290,7 @@ export function SubPageShell({
pathname ? `?from=${encodeURIComponent(pathname)}` : ""
}`}
>
<ListOrdered className="mr-1.5 size-4" aria-hidden="true" />
<ListOrdered className="size-4" aria-hidden="true" />
{t("insights.subPage.showAllValues")}
</Link>
</Button>
+7 -7
View File
@@ -4,6 +4,7 @@ import { useMemo } from "react";
import { useQuery } from "@tanstack/react-query";
import { Sparkles } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Skeleton } from "@/components/ui/skeleton";
import { useTranslations } from "@/lib/i18n/context";
import { useAuth } from "@/hooks/use-auth";
import { queryKeys } from "@/lib/query-keys";
@@ -158,9 +159,9 @@ export function TrendAnnotation({
className="border-border/60 bg-card/40 space-y-1.5 rounded-md border p-3 motion-reduce:animate-none"
aria-label={t("insights.trendAnnotation.pendingLabel")}
>
<div className="bg-muted/60 h-2.5 w-11/12 animate-pulse motion-reduce:animate-none rounded" />
<div className="bg-muted/60 h-2.5 w-9/12 animate-pulse motion-reduce:animate-none rounded" />
<div className="bg-muted/60 h-2.5 w-7/12 animate-pulse motion-reduce:animate-none rounded" />
<Skeleton className="h-2.5 w-11/12 rounded" />
<Skeleton className="h-2.5 w-9/12 rounded" />
<Skeleton className="h-2.5 w-7/12 rounded" />
</div>
);
}
@@ -289,9 +290,7 @@ export function TrendDescriptorCaption({
queryKey: queryKeys.insightsTrendSeries(primaryType),
queryFn: async () => {
const to = new Date();
const from = new Date(
to.getTime() - DESCRIPTOR_WINDOW_DAYS * 86_400_000,
);
const from = new Date(to.getTime() - DESCRIPTOR_WINDOW_DAYS * 86_400_000);
const params = new URLSearchParams({
type: primaryType,
sortBy: "measuredAt",
@@ -364,7 +363,8 @@ export function TrendDescriptorCaption({
// copy defensively (should not happen for the legacy triple).
const copy = isMood
? moodDescriptorCopy(descriptor)
: (numericDescriptorCopy(metric, descriptor) ?? moodDescriptorCopy(descriptor));
: (numericDescriptorCopy(metric, descriptor) ??
moodDescriptorCopy(descriptor));
return (
<TrendCaptionCard
+3 -2
View File
@@ -13,6 +13,7 @@ import {
} from "lucide-react";
import { Logo } from "@/components/ui/logo";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Skeleton } from "@/components/ui/skeleton";
import Link from "next/link";
import { useAuth, useLogout } from "@/hooks/use-auth";
import { useTheme } from "@/components/providers";
@@ -75,7 +76,7 @@ export function TopBar() {
{/* Mobile-only auth section (desktop uses sidebar user section) */}
<div className="flex items-center gap-2 md:hidden">
{isLoading ? (
<div className="bg-muted h-4 w-20 animate-pulse rounded motion-reduce:animate-none" />
<Skeleton className="bg-muted h-4 w-20 rounded" />
) : user ? (
<DropdownMenu>
<DropdownMenuTrigger
@@ -86,7 +87,7 @@ export function TopBar() {
// v1.4.34 IW-G — keyboard users get a visible ring on
// focus-visible. The previous `focus:outline-none` killed
// the focus indicator without replacing it.
className="text-muted-foreground hover:text-foreground flex min-h-11 min-w-11 items-center gap-1.5 rounded-md px-2 py-1.5 text-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50 focus-visible:ring-offset-2"
className="text-muted-foreground hover:text-foreground focus-visible:ring-ring/50 flex min-h-11 min-w-11 items-center gap-1.5 rounded-md px-2 py-1.5 text-sm transition-colors focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none"
>
<Avatar className="size-7">
{avatarUrl && (
@@ -121,7 +121,9 @@ export const ADD_TOKEN_ALIASES: Readonly<Record<string, string>> = {
* page-level dispatcher and the F-1 contract test consume the same
* resolver.
*/
export function resolveAddToken(token: string | null | undefined): string | null {
export function resolveAddToken(
token: string | null | undefined,
): string | null {
if (!token) return null;
const aliased = ADD_TOKEN_ALIASES[token] ?? token;
return MEASUREMENT_FORM_TYPE_VALUES.includes(aliased) ? aliased : null;
@@ -337,15 +339,11 @@ export function MeasurementForm({
{t("common.cancel")}
</Button>
)}
<Button
type="submit"
form={formId}
disabled={loading}
>
<Button type="submit" form={formId} disabled={loading}>
{loading ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin motion-reduce:animate-none" />
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
) : (
<Plus className="mr-2 h-4 w-4" />
<Plus className="h-4 w-4" />
)}
{t("common.save")}
</Button>
+184 -191
View File
@@ -263,9 +263,7 @@ export function MeasurementList({
const queryClient = useQueryClient();
// v1.8.5 — when `lockedType` is set the list is pinned to that metric
// and the type selector is hidden; the filter state seeds from it.
const [typeFilter, setTypeFilterRaw] = useState<string>(
lockedType ?? "ALL",
);
const [typeFilter, setTypeFilterRaw] = useState<string>(lockedType ?? "ALL");
// v1.15.13 — management-list source filter + optional date range.
// `ALL` clears the source filter; empty date strings clear the bound.
const [sourceFilter, setSourceFilterRaw] = useState<string>("ALL");
@@ -280,9 +278,7 @@ export function MeasurementList({
// v1.15.x audit — no "select across 200k rows"). The bulk-delete
// payload is intersected with the painted page ids, so it is always
// page-bounded (≤ PAGE_SIZE) and well under the server's 200 cap.
const [selectedIds, setSelectedIds] = useState<Set<string>>(
() => new Set(),
);
const [selectedIds, setSelectedIds] = useState<Set<string>>(() => new Set());
// v1.4.37 W7c — set of dayKeys whose drill-down is currently
// expanded. Each key maps to a one-shot query that lazy-fetches
@@ -312,9 +308,7 @@ export function MeasurementList({
const editErrorDescriptor = editError ? editErrorId : undefined;
// v1.4.27 R4 RC2 — Sheet-branch sticky-pinned footer slot.
const editFormId = useId();
const [editFooterEl, setEditFooterEl] = useState<HTMLDivElement | null>(
null,
);
const [editFooterEl, setEditFooterEl] = useState<HTMLDivElement | null>(null);
// v1.15.13 — every filter / page / sort change resets pagination to
// page 1 AND clears the page-scoped selection (the rows it referred to
@@ -474,7 +468,9 @@ export function MeasurementList({
onSuccess: async (deleted) => {
await invalidateKeys(queryClient, measurementDependentKeys);
clearSelection();
toast.success(t("measurements.bulkDeleteSuccess", { count: String(deleted) }));
toast.success(
t("measurements.bulkDeleteSuccess", { count: String(deleted) }),
);
},
onError: () => {
toast.error(t("measurements.bulkDeleteError"));
@@ -577,9 +573,7 @@ export function MeasurementList({
// path collapses to a single page. Per-sample lists continue to
// paginate at PAGE_SIZE = 25.
const totalPages =
data && !isDayGroupedFilter
? Math.ceil(data.meta.total / PAGE_SIZE)
: 0;
data && !isDayGroupedFilter ? Math.ceil(data.meta.total / PAGE_SIZE) : 0;
function startEdit(measurement: Measurement) {
if (onEdit) {
@@ -787,15 +781,12 @@ export function MeasurementList({
// v1.8.5 — a locked-type list has no "reset filter" path;
// the metric is fixed by the route.
typeFilter !== "ALL" && !lockedType ? (
<Button
variant="outline"
onClick={() => setTypeFilter("ALL")}
>
<Button variant="outline" onClick={() => setTypeFilter("ALL")}>
{t("measurements.emptyResetFilter")}
</Button>
) : onAddFirst ? (
<Button onClick={onAddFirst}>
<Plus className="mr-1 h-4 w-4" />
<Plus className="h-4 w-4" />
{t("measurements.emptyAddFirst")}
</Button>
) : undefined
@@ -876,7 +867,9 @@ export function MeasurementList({
const isSelected = selectedIds.has(m.id);
return (
<Fragment key={m.id}>
<TableRow data-state={isSelected ? "selected" : undefined}>
<TableRow
data-state={isSelected ? "selected" : undefined}
>
<TableCell className="pl-4">
{/* Grouped/synthetic rows aren't individually
deletable, so they have no checkbox. */}
@@ -1049,7 +1042,7 @@ export function MeasurementList({
<div
key={m.id}
data-state={isSelected ? "selected" : undefined}
className="bg-card border-border rounded-lg border p-3 data-[state=selected]:border-dracula-purple/60 data-[state=selected]:bg-dracula-purple/5"
className="bg-card border-border data-[state=selected]:border-dracula-purple/60 data-[state=selected]:bg-dracula-purple/5 rounded-lg border p-3"
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2 overflow-hidden">
@@ -1274,115 +1267,110 @@ export function MeasurementList({
title={t("measurements.editMeasurement")}
footer={<div ref={setEditFooterEl} className="flex w-full" />}
>
{editing && (
<form
id={editFormId}
onSubmit={submitEdit}
className="space-y-4"
>
<div className="flex items-center gap-1.5">
<Label className="shrink-0">{t("measurements.type")}</Label>
<span className="text-sm leading-none font-medium">
{TYPE_LABEL_KEYS[editing.type]
? t(TYPE_LABEL_KEYS[editing.type])
: editing.type}
{editing && (
<form id={editFormId} onSubmit={submitEdit} className="space-y-4">
<div className="flex items-center gap-1.5">
<Label className="shrink-0">{t("measurements.type")}</Label>
<span className="text-sm leading-none font-medium">
{TYPE_LABEL_KEYS[editing.type]
? t(TYPE_LABEL_KEYS[editing.type])
: editing.type}
</span>
</div>
<div className="space-y-2">
<Label htmlFor="edit-value">
{t("measurements.valueWithUnit", { unit: editing.unit })}
</Label>
<Input
id="edit-value"
type="number"
enterKeyHint="next"
step="any"
value={editValue}
onChange={(e) => setEditValue(e.target.value)}
required
aria-required="true"
aria-invalid={!!editError || undefined}
aria-describedby={editErrorDescriptor}
/>
</div>
<div className="space-y-2">
<Label htmlFor="edit-measuredAt">
{t("measurements.timestamp")}
</Label>
<DateTimeInput
id="edit-measuredAt"
value={editMeasuredAt}
onChange={(e) => setEditMeasuredAt(e.target.value)}
required
aria-required="true"
aria-invalid={!!editError || undefined}
aria-describedby={editErrorDescriptor}
/>
</div>
<div className="space-y-2">
<div className="flex items-center justify-between gap-3">
<Label htmlFor="edit-notes">
{t("measurements.notes")} ({t("common.optional")})
</Label>
<span className="text-muted-foreground text-xs">
{editNotes.length}/{MAX_COMMENT_LENGTH}
</span>
</div>
<Input
id="edit-notes"
value={editNotes}
onChange={(e) => setEditNotes(e.target.value)}
maxLength={MAX_COMMENT_LENGTH}
enterKeyHint="done"
autoCapitalize="sentences"
/>
</div>
<div className="space-y-2">
<Label htmlFor="edit-value">
{t("measurements.valueWithUnit", { unit: editing.unit })}
</Label>
<Input
id="edit-value"
type="number"
enterKeyHint="next"
step="any"
value={editValue}
onChange={(e) => setEditValue(e.target.value)}
required
aria-required="true"
aria-invalid={!!editError || undefined}
aria-describedby={editErrorDescriptor}
/>
{editError && (
<div
id={editErrorId}
role="alert"
aria-live="assertive"
className="bg-destructive/10 text-destructive rounded-lg p-3 text-sm"
>
{editError}
</div>
)}
<div className="space-y-2">
<Label htmlFor="edit-measuredAt">
{t("measurements.timestamp")}
</Label>
<DateTimeInput
id="edit-measuredAt"
value={editMeasuredAt}
onChange={(e) => setEditMeasuredAt(e.target.value)}
required
aria-required="true"
aria-invalid={!!editError || undefined}
aria-describedby={editErrorDescriptor}
/>
</div>
{editFooterEl
? createPortal(
<div className="flex w-full items-center justify-between gap-2">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
type="button"
variant="outline"
size="icon"
className="size-11"
disabled={
updateMutation.isPending || deleteMutation.isPending
}
aria-label={t("common.moreOptions")}
>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
<DropdownMenuItem
variant="destructive"
onClick={() => setEditDeleteDialogOpen(true)}
>
<Trash2 className="mr-2 h-4 w-4" />
{t("common.delete")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<div className="space-y-2">
<div className="flex items-center justify-between gap-3">
<Label htmlFor="edit-notes">
{t("measurements.notes")} ({t("common.optional")})
</Label>
<span className="text-muted-foreground text-xs">
{editNotes.length}/{MAX_COMMENT_LENGTH}
</span>
</div>
<Input
id="edit-notes"
value={editNotes}
onChange={(e) => setEditNotes(e.target.value)}
maxLength={MAX_COMMENT_LENGTH}
enterKeyHint="done"
autoCapitalize="sentences"
/>
</div>
{editError && (
<div
id={editErrorId}
role="alert"
aria-live="assertive"
className="bg-destructive/10 text-destructive rounded-lg p-3 text-sm"
>
{editError}
</div>
)}
{editFooterEl
? createPortal(
<div className="flex w-full items-center justify-between gap-2">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
type="button"
variant="outline"
size="icon"
className="size-11"
disabled={
updateMutation.isPending ||
deleteMutation.isPending
}
aria-label={t("common.moreOptions")}
>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
<DropdownMenuItem
variant="destructive"
onClick={() => setEditDeleteDialogOpen(true)}
>
<Trash2 className="mr-2 h-4 w-4" />
{t("common.delete")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
{/*
{/*
v1.4.43 QoL (L7) — `[Cancel] [Save]` order is
iOS-first intentional per Apple HIG, which
puts the primary / confirmation action
@@ -1394,69 +1382,67 @@ export function MeasurementList({
refactor — fork a platform-specific shell
instead if Android becomes a real audience.
*/}
<div className="flex items-center gap-2">
<Button
type="button"
variant="outline"
onClick={closeEdit}
disabled={
updateMutation.isPending ||
deleteMutation.isPending
}
>
{t("common.cancel")}
</Button>
<Button
type="submit"
form={editFormId}
disabled={
updateMutation.isPending ||
deleteMutation.isPending
}
>
{updateMutation.isPending ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin motion-reduce:animate-none" />
) : null}
{t("common.save")}
</Button>
</div>
</div>,
editFooterEl,
)
: null}
<div className="flex items-center gap-2">
<Button
type="button"
variant="outline"
onClick={closeEdit}
disabled={
updateMutation.isPending || deleteMutation.isPending
}
>
{t("common.cancel")}
</Button>
<Button
type="submit"
form={editFormId}
disabled={
updateMutation.isPending || deleteMutation.isPending
}
>
{updateMutation.isPending ? (
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
) : null}
{t("common.save")}
</Button>
</div>
</div>,
editFooterEl,
)
: null}
<AlertDialog
open={editDeleteDialogOpen}
onOpenChange={setEditDeleteDialogOpen}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{t("measurements.deleteConfirmTitle")}
</AlertDialogTitle>
<AlertDialogDescription>
{t("measurements.deleteConfirmDescription")}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={deleteMutation.isPending}>
{t("common.cancel")}
</AlertDialogCancel>
<AlertDialogAction
variant="destructive"
onClick={deleteEditingMeasurement}
disabled={deleteMutation.isPending}
>
{deleteMutation.isPending ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin motion-reduce:animate-none" />
) : null}
{t("common.delete")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</form>
)}
<AlertDialog
open={editDeleteDialogOpen}
onOpenChange={setEditDeleteDialogOpen}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{t("measurements.deleteConfirmTitle")}
</AlertDialogTitle>
<AlertDialogDescription>
{t("measurements.deleteConfirmDescription")}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={deleteMutation.isPending}>
{t("common.cancel")}
</AlertDialogCancel>
<AlertDialogAction
variant="destructive"
onClick={deleteEditingMeasurement}
disabled={deleteMutation.isPending}
>
{deleteMutation.isPending ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin motion-reduce:animate-none" />
) : null}
{t("common.delete")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</form>
)}
</ResponsiveSheet>
</>
);
@@ -1511,7 +1497,7 @@ function DayDrillDown({
}
if (error) {
return (
<div className="text-destructive py-2 text-xs">
<div className="text-destructive py-2 text-sm">
{t("measurements.loadError")}
</div>
);
@@ -1536,10 +1522,14 @@ function DayDrillDown({
className="flex items-center justify-between gap-3 px-2 py-1 text-xs"
>
<span className="text-muted-foreground tabular-nums">
{isSleep ? sleepStageLabel(s.sleepStage, t) : formatDateTime(s.measuredAt)}
{isSleep
? sleepStageLabel(s.sleepStage, t)
: formatDateTime(s.measuredAt)}
</span>
<span className="font-medium tabular-nums">
{isSleep ? formatSleepMinutes(s.value, locale) : `${s.value} ${unit}`}
{isSleep
? formatSleepMinutes(s.value, locale)
: `${s.value} ${unit}`}
</span>
</div>
))}
@@ -1554,10 +1544,14 @@ function DayDrillDown({
className="flex items-center justify-between gap-2 px-1 py-1 text-xs"
>
<span className="text-muted-foreground tabular-nums">
{isSleep ? sleepStageLabel(s.sleepStage, t) : formatDateTime(s.measuredAt)}
{isSleep
? sleepStageLabel(s.sleepStage, t)
: formatDateTime(s.measuredAt)}
</span>
<span className="font-medium tabular-nums">
{isSleep ? formatSleepMinutes(s.value, locale) : `${s.value} ${unit}`}
{isSleep
? formatSleepMinutes(s.value, locale)
: `${s.value} ${unit}`}
</span>
</div>
))}
@@ -1590,4 +1584,3 @@ function SleepNightCaption({ m }: { m: Measurement }) {
</span>
);
}
@@ -85,9 +85,7 @@ export function ResearchModeAcknowledgmentDialog({
const json = await res.json().catch(() => ({}));
if (!res.ok) {
const code =
typeof json?.error === "string"
? json.error
: `http-${res.status}`;
typeof json?.error === "string" ? json.error : `http-${res.status}`;
throw new Error(code);
}
return json?.data ?? null;
@@ -137,7 +135,7 @@ export function ResearchModeAcknowledgmentDialog({
description={t("medications.researchMode.dialog.intro")}
className="sm:max-w-xl"
footer={
<div className="flex w-full gap-2 sm:justify-end sm:gap-2">
<>
<Button
variant="outline"
onClick={handleCancel}
@@ -152,118 +150,118 @@ export function ResearchModeAcknowledgmentDialog({
data-slot="research-mode-acknowledge"
>
{mutation.isPending ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin motion-reduce:animate-none" />
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
) : null}
{t("medications.researchMode.dialog.acknowledgeCta")}
</Button>
</div>
</>
}
>
<div
data-slot="research-mode-acknowledgment-dialog"
className="space-y-4 text-sm"
<div
data-slot="research-mode-acknowledgment-dialog"
className="space-y-4 text-sm"
>
<section
aria-labelledby="research-mode-what-it-is"
data-slot="research-mode-what-it-is"
>
<section
aria-labelledby="research-mode-what-it-is"
data-slot="research-mode-what-it-is"
<h3
id="research-mode-what-it-is"
className="text-foreground font-medium"
>
<h3
id="research-mode-what-it-is"
className="text-foreground font-medium"
>
{t("medications.researchMode.dialog.whatItIsHeader")}
</h3>
<p className="text-muted-foreground mt-1">
{t("medications.researchMode.dialog.whatItIs")}
</p>
</section>
{t("medications.researchMode.dialog.whatItIsHeader")}
</h3>
<p className="text-muted-foreground mt-1">
{t("medications.researchMode.dialog.whatItIs")}
</p>
</section>
<section
aria-labelledby="research-mode-what-it-isnt"
data-slot="research-mode-what-it-isnt"
<section
aria-labelledby="research-mode-what-it-isnt"
data-slot="research-mode-what-it-isnt"
>
<h3
id="research-mode-what-it-isnt"
className="text-foreground font-medium"
>
<h3
id="research-mode-what-it-isnt"
className="text-foreground font-medium"
>
{t("medications.researchMode.dialog.whatItIsntHeader")}
</h3>
<p className="text-muted-foreground mt-1">
{t("medications.researchMode.dialog.whatItIsnt")}
</p>
</section>
{t("medications.researchMode.dialog.whatItIsntHeader")}
</h3>
<p className="text-muted-foreground mt-1">
{t("medications.researchMode.dialog.whatItIsnt")}
</p>
</section>
<section
aria-labelledby="research-mode-why-estimate"
data-slot="research-mode-why-estimate"
<section
aria-labelledby="research-mode-why-estimate"
data-slot="research-mode-why-estimate"
>
<h3
id="research-mode-why-estimate"
className="text-foreground font-medium"
>
<h3
id="research-mode-why-estimate"
className="text-foreground font-medium"
>
{t("medications.researchMode.dialog.whyEstimateHeader")}
</h3>
<p className="text-muted-foreground mt-1">
{t("medications.researchMode.dialog.whyEstimate")}
</p>
</section>
{t("medications.researchMode.dialog.whyEstimateHeader")}
</h3>
<p className="text-muted-foreground mt-1">
{t("medications.researchMode.dialog.whyEstimate")}
</p>
</section>
<section
aria-labelledby="research-mode-mdr"
data-slot="research-mode-mdr-boundary"
className="bg-warning/10 border-warning/30 rounded-md border-l-4 px-3 py-2"
<section
aria-labelledby="research-mode-mdr"
data-slot="research-mode-mdr-boundary"
className="bg-warning/10 border-warning/30 rounded-md border-l-4 px-3 py-2"
>
<h3
id="research-mode-mdr"
className="text-foreground flex items-center gap-2 font-medium"
>
<h3
id="research-mode-mdr"
className="text-foreground flex items-center gap-2 font-medium"
>
<AlertTriangle
className="text-warning h-4 w-4 shrink-0"
aria-hidden="true"
/>
{t("medications.researchMode.dialog.mdrBoundaryHeader")}
</h3>
<p className="text-foreground/85 mt-1">
{t("medications.researchMode.dialog.mdrBoundary")}
</p>
</section>
<AlertTriangle
className="text-warning h-4 w-4 shrink-0"
aria-hidden="true"
/>
{t("medications.researchMode.dialog.mdrBoundaryHeader")}
</h3>
<p className="text-foreground/85 mt-1">
{t("medications.researchMode.dialog.mdrBoundary")}
</p>
</section>
<section
aria-labelledby="research-mode-citations"
data-slot="research-mode-citations"
<section
aria-labelledby="research-mode-citations"
data-slot="research-mode-citations"
>
<h3
id="research-mode-citations"
className="text-foreground font-medium"
>
<h3
id="research-mode-citations"
className="text-foreground font-medium"
>
{t("medications.researchMode.dialog.citationsHeader")}
</h3>
<p className="text-muted-foreground mt-1">
{t("medications.researchMode.dialog.citations")}
</p>
</section>
{t("medications.researchMode.dialog.citationsHeader")}
</h3>
<p className="text-muted-foreground mt-1">
{t("medications.researchMode.dialog.citations")}
</p>
</section>
{currentDisclaimerVersion && (
<p
className="text-muted-foreground text-xs italic"
data-slot="research-mode-version"
>
{t("medications.researchMode.dialog.versionLine", {
version: currentDisclaimerVersion,
})}
</p>
)}
{currentDisclaimerVersion && (
<p
className="text-muted-foreground text-xs italic"
data-slot="research-mode-version"
>
{t("medications.researchMode.dialog.versionLine", {
version: currentDisclaimerVersion,
})}
</p>
)}
{errorMessage && (
<p
role="alert"
className="text-destructive text-sm"
data-slot="research-mode-error"
>
{errorMessage}
</p>
)}
</div>
{errorMessage && (
<p
role="alert"
className="text-destructive text-sm"
data-slot="research-mode-error"
>
{errorMessage}
</p>
)}
</div>
</ResponsiveSheet>
);
}
@@ -251,7 +251,7 @@ export function SideEffectsSection({ medicationId }: SideEffectsSectionProps) {
setOpen(true);
}}
>
<Plus className="mr-1.5 h-3.5 w-3.5" />
<Plus className="h-3.5 w-3.5" />
{t("medications.sideEffects.addCta")}
</Button>
</>
@@ -512,7 +512,7 @@ export function SideEffectsSection({ medicationId }: SideEffectsSectionProps) {
disabled={createMutation.isPending}
>
{createMutation.isPending && (
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
)}
{t("medications.sideEffects.submitCta")}
</Button>
@@ -36,9 +36,9 @@ export function MedicationIntakeActions({
disabled={!!intakeLoading}
>
{intakeLoading === "take" ? (
<Loader2 className="mr-1 h-4 w-4 animate-spin motion-reduce:animate-none" />
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
) : (
<Check className="mr-1 h-4 w-4" />
<Check className="h-4 w-4" />
)}
{t("medications.taken")}
</Button>
@@ -49,9 +49,9 @@ export function MedicationIntakeActions({
disabled={!!intakeLoading}
>
{intakeLoading === "skip" ? (
<Loader2 className="mr-1 h-4 w-4 animate-spin motion-reduce:animate-none" />
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
) : (
<SkipForward className="mr-1 h-4 w-4" />
<SkipForward className="h-4 w-4" />
)}
{t("medications.skipped")}
</Button>
@@ -71,9 +71,7 @@ import { SideEffectsSection } from "@/components/medications/SideEffectsSection"
import { ChartSkeleton } from "@/components/charts/chart-skeleton";
import { estimateRunwayDays } from "@/components/medications/detail/supply-runway";
import { MedicationWizardDialog } from "@/components/medications/wizard/MedicationWizardDialog";
import {
parseScheduleRecurrence,
} from "@/lib/medication-schedule";
import { parseScheduleRecurrence } from "@/lib/medication-schedule";
import { useQuery } from "@tanstack/react-query";
import { queryKeys } from "@/lib/query-keys";
import { useTranslations, useFormatters } from "@/lib/i18n/context";
@@ -234,8 +232,7 @@ export function MedicationDetailTabs({
// medications, so a stale `?tab=injektion` deep-link falls back to the
// landing tab instead of rendering an empty surface.
const availableTabs = useMemo<TabSlug[]>(
() =>
TAB_SLUGS.filter((slug) => slug !== "injektion" || isInjectable),
() => TAB_SLUGS.filter((slug) => slug !== "injektion" || isInjectable),
[isInjectable],
);
@@ -322,9 +319,7 @@ export function MedicationDetailTabs({
staleTime: 30_000,
});
const inventoryItems = Array.isArray(inventory?.items)
? inventory.items
: [];
const inventoryItems = Array.isArray(inventory?.items) ? inventory.items : [];
const liveItems = inventoryItems.filter((i) => i.state !== "USED_UP");
const dosesRemaining = liveItems.reduce(
(sum, i) => sum + i.dosesRemaining,
@@ -337,7 +332,7 @@ export function MedicationDetailTabs({
<div className="space-y-6" data-slot="medication-detail-page">
<Button variant="ghost" size="sm" className="-ml-2 w-fit" asChild>
<Link href="/medications">
<ArrowLeft aria-hidden="true" className="mr-1 size-4" />
<ArrowLeft aria-hidden="true" className="size-4" />
{t("medications.back")}
</Link>
</Button>
@@ -587,9 +582,7 @@ export function MedicationDetailTabs({
<DoseStrengthCurve medicationId={id} />
</div>
)}
{!isGlp1 && (
<DoseStrengthCurve medicationId={id} />
)}
{!isGlp1 && <DoseStrengthCurve medicationId={id} />}
</TabsContent>
)}
@@ -35,10 +35,7 @@ import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
import { Switch } from "@/components/ui/switch";
import { useTranslations } from "@/lib/i18n/context";
import {
invalidateKeys,
medicationDependentKeys,
} from "@/lib/query-keys";
import { invalidateKeys, medicationDependentKeys } from "@/lib/query-keys";
import type { QueryKey } from "@tanstack/react-query";
import type { LedgerSchedule } from "@/components/medications/dose-history-ledger";
@@ -187,7 +184,10 @@ export function LedgerAddDialog({
{ name: medicationName },
),
);
await invalidateKeys(queryClient, [...medicationDependentKeys, ledgerKey]);
await invalidateKeys(queryClient, [
...medicationDependentKeys,
ledgerKey,
]);
setNudgeOpen(false);
onOpenChange(false);
} catch {
@@ -220,7 +220,7 @@ export function LedgerAddDialog({
</DialogHeader>
<div className="space-y-3">
<div className="space-y-1">
<div className="space-y-2">
<Label htmlFor="ledger-add-taken-at">
{t("medications.detail.verlauf.addDialog.takenAtLabel")}
</Label>
@@ -277,7 +277,7 @@ export function LedgerAddDialog({
data-slot="ledger-add-submit"
>
{busy && (
<Loader2 className="mr-2 h-4 w-4 animate-spin motion-reduce:animate-none" />
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
)}
{t("medications.detail.verlauf.addDialog.submit")}
</Button>
@@ -317,7 +317,7 @@ export function LedgerAddDialog({
data-slot="ledger-add-nudge-pin"
>
{busy && (
<Loader2 className="mr-2 h-4 w-4 animate-spin motion-reduce:animate-none" />
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
)}
{t("medications.detail.verlauf.nudge.pin")}
</Button>
@@ -163,7 +163,7 @@ function IntakeEditDialogBody({
</DialogTitle>
</DialogHeader>
<div className="space-y-3">
<div className="space-y-1">
<div className="space-y-2">
<Label htmlFor="intake-edit-taken-at">
{t("medications.detail.intake.edit.takenAtLabel")}
</Label>
@@ -220,7 +220,7 @@ function IntakeEditDialogBody({
aria-busy={busy || undefined}
>
{busy && (
<Loader2 className="mr-2 h-4 w-4 animate-spin motion-reduce:animate-none" />
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
)}
{t("medications.detail.intake.edit.save")}
</Button>
@@ -156,7 +156,7 @@ export function IntakeImportDialog({
<p className="text-muted-foreground text-sm">
{t("medications.importDescription")}
</p>
<div className="space-y-1.5">
<div className="space-y-2">
<Label htmlFor="intake-import-file" className="text-xs font-medium">
{t("medications.importUploadFile")}
</Label>
@@ -236,9 +236,9 @@ export function IntakeImportDialog({
className="min-h-11 sm:min-h-9"
>
{importing ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin motion-reduce:animate-none" />
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
) : (
<Upload className="mr-2 h-4 w-4" />
<Upload className="h-4 w-4" />
)}
{t("common.import")}
</Button>
@@ -174,7 +174,7 @@ export function LogInjectionSiteDialog({
}}
>
{submitting ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin motion-reduce:animate-none" />
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
) : null}
{t("medications.logInjectionSiteConfirm")}
</Button>
@@ -189,7 +189,7 @@ export function LogIntakeDialog({
</p>
) : (
<div className="space-y-3">
<div className="space-y-1">
<div className="space-y-2">
<Label htmlFor="log-intake-medication">
{t("medications.logIntake.medicationLabel")}
</Label>
@@ -207,7 +207,7 @@ export function LogIntakeDialog({
</div>
{slotTimes.length > 0 && (
<div className="space-y-1">
<div className="space-y-2">
<Label htmlFor="log-intake-slot">
{t("medications.logIntake.slotLabel")}
</Label>
@@ -228,7 +228,7 @@ export function LogIntakeDialog({
</div>
)}
<div className="space-y-1">
<div className="space-y-2">
<Label htmlFor="log-intake-taken-at">
{t("medications.logIntake.takenAtLabel")}
</Label>
@@ -275,7 +275,7 @@ export function LogIntakeDialog({
aria-busy={busy || undefined}
>
{busy && (
<Loader2 className="mr-2 h-4 w-4 animate-spin motion-reduce:animate-none" />
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
)}
{t("medications.logIntake.submit")}
</Button>
@@ -131,7 +131,7 @@ export function CourseWindowRow({
<div className="space-y-3" data-slot="course-window-row">
<div className="flex flex-col gap-3 sm:flex-row sm:items-end">
{/* startsOn */}
<div className="flex-1 space-y-1.5">
<div className="flex-1 space-y-2">
<Label htmlFor={startId} className="text-sm">
{t(`${i18nPrefix}.startsOn.label`)}
</Label>
@@ -148,7 +148,7 @@ export function CourseWindowRow({
</div>
{/* endsOn */}
<div className="flex-1 space-y-1.5">
<div className="flex-1 space-y-2">
<Label htmlFor={endId} className="text-sm">
{t(`${i18nPrefix}.endsOn.label`)}
</Label>
@@ -156,9 +156,7 @@ export function CourseWindowRow({
id={endId}
type="date"
value={endsIso}
disabled={
disabled || noEndDate || lockEndsToStart
}
disabled={disabled || noEndDate || lockEndsToStart}
readOnly={lockEndsToStart}
onChange={(e) => onEndsChange(e.target.value)}
className="h-11 w-full"
@@ -199,7 +197,7 @@ export function CourseWindowRow({
{!valid && (
<p
className="text-destructive text-xs"
className="text-destructive text-sm"
role="alert"
data-slot="course-window-error"
>
@@ -68,10 +68,7 @@ export function DoseWindowEditor({
const generatedId = useId();
const prefix = idPrefix ?? generatedId;
const rows = useMemo(
() => buildRows(timesOfDay, value),
[timesOfDay, value],
);
const rows = useMemo(() => buildRows(timesOfDay, value), [timesOfDay, value]);
// Rebuild the persisted set from the next row state and bubble it up.
const emit = useCallback(
@@ -84,9 +81,7 @@ export function DoseWindowEditor({
const setRow = useCallback(
(timeOfDay: string, patch: Partial<DoseWindowRow>) => {
emit(
rows.map((r) =>
r.timeOfDay === timeOfDay ? { ...r, ...patch } : r,
),
rows.map((r) => (r.timeOfDay === timeOfDay ? { ...r, ...patch } : r)),
);
},
[emit, rows],
@@ -201,7 +196,7 @@ export function DoseWindowEditor({
isValidHhmm(row.end) &&
!ordered ? (
<p
className="text-destructive text-xs"
className="text-destructive text-sm"
role="alert"
data-slot="dose-window-error"
>
@@ -273,7 +273,7 @@ export function TimesOfDayChips({
aria-label={t(`${i18nPrefix}.add`)}
data-slot="times-of-day-add"
>
<Plus className="mr-1 h-4 w-4" aria-hidden="true" />
<Plus className="h-4 w-4" aria-hidden="true" />
{t(`${i18nPrefix}.add`)}
</Button>
</div>
@@ -156,7 +156,7 @@ export function DataPortabilityRow({
{t("medications.detail.advanced.dataPortability.export.button")}
</Button>
{error && (
<p role="alert" className="text-destructive text-xs">
<p role="alert" className="text-destructive text-sm">
{error}
</p>
)}
@@ -311,7 +311,7 @@ function PhaseConfigForm({
{saving && (
<Loader2
aria-hidden="true"
className="mr-2 h-4 w-4 animate-spin motion-reduce:animate-none"
className="h-4 w-4 animate-spin motion-reduce:animate-none"
/>
)}
{t("common.save")}
@@ -27,13 +27,7 @@
* dialog reaches a new locale.
*/
import {
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useQueryClient } from "@tanstack/react-query";
import { useRouter } from "next/navigation";
import { toast } from "sonner";
@@ -189,15 +183,17 @@ function WizardDialogShell({
// payload validates every gate, so the ceiling is the whole path.
const reachableUntil = useMemo(
() =>
Math.max(fallbackIndex, firstInvalidIndex(payload, stepList, fallbackIndex)),
Math.max(
fallbackIndex,
firstInvalidIndex(payload, stepList, fallbackIndex),
),
[payload, stepList, fallbackIndex],
);
// Jump-to-last is enabled only when the WHOLE path validates — i.e.
// every step from the first slot onward passes its gate, so a single
// tap can land the user on the review step without stranding them
// behind an unmet requirement.
const lastReachable =
firstInvalidIndex(payload, stepList, 0) >= totalSteps;
const lastReachable = firstInvalidIndex(payload, stepList, 0) >= totalSteps;
const canContinue = useMemo(
() => validateStep(payload, step),
@@ -345,16 +341,7 @@ function WizardDialogShell({
} finally {
setSubmitting(false);
}
}, [
initial,
mode,
onOpenChange,
onSuccess,
payload,
queryClient,
router,
t,
]);
}, [initial, mode, onOpenChange, onSuccess, payload, queryClient, router, t]);
const Icon = STEP_ICONS[step];
@@ -445,7 +432,7 @@ function WizardDialogShell({
className="h-11"
>
{submitting && (
<Loader2 className="mr-2 h-4 w-4 animate-spin motion-reduce:animate-none" />
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
)}
{primaryCtaLabel}
</Button>
@@ -495,10 +482,7 @@ function WizardDialogShell({
data-slot="wizard-nl-button"
aria-label={t("medications.wizard.nl.button")}
>
<Sparkles
className="h-3.5 w-3.5"
aria-hidden="true"
/>
<Sparkles className="h-3.5 w-3.5" aria-hidden="true" />
{t("medications.wizard.nl.button")}
</Button>
</div>
@@ -515,7 +499,7 @@ function WizardDialogShell({
snaps the step. The slide proposed in D-2 was stripped. */}
<div
ref={bodyRef}
className="flex flex-col gap-6 p-4 pr-12 sm:gap-8 sm:p-6 sm:pr-14 animate-in fade-in-0 duration-200 motion-reduce:animate-none"
className="animate-in fade-in-0 flex flex-col gap-6 p-4 pr-12 duration-200 motion-reduce:animate-none sm:gap-8 sm:p-6 sm:pr-14"
data-slot="wizard-step-body"
key={step}
>
@@ -545,13 +529,11 @@ function WizardDialogShell({
<h2
ref={stepTitleRef}
tabIndex={-1}
className="text-foreground text-base font-semibold leading-tight tracking-tight focus-visible:outline-none"
className="text-foreground text-base leading-tight font-semibold tracking-tight focus-visible:outline-none"
>
{stepTitle}
</h2>
<p className="text-muted-foreground text-sm">
{stepSubline}
</p>
<p className="text-muted-foreground text-sm">{stepSubline}</p>
{/* v1.8.6 W4b — muted "what's next" hint so the user
knows what the Next tap leads to (or, on the final
slot, that Next saves). */}
@@ -582,10 +564,7 @@ function WizardDialogShell({
<Step4Window payload={payload} applyPartial={applyPartial} />
)}
{step === 5 && (
<Step5Cadence
payload={payload}
applyPartial={applyPartial}
/>
<Step5Cadence payload={payload} applyPartial={applyPartial} />
)}
{step === 6 && (
<Step6SubCadence
@@ -594,10 +573,7 @@ function WizardDialogShell({
/>
)}
{step === 7 && (
<Step7Times
payload={payload}
applyPartial={applyPartial}
/>
<Step7Times payload={payload} applyPartial={applyPartial} />
)}
{step === 8 && (
<Step8Summary
@@ -14,7 +14,7 @@ export interface StepProps {
export function Step1Name({ payload, applyPartial }: StepProps) {
const { t } = useTranslations();
return (
<div className="space-y-1.5" data-slot="wizard-step1">
<div className="space-y-2" data-slot="wizard-step1">
<Label htmlFor="wizard-name" className="sr-only">
{t("medications.wizard.steps.step1.label")}
</Label>
@@ -65,16 +65,14 @@ export function Step3Dose({ payload, applyPartial }: StepProps) {
if (checked) current.add(site);
else current.delete(site);
applyPartial({
allowedInjectionSites: INJECTION_SITE_KEYS.filter((s) =>
current.has(s),
),
allowedInjectionSites: INJECTION_SITE_KEYS.filter((s) => current.has(s)),
});
}
return (
<div className="space-y-6" data-slot="wizard-step3">
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<div className="space-y-2">
<Label htmlFor="wizard-dose-amount" className="text-sm">
{t("medications.wizard.steps.step3.amountLabel")}
</Label>
@@ -90,7 +88,7 @@ export function Step3Dose({ payload, applyPartial }: StepProps) {
enterKeyHint="next"
/>
</div>
<div className="space-y-1.5">
<div className="space-y-2">
<Label htmlFor="wizard-dose-unit" className="text-sm">
{t("medications.wizard.steps.step3.unitLabel")}
</Label>
@@ -116,7 +114,7 @@ export function Step3Dose({ payload, applyPartial }: StepProps) {
the Step 2 treatment row so an INJECTION delivery surfaces the
rotation map for any class, not only GLP-1. */}
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<div className="space-y-2">
<Label htmlFor="wizard-delivery-form" className="text-sm">
{t("medications.wizard.steps.step3.deliveryFormLabel")}
</Label>
@@ -138,7 +136,7 @@ export function Step3Dose({ payload, applyPartial }: StepProps) {
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<div className="space-y-2">
<Label htmlFor="wizard-doses-per-unit" className="text-sm">
{t("medications.wizard.steps.step3.dosesPerUnitLabel")}
</Label>
+135 -137
View File
@@ -248,15 +248,11 @@ export function MoodForm({ onSuccess, onCancel, footerSlot }: MoodFormProps) {
{t("common.cancel")}
</Button>
)}
<Button
type="submit"
form={formId}
disabled={loading || !mood}
>
<Button type="submit" form={formId} disabled={loading || !mood}>
{loading ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin motion-reduce:animate-none" />
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
) : (
<Plus className="mr-2 h-4 w-4" />
<Plus className="h-4 w-4" />
)}
{t("common.save")}
</Button>
@@ -328,145 +324,150 @@ export function MoodForm({ onSuccess, onCancel, footerSlot }: MoodFormProps) {
</div>
{moodPicked && (
<div className="space-y-4 border-t pt-4" data-slot="mood-annotate-panel">
<div className="space-y-2">
<Label htmlFor="mood-logged-at">{t("mood.timestamp")}</Label>
<DateTimeInput
id="mood-logged-at"
value={moodLoggedAt}
onChange={(e) => setMoodLoggedAt(e.target.value)}
required
aria-required="true"
aria-invalid={!!error || undefined}
aria-describedby={errorDescriptor}
/>
</div>
<div
className="space-y-4 border-t pt-4"
data-slot="mood-annotate-panel"
>
<div className="space-y-2">
<Label htmlFor="mood-logged-at">{t("mood.timestamp")}</Label>
<DateTimeInput
id="mood-logged-at"
value={moodLoggedAt}
onChange={(e) => setMoodLoggedAt(e.target.value)}
required
aria-required="true"
aria-invalid={!!error || undefined}
aria-describedby={errorDescriptor}
/>
</div>
<div className="space-y-2">
<div className="flex items-center justify-between gap-3">
<Label htmlFor="tags">
{t("mood.tags")}{" "}
<span className="text-muted-foreground font-normal">
({t("common.optional")})
</span>
</Label>
<span className="text-muted-foreground text-xs">
{t("mood.tagsHelp")}
</span>
</div>
<Input
id="tags"
value={tagsInput}
onChange={(e) => setTagsInput(e.target.value)}
placeholder={t("mood.tagsPlaceholder")}
enterKeyHint="done"
autoCapitalize="none"
autoComplete="off"
/>
{/* v1.4.25 W4d — GLP-1 side-effect quick-tags. Tapping a chip
<div className="space-y-2">
<div className="flex items-center justify-between gap-3">
<Label htmlFor="tags">
{t("mood.tags")}{" "}
<span className="text-muted-foreground font-normal">
({t("common.optional")})
</span>
</Label>
<span className="text-muted-foreground text-xs">
{t("mood.tagsHelp")}
</span>
</div>
<Input
id="tags"
value={tagsInput}
onChange={(e) => setTagsInput(e.target.value)}
placeholder={t("mood.tagsPlaceholder")}
enterKeyHint="done"
autoCapitalize="none"
autoComplete="off"
/>
{/* v1.4.25 W4d — GLP-1 side-effect quick-tags. Tapping a chip
appends the localised label to the free-text tag list.
Always visible for now (cheap UX; the Coach side-effect
aggregator filters on the canonical English tag set so the
German labels still register correctly). */}
<div className="space-y-1.5 pt-1">
<p className="text-muted-foreground text-xs">
{t("medications.sideEffectTagsHelp")}
</p>
<div className="flex flex-wrap gap-1.5">
{GLP1_SIDE_EFFECT_KEYS.map((key) => {
const label = t(key);
const tags = tagsInput
.split(",")
.map((p) => p.trim().toLowerCase());
const isActive = tags.includes(label.toLowerCase());
return (
<button
key={key}
type="button"
onClick={() => {
if (isActive) {
const next = tagsInput
.split(",")
.map((p) => p.trim())
.filter((p) => p.toLowerCase() !== label.toLowerCase());
setTagsInput(next.join(", "));
} else {
const next = tagsInput.trim()
? `${tagsInput.replace(/[,\s]+$/, "")}, ${label}`
: label;
setTagsInput(next);
}
}}
className={`inline-flex min-h-11 items-center rounded-full border px-3 py-2 text-xs transition-colors ${
isActive
? "border-primary bg-primary/15 text-primary"
: "border-border/70 bg-muted text-foreground/75 hover:bg-accent hover:text-foreground"
}`}
aria-pressed={isActive}
>
{label}
</button>
);
})}
<div className="space-y-1.5 pt-1">
<p className="text-muted-foreground text-xs">
{t("medications.sideEffectTagsHelp")}
</p>
<div className="flex flex-wrap gap-1.5">
{GLP1_SIDE_EFFECT_KEYS.map((key) => {
const label = t(key);
const tags = tagsInput
.split(",")
.map((p) => p.trim().toLowerCase());
const isActive = tags.includes(label.toLowerCase());
return (
<button
key={key}
type="button"
onClick={() => {
if (isActive) {
const next = tagsInput
.split(",")
.map((p) => p.trim())
.filter(
(p) => p.toLowerCase() !== label.toLowerCase(),
);
setTagsInput(next.join(", "));
} else {
const next = tagsInput.trim()
? `${tagsInput.replace(/[,\s]+$/, "")}, ${label}`
: label;
setTagsInput(next);
}
}}
className={`inline-flex min-h-11 items-center rounded-full border px-3 py-2 text-xs transition-colors ${
isActive
? "border-primary bg-primary/15 text-primary"
: "border-border/70 bg-muted text-foreground/75 hover:bg-accent hover:text-foreground"
}`}
aria-pressed={isActive}
>
{label}
</button>
);
})}
</div>
</div>
</div>
</div>
</div>
{/* v1.8.5 / v1.12.0 — structured-tag taxonomy picker. Additive next
{/* v1.8.5 / v1.12.0 — structured-tag taxonomy picker. Additive next
to the free-text input above; an entry can carry both axes. The
picker also surfaces the FACTOR-kind tags as 1..scaleMax rating
controls, lifted here as `ratedFactors`. */}
<div className="space-y-2">
<Label>
{t("mood.tagPicker")}{" "}
<span className="text-muted-foreground font-normal">
({t("common.optional")})
</span>
</Label>
<MoodTagPicker
selected={tagKeys}
onToggle={toggleTagKey}
ratedFactors={ratedFactors}
onRateFactor={rateFactor}
/>
</div>
<div className="space-y-2">
<Label>
{t("mood.tagPicker")}{" "}
<span className="text-muted-foreground font-normal">
({t("common.optional")})
</span>
</Label>
<MoodTagPicker
selected={tagKeys}
onToggle={toggleTagKey}
ratedFactors={ratedFactors}
onRateFactor={rateFactor}
/>
</div>
{/* v1.8.5 (C1) — free-text note. */}
<div className="space-y-2">
<div className="flex items-center justify-between gap-3">
<Label htmlFor="mood-note">
{t("mood.note")}{" "}
<span className="text-muted-foreground font-normal">
({t("common.optional")})
</span>
</Label>
{/* v1.11.5 — character counter so the `maxLength` cap no longer
{/* v1.8.5 (C1) — free-text note. */}
<div className="space-y-2">
<div className="flex items-center justify-between gap-3">
<Label htmlFor="mood-note">
{t("mood.note")}{" "}
<span className="text-muted-foreground font-normal">
({t("common.optional")})
</span>
</Label>
{/* v1.11.5 — character counter so the `maxLength` cap no longer
truncates silently. Turns destructive (warns) as the input
approaches the limit. */}
<span
data-testid="mood-note-counter"
className={`text-xs tabular-nums ${
note.length >= NOTE_MAX_LENGTH
? "text-destructive"
: "text-muted-foreground"
}`}
aria-live="polite"
>
{t("mood.noteCharCount", {
count: String(note.length),
max: String(NOTE_MAX_LENGTH),
})}
</span>
</div>
<Textarea
id="mood-note"
value={note}
onChange={(e) => setNote(e.target.value)}
placeholder={t("mood.notePlaceholder")}
maxLength={NOTE_MAX_LENGTH}
rows={3}
/>
</div>
<span
data-testid="mood-note-counter"
className={`text-xs tabular-nums ${
note.length >= NOTE_MAX_LENGTH
? "text-destructive"
: "text-muted-foreground"
}`}
aria-live="polite"
>
{t("mood.noteCharCount", {
count: String(note.length),
max: String(NOTE_MAX_LENGTH),
})}
</span>
</div>
<Textarea
id="mood-note"
value={note}
onChange={(e) => setNote(e.target.value)}
placeholder={t("mood.notePlaceholder")}
maxLength={NOTE_MAX_LENGTH}
rows={3}
/>
</div>
</div>
)}
@@ -497,10 +498,7 @@ export function MoodForm({ onSuccess, onCancel, footerSlot }: MoodFormProps) {
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{t("common.cancel")}</AlertDialogCancel>
<AlertDialogAction
variant="destructive"
onClick={resetForm}
>
<AlertDialogAction variant="destructive" onClick={resetForm}>
{t("mood.formResetConfirmAction")}
</AlertDialogAction>
</AlertDialogFooter>
+328 -338
View File
@@ -62,15 +62,8 @@ import { createPortal } from "react-dom";
import { toast } from "sonner";
import { formatDateTime } from "@/lib/format";
import { useTranslations, useFormatters } from "@/lib/i18n/context";
import {
MOOD_LABEL_KEYS,
MOOD_SCORE_BY_ENUM,
} from "@/lib/mood/labels";
import {
invalidateKeys,
moodDependentKeys,
queryKeys,
} from "@/lib/query-keys";
import { MOOD_LABEL_KEYS, MOOD_SCORE_BY_ENUM } from "@/lib/mood/labels";
import { invalidateKeys, moodDependentKeys, queryKeys } from "@/lib/query-keys";
import { moodSourceEnum } from "@/lib/validations/moodlog";
import { Checkbox } from "@/components/ui/checkbox";
import {
@@ -146,9 +139,7 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) {
const [sortDir, setSortDir] = useState<"asc" | "desc">("desc");
// v1.15.13 — page-scoped multi-select selection (current page ids only).
const [selectedIds, setSelectedIds] = useState<Set<string>>(
() => new Set(),
);
const [selectedIds, setSelectedIds] = useState<Set<string>>(() => new Set());
const [editing, setEditing] = useState<MoodEntry | null>(null);
const [editMood, setEditMood] = useState("");
@@ -173,9 +164,7 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) {
const [editDeleteDialogOpen, setEditDeleteDialogOpen] = useState(false);
// v1.4.27 R4 RC2 — Sheet-branch sticky-pinned footer slot.
const editFormId = useId();
const [editFooterEl, setEditFooterEl] = useState<HTMLDivElement | null>(
null,
);
const [editFooterEl, setEditFooterEl] = useState<HTMLDivElement | null>(null);
// v1.15.13 — every filter / page / sort change resets pagination AND
// clears the page-scoped selection.
@@ -460,7 +449,9 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) {
<SelectValue placeholder={t("dataList.allSources")} />
</SelectTrigger>
<SelectContent>
<SelectItem value="ALL">{t("dataList.allSources")}</SelectItem>
<SelectItem value="ALL">
{t("dataList.allSources")}
</SelectItem>
{MOOD_SOURCE_OPTIONS.map((src) => (
<SelectItem key={src} value={src}>
{formatMoodSource(src, t)}
@@ -472,7 +463,10 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) {
{/* v1.15.13 — optional date range, wired to from/to. */}
<div className="flex flex-col gap-1">
<Label htmlFor="mood-from" className="text-muted-foreground text-xs">
<Label
htmlFor="mood-from"
className="text-muted-foreground text-xs"
>
{t("dataList.dateFrom")}
</Label>
<DateInput
@@ -484,7 +478,10 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) {
/>
</div>
<div className="flex flex-col gap-1">
<Label htmlFor="mood-to" className="text-muted-foreground text-xs">
<Label
htmlFor="mood-to"
className="text-muted-foreground text-xs"
>
{t("dataList.dateTo")}
</Label>
<DateInput
@@ -537,7 +534,7 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) {
</Button>
) : onAddFirst ? (
<Button size="sm" onClick={onAddFirst}>
<Plus className="mr-1 h-4 w-4" />
<Plus className="h-4 w-4" />
{t("mood.emptyAddFirst")}
</Button>
) : undefined
@@ -598,77 +595,79 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) {
{data.entries.map((entry) => {
const isSelected = selectedIds.has(entry.id);
return (
<TableRow
key={entry.id}
data-state={isSelected ? "selected" : undefined}
>
<TableCell className="pl-4">
<Checkbox
checked={isSelected}
onCheckedChange={() => onToggleRow(entry.id)}
aria-label={t("dataList.selectRow")}
/>
</TableCell>
<TableCell className="font-semibold tabular-nums">
{entry.score}{" "}
<span className="text-muted-foreground font-normal">
(
{MOOD_LABEL_KEYS[entry.mood]
? t(MOOD_LABEL_KEYS[entry.mood])
: entry.mood}
)
</span>
</TableCell>
<TableCell className="text-muted-foreground max-w-[18rem] text-sm">
<span className="block truncate">
{entry.tags.length > 0 ? entry.tags.join(", ") : "-"}
</span>
{entry.note && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<p className="text-muted-foreground/80 mt-0.5 line-clamp-2 cursor-default text-xs italic">
{entry.note}
</p>
</TooltipTrigger>
<TooltipContent className="max-w-xs">
<p className="text-xs whitespace-pre-wrap">
{entry.note}
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
</TableCell>
<TableCell className="text-muted-foreground text-sm">
{formatDateTime(entry.moodLoggedAt)}
</TableCell>
<TableCell>
{entry.source !== "MANUAL" && (
<Badge variant="outline" className="text-xs">
{formatMoodSource(entry.source, t)}
</Badge>
)}
</TableCell>
<TableCell className="pr-4 text-right">
<div className="flex items-center justify-end gap-1">
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={() => startEdit(entry)}
aria-label={t("common.edit")}
>
<Pencil className="h-3.5 w-3.5" />
</Button>
<DeleteButton
onConfirm={() => deleteMutation.mutate(entry.id)}
title={t("mood.deleteConfirmTitle")}
description={t("mood.deleteConfirmDescription")}
<TableRow
key={entry.id}
data-state={isSelected ? "selected" : undefined}
>
<TableCell className="pl-4">
<Checkbox
checked={isSelected}
onCheckedChange={() => onToggleRow(entry.id)}
aria-label={t("dataList.selectRow")}
/>
</div>
</TableCell>
</TableRow>
</TableCell>
<TableCell className="font-semibold tabular-nums">
{entry.score}{" "}
<span className="text-muted-foreground font-normal">
(
{MOOD_LABEL_KEYS[entry.mood]
? t(MOOD_LABEL_KEYS[entry.mood])
: entry.mood}
)
</span>
</TableCell>
<TableCell className="text-muted-foreground max-w-[18rem] text-sm">
<span className="block truncate">
{entry.tags.length > 0
? entry.tags.join(", ")
: "-"}
</span>
{entry.note && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<p className="text-muted-foreground/80 mt-0.5 line-clamp-2 cursor-default text-xs italic">
{entry.note}
</p>
</TooltipTrigger>
<TooltipContent className="max-w-xs">
<p className="text-xs whitespace-pre-wrap">
{entry.note}
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
</TableCell>
<TableCell className="text-muted-foreground text-sm">
{formatDateTime(entry.moodLoggedAt)}
</TableCell>
<TableCell>
{entry.source !== "MANUAL" && (
<Badge variant="outline" className="text-xs">
{formatMoodSource(entry.source, t)}
</Badge>
)}
</TableCell>
<TableCell className="pr-4 text-right">
<div className="flex items-center justify-end gap-1">
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={() => startEdit(entry)}
aria-label={t("common.edit")}
>
<Pencil className="h-3.5 w-3.5" />
</Button>
<DeleteButton
onConfirm={() => deleteMutation.mutate(entry.id)}
title={t("mood.deleteConfirmTitle")}
description={t("mood.deleteConfirmDescription")}
/>
</div>
</TableCell>
</TableRow>
);
})}
</TableBody>
@@ -688,87 +687,87 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) {
{data.entries.map((entry) => {
const isSelected = selectedIds.has(entry.id);
return (
<div
key={entry.id}
data-testid="mood-row"
data-state={isSelected ? "selected" : undefined}
className="bg-card border-border flex items-center justify-between rounded-lg border p-3 data-[state=selected]:border-dracula-purple/60 data-[state=selected]:bg-dracula-purple/5"
>
<div className="flex items-center gap-2 overflow-hidden">
{/* v1.15.13 MEDIUM-1 — a Radix Checkbox renders a 16px
<div
key={entry.id}
data-testid="mood-row"
data-state={isSelected ? "selected" : undefined}
className="bg-card border-border data-[state=selected]:border-dracula-purple/60 data-[state=selected]:bg-dracula-purple/5 flex items-center justify-between rounded-lg border p-3"
>
<div className="flex items-center gap-2 overflow-hidden">
{/* v1.15.13 MEDIUM-1 — a Radix Checkbox renders a 16px
`<button role=checkbox>`; a wrapping `<label>` does
NOT forward taps to a button, so the effective tap
target was 16px (fails WCAG 2.5.5). The 44px button
owns the whole hit area and is the single toggle
source; the inner Checkbox is a `pointer-events-none`
controlled visual, so a tap fires the handler once. */}
<button
type="button"
role="checkbox"
aria-checked={isSelected}
aria-label={t("dataList.selectRow")}
onClick={() => onToggleRow(entry.id)}
className="focus-visible:ring-ring/50 flex size-11 shrink-0 items-center justify-center rounded focus-visible:ring-2 focus-visible:outline-none"
>
<Checkbox
checked={isSelected}
tabIndex={-1}
aria-hidden="true"
className="pointer-events-none"
/>
</button>
<div className="bg-muted flex h-8 w-8 shrink-0 items-center justify-center rounded-lg">
<span
data-testid="mood-row-score"
className="text-lg font-bold tabular-nums"
<button
type="button"
role="checkbox"
aria-checked={isSelected}
aria-label={t("dataList.selectRow")}
onClick={() => onToggleRow(entry.id)}
className="focus-visible:ring-ring/50 flex size-11 shrink-0 items-center justify-center rounded focus-visible:ring-2 focus-visible:outline-none"
>
{entry.score}
</span>
</div>
<div className="min-w-0">
<span className="text-sm font-semibold">
{MOOD_LABEL_KEYS[entry.mood]
? t(MOOD_LABEL_KEYS[entry.mood])
: entry.mood}
</span>
<p className="text-muted-foreground truncate text-xs">
{formatDateTime(entry.moodLoggedAt)}
</p>
{entry.tags.length > 0 && (
<Checkbox
checked={isSelected}
tabIndex={-1}
aria-hidden="true"
className="pointer-events-none"
/>
</button>
<div className="bg-muted flex h-8 w-8 shrink-0 items-center justify-center rounded-lg">
<span
data-testid="mood-row-score"
className="text-lg font-bold tabular-nums"
>
{entry.score}
</span>
</div>
<div className="min-w-0">
<span className="text-sm font-semibold">
{MOOD_LABEL_KEYS[entry.mood]
? t(MOOD_LABEL_KEYS[entry.mood])
: entry.mood}
</span>
<p className="text-muted-foreground truncate text-xs">
{entry.tags.join(", ")}
{formatDateTime(entry.moodLoggedAt)}
</p>
)}
{entry.note && (
<p className="text-muted-foreground/80 truncate text-xs italic">
{entry.note}
</p>
)}
{entry.tags.length > 0 && (
<p className="text-muted-foreground truncate text-xs">
{entry.tags.join(", ")}
</p>
)}
{entry.note && (
<p className="text-muted-foreground/80 truncate text-xs italic">
{entry.note}
</p>
)}
</div>
</div>
</div>
<div className="flex shrink-0 items-center gap-1">
{/* Phase A5 mobile audit: bumped from h-8 w-8 (32px)
<div className="flex shrink-0 items-center gap-1">
{/* Phase A5 mobile audit: bumped from h-8 w-8 (32px)
to min-h-11 min-w-11 (44px) so the per-row edit
and delete actions meet WCAG 2.5.5 on touch
devices. The desktop table keeps its denser
h-8 w-8 since pointer targets allow it. */}
<Button
variant="ghost"
size="icon"
className="min-h-11 min-w-11"
onClick={() => startEdit(entry)}
aria-label={t("common.edit")}
>
<Pencil className="h-4 w-4" />
</Button>
<DeleteButton
onConfirm={() => deleteMutation.mutate(entry.id)}
iconClassName="h-4 w-4"
title={t("mood.deleteConfirmTitle")}
description={t("mood.deleteConfirmDescription")}
/>
<Button
variant="ghost"
size="icon"
className="min-h-11 min-w-11"
onClick={() => startEdit(entry)}
aria-label={t("common.edit")}
>
<Pencil className="h-4 w-4" />
</Button>
<DeleteButton
onConfirm={() => deleteMutation.mutate(entry.id)}
iconClassName="h-4 w-4"
title={t("mood.deleteConfirmTitle")}
description={t("mood.deleteConfirmDescription")}
/>
</div>
</div>
</div>
);
})}
</div>
@@ -833,205 +832,196 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) {
title={t("mood.editEntry")}
footer={<div ref={setEditFooterEl} className="flex w-full" />}
>
{editing && (
<form
id={editFormId}
onSubmit={submitEdit}
className="space-y-4"
>
<div className="space-y-2">
<Label id="edit-mood-level-label">{t("mood.moodLevel")}</Label>
<div
role="radiogroup"
aria-labelledby="edit-mood-level-label"
className="grid grid-cols-5 gap-2"
>
{MOOD_LEVELS_LIST.map((level, index) => {
const isSelected = editMood === level;
return (
<button
key={level}
type="button"
role="radio"
aria-checked={isSelected}
onClick={() => setEditMood(level)}
{...getEditMoodRadioProps(index)}
className={`flex flex-col items-center gap-1 rounded-lg border p-2 text-center transition-colors ${
isSelected
? "border-primary bg-primary/10 text-primary border-2"
: "border-border hover:bg-accent"
}`}
>
<span className="text-lg font-semibold tabular-nums">
{MOOD_SCORES[level]}
</span>
<span className="text-[10px] leading-tight sm:text-xs">
{t(MOOD_LABEL_KEYS[level])}
</span>
</button>
);
})}
</div>
{editing && (
<form id={editFormId} onSubmit={submitEdit} className="space-y-4">
<div className="space-y-2">
<Label id="edit-mood-level-label">{t("mood.moodLevel")}</Label>
<div
role="radiogroup"
aria-labelledby="edit-mood-level-label"
className="grid grid-cols-5 gap-2"
>
{MOOD_LEVELS_LIST.map((level, index) => {
const isSelected = editMood === level;
return (
<button
key={level}
type="button"
role="radio"
aria-checked={isSelected}
onClick={() => setEditMood(level)}
{...getEditMoodRadioProps(index)}
className={`flex flex-col items-center gap-1 rounded-lg border p-2 text-center transition-colors ${
isSelected
? "border-primary bg-primary/10 text-primary border-2"
: "border-border hover:bg-accent"
}`}
>
<span className="text-lg font-semibold tabular-nums">
{MOOD_SCORES[level]}
</span>
<span className="text-[10px] leading-tight sm:text-xs">
{t(MOOD_LABEL_KEYS[level])}
</span>
</button>
);
})}
</div>
</div>
<div className="space-y-2">
<Label htmlFor="edit-mood-logged-at">
{t("mood.timestamp")}
<div className="space-y-2">
<Label htmlFor="edit-mood-logged-at">{t("mood.timestamp")}</Label>
<DateTimeInput
id="edit-mood-logged-at"
value={editMoodLoggedAt}
onChange={(e) => setEditMoodLoggedAt(e.target.value)}
required
/>
</div>
<div className="space-y-2">
<div className="flex items-center justify-between gap-3">
<Label htmlFor="edit-tags">
{t("mood.tags")} ({t("common.optional")})
</Label>
<DateTimeInput
id="edit-mood-logged-at"
value={editMoodLoggedAt}
onChange={(e) => setEditMoodLoggedAt(e.target.value)}
required
/>
<span className="text-muted-foreground text-xs">
{t("mood.tagsHelp")}
</span>
</div>
<Input
id="edit-tags"
value={editTagsInput}
onChange={(e) => setEditTagsInput(e.target.value)}
placeholder={t("mood.tagsPlaceholder")}
/>
</div>
<div className="space-y-2">
<div className="flex items-center justify-between gap-3">
<Label htmlFor="edit-tags">
{t("mood.tags")} ({t("common.optional")})
</Label>
<span className="text-muted-foreground text-xs">
{t("mood.tagsHelp")}
</span>
</div>
<Input
id="edit-tags"
value={editTagsInput}
onChange={(e) => setEditTagsInput(e.target.value)}
placeholder={t("mood.tagsPlaceholder")}
/>
{/* v1.8.5 — structured-tag taxonomy picker. */}
<div className="space-y-2">
<Label>
{t("mood.tagPicker")} ({t("common.optional")})
</Label>
<MoodTagPicker
selected={editTagKeys}
onToggle={toggleEditTagKey}
/>
</div>
{/* v1.8.5 (C1) — free-text note. */}
<div className="space-y-2">
<Label htmlFor="edit-mood-note">
{t("mood.note")} ({t("common.optional")})
</Label>
<Textarea
id="edit-mood-note"
value={editNote}
onChange={(e) => setEditNote(e.target.value)}
placeholder={t("mood.notePlaceholder")}
maxLength={500}
rows={3}
/>
</div>
{editError && (
<div
role="alert"
aria-live="assertive"
className="bg-destructive/10 text-destructive rounded-lg p-3 text-sm"
>
{editError}
</div>
)}
{/* v1.8.5 — structured-tag taxonomy picker. */}
<div className="space-y-2">
<Label>
{t("mood.tagPicker")} ({t("common.optional")})
</Label>
<MoodTagPicker
selected={editTagKeys}
onToggle={toggleEditTagKey}
/>
</div>
{/* v1.8.5 (C1) — free-text note. */}
<div className="space-y-2">
<Label htmlFor="edit-mood-note">
{t("mood.note")} ({t("common.optional")})
</Label>
<Textarea
id="edit-mood-note"
value={editNote}
onChange={(e) => setEditNote(e.target.value)}
placeholder={t("mood.notePlaceholder")}
maxLength={500}
rows={3}
/>
</div>
{editError && (
<div
role="alert"
aria-live="assertive"
className="bg-destructive/10 text-destructive rounded-lg p-3 text-sm"
>
{editError}
</div>
)}
{editFooterEl
? createPortal(
<div className="flex w-full items-center justify-between gap-2">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
type="button"
variant="outline"
size="icon"
className="h-11 w-11"
disabled={
updateMutation.isPending ||
deleteMutation.isPending
}
aria-label={t("common.moreOptions")}
>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
<DropdownMenuItem
variant="destructive"
onClick={() => setEditDeleteDialogOpen(true)}
>
<Trash2 className="mr-2 h-4 w-4" />
{t("common.delete")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<div className="flex items-center gap-2">
{editFooterEl
? createPortal(
<div className="flex w-full items-center justify-between gap-2">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
type="button"
variant="outline"
onClick={closeEdit}
size="icon"
className="h-11 w-11"
disabled={
updateMutation.isPending ||
deleteMutation.isPending
updateMutation.isPending || deleteMutation.isPending
}
aria-label={t("common.moreOptions")}
>
{t("common.cancel")}
<MoreHorizontal className="h-4 w-4" />
</Button>
<Button
type="submit"
form={editFormId}
disabled={
updateMutation.isPending ||
deleteMutation.isPending
}
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
<DropdownMenuItem
variant="destructive"
onClick={() => setEditDeleteDialogOpen(true)}
>
{updateMutation.isPending ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin motion-reduce:animate-none" />
) : null}
{t("common.save")}
</Button>
</div>
</div>,
editFooterEl,
)
: null}
<Trash2 className="mr-2 h-4 w-4" />
{t("common.delete")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<AlertDialog
open={editDeleteDialogOpen}
onOpenChange={setEditDeleteDialogOpen}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{t("mood.deleteConfirmTitle")}
</AlertDialogTitle>
<AlertDialogDescription>
{t("mood.deleteConfirmDescription")}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={deleteMutation.isPending}>
{t("common.cancel")}
</AlertDialogCancel>
<AlertDialogAction
variant="destructive"
onClick={deleteEditingEntry}
disabled={deleteMutation.isPending}
>
{deleteMutation.isPending ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin motion-reduce:animate-none" />
) : null}
{t("common.delete")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</form>
)}
<div className="flex items-center gap-2">
<Button
type="button"
variant="outline"
onClick={closeEdit}
disabled={
updateMutation.isPending || deleteMutation.isPending
}
>
{t("common.cancel")}
</Button>
<Button
type="submit"
form={editFormId}
disabled={
updateMutation.isPending || deleteMutation.isPending
}
>
{updateMutation.isPending ? (
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
) : null}
{t("common.save")}
</Button>
</div>
</div>,
editFooterEl,
)
: null}
<AlertDialog
open={editDeleteDialogOpen}
onOpenChange={setEditDeleteDialogOpen}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{t("mood.deleteConfirmTitle")}
</AlertDialogTitle>
<AlertDialogDescription>
{t("mood.deleteConfirmDescription")}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={deleteMutation.isPending}>
{t("common.cancel")}
</AlertDialogCancel>
<AlertDialogAction
variant="destructive"
onClick={deleteEditingEntry}
disabled={deleteMutation.isPending}
>
{deleteMutation.isPending ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin motion-reduce:animate-none" />
) : null}
{t("common.delete")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</form>
)}
</ResponsiveSheet>
</>
);
+12 -5
View File
@@ -23,7 +23,12 @@
import { useState } from "react";
import Link from "next/link";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Loader2, MessageCircleQuestion, NotebookPen, Trash2 } from "lucide-react";
import {
Loader2,
MessageCircleQuestion,
NotebookPen,
Trash2,
} from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
@@ -180,9 +185,7 @@ export function AboutMeSection({
maxLength={fieldMaxChars}
placeholder={placeholder}
disabled={disabled}
onChange={(e) =>
setDrafts((d) => ({ ...d, [key]: e.target.value }))
}
onChange={(e) => setDrafts((d) => ({ ...d, [key]: e.target.value }))}
/>
</div>
);
@@ -342,7 +345,11 @@ export function AboutMeSection({
)}
{query.isError && (
<p role="status" aria-live="polite" className="text-destructive text-xs">
<p
role="status"
aria-live="polite"
className="text-destructive text-sm"
>
{t("settings.ai.aboutMe.loadError")}
</p>
)}
+8 -8
View File
@@ -334,9 +334,9 @@ export function AboutSection() {
target="_blank"
rel="noopener noreferrer"
>
<GitBranch className="mr-2 h-4 w-4" />
<GitBranch className="h-4 w-4" />
{t("settings.about.repository")}
<ExternalLink className="ml-2 h-3.5 w-3.5" />
<ExternalLink className="h-3.5 w-3.5" />
</a>
</Button>
<Button asChild variant="outline" size="sm">
@@ -345,16 +345,16 @@ export function AboutSection() {
target="_blank"
rel="noopener noreferrer"
>
<Sparkles className="mr-2 h-4 w-4" />
<Sparkles className="h-4 w-4" />
{t("settings.about.changelog")}
<ExternalLink className="ml-2 h-3.5 w-3.5" />
<ExternalLink className="h-3.5 w-3.5" />
</a>
</Button>
<Button asChild variant="outline" size="sm">
<a href={version.docs} target="_blank" rel="noopener noreferrer">
<BookOpen className="mr-2 h-4 w-4" />
<BookOpen className="h-4 w-4" />
{t("settings.about.docs")}
<ExternalLink className="ml-2 h-3.5 w-3.5" />
<ExternalLink className="h-3.5 w-3.5" />
</a>
</Button>
</div>
@@ -392,9 +392,9 @@ export function AboutSection() {
className="w-full shrink-0 sm:w-auto"
>
{replayingTour ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin motion-reduce:animate-none" />
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
) : (
<Compass className="mr-2 h-4 w-4" />
<Compass className="h-4 w-4" />
)}
{t("settings.about.tourReplay")}
</Button>
+12 -12
View File
@@ -587,9 +587,9 @@ export function AccountSection() {
<div className="flex justify-end">
<Button type="submit" disabled={saving}>
{saving ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin motion-reduce:animate-none" />
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
) : (
<Save className="mr-2 h-4 w-4" />
<Save className="h-4 w-4" />
)}
{t("common.save")}
</Button>
@@ -623,9 +623,9 @@ export function AccountSection() {
disabled={passkeyLoading}
>
{passkeyLoading ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin motion-reduce:animate-none" />
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
) : (
<KeyRound className="mr-2 h-4 w-4" />
<KeyRound className="h-4 w-4" />
)}
{t("settings.addPasskey")}
</Button>
@@ -703,9 +703,9 @@ export function AccountSection() {
className="w-full shrink-0 sm:w-auto"
>
{tourRestarting ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin motion-reduce:animate-none" />
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
) : (
<Compass className="mr-2 h-4 w-4" />
<Compass className="h-4 w-4" />
)}
{t("onboarding.tour.restart")}
</Button>
@@ -794,9 +794,9 @@ export function AccountSection() {
<Button type="submit" variant="outline" disabled={passwordSaving}>
{passwordSaving ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin motion-reduce:animate-none" />
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
) : (
<Save className="mr-2 h-4 w-4" />
<Save className="h-4 w-4" />
)}
{t("settings.changePassword")}
</Button>
@@ -1180,9 +1180,9 @@ function AvatarSection() {
onClick={() => fileInputRef.current?.click()}
>
{upload.isPending ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin motion-reduce:animate-none" />
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
) : (
<ImageUp className="mr-2 h-4 w-4" />
<ImageUp className="h-4 w-4" />
)}
{avatarUrl
? t("settings.avatar.replace")
@@ -1200,9 +1200,9 @@ function AvatarSection() {
}}
>
{remove.isPending ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin motion-reduce:animate-none" />
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
) : (
<Trash2 className="mr-2 h-4 w-4" />
<Trash2 className="h-4 w-4" />
)}
{t("settings.avatar.remove")}
</Button>
+5 -7
View File
@@ -90,9 +90,7 @@ function researchModeStatusLabel(
if (gate === "off") return t("settings.researchMode.disabledStatus");
if (gate === "stale") return t("settings.researchMode.enabledStaleStatus");
return t("settings.researchMode.acknowledgedOn", {
date: status?.acknowledgedAt
? formatDateTime(status.acknowledgedAt)
: "—",
date: status?.acknowledgedAt ? formatDateTime(status.acknowledgedAt) : "—",
});
}
@@ -306,9 +304,9 @@ function DataResetCard() {
className="w-full shrink-0 sm:w-auto"
>
{deleting ? (
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
) : (
<Trash2 className="mr-1 h-3.5 w-3.5" />
<Trash2 className="h-3.5 w-3.5" />
)}
{t("settings.dangerZone")}
</Button>
@@ -450,9 +448,9 @@ function AccountDeleteCard() {
data-slot="settings-account-delete-trigger"
>
{deleting ? (
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
) : (
<Trash2 className="mr-1 h-3.5 w-3.5" />
<Trash2 className="h-3.5 w-3.5" />
)}
{t("settings.deleteAccountCta")}
</Button>
+19 -21
View File
@@ -201,9 +201,7 @@ export function AiSection() {
{/* v1.11.2 — "What the Coach remembers": durable-fact review +
forget controls. Gated on the Coach being enabled. */}
{coachEnabled && (
<CoachMemorySection isAuthenticated={isAuthenticated} />
)}
{coachEnabled && <CoachMemorySection isAuthenticated={isAuthenticated} />}
{/* v1.15.20 — user-authored "about me" context for the Coach +
daily briefing. Not gated on the Coach toggle: the briefing
@@ -836,9 +834,9 @@ function CodexProviderForm({
disabled={disconnecting}
>
{disconnecting ? (
<Loader2 className="mr-1 h-4 w-4 animate-spin motion-reduce:animate-none" />
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
) : (
<Trash2 className="mr-1 h-4 w-4" />
<Trash2 className="h-4 w-4" />
)}
{t("settings.ai.codex.disconnectButton")}
</Button>
@@ -900,9 +898,9 @@ function CodexProviderForm({
className="w-full sm:w-auto"
>
{devicePolling ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin motion-reduce:animate-none" />
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
) : (
<Sparkles className="mr-2 h-4 w-4" />
<Sparkles className="h-4 w-4" />
)}
{t("settings.ai.codex.connectButton")}
</Button>
@@ -1096,9 +1094,9 @@ function OpenAIProviderForm({
disabled={saveMutation.isPending}
>
{saveMutation.isPending ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin motion-reduce:animate-none" />
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
) : (
<Save className="mr-2 h-4 w-4" />
<Save className="h-4 w-4" />
)}
{t("settings.ai.saveCta")}
</Button>
@@ -1252,9 +1250,9 @@ function AnthropicProviderForm({
disabled={saveMutation.isPending}
>
{saveMutation.isPending ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin motion-reduce:animate-none" />
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
) : (
<Save className="mr-2 h-4 w-4" />
<Save className="h-4 w-4" />
)}
{t("settings.ai.saveCta")}
</Button>
@@ -1409,9 +1407,9 @@ function LocalProviderForm({
disabled={saveMutation.isPending}
>
{saveMutation.isPending ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin motion-reduce:animate-none" />
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
) : (
<Save className="mr-2 h-4 w-4" />
<Save className="h-4 w-4" />
)}
{t("settings.ai.saveCta")}
</Button>
@@ -1661,16 +1659,16 @@ function FallbackChainCard({
data-testid="ai-fallback-chain-save"
>
{saveMutation.isPending ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin motion-reduce:animate-none" />
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
) : (
<Save className="mr-2 h-4 w-4" />
<Save className="h-4 w-4" />
)}
{t("settings.ai.providerChain.saveOrder")}
</Button>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button size="sm" variant="outline">
<RotateCcw className="mr-2 h-4 w-4" />
<RotateCcw className="h-4 w-4" />
{t("settings.ai.providerChain.resetDefaults")}
</Button>
</AlertDialogTrigger>
@@ -1734,7 +1732,7 @@ function AddProviderControl({
if (isProviderType(picked)) onAdd(picked);
}}
>
<PlusCircle className="mr-2 h-4 w-4" />
<PlusCircle className="h-4 w-4" />
{t("settings.ai.providerChain.addProvider")}
</Button>
</div>
@@ -1933,9 +1931,9 @@ function RuntimeActionsRow({
data-testid="ai-test-active-provider"
>
{testing ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin motion-reduce:animate-none" />
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
) : (
<Sparkles className="mr-2 h-4 w-4" />
<Sparkles className="h-4 w-4" />
)}
{t("settings.ai.testProvider")}
</Button>
@@ -1947,9 +1945,9 @@ function RuntimeActionsRow({
disabled={regen}
>
{regen ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin motion-reduce:animate-none" />
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
) : (
<RefreshCw className="mr-2 h-4 w-4" />
<RefreshCw className="h-4 w-4" />
)}
{t("settings.regenerateInsights")}
</Button>
+2 -2
View File
@@ -281,7 +281,7 @@ function ApiTokensCard() {
disabled={creating || !newName.trim()}
>
{creating && (
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
)}
{t("common.create")}
</Button>
@@ -502,7 +502,7 @@ function ApiTokensCard() {
size="sm"
className="text-destructive border-destructive/30 min-h-11 w-full"
>
<Trash2 className="mr-2 h-3.5 w-3.5" />
<Trash2 className="h-3.5 w-3.5" />
{t("settings.tokenRevoke")}
</Button>
</AlertDialogTrigger>
@@ -216,7 +216,7 @@ export function CoachMemorySection({
<p
role="status"
aria-live="polite"
className="text-destructive text-xs"
className="text-destructive text-sm"
>
{t("settings.ai.coachMemory.loadError")}
</p>
@@ -324,7 +324,7 @@ export function DashboardLayoutSection({ id }: { id: string }) {
disabled={resetMutation.isPending}
className="self-end sm:self-auto"
>
<RotateCcw className="mr-2 h-3.5 w-3.5" />
<RotateCcw className="h-3.5 w-3.5" />
{t("dashboard.layoutReset")}
</Button>
</div>
@@ -512,7 +512,7 @@ export function DashboardLayoutSection({ id }: { id: string }) {
disabled={saveMutation.isPending}
>
{saveMutation.isPending && (
<Loader2 className="mr-2 h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
)}
{t("common.save")}
</Button>
@@ -637,7 +637,7 @@ function SortableWidgetRow({
// visible GripVertical at 28 px (matches the Switch primitive
// pattern from v1.4.43 W5-H1). v1.4.47 W10 design-M1 — drop
// dnd-kit's CSS transition under prefers-reduced-motion.
className="text-muted-foreground hover:text-foreground focus-visible:ring-ring focus-visible:ring-offset-background -m-1 relative inline-flex h-7 w-7 cursor-grab touch-none items-center justify-center rounded transition-colors before:absolute before:inset-[-8px] before:content-[''] focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none active:cursor-grabbing disabled:cursor-not-allowed disabled:opacity-50 motion-reduce:transition-none"
className="text-muted-foreground hover:text-foreground focus-visible:ring-ring focus-visible:ring-offset-background relative -m-1 inline-flex h-7 w-7 cursor-grab touch-none items-center justify-center rounded transition-colors before:absolute before:inset-[-8px] before:content-[''] focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none active:cursor-grabbing disabled:cursor-not-allowed disabled:opacity-50 motion-reduce:transition-none"
>
<GripVertical className="h-4 w-4" />
</button>
+12 -12
View File
@@ -289,9 +289,9 @@ function CsvCard({
disabled={busy}
>
{busy ? (
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
) : (
<Download className="mr-1 h-3.5 w-3.5" />
<Download className="h-3.5 w-3.5" />
)}
{t("settings.sections.export.actions.download")}
</Button>
@@ -305,7 +305,7 @@ function CsvCard({
setUntil={setUntil}
/>
{error && (
<p role="alert" className="text-destructive text-xs">
<p role="alert" className="text-destructive text-sm">
{error}
</p>
)}
@@ -376,9 +376,9 @@ function MedicationsCsvCard() {
disabled={busy}
>
{busy ? (
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
) : (
<Download className="mr-1 h-3.5 w-3.5" />
<Download className="h-3.5 w-3.5" />
)}
{t("settings.sections.export.actions.download")}
</Button>
@@ -406,7 +406,7 @@ function MedicationsCsvCard() {
</span>
</label>
{error && (
<p role="alert" className="text-destructive text-xs">
<p role="alert" className="text-destructive text-sm">
{error}
</p>
)}
@@ -465,16 +465,16 @@ function FullBackupCard() {
disabled={busy}
>
{busy ? (
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
) : (
<FileText className="mr-1 h-3.5 w-3.5" />
<FileText className="h-3.5 w-3.5" />
)}
{t("settings.sections.export.actions.download")}
</Button>
}
>
{error && (
<p role="alert" className="text-destructive text-xs">
<p role="alert" className="text-destructive text-sm">
{error}
</p>
)}
@@ -575,16 +575,16 @@ function CycleExportCard() {
disabled={busy}
>
{busy ? (
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
) : (
<Download className="mr-1 h-3.5 w-3.5" />
<Download className="h-3.5 w-3.5" />
)}
{t("settings.sections.export.actions.download")}
</Button>
}
>
{error && (
<p role="alert" className="text-destructive text-xs">
<p role="alert" className="text-destructive text-sm">
{error}
</p>
)}
@@ -168,8 +168,7 @@ export function HealthRecordExportPanel() {
return;
}
const blob = await res.blob();
const ext =
format === "pdf" ? "pdf" : format === "fhir" ? "json" : "zip";
const ext = format === "pdf" ? "pdf" : format === "fhir" ? "json" : "zip";
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
@@ -306,127 +305,127 @@ export function HealthRecordExportPanel() {
className="animate-insight-in space-y-3"
style={{ animationDuration: "200ms" }}
>
<div className="border-border space-y-2 rounded-lg border p-3">
<p className="text-xs font-semibold">
{t("settings.healthRecord.groupVitals")}
</p>
<div className="grid gap-2 sm:grid-cols-2">
<ToggleRow
label={t("settings.healthRecord.weight")}
checked={sections.weight}
onToggle={() => toggle("weight")}
/>
<ToggleRow
label={t("settings.healthRecord.bp")}
checked={sections.bp}
onToggle={() => toggle("bp")}
/>
<ToggleRow
label={t("settings.healthRecord.pulse")}
checked={sections.pulse}
onToggle={() => toggle("pulse")}
/>
<ToggleRow
label={t("settings.healthRecord.oxygenSaturation")}
checked={sections.oxygenSaturation}
onToggle={() => toggle("oxygenSaturation")}
/>
<ToggleRow
label={t("settings.healthRecord.bodyFat")}
checked={sections.bodyFat}
onToggle={() => toggle("bodyFat")}
/>
<ToggleRow
label={t("settings.healthRecord.bodyComposition")}
checked={sections.bodyComposition}
onToggle={() => toggle("bodyComposition")}
/>
</div>
</div>
<div className="border-border space-y-2 rounded-lg border p-3">
<p className="text-xs font-semibold">
{t("settings.healthRecord.groupVitals")}
</p>
<div className="grid gap-2 sm:grid-cols-2">
<ToggleRow
label={t("settings.healthRecord.weight")}
checked={sections.weight}
onToggle={() => toggle("weight")}
/>
<ToggleRow
label={t("settings.healthRecord.bp")}
checked={sections.bp}
onToggle={() => toggle("bp")}
/>
<ToggleRow
label={t("settings.healthRecord.pulse")}
checked={sections.pulse}
onToggle={() => toggle("pulse")}
/>
<ToggleRow
label={t("settings.healthRecord.oxygenSaturation")}
checked={sections.oxygenSaturation}
onToggle={() => toggle("oxygenSaturation")}
/>
<ToggleRow
label={t("settings.healthRecord.bodyFat")}
checked={sections.bodyFat}
onToggle={() => toggle("bodyFat")}
/>
<ToggleRow
label={t("settings.healthRecord.bodyComposition")}
checked={sections.bodyComposition}
onToggle={() => toggle("bodyComposition")}
/>
</div>
</div>
<div className="border-border space-y-2 rounded-lg border p-3">
<p className="text-xs font-semibold">
{t("settings.healthRecord.groupCardio")}
</p>
<div className="grid gap-2 sm:grid-cols-2">
<ToggleRow
label={t("settings.healthRecord.restingHeartRate")}
checked={sections.restingHeartRate}
onToggle={() => toggle("restingHeartRate")}
/>
<ToggleRow
label={t("settings.healthRecord.hrv")}
checked={sections.hrv}
onToggle={() => toggle("hrv")}
/>
<ToggleRow
label={t("settings.healthRecord.vo2max")}
checked={sections.vo2max}
onToggle={() => toggle("vo2max")}
/>
</div>
</div>
<div className="border-border space-y-2 rounded-lg border p-3">
<p className="text-xs font-semibold">
{t("settings.healthRecord.groupCardio")}
</p>
<div className="grid gap-2 sm:grid-cols-2">
<ToggleRow
label={t("settings.healthRecord.restingHeartRate")}
checked={sections.restingHeartRate}
onToggle={() => toggle("restingHeartRate")}
/>
<ToggleRow
label={t("settings.healthRecord.hrv")}
checked={sections.hrv}
onToggle={() => toggle("hrv")}
/>
<ToggleRow
label={t("settings.healthRecord.vo2max")}
checked={sections.vo2max}
onToggle={() => toggle("vo2max")}
/>
</div>
</div>
<div className="border-border space-y-2 rounded-lg border p-3">
<p className="text-xs font-semibold">
{t("settings.healthRecord.groupActivity")}
</p>
<div className="grid gap-2 sm:grid-cols-2">
<ToggleRow
label={t("settings.healthRecord.steps")}
checked={sections.steps}
onToggle={() => toggle("steps")}
/>
<ToggleRow
label={t("settings.healthRecord.distance")}
checked={sections.distance}
onToggle={() => toggle("distance")}
/>
<ToggleRow
label={t("settings.healthRecord.sleep")}
checked={sections.sleep}
onToggle={() => toggle("sleep")}
/>
</div>
</div>
<div className="border-border space-y-2 rounded-lg border p-3">
<p className="text-xs font-semibold">
{t("settings.healthRecord.groupActivity")}
</p>
<div className="grid gap-2 sm:grid-cols-2">
<ToggleRow
label={t("settings.healthRecord.steps")}
checked={sections.steps}
onToggle={() => toggle("steps")}
/>
<ToggleRow
label={t("settings.healthRecord.distance")}
checked={sections.distance}
onToggle={() => toggle("distance")}
/>
<ToggleRow
label={t("settings.healthRecord.sleep")}
checked={sections.sleep}
onToggle={() => toggle("sleep")}
/>
</div>
</div>
<div className="grid gap-2 sm:grid-cols-2">
<ToggleRow
label={t("settings.healthRecord.glucose")}
checked={sections.glucose}
onToggle={() => toggle("glucose")}
/>
<ToggleRow
label={t("settings.healthRecord.medications")}
checked={sections.medList && sections.compliance}
onToggle={toggleMedications}
/>
<ToggleRow
label={t("settings.healthRecord.bmi")}
checked={sections.bmi}
onToggle={() => toggle("bmi")}
/>
<ToggleRow
label={t("settings.healthRecord.mood")}
checked={sections.mood}
onToggle={() => toggle("mood")}
/>
</div>
<div className="grid gap-2 sm:grid-cols-2">
<ToggleRow
label={t("settings.healthRecord.glucose")}
checked={sections.glucose}
onToggle={() => toggle("glucose")}
/>
<ToggleRow
label={t("settings.healthRecord.medications")}
checked={sections.medList && sections.compliance}
onToggle={toggleMedications}
/>
<ToggleRow
label={t("settings.healthRecord.bmi")}
checked={sections.bmi}
onToggle={() => toggle("bmi")}
/>
<ToggleRow
label={t("settings.healthRecord.mood")}
checked={sections.mood}
onToggle={() => toggle("mood")}
/>
</div>
{isPdfLike && (
<ToggleRow
label={t("settings.healthRecord.includeCharts")}
checked={includeCharts}
onToggle={() => setIncludeCharts((v) => !v)}
/>
)}
{isPdfLike && (
<ToggleRow
label={t("settings.healthRecord.includeAiSummary")}
checked={includeAiSummary}
onToggle={() => setIncludeAiSummary((v) => !v)}
/>
)}
{isPdfLike && (
<ToggleRow
label={t("settings.healthRecord.includeCharts")}
checked={includeCharts}
onToggle={() => setIncludeCharts((v) => !v)}
/>
)}
{isPdfLike && (
<ToggleRow
label={t("settings.healthRecord.includeAiSummary")}
checked={includeAiSummary}
onToggle={() => setIncludeAiSummary((v) => !v)}
/>
)}
</div>
)}
</fieldset>
@@ -449,9 +448,9 @@ export function HealthRecordExportPanel() {
className="ml-auto min-h-11 sm:min-h-9"
>
{busy ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin motion-reduce:animate-none" />
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
) : (
<Download className="mr-2 h-4 w-4" />
<Download className="h-4 w-4" />
)}
{t("settings.healthRecord.generate")}
</Button>
+30 -20
View File
@@ -168,15 +168,21 @@ function AppleHealthImportCard() {
body: form,
});
if (res.status === 429) {
setUploadError(t("settings.sections.export.import.appleHealth.rateLimited"));
setUploadError(
t("settings.sections.export.import.appleHealth.rateLimited"),
);
return;
}
if (res.status === 413) {
setUploadError(t("settings.sections.export.import.appleHealth.tooLarge"));
setUploadError(
t("settings.sections.export.import.appleHealth.tooLarge"),
);
return;
}
if (!res.ok) {
setUploadError(t("settings.sections.export.import.appleHealth.uploadFailed"));
setUploadError(
t("settings.sections.export.import.appleHealth.uploadFailed"),
);
return;
}
const body = (await res.json()).data as {
@@ -184,12 +190,16 @@ function AppleHealthImportCard() {
status: string;
};
if (!body?.jobId) {
setUploadError(t("settings.sections.export.import.appleHealth.uploadFailed"));
setUploadError(
t("settings.sections.export.import.appleHealth.uploadFailed"),
);
return;
}
setJobId(body.jobId);
} catch {
setUploadError(t("settings.sections.export.import.appleHealth.uploadFailed"));
setUploadError(
t("settings.sections.export.import.appleHealth.uploadFailed"),
);
} finally {
setUploading(false);
}
@@ -212,8 +222,7 @@ function AppleHealthImportCard() {
}
const status = statusQuery.data ?? null;
const isRunning =
status !== null && !TERMINAL_STATES.includes(status.status);
const isRunning = status !== null && !TERMINAL_STATES.includes(status.status);
const isDone = status?.status === "done";
const isFailed = status?.status === "failed";
const busy = uploading || isRunning;
@@ -265,7 +274,9 @@ function AppleHealthImportCard() {
type="file"
accept=".zip,application/zip"
className="sr-only"
aria-label={t("settings.sections.export.import.appleHealth.fileInputLabel")}
aria-label={t(
"settings.sections.export.import.appleHealth.fileInputLabel",
)}
onChange={onFileChange}
/>
@@ -292,9 +303,7 @@ function AppleHealthImportCard() {
`settings.sections.export.import.appleHealth.phase.${status?.status ?? "queued"}`,
)}
</p>
{typeof percent === "number" && (
<Progress value={percent} />
)}
{typeof percent === "number" && <Progress value={percent} />}
{typeof status?.progress?.rowsUpserted === "number" && (
<p className="text-muted-foreground text-xs">
{t("settings.sections.export.import.appleHealth.rowsImported", {
@@ -325,7 +334,7 @@ function AppleHealthImportCard() {
{(isFailed || uploadError) && (
<p
role="alert"
className="text-destructive flex items-start gap-2 text-xs"
className="text-destructive flex items-start gap-2 text-sm"
>
<AlertCircle
className="mt-0.5 h-3.5 w-3.5 shrink-0"
@@ -350,9 +359,9 @@ function AppleHealthImportCard() {
data-testid="import-action-apple-health"
>
{busy ? (
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
) : (
<Upload className="mr-1 h-3.5 w-3.5" />
<Upload className="h-3.5 w-3.5" />
)}
{t("settings.sections.export.import.appleHealth.choose")}
</Button>
@@ -485,7 +494,8 @@ function JsonImportCard() {
if (res.status === 422) {
const body = await res.json().catch(() => null);
setError(
body?.error ?? t("settings.sections.export.import.json.invalidPayload"),
body?.error ??
t("settings.sections.export.import.json.invalidPayload"),
);
return;
}
@@ -570,7 +580,7 @@ function JsonImportCard() {
{error && (
<p
role="alert"
className="text-destructive flex items-start gap-2 text-xs"
className="text-destructive flex items-start gap-2 text-sm"
>
<AlertCircle
className="mt-0.5 h-3.5 w-3.5 shrink-0"
@@ -589,7 +599,7 @@ function JsonImportCard() {
onClick={() => fileInputRef.current?.click()}
data-testid="import-json-choose-file"
>
<Upload className="mr-1 h-3.5 w-3.5" />
<Upload className="h-3.5 w-3.5" />
{t("settings.sections.export.import.json.uploadFile")}
</Button>
<Button
@@ -599,7 +609,7 @@ function JsonImportCard() {
onClick={downloadExample}
data-testid="import-json-download-example"
>
<Download className="mr-1 h-3.5 w-3.5" />
<Download className="h-3.5 w-3.5" />
{t("settings.sections.export.import.json.downloadExample")}
</Button>
<Button
@@ -610,9 +620,9 @@ function JsonImportCard() {
data-testid="import-action-json"
>
{busy ? (
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
) : (
<FileJson className="mr-1 h-3.5 w-3.5" />
<FileJson className="h-3.5 w-3.5" />
)}
{t("settings.sections.export.import.json.import")}
</Button>
@@ -106,8 +106,8 @@ export function InsightsPillOrderSection({ id }: { id?: string }) {
// differs from what we last seeded from.
const serverSignature = layoutSignature(layout.tiles);
const [seededSignature, setSeededSignature] = useState(serverSignature);
const [draftTiles, setDraftTiles] = useState<InsightsTileConfig[]>(
() => [...layout.tiles].sort((a, b) => a.order - b.order),
const [draftTiles, setDraftTiles] = useState<InsightsTileConfig[]>(() =>
[...layout.tiles].sort((a, b) => a.order - b.order),
);
if (isSuccess && serverSignature !== seededSignature) {
setSeededSignature(serverSignature);
@@ -215,10 +215,7 @@ export function InsightsPillOrderSection({ id }: { id?: string }) {
>
<div className="flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between sm:gap-3">
<div className="space-y-1">
<h2
id="insights-pill-order-title"
className="text-lg font-semibold"
>
<h2 id="insights-pill-order-title" className="text-lg font-semibold">
{t("insights.pillOrder.title")}
</h2>
<p className="text-muted-foreground text-sm">
@@ -233,7 +230,7 @@ export function InsightsPillOrderSection({ id }: { id?: string }) {
className="self-end sm:self-auto"
>
{saveMutation.isPending && (
<Loader2 className="mr-2 h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
)}
{t("insights.pillOrder.save")}
</Button>
@@ -320,8 +317,14 @@ function SortablePillRow({
hideLabel: string;
onToggle: (id: string, visible: boolean) => void;
}) {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } =
useSortable({ id });
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id });
const style: React.CSSProperties = {
transform: CSS.Transform.toString(transform),
@@ -165,7 +165,7 @@ function IntegrationErrorMessage({ message }: { message: string }) {
return (
<p
data-testid="integration-error-message"
className="text-destructive flex items-start gap-1.5 text-xs"
className="text-destructive flex items-start gap-1.5 text-sm"
>
<AlertCircle className="mt-0.5 h-3.5 w-3.5 shrink-0" />
<span className="min-w-0 break-words">{message}</span>
@@ -321,7 +321,9 @@ function WithingsCard({
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: queryKeys.withings() });
queryClient.invalidateQueries({ queryKey: queryKeys.integrationsStatus() });
queryClient.invalidateQueries({
queryKey: queryKeys.integrationsStatus(),
});
},
});
@@ -343,7 +345,9 @@ function WithingsCard({
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: queryKeys.withings() });
queryClient.invalidateQueries({ queryKey: queryKeys.integrationsStatus() });
queryClient.invalidateQueries({
queryKey: queryKeys.integrationsStatus(),
});
},
});
@@ -368,7 +372,9 @@ function WithingsCard({
);
setSyncMsgType("success");
void invalidateKeys(queryClient, measurementDependentKeys);
queryClient.invalidateQueries({ queryKey: queryKeys.integrationsStatus() });
queryClient.invalidateQueries({
queryKey: queryKeys.integrationsStatus(),
});
} else {
setSyncMsg(json.error || t("settings.withingsSyncFailed"));
setSyncMsgType("error");
@@ -445,7 +451,10 @@ function WithingsCard({
title={t("settings.withings")}
description={t("settings.withingsDescription")}
status={
<IntegrationStatusPill state={pillState} lastSyncAt={pillLastSyncAt} />
<IntegrationStatusPill
state={pillState}
lastSyncAt={pillLastSyncAt}
/>
}
/>
@@ -496,7 +505,7 @@ function WithingsCard({
data-testid="withings-parked-banner"
className="border-warning/30 bg-warning/10 flex flex-wrap items-center justify-between gap-2 rounded-md border px-3 py-2 text-sm"
>
<span className="text-warning min-w-0 break-words text-xs">
<span className="text-warning min-w-0 text-xs break-words">
{t("settings.integrationPill.parkedReconnect")}
</span>
<Button
@@ -509,9 +518,9 @@ function WithingsCard({
className="min-h-11"
>
{resume.isPending ? (
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
) : (
<Link2 className="mr-1 h-3.5 w-3.5" />
<Link2 className="h-3.5 w-3.5" />
)}
{t("settings.integrationPill.resumeCta")}
</Button>
@@ -520,7 +529,7 @@ function WithingsCard({
{resume.isError && (
<p
role="alert"
className="text-destructive text-xs"
className="text-destructive text-sm"
data-testid="withings-resume-error"
>
{t("settings.integrationPill.resumeError")}
@@ -604,9 +613,9 @@ function WithingsCard({
}
>
{credsSaving ? (
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
) : (
<Save className="mr-1 h-3.5 w-3.5" />
<Save className="h-3.5 w-3.5" />
)}
{t("settings.withingsSaveCredentials")}
</Button>
@@ -639,9 +648,9 @@ function WithingsCard({
disabled={syncing}
>
{syncing ? (
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
) : (
<RefreshCw className="mr-1 h-3.5 w-3.5" />
<RefreshCw className="h-3.5 w-3.5" />
)}
{t("settings.withingsSync")}
</Button>
@@ -654,9 +663,9 @@ function WithingsCard({
disabled={syncing}
>
{syncing ? (
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
) : (
<RefreshCw className="mr-1 h-3.5 w-3.5" />
<RefreshCw className="h-3.5 w-3.5" />
)}
{t("settings.withingsFullSync")}
</Button>
@@ -689,7 +698,7 @@ function WithingsCard({
size="sm"
className="text-destructive min-h-11"
>
<Unlink className="mr-1 h-3.5 w-3.5" />
<Unlink className="h-3.5 w-3.5" />
{t("settings.withingsDisconnect")}
</Button>
</AlertDialogTrigger>
@@ -730,7 +739,7 @@ function WithingsCard({
window.location.href = "/api/withings/connect";
}}
>
<Link2 className="mr-2 h-4 w-4" />
<Link2 className="h-4 w-4" />
{t("settings.withingsConnect")}
</Button>
) : (
@@ -816,7 +825,9 @@ function WhoopCard({
});
const json = await res.json();
if (res.ok) {
setSyncMsg(t("settings.whoopSyncResult", { count: json.data.imported }));
setSyncMsg(
t("settings.whoopSyncResult", { count: json.data.imported }),
);
setSyncMsgType("success");
void invalidateKeys(queryClient, measurementDependentKeys);
queryClient.invalidateQueries({ queryKey: queryKeys.whoop() });
@@ -897,7 +908,10 @@ function WhoopCard({
</>
}
status={
<IntegrationStatusPill state={pillState} lastSyncAt={pillLastSyncAt} />
<IntegrationStatusPill
state={pillState}
lastSyncAt={pillLastSyncAt}
/>
}
/>
@@ -920,7 +934,7 @@ function WhoopCard({
data-testid="whoop-parked-banner"
className="border-warning/30 bg-warning/10 flex flex-wrap items-center justify-between gap-2 rounded-md border px-3 py-2 text-sm"
>
<span className="text-warning min-w-0 break-words text-xs">
<span className="text-warning min-w-0 text-xs break-words">
{t("settings.integrationPill.parkedReconnect")}
</span>
<Button
@@ -933,9 +947,9 @@ function WhoopCard({
className="min-h-11"
>
{resume.isPending ? (
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
) : (
<Link2 className="mr-1 h-3.5 w-3.5" />
<Link2 className="h-3.5 w-3.5" />
)}
{t("settings.integrationPill.resumeCta")}
</Button>
@@ -944,7 +958,7 @@ function WhoopCard({
{resume.isError && (
<p
role="alert"
className="text-destructive text-xs"
className="text-destructive text-sm"
data-testid="whoop-resume-error"
>
{t("settings.integrationPill.resumeError")}
@@ -1023,9 +1037,9 @@ function WhoopCard({
}
>
{credsSaving ? (
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
) : (
<Save className="mr-1 h-3.5 w-3.5" />
<Save className="h-3.5 w-3.5" />
)}
{t("settings.whoopSaveCredentials")}
</Button>
@@ -1052,9 +1066,9 @@ function WhoopCard({
disabled={syncing}
>
{syncing ? (
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
) : (
<RefreshCw className="mr-1 h-3.5 w-3.5" />
<RefreshCw className="h-3.5 w-3.5" />
)}
{t("settings.whoopSync")}
</Button>
@@ -1067,9 +1081,9 @@ function WhoopCard({
disabled={syncing}
>
{syncing ? (
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
) : (
<RefreshCw className="mr-1 h-3.5 w-3.5" />
<RefreshCw className="h-3.5 w-3.5" />
)}
{t("settings.whoopFullSync")}
</Button>
@@ -1102,7 +1116,7 @@ function WhoopCard({
size="sm"
className="text-destructive min-h-11"
>
<Unlink className="mr-1 h-3.5 w-3.5" />
<Unlink className="h-3.5 w-3.5" />
{t("settings.whoopDisconnect")}
</Button>
</AlertDialogTrigger>
@@ -1148,7 +1162,7 @@ function WhoopCard({
window.location.href = "/api/whoop/connect";
}}
>
<Link2 className="mr-2 h-4 w-4" />
<Link2 className="h-4 w-4" />
{t("settings.whoopConnect")}
</Button>
) : (
@@ -1344,7 +1358,10 @@ function FitbitCard({
</>
}
status={
<IntegrationStatusPill state={pillState} lastSyncAt={pillLastSyncAt} />
<IntegrationStatusPill
state={pillState}
lastSyncAt={pillLastSyncAt}
/>
}
/>
@@ -1366,7 +1383,7 @@ function FitbitCard({
data-testid="fitbit-parked-banner"
className="border-warning/30 bg-warning/10 flex flex-wrap items-center justify-between gap-2 rounded-md border px-3 py-2 text-sm"
>
<span className="text-warning min-w-0 break-words text-xs">
<span className="text-warning min-w-0 text-xs break-words">
{t("settings.integrationPill.parkedReconnect")}
</span>
<Button
@@ -1379,9 +1396,9 @@ function FitbitCard({
className="min-h-11"
>
{resume.isPending ? (
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
) : (
<Link2 className="mr-1 h-3.5 w-3.5" />
<Link2 className="h-3.5 w-3.5" />
)}
{t("settings.integrationPill.resumeCta")}
</Button>
@@ -1390,7 +1407,7 @@ function FitbitCard({
{resume.isError && (
<p
role="alert"
className="text-destructive text-xs"
className="text-destructive text-sm"
data-testid="fitbit-resume-error"
>
{t("settings.integrationPill.resumeError")}
@@ -1469,9 +1486,9 @@ function FitbitCard({
}
>
{credsSaving ? (
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
) : (
<Save className="mr-1 h-3.5 w-3.5" />
<Save className="h-3.5 w-3.5" />
)}
{t("settings.fitbitSaveCredentials")}
</Button>
@@ -1498,9 +1515,9 @@ function FitbitCard({
disabled={syncing}
>
{syncing ? (
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
) : (
<RefreshCw className="mr-1 h-3.5 w-3.5" />
<RefreshCw className="h-3.5 w-3.5" />
)}
{t("settings.fitbitSync")}
</Button>
@@ -1513,9 +1530,9 @@ function FitbitCard({
disabled={syncing}
>
{syncing ? (
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
) : (
<RefreshCw className="mr-1 h-3.5 w-3.5" />
<RefreshCw className="h-3.5 w-3.5" />
)}
{t("settings.fitbitFullSync")}
</Button>
@@ -1548,7 +1565,7 @@ function FitbitCard({
size="sm"
className="text-destructive min-h-11"
>
<Unlink className="mr-1 h-3.5 w-3.5" />
<Unlink className="h-3.5 w-3.5" />
{t("settings.fitbitDisconnect")}
</Button>
</AlertDialogTrigger>
@@ -1594,7 +1611,7 @@ function FitbitCard({
window.location.href = "/api/fitbit/connect";
}}
>
<Link2 className="mr-2 h-4 w-4" />
<Link2 className="h-4 w-4" />
{t("settings.fitbitConnect")}
</Button>
) : (
@@ -92,7 +92,9 @@ export function NotificationStatusCard() {
if (!res.ok) throw new Error("Failed");
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: queryKeys.notificationsStatus() });
queryClient.invalidateQueries({
queryKey: queryKeys.notificationsStatus(),
});
},
});
@@ -105,7 +107,9 @@ export function NotificationStatusCard() {
}
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: queryKeys.notificationsStatus() });
queryClient.invalidateQueries({
queryKey: queryKeys.notificationsStatus(),
});
},
});
@@ -286,9 +290,9 @@ function ChannelRow({
className="min-h-11"
>
{reEnablePending ? (
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
) : (
<RotateCw className="mr-1 h-3.5 w-3.5" />
<RotateCw className="h-3.5 w-3.5" />
)}
{t("settings.notificationStatus.reEnable")}
</Button>
@@ -303,9 +307,9 @@ function ChannelRow({
className="min-h-11"
>
{testPending ? (
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
) : (
<Send className="mr-1 h-3.5 w-3.5" />
<Send className="h-3.5 w-3.5" />
)}
{t("settings.notificationStatus.sendTest")}
</Button>
@@ -328,8 +332,7 @@ function stateBadgeFor(
return {
label: t("settings.notificationStatus.stateActive"),
variant: "default",
className:
"border-success/30 bg-success/15 text-success",
className: "border-success/30 bg-success/15 text-success",
icon: <CheckCircle2 className="h-3 w-3" aria-hidden />,
};
case "auto_disabled":
+1 -1
View File
@@ -166,7 +166,7 @@ export function NtfyCard({ isAuthenticated }: { isAuthenticated: boolean }) {
className="min-h-11"
>
{save.isPending && (
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
)}
{t("common.save")}
</Button>
+3 -3
View File
@@ -337,7 +337,7 @@ export function SourcesSection() {
disabled={resetMutation.isPending || saveMutation.isPending}
className="self-end sm:self-auto"
>
<RotateCcw className="mr-2 h-3.5 w-3.5" />
<RotateCcw className="h-3.5 w-3.5" />
{t("settings.sections.sources.resetDefaults")}
</Button>
</div>
@@ -523,7 +523,7 @@ export function SourcesSection() {
onClick={resetDeviceTypeAxis}
disabled={saveMutation.isPending}
>
<RotateCcw className="mr-1 h-3 w-3" />
<RotateCcw className="h-3 w-3" />
{t("settings.sections.sources.resetDeviceTypes")}
</Button>
</div>
@@ -597,7 +597,7 @@ export function SourcesSection() {
disabled={saveMutation.isPending}
>
{saveMutation.isPending && (
<Loader2 className="mr-2 h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
)}
{t("common.save")}
</Button>
+3 -5
View File
@@ -156,9 +156,7 @@ export function TelegramCard({
<p
role="alert"
className={`text-sm ${
msgType === "success"
? "text-success"
: "text-destructive"
msgType === "success" ? "text-success" : "text-destructive"
}`}
>
{msg}
@@ -172,9 +170,9 @@ export function TelegramCard({
/>
<Button type="submit" disabled={saving} className="min-h-11">
{saving ? (
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
) : (
<Save className="mr-1 h-3.5 w-3.5" />
<Save className="h-3.5 w-3.5" />
)}
{t("common.save")}
</Button>
@@ -88,9 +88,9 @@ export function TestConnectionButton({
disabled={disabled || testing}
>
{testing ? (
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
) : (
<Plug className="mr-1 h-3.5 w-3.5" />
<Plug className="h-3.5 w-3.5" />
)}
{testing
? t("settings.testConnection.testing")
@@ -110,7 +110,7 @@ export function TestConnectionButton({
{result?.kind === "error" && (
<p
role="alert"
className="text-destructive flex items-center gap-1.5 text-xs"
className="text-destructive flex items-center gap-1.5 text-sm"
>
<XCircle className="h-3.5 w-3.5" />
{describeError(result.errorCode)}
@@ -138,7 +138,7 @@ export function ThresholdsEditorSection({ id }: { id: string }) {
onClick={() => resetMutation.mutate(null)}
disabled={resetMutation.isPending}
>
<RotateCcw className="mr-2 h-3.5 w-3.5" />
<RotateCcw className="h-3.5 w-3.5" />
{t("thresholds.resetAllAction")}
</Button>
)}
+3 -3
View File
@@ -172,7 +172,7 @@ export function WebPushCard() {
disabled={actionLoading}
>
{actionLoading && (
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
)}
{t("settings.webPushUnsubscribe")}
</Button>
@@ -185,9 +185,9 @@ export function WebPushCard() {
disabled={actionLoading}
>
{actionLoading ? (
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
) : (
<BellRing className="mr-1 h-3.5 w-3.5" />
<BellRing className="h-3.5 w-3.5" />
)}
{t("settings.webPushSubscribe")}
</Button>
+66 -68
View File
@@ -131,9 +131,7 @@ function TargetEditSheetBody({
// to canonical mg/dL before the PUT — otherwise a `5.5 mmol/L` target
// is rejected by the 40–400 mg/dL bounds or stored verbatim as 5.5.
const isGlucoseMmol =
metric != null &&
metric.startsWith("BLOOD_GLUCOSE") &&
unit === "mmol/L";
metric != null && metric.startsWith("BLOOD_GLUCOSE") && unit === "mmol/L";
const toDisplay = (mgdl: number) =>
isGlucoseMmol ? convertGlucose(mgdl, "mmol/L") : mgdl;
const toCanonical = (displayValue: number) =>
@@ -374,7 +372,7 @@ function TargetEditSheetBody({
disabled={busy}
data-slot="target-edit-reset"
>
<RotateCcw className="mr-2 h-3.5 w-3.5" />
<RotateCcw className="h-3.5 w-3.5" />
{t("targets.edit.resetToDefault")}
</Button>
) : (
@@ -414,88 +412,88 @@ function TargetEditSheetBody({
{!isDerivedMetric && primaryBounds && (
<div className="space-y-4">
<div
className="grid grid-cols-1 gap-3 sm:grid-cols-2"
data-slot="target-edit-primary-row"
>
<div className="space-y-1">
<Label htmlFor="target-edit-min" className="text-xs">
{isBp
? t("targets.edit.systolicMin")
: t("targets.edit.minLabel")}
</Label>
<Input
id="target-edit-min"
ref={firstInputRef}
type="number"
step={targetType === "ACTIVITY_STEPS" ? 100 : 0.1}
min={primaryBounds.min}
max={primaryBounds.max}
value={displayMin}
onChange={(e) => setMinStr(e.target.value)}
disabled={busy}
data-slot="target-edit-min-input"
/>
</div>
<div className="space-y-1">
<Label htmlFor="target-edit-max" className="text-xs">
{isBp
? t("targets.edit.systolicMax")
: t("targets.edit.maxLabel")}
</Label>
<Input
id="target-edit-max"
type="number"
step={targetType === "ACTIVITY_STEPS" ? 100 : 0.1}
min={primaryBounds.min}
max={primaryBounds.max}
value={displayMax}
onChange={(e) => setMaxStr(e.target.value)}
disabled={busy}
data-slot="target-edit-max-input"
/>
</div>
</div>
{isBp && secondaryBounds && (
<div
className="grid grid-cols-1 gap-3 sm:grid-cols-2"
data-slot="target-edit-secondary-row"
data-slot="target-edit-primary-row"
>
<div className="space-y-1">
<Label htmlFor="target-edit-dia-min" className="text-xs">
{t("targets.edit.diastolicMin")}
<Label htmlFor="target-edit-min" className="text-xs">
{isBp
? t("targets.edit.systolicMin")
: t("targets.edit.minLabel")}
</Label>
<Input
id="target-edit-dia-min"
id="target-edit-min"
ref={firstInputRef}
type="number"
step={0.1}
min={secondaryBounds.min}
max={secondaryBounds.max}
value={displayDiaMin}
onChange={(e) => setDiaMinStr(e.target.value)}
step={targetType === "ACTIVITY_STEPS" ? 100 : 0.1}
min={primaryBounds.min}
max={primaryBounds.max}
value={displayMin}
onChange={(e) => setMinStr(e.target.value)}
disabled={busy}
data-slot="target-edit-dia-min-input"
data-slot="target-edit-min-input"
/>
</div>
<div className="space-y-1">
<Label htmlFor="target-edit-dia-max" className="text-xs">
{t("targets.edit.diastolicMax")}
<Label htmlFor="target-edit-max" className="text-xs">
{isBp
? t("targets.edit.systolicMax")
: t("targets.edit.maxLabel")}
</Label>
<Input
id="target-edit-dia-max"
id="target-edit-max"
type="number"
step={0.1}
min={secondaryBounds.min}
max={secondaryBounds.max}
value={displayDiaMax}
onChange={(e) => setDiaMaxStr(e.target.value)}
step={targetType === "ACTIVITY_STEPS" ? 100 : 0.1}
min={primaryBounds.min}
max={primaryBounds.max}
value={displayMax}
onChange={(e) => setMaxStr(e.target.value)}
disabled={busy}
data-slot="target-edit-dia-max-input"
data-slot="target-edit-max-input"
/>
</div>
</div>
)}
{isBp && secondaryBounds && (
<div
className="grid grid-cols-1 gap-3 sm:grid-cols-2"
data-slot="target-edit-secondary-row"
>
<div className="space-y-1">
<Label htmlFor="target-edit-dia-min" className="text-xs">
{t("targets.edit.diastolicMin")}
</Label>
<Input
id="target-edit-dia-min"
type="number"
step={0.1}
min={secondaryBounds.min}
max={secondaryBounds.max}
value={displayDiaMin}
onChange={(e) => setDiaMinStr(e.target.value)}
disabled={busy}
data-slot="target-edit-dia-min-input"
/>
</div>
<div className="space-y-1">
<Label htmlFor="target-edit-dia-max" className="text-xs">
{t("targets.edit.diastolicMax")}
</Label>
<Input
id="target-edit-dia-max"
type="number"
step={0.1}
min={secondaryBounds.min}
max={secondaryBounds.max}
value={displayDiaMax}
onChange={(e) => setDiaMaxStr(e.target.value)}
disabled={busy}
data-slot="target-edit-dia-max-input"
/>
</div>
</div>
)}
<p className="text-muted-foreground text-xs">
{t("targets.edit.boundsHint", {
+2 -2
View File
@@ -18,9 +18,9 @@ import { cn } from "@/lib/utils";
* ```tsx
* <Button disabled={loading} aria-busy={loading || undefined}>
* {loading ? (
* <Loader2 className="mr-2 h-4 w-4 animate-spin motion-reduce:animate-none" />
* <Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
* ) : (
* <Icon className="mr-2 h-4 w-4" />
* <Icon className="h-4 w-4" />
* )}
* {label}
* </Button>
+13 -2
View File
@@ -42,7 +42,18 @@ export interface ResponsiveSheetProps {
*/
hideHeader?: boolean;
/**
* Footer slot. The Sheet branch sticky-pins the footer to the
* Footer slot. Pass the action `<Button>`s directly (a fragment is
* fine) — never a wrapper `<div className="flex …">`. The two
* branches deliberately stack differently and both rely on owning
* the flex context: the Sheet branch pins a `flex-row justify-end`
* bar so Cancel/Save sit side by side above the keyboard, while the
* Dialog branch defers to `DialogFooter`'s `flex-col-reverse
* sm:flex-row` so narrow dialogs stack the primary action on top.
* A consumer-supplied wrapper div collapses both into a single flex
* child and breaks that responsive stacking. This split is a
* decision, not drift — don't unify the two branches.
*
* The Sheet branch sticky-pins the footer to the
* bottom of the bottom-sheet so the primary CTA stays reachable
* once the body scrolls; the Dialog branch lets the footer flow
* normally beneath the body.
@@ -167,7 +178,7 @@ export function ResponsiveSheet({
{footer ? (
<SheetFooter
data-slot="responsive-sheet-footer"
className="border-border/70 sticky bottom-0 mt-0 flex-row justify-end gap-2 border-t bg-background/95 p-4 backdrop-blur supports-[backdrop-filter]:bg-background/80"
className="border-border/70 bg-background/95 supports-[backdrop-filter]:bg-background/80 sticky bottom-0 mt-0 flex-row justify-end gap-2 border-t p-4 backdrop-blur"
>
{footer}
</SheetFooter>