mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
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:
1 parent
4ca3de5ff3
commit
04765ea8ac
92 files changed
+1433
-1445
No files matched your search
+18
-4
@@ -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
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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>
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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" />
|
||||
),
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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" />
|
||||
),
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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
@@ -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>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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", {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in new issue
Block a user