mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 14:16:12 +00:00
Merge branch 'fix/qol-a11y-sweep' into release/v1.27.32
# Conflicts: # src/components/dashboard/recent-workouts-tile.tsx
This commit is contained in:
commit
e4eb3b04ab
63 files changed
+1389
-889
No files matched your search
@@ -192,6 +192,11 @@ export function SettingsToggle({
|
||||
</div>
|
||||
</div>
|
||||
<Switch
|
||||
// The visible label is a sibling `<p>` with no association to the
|
||||
// Radix switch button, so a screen reader would announce a bare
|
||||
// "switch, on/off". Name it explicitly — one fix covers every admin
|
||||
// toggle that mounts this shared component.
|
||||
aria-label={label}
|
||||
checked={checked}
|
||||
onCheckedChange={onCheckedChange}
|
||||
disabled={disabled}
|
||||
|
||||
@@ -56,7 +56,7 @@ export function ChartEmptyState({
|
||||
style={height === undefined ? undefined : { height }}
|
||||
>
|
||||
<LineChartIcon
|
||||
className="text-muted-foreground/60 h-8 w-8"
|
||||
className="text-muted-foreground h-8 w-8"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<p className="text-sm font-medium">{title}</p>
|
||||
|
||||
@@ -73,7 +73,7 @@ export function ChartErrorState({
|
||||
style={height === undefined ? undefined : { height }}
|
||||
>
|
||||
<AlertTriangle
|
||||
className="text-muted-foreground/60 h-8 w-8"
|
||||
className="text-muted-foreground h-8 w-8"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<p className="text-sm font-medium">{title}</p>
|
||||
|
||||
@@ -90,6 +90,7 @@ export function ComplianceLineChart({
|
||||
<Button
|
||||
key={r.label}
|
||||
variant={activeRangePoints === r.points ? "default" : "ghost"}
|
||||
aria-pressed={activeRangePoints === r.points}
|
||||
size="sm"
|
||||
className="min-h-11 px-3 text-xs"
|
||||
onClick={() => setActiveRangePoints(r.points)}
|
||||
|
||||
@@ -1666,6 +1666,7 @@ export function HealthChart({
|
||||
<Button
|
||||
key={r.labelKey}
|
||||
variant={rangePoints === r.points ? "default" : "ghost"}
|
||||
aria-pressed={rangePoints === r.points}
|
||||
size="sm"
|
||||
className="min-h-11 px-2 text-xs sm:px-3"
|
||||
onClick={() => {
|
||||
|
||||
@@ -1050,6 +1050,7 @@ export function MoodChart({
|
||||
<Button
|
||||
key={r.labelKey}
|
||||
variant={rangePoints === r.points ? "default" : "ghost"}
|
||||
aria-pressed={rangePoints === r.points}
|
||||
size="sm"
|
||||
className="min-h-11 px-2 text-xs sm:px-3"
|
||||
onClick={() => setRangePoints(r.points)}
|
||||
|
||||
@@ -106,6 +106,7 @@ export function CustomMetricChart({
|
||||
key={r.key}
|
||||
size="sm"
|
||||
variant={range === r.key ? "secondary" : "ghost"}
|
||||
aria-pressed={range === r.key}
|
||||
className="h-7 px-2 text-xs"
|
||||
onClick={() => setRange(r.key)}
|
||||
>
|
||||
|
||||
@@ -15,6 +15,7 @@ import { toast } from "sonner";
|
||||
import { DeleteButton } from "@/components/data-list";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { QueryErrorCard } from "@/components/ui/query-error-card";
|
||||
import { EmptyState } from "@/components/ui/empty-state";
|
||||
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
@@ -178,23 +179,13 @@ export function CustomMetricDetail({
|
||||
{description}
|
||||
</p>
|
||||
</header>
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center gap-3 py-8 text-center">
|
||||
<p className="text-destructive text-sm">
|
||||
{t("customMetrics.loadError")}
|
||||
</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
if (metricError) void refetchMetric();
|
||||
if (listError) void refetchList();
|
||||
}}
|
||||
>
|
||||
{t("common.retry")}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<QueryErrorCard
|
||||
description={t("customMetrics.loadError")}
|
||||
onRetry={() => {
|
||||
if (metricError) void refetchMetric();
|
||||
if (listError) void refetchList();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,11 +2,13 @@
|
||||
|
||||
import { useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { ChevronRight, Gauge, Plus } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { QueryErrorCard } from "@/components/ui/query-error-card";
|
||||
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { apiGet } from "@/lib/api/api-fetch";
|
||||
@@ -27,10 +29,11 @@ import type { CustomMetricDto, CustomMetricListResponse } from "./types";
|
||||
*/
|
||||
export function CustomMetricList() {
|
||||
const { t } = useTranslations();
|
||||
const router = useRouter();
|
||||
const [addOpen, setAddOpen] = useState(false);
|
||||
const [addFooterEl, setAddFooterEl] = useState<HTMLDivElement | null>(null);
|
||||
|
||||
const { data, isLoading, isError } = useQuery({
|
||||
const { data, isLoading, isError, refetch } = useQuery({
|
||||
queryKey: queryKeys.customMetrics(),
|
||||
queryFn: () => apiGet<CustomMetricListResponse>("/api/custom-metrics"),
|
||||
});
|
||||
@@ -40,10 +43,8 @@ export function CustomMetricList() {
|
||||
function afterAdd(saved: CustomMetricDto) {
|
||||
setAddOpen(false);
|
||||
// Land the user on the new metric's detail page so the next step (logging
|
||||
// a value) is one tap away.
|
||||
if (typeof window !== "undefined") {
|
||||
window.location.assign(`/custom-metrics/${saved.id}`);
|
||||
}
|
||||
// a value) is one tap away — client nav, not a full-page reload.
|
||||
router.push(`/custom-metrics/${saved.id}`);
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -80,9 +81,10 @@ export function CustomMetricList() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : isError ? (
|
||||
<p role="alert" className="text-destructive py-4 text-center text-sm">
|
||||
{t("customMetrics.loadError")}
|
||||
</p>
|
||||
<QueryErrorCard
|
||||
description={t("customMetrics.loadError")}
|
||||
onRetry={() => refetch()}
|
||||
/>
|
||||
) : metrics.length === 0 ? (
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center gap-3 py-6 text-center">
|
||||
|
||||
@@ -3,7 +3,13 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import dynamic from "next/dynamic";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { Loader2, Plus, RefreshCw, Sparkles } from "lucide-react";
|
||||
import {
|
||||
Loader2,
|
||||
Plus,
|
||||
RefreshCw,
|
||||
SlidersHorizontal,
|
||||
Sparkles,
|
||||
} from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
@@ -100,6 +106,13 @@ export function CycleView() {
|
||||
: "calendar";
|
||||
}, [searchParams]);
|
||||
|
||||
// Controlled so the header "cycle settings" wrench (parity with the
|
||||
// labs/medications/illness modules, which expose a customize glyph) can jump
|
||||
// to the settings tab. Initialised from the `?tab=` deep-link, matching the
|
||||
// prior uncontrolled `defaultValue` behaviour (the initial param is honoured;
|
||||
// a later param change does not force-switch a tab the user is reading).
|
||||
const [tab, setTab] = useState<CycleTab>(initialTab);
|
||||
|
||||
const today = useMemo(() => shiftToday(0), []);
|
||||
const from = useMemo(() => shiftToday(-90), []);
|
||||
const to = useMemo(() => shiftToday(180), []);
|
||||
@@ -158,13 +171,26 @@ export function CycleView() {
|
||||
title={<span data-tour-id="cycle-hero">{t("cycle.title")}</span>}
|
||||
description={t("cycle.subtitle")}
|
||||
actions={
|
||||
<Button
|
||||
onClick={() => openSheet(today)}
|
||||
className="min-h-11 sm:min-h-9"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
{t("cycle.logToday")}
|
||||
</Button>
|
||||
<>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="min-h-11 min-w-11 sm:min-h-9 sm:min-w-9"
|
||||
aria-label={t("cycle.tabs.settings")}
|
||||
title={t("cycle.tabs.settings")}
|
||||
data-slot="cycle-settings-wrench"
|
||||
onClick={() => setTab("settings")}
|
||||
>
|
||||
<SlidersHorizontal className="h-4 w-4" aria-hidden="true" />
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => openSheet(today)}
|
||||
className="min-h-11 sm:min-h-9"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
{t("cycle.logToday")}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -266,7 +292,7 @@ export function CycleView() {
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<Tabs defaultValue={initialTab}>
|
||||
<Tabs value={tab} onValueChange={(v) => setTab(v as CycleTab)}>
|
||||
{/* v1.15.10 — the four German labels (Kalender/Prognosen/
|
||||
Erkenntnisse/Einstellungen) overflowed the `flex-1` equal-width
|
||||
cells at 375 px and forced a horizontal scroll. Below `sm` the
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { useWorkouts, type WorkoutListEntry } from "@/hooks/use-workouts";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { TileHeader } from "@/components/insights/tile-header";
|
||||
import { getDateTimeFormat } from "@/lib/intl/formatter-cache";
|
||||
|
||||
/**
|
||||
@@ -108,23 +109,23 @@ export function RecentWorkoutsTile() {
|
||||
"bg-card border-border space-y-3 rounded-xl border p-4 md:p-6",
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Activity
|
||||
className="text-muted-foreground size-4"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<h2 className="text-sm font-semibold">
|
||||
{t("dashboard.recentWorkouts.title")}
|
||||
</h2>
|
||||
</div>
|
||||
<Link
|
||||
href="/insights/workouts"
|
||||
className="text-muted-foreground hover:text-foreground inline-flex min-h-11 items-center text-xs"
|
||||
>
|
||||
{t("dashboard.recentWorkouts.viewAll")}
|
||||
</Link>
|
||||
</div>
|
||||
{/* Canonical tile header (foreground icon + title) — the same
|
||||
`TileHeader` contract the Insights tiles use, so the dashboard
|
||||
stops speaking a separate header language. `size="sm"` matches the
|
||||
compact dashboard tile. */}
|
||||
<TileHeader
|
||||
icon={Activity}
|
||||
size="sm"
|
||||
title={t("dashboard.recentWorkouts.title")}
|
||||
right={
|
||||
<Link
|
||||
href="/insights/workouts"
|
||||
className="text-muted-foreground hover:text-foreground inline-flex min-h-11 items-center text-xs"
|
||||
>
|
||||
{t("dashboard.recentWorkouts.viewAll")}
|
||||
</Link>
|
||||
}
|
||||
/>
|
||||
|
||||
{isLoading ? (
|
||||
// v1.4.43 W11-L6 — reserve roughly the loaded tile height
|
||||
|
||||
@@ -95,6 +95,36 @@ describe("<DailyBriefing>", () => {
|
||||
expect(html).toContain("Weight down 30 d");
|
||||
});
|
||||
|
||||
it("links the key-finding rows for metrics whose Insights sub-page exists", () => {
|
||||
// Regression guard for the stale METRIC_HREF map: hrv / resting_hr /
|
||||
// active_energy / flights / distance / body_temp / recovery all have
|
||||
// dedicated Insights sub-pages now, so their briefing rows must be
|
||||
// tappable `<a>` links, not inert rows.
|
||||
const html = render(
|
||||
<DailyBriefing
|
||||
briefing={{
|
||||
...baseBriefing,
|
||||
keyFindings: [
|
||||
{ ...baseBriefing.keyFindings[2], sourceMetric: "hrv" },
|
||||
{ ...baseBriefing.keyFindings[2], sourceMetric: "resting_hr" },
|
||||
{ ...baseBriefing.keyFindings[2], sourceMetric: "active_energy" },
|
||||
{ ...baseBriefing.keyFindings[2], sourceMetric: "flights" },
|
||||
{ ...baseBriefing.keyFindings[2], sourceMetric: "distance" },
|
||||
{ ...baseBriefing.keyFindings[2], sourceMetric: "body_temp" },
|
||||
{ ...baseBriefing.keyFindings[2], sourceMetric: "recovery" },
|
||||
],
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
expect(html).toContain('href="/insights/hrv"');
|
||||
expect(html).toContain('href="/insights/resting-pulse"');
|
||||
expect(html).toContain('href="/insights/active-energy"');
|
||||
expect(html).toContain('href="/insights/flights-climbed"');
|
||||
expect(html).toContain('href="/insights/walking-distance"');
|
||||
expect(html).toContain('href="/insights/body-temperature"');
|
||||
expect(html).toContain('href="/insights/recovery"');
|
||||
});
|
||||
|
||||
it("renders the delta string when supplied", () => {
|
||||
const html = render(<DailyBriefing briefing={baseBriefing} />);
|
||||
expect(html).toMatch(/data-slot="daily-briefing-delta"/);
|
||||
|
||||
@@ -53,28 +53,28 @@ export const METRIC_HREF: Record<
|
||||
pulse: "/insights/pulse",
|
||||
mood: "/insights/mood",
|
||||
compliance: "/insights/medications",
|
||||
// Apple Health / GLP-1 additive metrics — no dedicated sub-page yet.
|
||||
// They render as static rows; v1.4.28 can wire each as they ship.
|
||||
hrv: null,
|
||||
// Apple Health / GLP-1 additive metrics — each now routes to its
|
||||
// dedicated sub-page that shipped since the original map was written.
|
||||
hrv: "/insights/hrv",
|
||||
sleep: "/insights/sleep",
|
||||
resting_hr: null,
|
||||
resting_hr: "/insights/resting-pulse",
|
||||
// v1.12.4 — steps has no dedicated metric sub-page, so it routes to the
|
||||
// generic readings view keyed to its `MeasurementType` (the same target
|
||||
// the category pages use to drill into a single type). Every other
|
||||
// briefing finding with a real destination is already tappable; steps
|
||||
// was the lone static row.
|
||||
steps: "/insights/steps",
|
||||
active_energy: null,
|
||||
flights: null,
|
||||
distance: null,
|
||||
active_energy: "/insights/active-energy",
|
||||
flights: "/insights/flights-climbed",
|
||||
distance: "/insights/walking-distance",
|
||||
vo2_max: "/insights/pulse",
|
||||
body_temp: null,
|
||||
body_temp: "/insights/body-temperature",
|
||||
glp1_plateau: "/insights/medications",
|
||||
// ── v1.10.0 derived-wellness additive ──
|
||||
// Readiness has a score-anatomy detail page; recovery is a read-only
|
||||
// ring with no dedicated sub-page yet (renders as a static row).
|
||||
// Readiness has a score-anatomy detail page; recovery has its own
|
||||
// read-only sub-page.
|
||||
readiness: "/insights/scores/readiness",
|
||||
recovery: null,
|
||||
recovery: "/insights/recovery",
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -291,8 +291,11 @@ export function HeroStrip({
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-5">
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Header glyph stays in the foreground tier — UI-STANDARDS §1
|
||||
reserves primary/accent for actions, not decorative header
|
||||
icons. */}
|
||||
<Sparkles
|
||||
className="text-primary h-5 w-5 shrink-0"
|
||||
className="text-foreground h-5 w-5 shrink-0"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<h1
|
||||
|
||||
@@ -115,6 +115,7 @@ export function LabBiomarkerChart({
|
||||
key={r.key}
|
||||
size="sm"
|
||||
variant={range === r.key ? "secondary" : "ghost"}
|
||||
aria-pressed={range === r.key}
|
||||
className="h-7 px-2 text-xs"
|
||||
onClick={() => setRange(r.key)}
|
||||
>
|
||||
|
||||
@@ -9,9 +9,17 @@ import {
|
||||
useQuery,
|
||||
useQueryClient,
|
||||
} from "@tanstack/react-query";
|
||||
import { FlaskConical, ListOrdered, Pencil, Plus } from "lucide-react";
|
||||
import {
|
||||
ArrowRight,
|
||||
FlaskConical,
|
||||
FolderOpen,
|
||||
ListOrdered,
|
||||
Pencil,
|
||||
Plus,
|
||||
} from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { DeleteButton } from "@/components/data-list";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
@@ -84,6 +92,7 @@ const READINGS_PAGE_SIZE = 200;
|
||||
* it. Mirrors the measurement-detail layout.
|
||||
*/
|
||||
export function LabBiomarkerDetail({ biomarkerId }: { biomarkerId: string }) {
|
||||
const { user } = useAuth();
|
||||
const { t } = useTranslations();
|
||||
const router = useRouter();
|
||||
const queryClient = useQueryClient();
|
||||
@@ -367,6 +376,22 @@ export function LabBiomarkerDetail({ biomarkerId }: { biomarkerId: string }) {
|
||||
catalog-less marker still carries a description. */}
|
||||
<p className="text-foreground text-sm leading-relaxed">{description}</p>
|
||||
|
||||
{/* Bridge to the source report PDFs. There is no per-value document
|
||||
relation in the data model yet, so this links to the vault filtered
|
||||
to lab-result documents rather than the exact scan behind one value.
|
||||
Only when the documents module is enabled. */}
|
||||
{user?.modules?.inboundDocuments ? (
|
||||
<Link
|
||||
href="/documents?kind=LAB_RESULT"
|
||||
data-slot="lab-biomarker-documents-link"
|
||||
className="text-muted-foreground hover:text-foreground focus-visible:ring-ring/50 inline-flex items-center gap-1.5 rounded-md text-sm transition-colors focus-visible:ring-[3px] focus-visible:outline-none"
|
||||
>
|
||||
<FolderOpen className="size-4" aria-hidden="true" />
|
||||
{t("labs.documentsLink")}
|
||||
<ArrowRight className="size-3.5" aria-hidden="true" />
|
||||
</Link>
|
||||
) : null}
|
||||
|
||||
{isLoading ? (
|
||||
<Card>
|
||||
<CardContent>
|
||||
|
||||
@@ -230,11 +230,17 @@ export function LabList({ onAddFirst }: { onAddFirst?: () => void } = {}) {
|
||||
<ChevronRight className="text-muted-foreground h-4 w-4 shrink-0" />
|
||||
</Link>
|
||||
) : (
|
||||
// Un-linked group (not backfilled to a catalog biomarker):
|
||||
// render inert but say so, so it doesn't read as a broken
|
||||
// link next to its clickable neighbours.
|
||||
<div className="flex min-w-0 flex-1 items-center gap-3">
|
||||
{inner}
|
||||
<LabTrendSparkline
|
||||
values={group.readings.map((r) => r.value)}
|
||||
/>
|
||||
<span className="text-muted-foreground shrink-0 text-xs italic">
|
||||
{t("labs.notLinkedYet")}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -319,6 +325,14 @@ export function LabList({ onAddFirst }: { onAddFirst?: () => void } = {}) {
|
||||
values={group.readings.map((r) => r.value)}
|
||||
/>
|
||||
</div>
|
||||
{/* Un-linked group: the header renders no link, so name the
|
||||
reason rather than leaving a card that silently does
|
||||
nothing on tap. */}
|
||||
{!group.biomarkerId ? (
|
||||
<p className="text-muted-foreground text-xs italic">
|
||||
{t("labs.notLinkedYet")}
|
||||
</p>
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</li>
|
||||
|
||||
@@ -54,7 +54,7 @@ export function LabTrendSparkline({
|
||||
width={width}
|
||||
height={height}
|
||||
viewBox={`0 0 ${width} ${height}`}
|
||||
className="text-muted-foreground/70 overflow-visible"
|
||||
className="text-muted-foreground overflow-visible"
|
||||
aria-hidden
|
||||
role="presentation"
|
||||
>
|
||||
|
||||
@@ -17,20 +17,13 @@
|
||||
* ad-hoc (no `scheduledFor`); an exact slot match pins directly with no nudge.
|
||||
*/
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import { useId, useMemo, useState } from "react";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { DateTimeField } from "@/components/ui/date-time-field";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
@@ -135,6 +128,7 @@ export function LedgerAddDialog({
|
||||
}: LedgerAddDialogProps) {
|
||||
const { t } = useTranslations();
|
||||
const queryClient = useQueryClient();
|
||||
const formId = useId();
|
||||
|
||||
const [takenAt, setTakenAt] = useState(() => toDateTimeLocal(new Date()));
|
||||
const [skipped, setSkipped] = useState(false);
|
||||
@@ -200,136 +194,127 @@ export function LedgerAddDialog({
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(o) => !busy && onOpenChange(o)}>
|
||||
<DialogContent data-slot="ledger-add-dialog">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{t("medications.detail.verlauf.addDialog.title")}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t("medications.detail.verlauf.addDialog.description")}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{/* v1.16.4 — a real form so Enter in the datetime field submits;
|
||||
the buttons carry explicit types so cancel never submits. The
|
||||
nested nudge dialog renders through a portal, so its buttons
|
||||
never land inside this form. */}
|
||||
<form
|
||||
className="space-y-4"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
submit();
|
||||
}}
|
||||
>
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="ledger-add-taken-at">
|
||||
{t("medications.detail.verlauf.addDialog.takenAtLabel")}
|
||||
</Label>
|
||||
<DateTimeField
|
||||
id="ledger-add-taken-at"
|
||||
value={takenAt}
|
||||
max={toDateTimeLocal(new Date())}
|
||||
onChange={(value) => setTakenAt(value)}
|
||||
disabled={skipped}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<label
|
||||
htmlFor="ledger-add-skipped"
|
||||
className="flex items-center justify-between gap-3"
|
||||
>
|
||||
<span className="text-sm font-medium">
|
||||
{t("medications.detail.verlauf.addDialog.skippedLabel")}
|
||||
</span>
|
||||
<Switch
|
||||
id="ledger-add-skipped"
|
||||
checked={skipped}
|
||||
onCheckedChange={setSkipped}
|
||||
/>
|
||||
</label>
|
||||
|
||||
{/* A near-miss take previews where it will land before the user
|
||||
commits — calm, not alarming. */}
|
||||
{!skipped && classification === "nudge" && nearest && (
|
||||
<p
|
||||
className="text-muted-foreground text-xs"
|
||||
data-slot="ledger-add-nudge-hint"
|
||||
>
|
||||
{t("medications.detail.verlauf.addDialog.nudgeHint", {
|
||||
slot: nearest.slotHm,
|
||||
})}
|
||||
</p>
|
||||
<ResponsiveSheet
|
||||
open={open}
|
||||
onOpenChange={(o) => !busy && onOpenChange(o)}
|
||||
title={t("medications.detail.verlauf.addDialog.title")}
|
||||
description={t("medications.detail.verlauf.addDialog.description")}
|
||||
footer={
|
||||
<>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => onOpenChange(false)}
|
||||
disabled={busy}
|
||||
>
|
||||
{t("medications.detail.verlauf.addDialog.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
form={formId}
|
||||
disabled={busy}
|
||||
aria-busy={busy || undefined}
|
||||
data-slot="ledger-add-submit"
|
||||
>
|
||||
{busy && (
|
||||
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
|
||||
)}
|
||||
{t("medications.detail.verlauf.addDialog.submit")}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
{/* v1.16.4 — a real form so Enter in the datetime field submits;
|
||||
the buttons carry explicit types so cancel never submits. */}
|
||||
<form
|
||||
id={formId}
|
||||
className="space-y-4"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
submit();
|
||||
}}
|
||||
>
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="ledger-add-taken-at">
|
||||
{t("medications.detail.verlauf.addDialog.takenAtLabel")}
|
||||
</Label>
|
||||
<DateTimeField
|
||||
id="ledger-add-taken-at"
|
||||
value={takenAt}
|
||||
max={toDateTimeLocal(new Date())}
|
||||
onChange={(value) => setTakenAt(value)}
|
||||
disabled={skipped}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => onOpenChange(false)}
|
||||
disabled={busy}
|
||||
>
|
||||
{t("medications.detail.verlauf.addDialog.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={busy}
|
||||
aria-busy={busy || undefined}
|
||||
data-slot="ledger-add-submit"
|
||||
>
|
||||
{busy && (
|
||||
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
|
||||
)}
|
||||
{t("medications.detail.verlauf.addDialog.submit")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
|
||||
{/* The "diesem Slot zuordnen?" nudge — pin onto the slot, or keep the
|
||||
take as a standalone ad-hoc entry. */}
|
||||
{nudgeOpen && nearest && (
|
||||
<Dialog
|
||||
open={nudgeOpen}
|
||||
onOpenChange={(o) => !busy && setNudgeOpen(o)}
|
||||
<label
|
||||
htmlFor="ledger-add-skipped"
|
||||
className="flex items-center justify-between gap-3"
|
||||
>
|
||||
<DialogContent data-slot="ledger-add-nudge-dialog">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{t("medications.detail.verlauf.nudge.title")}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t("medications.detail.verlauf.nudge.body", {
|
||||
slot: nearest.slotHm,
|
||||
})}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => void write(false)}
|
||||
disabled={busy}
|
||||
data-slot="ledger-add-nudge-adhoc"
|
||||
>
|
||||
{t("medications.detail.verlauf.nudge.keepAdHoc")}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => void write(true)}
|
||||
disabled={busy}
|
||||
aria-busy={busy || undefined}
|
||||
data-slot="ledger-add-nudge-pin"
|
||||
>
|
||||
{busy && (
|
||||
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
|
||||
)}
|
||||
{t("medications.detail.verlauf.nudge.pin")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<span className="text-sm font-medium">
|
||||
{t("medications.detail.verlauf.addDialog.skippedLabel")}
|
||||
</span>
|
||||
<Switch
|
||||
id="ledger-add-skipped"
|
||||
checked={skipped}
|
||||
onCheckedChange={setSkipped}
|
||||
/>
|
||||
</label>
|
||||
|
||||
{/* A near-miss take previews where it will land before the user
|
||||
commits — calm, not alarming. */}
|
||||
{!skipped && classification === "nudge" && nearest && (
|
||||
<p
|
||||
className="text-muted-foreground text-xs"
|
||||
data-slot="ledger-add-nudge-hint"
|
||||
>
|
||||
{t("medications.detail.verlauf.addDialog.nudgeHint", {
|
||||
slot: nearest.slotHm,
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{/* The "diesem Slot zuordnen?" nudge — pin onto the slot, or keep the
|
||||
take as a standalone ad-hoc entry. */}
|
||||
{nudgeOpen && nearest && (
|
||||
<ResponsiveSheet
|
||||
open={nudgeOpen}
|
||||
onOpenChange={(o) => !busy && setNudgeOpen(o)}
|
||||
title={t("medications.detail.verlauf.nudge.title")}
|
||||
footer={
|
||||
<>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => void write(false)}
|
||||
disabled={busy}
|
||||
data-slot="ledger-add-nudge-adhoc"
|
||||
>
|
||||
{t("medications.detail.verlauf.nudge.keepAdHoc")}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => void write(true)}
|
||||
disabled={busy}
|
||||
aria-busy={busy || undefined}
|
||||
data-slot="ledger-add-nudge-pin"
|
||||
>
|
||||
{busy && (
|
||||
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
|
||||
)}
|
||||
{t("medications.detail.verlauf.nudge.pin")}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("medications.detail.verlauf.nudge.body", {
|
||||
slot: nearest.slotHm,
|
||||
})}
|
||||
</p>
|
||||
</ResponsiveSheet>
|
||||
)}
|
||||
</ResponsiveSheet>
|
||||
);
|
||||
}
|
||||
@@ -927,7 +927,7 @@ function DeleteRowConfirm({
|
||||
e.preventDefault();
|
||||
onConfirm();
|
||||
}}
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
className="bg-destructive dark:bg-destructive/60 hover:bg-destructive/90 text-white"
|
||||
>
|
||||
{t("medications.detail.intake.deleteRow.confirmAction")}
|
||||
</AlertDialogAction>
|
||||
|
||||
@@ -21,19 +21,13 @@
|
||||
* persisted it (audit LOW-10), so the dialog no longer pretends.
|
||||
*/
|
||||
|
||||
import { useState } from "react";
|
||||
import { useId, useState } from "react";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import { Loader2 } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
|
||||
import { DateTimeField } from "@/components/ui/date-time-field";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
@@ -107,6 +101,7 @@ function IntakeEditDialogBody({
|
||||
const { t } = useTranslations();
|
||||
const fmt = useFormatters();
|
||||
const queryClient = useQueryClient();
|
||||
const formId = useId();
|
||||
const [takenAt, setTakenAt] = useState(() =>
|
||||
toDateTimeLocal(event?.takenAt ?? null),
|
||||
);
|
||||
@@ -145,87 +140,92 @@ function IntakeEditDialogBody({
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={!!event} onOpenChange={(open) => !open && onClose()}>
|
||||
<DialogContent data-slot="intake-edit-dialog">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{t("medications.detail.intake.edit.dialogTitle")}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
{/* v1.16.4 — a real form so Enter in the datetime field submits;
|
||||
<ResponsiveSheet
|
||||
open={!!event}
|
||||
onOpenChange={(open) => !open && onClose()}
|
||||
title={t("medications.detail.intake.edit.dialogTitle")}
|
||||
footer={
|
||||
<>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={onClose}
|
||||
disabled={busy}
|
||||
>
|
||||
{t("medications.detail.intake.edit.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
form={formId}
|
||||
disabled={busy}
|
||||
aria-busy={busy || undefined}
|
||||
>
|
||||
{busy && (
|
||||
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
|
||||
)}
|
||||
{t("medications.detail.intake.edit.save")}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
{/* v1.16.4 — a real form so Enter in the datetime field submits;
|
||||
the buttons carry explicit types so cancel never submits. */}
|
||||
<form
|
||||
className="space-y-4"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
void save();
|
||||
}}
|
||||
>
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="intake-edit-taken-at">
|
||||
{t("medications.detail.intake.edit.takenAtLabel")}
|
||||
</Label>
|
||||
<DateTimeField
|
||||
id="intake-edit-taken-at"
|
||||
value={takenAt}
|
||||
max={toDateTimeLocal(new Date())}
|
||||
onChange={(value) => setTakenAt(value)}
|
||||
disabled={skipped}
|
||||
/>
|
||||
{scheduledFor && (
|
||||
<p
|
||||
className="text-muted-foreground text-xs"
|
||||
data-slot="intake-edit-scheduled-hint"
|
||||
>
|
||||
{t("medications.detail.intake.edit.scheduledForHint", {
|
||||
dateTime: fmt.dateTime(scheduledFor),
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
{/* Non-blocking: a 48h+ gap to the slot is almost always a
|
||||
<form
|
||||
id={formId}
|
||||
className="space-y-4"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
void save();
|
||||
}}
|
||||
>
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="intake-edit-taken-at">
|
||||
{t("medications.detail.intake.edit.takenAtLabel")}
|
||||
</Label>
|
||||
<DateTimeField
|
||||
id="intake-edit-taken-at"
|
||||
value={takenAt}
|
||||
max={toDateTimeLocal(new Date())}
|
||||
onChange={(value) => setTakenAt(value)}
|
||||
disabled={skipped}
|
||||
/>
|
||||
{scheduledFor && (
|
||||
<p
|
||||
className="text-muted-foreground text-xs"
|
||||
data-slot="intake-edit-scheduled-hint"
|
||||
>
|
||||
{t("medications.detail.intake.edit.scheduledForHint", {
|
||||
dateTime: fmt.dateTime(scheduledFor),
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
{/* Non-blocking: a 48h+ gap to the slot is almost always a
|
||||
date typo, but a deliberate far edit stays saveable. */}
|
||||
{showFarHint && (
|
||||
<p
|
||||
className="text-warning text-xs"
|
||||
data-slot="intake-edit-far-hint"
|
||||
>
|
||||
{t("medications.detail.intake.edit.farFromScheduledWarning")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<label
|
||||
htmlFor="intake-edit-skipped"
|
||||
className="flex items-center justify-between gap-3"
|
||||
>
|
||||
<span className="text-sm font-medium">
|
||||
{t("medications.detail.intake.edit.skippedLabel")}
|
||||
</span>
|
||||
<Switch
|
||||
id="intake-edit-skipped"
|
||||
checked={skipped}
|
||||
onCheckedChange={(checked) => setSkipped(checked)}
|
||||
/>
|
||||
</label>
|
||||
{showFarHint && (
|
||||
<p
|
||||
className="text-warning text-xs"
|
||||
data-slot="intake-edit-far-hint"
|
||||
>
|
||||
{t("medications.detail.intake.edit.farFromScheduledWarning")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={onClose}
|
||||
disabled={busy}
|
||||
>
|
||||
{t("medications.detail.intake.edit.cancel")}
|
||||
</Button>
|
||||
<Button type="submit" disabled={busy} aria-busy={busy || undefined}>
|
||||
{busy && (
|
||||
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
|
||||
)}
|
||||
{t("medications.detail.intake.edit.save")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<label
|
||||
htmlFor="intake-edit-skipped"
|
||||
className="flex items-center justify-between gap-3"
|
||||
>
|
||||
<span className="text-sm font-medium">
|
||||
{t("medications.detail.intake.edit.skippedLabel")}
|
||||
</span>
|
||||
<Switch
|
||||
id="intake-edit-skipped"
|
||||
checked={skipped}
|
||||
onCheckedChange={(checked) => setSkipped(checked)}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</form>
|
||||
</ResponsiveSheet>
|
||||
);
|
||||
}
|
||||
@@ -228,7 +228,7 @@ export function IntakeHistoryEditable({
|
||||
}}
|
||||
disabled={bulkBusy}
|
||||
aria-busy={bulkBusy || undefined}
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
className="bg-destructive dark:bg-destructive/60 hover:bg-destructive/90 text-white"
|
||||
>
|
||||
{t("medications.detail.intake.bulkDelete.confirmAction")}
|
||||
</AlertDialogAction>
|
||||
@@ -262,7 +262,7 @@ export function IntakeHistoryEditable({
|
||||
}}
|
||||
disabled={rowDeleteBusy}
|
||||
aria-busy={rowDeleteBusy || undefined}
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
className="bg-destructive dark:bg-destructive/60 hover:bg-destructive/90 text-white"
|
||||
>
|
||||
{t("medications.detail.intake.deleteRow.confirmAction")}
|
||||
</AlertDialogAction>
|
||||
|
||||
@@ -22,12 +22,7 @@ import { useQueryClient } from "@tanstack/react-query";
|
||||
import { Loader2, MoreHorizontal, RotateCcw, Upload } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
@@ -143,105 +138,104 @@ export function IntakeImportDialog({
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={!!medicationId} onOpenChange={() => handleClose()}>
|
||||
<DialogContent className="sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("medications.importIntakes")}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("medications.importDescription")}
|
||||
</p>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="intake-import-file" className="text-xs font-medium">
|
||||
{t("medications.importUploadFile")}
|
||||
</Label>
|
||||
<input
|
||||
key={fileInputKey}
|
||||
id="intake-import-file"
|
||||
type="file"
|
||||
accept="application/json,.json"
|
||||
onChange={handleFileSelect}
|
||||
className="border-input bg-background text-foreground file:bg-muted file:text-foreground w-full cursor-pointer rounded-md border text-sm file:mr-2 file:border-0 file:px-3 file:py-2"
|
||||
/>
|
||||
{selectedFileName && (
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("medications.importSelected", { name: selectedFileName })}
|
||||
</p>
|
||||
<ResponsiveSheet
|
||||
open={!!medicationId}
|
||||
onOpenChange={() => handleClose()}
|
||||
title={t("medications.importIntakes")}
|
||||
contentWidth="lg"
|
||||
footer={
|
||||
<>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="mr-auto h-9 w-9"
|
||||
disabled={importing}
|
||||
aria-label={t("common.moreOptions")}
|
||||
>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start">
|
||||
<DropdownMenuItem onClick={resetImportForm}>
|
||||
<RotateCcw className="mr-2 h-4 w-4" />
|
||||
{t("common.reset")}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleClose}
|
||||
disabled={importing}
|
||||
className="min-h-11 sm:min-h-9"
|
||||
>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleImport}
|
||||
disabled={importing || !jsonText.trim()}
|
||||
aria-busy={importing || undefined}
|
||||
className="min-h-11 sm:min-h-9"
|
||||
>
|
||||
{importing ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
|
||||
) : (
|
||||
<Upload className="h-4 w-4" />
|
||||
)}
|
||||
</div>
|
||||
<pre className="bg-muted text-muted-foreground rounded-lg p-3 text-xs">
|
||||
{`[
|
||||
{t("common.import")}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("medications.importDescription")}
|
||||
</p>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="intake-import-file" className="text-xs font-medium">
|
||||
{t("medications.importUploadFile")}
|
||||
</Label>
|
||||
<input
|
||||
key={fileInputKey}
|
||||
id="intake-import-file"
|
||||
type="file"
|
||||
accept="application/json,.json"
|
||||
onChange={handleFileSelect}
|
||||
className="border-input bg-background text-foreground file:bg-muted file:text-foreground w-full cursor-pointer rounded-md border text-sm file:mr-2 file:border-0 file:px-3 file:py-2"
|
||||
/>
|
||||
{selectedFileName && (
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("medications.importSelected", { name: selectedFileName })}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<pre className="bg-muted text-muted-foreground rounded-lg p-3 text-xs">
|
||||
{`[
|
||||
{"datum": "2026-02-14", "uhrzeit": "10:27:43", "zaehler": 523},
|
||||
{"datum": "2026-02-14", "uhrzeit": "23:33:42", "zaehler": 524}
|
||||
]`}
|
||||
</pre>
|
||||
<Textarea
|
||||
value={jsonText}
|
||||
onChange={(e) => setJsonText(e.target.value)}
|
||||
placeholder={t("medications.importPaste")}
|
||||
rows={8}
|
||||
autoCapitalize="none"
|
||||
spellCheck={false}
|
||||
className="font-mono"
|
||||
/>
|
||||
{result && (
|
||||
<p
|
||||
className={`text-sm ${resultType === "success" ? "text-success" : "text-destructive"}`}
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
>
|
||||
{result}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="h-9 w-9"
|
||||
disabled={importing}
|
||||
aria-label={t("common.moreOptions")}
|
||||
>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start">
|
||||
<DropdownMenuItem onClick={resetImportForm}>
|
||||
<RotateCcw className="mr-2 h-4 w-4" />
|
||||
{t("common.reset")}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
<div className="flex flex-wrap items-center justify-end gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleClose}
|
||||
disabled={importing}
|
||||
className="min-h-11 sm:min-h-9"
|
||||
>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleImport}
|
||||
disabled={importing || !jsonText.trim()}
|
||||
aria-busy={importing || undefined}
|
||||
className="min-h-11 sm:min-h-9"
|
||||
>
|
||||
{importing ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
|
||||
) : (
|
||||
<Upload className="h-4 w-4" />
|
||||
)}
|
||||
{t("common.import")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</pre>
|
||||
<Textarea
|
||||
value={jsonText}
|
||||
onChange={(e) => setJsonText(e.target.value)}
|
||||
placeholder={t("medications.importPaste")}
|
||||
rows={8}
|
||||
autoCapitalize="none"
|
||||
spellCheck={false}
|
||||
className="font-mono"
|
||||
/>
|
||||
{result && (
|
||||
<p
|
||||
className={`text-sm ${resultType === "success" ? "text-success" : "text-destructive"}`}
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
>
|
||||
{result}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</ResponsiveSheet>
|
||||
);
|
||||
}
|
||||
@@ -4,14 +4,7 @@ import { useState } from "react";
|
||||
|
||||
import { Loader2 } from "lucide-react";
|
||||
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { InjectionSitePicker } from "@/components/medications/injection-site-picker";
|
||||
import {
|
||||
@@ -85,7 +78,7 @@ export function LogInjectionSiteDialog({
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
<ResponsiveSheet
|
||||
open={open}
|
||||
onOpenChange={(next) => {
|
||||
// Hold the dialog while the PATCH is in flight so a backdrop tap /
|
||||
@@ -93,76 +86,12 @@ export function LogInjectionSiteDialog({
|
||||
if (submitting) return;
|
||||
if (!next) onSkip();
|
||||
}}
|
||||
>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("medications.logInjectionSiteTitle")}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t("medications.logInjectionSiteDescription", {
|
||||
name: medicationName,
|
||||
})}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{allowed.length === 0 ? (
|
||||
<p className="text-muted-foreground py-4 text-center text-sm">
|
||||
{t("medications.logInjectionSiteNoneAvailable")}
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
<InjectionSitePicker
|
||||
value={selected}
|
||||
history={history}
|
||||
allowed={allowed}
|
||||
onChange={setSelected}
|
||||
/>
|
||||
{/* Marker legend — explains the two body-map annotations so the
|
||||
dashed primary ring and the amber ring read at a glance. The
|
||||
swatches mirror the picker's own marker strokes exactly. */}
|
||||
<ul
|
||||
className="text-muted-foreground flex flex-wrap items-center justify-center gap-x-4 gap-y-1 text-xs"
|
||||
aria-label={t("medications.injectionSiteLegendAriaLabel")}
|
||||
>
|
||||
<li className="flex items-center gap-1.5">
|
||||
<svg
|
||||
viewBox="0 0 16 16"
|
||||
className="h-3.5 w-3.5 shrink-0"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<circle
|
||||
cx="8"
|
||||
cy="8"
|
||||
r="6"
|
||||
fill="none"
|
||||
className="stroke-primary"
|
||||
strokeWidth="1.6"
|
||||
strokeDasharray="3 3"
|
||||
/>
|
||||
</svg>
|
||||
{t("medications.injectionSiteLegendRecommended")}
|
||||
</li>
|
||||
<li className="flex items-center gap-1.5">
|
||||
<svg
|
||||
viewBox="0 0 16 16"
|
||||
className="h-3.5 w-3.5 shrink-0"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<circle
|
||||
cx="8"
|
||||
cy="8"
|
||||
r="6"
|
||||
fill="none"
|
||||
className="stroke-[var(--warning)]"
|
||||
strokeWidth="1.8"
|
||||
/>
|
||||
</svg>
|
||||
{t("medications.injectionSiteLegendLastUsed")}
|
||||
</li>
|
||||
</ul>
|
||||
</>
|
||||
)}
|
||||
|
||||
<DialogFooter className="gap-2 sm:gap-2">
|
||||
title={t("medications.logInjectionSiteTitle")}
|
||||
description={t("medications.logInjectionSiteDescription", {
|
||||
name: medicationName,
|
||||
})}
|
||||
footer={
|
||||
<>
|
||||
<Button variant="outline" onClick={onSkip} disabled={submitting}>
|
||||
{t("medications.logInjectionSiteSkip")}
|
||||
</Button>
|
||||
@@ -178,8 +107,66 @@ export function LogInjectionSiteDialog({
|
||||
) : null}
|
||||
{t("medications.logInjectionSiteConfirm")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
}
|
||||
>
|
||||
{allowed.length === 0 ? (
|
||||
<p className="text-muted-foreground py-4 text-center text-sm">
|
||||
{t("medications.logInjectionSiteNoneAvailable")}
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
<InjectionSitePicker
|
||||
value={selected}
|
||||
history={history}
|
||||
allowed={allowed}
|
||||
onChange={setSelected}
|
||||
/>
|
||||
{/* Marker legend — explains the two body-map annotations so the
|
||||
dashed primary ring and the amber ring read at a glance. The
|
||||
swatches mirror the picker's own marker strokes exactly. */}
|
||||
<ul
|
||||
className="text-muted-foreground flex flex-wrap items-center justify-center gap-x-4 gap-y-1 text-xs"
|
||||
aria-label={t("medications.injectionSiteLegendAriaLabel")}
|
||||
>
|
||||
<li className="flex items-center gap-1.5">
|
||||
<svg
|
||||
viewBox="0 0 16 16"
|
||||
className="h-3.5 w-3.5 shrink-0"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<circle
|
||||
cx="8"
|
||||
cy="8"
|
||||
r="6"
|
||||
fill="none"
|
||||
className="stroke-primary"
|
||||
strokeWidth="1.6"
|
||||
strokeDasharray="3 3"
|
||||
/>
|
||||
</svg>
|
||||
{t("medications.injectionSiteLegendRecommended")}
|
||||
</li>
|
||||
<li className="flex items-center gap-1.5">
|
||||
<svg
|
||||
viewBox="0 0 16 16"
|
||||
className="h-3.5 w-3.5 shrink-0"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<circle
|
||||
cx="8"
|
||||
cy="8"
|
||||
r="6"
|
||||
fill="none"
|
||||
className="stroke-[var(--warning)]"
|
||||
strokeWidth="1.8"
|
||||
/>
|
||||
</svg>
|
||||
{t("medications.injectionSiteLegendLastUsed")}
|
||||
</li>
|
||||
</ul>
|
||||
</>
|
||||
)}
|
||||
</ResponsiveSheet>
|
||||
);
|
||||
}
|
||||
@@ -19,20 +19,13 @@
|
||||
* the form state + the medication/slot pickers.
|
||||
*/
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import { useId, useMemo, useState } from "react";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { DateTimeField } from "@/components/ui/date-time-field";
|
||||
@@ -119,6 +112,7 @@ export function LogIntakeDialog({
|
||||
}: LogIntakeDialogProps) {
|
||||
const { t } = useTranslations();
|
||||
const queryClient = useQueryClient();
|
||||
const formId = useId();
|
||||
|
||||
const [medicationId, setMedicationId] = useState<string>(
|
||||
() => medications[0]?.id ?? "",
|
||||
@@ -208,147 +202,146 @@ export function LogIntakeDialog({
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(o) => !busy && onOpenChange(o)}>
|
||||
<DialogContent data-slot="log-intake-dialog">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("medications.logIntake.dialogTitle")}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t("medications.logIntake.dialogDescription")}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{/* v1.16.4 — a real form so Enter in the dose / datetime fields
|
||||
<ResponsiveSheet
|
||||
open={open}
|
||||
onOpenChange={(o) => !busy && onOpenChange(o)}
|
||||
title={t("medications.logIntake.dialogTitle")}
|
||||
description={t("medications.logIntake.dialogDescription")}
|
||||
footer={
|
||||
<>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => onOpenChange(false)}
|
||||
disabled={busy}
|
||||
>
|
||||
{t("medications.logIntake.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
form={formId}
|
||||
disabled={busy || medications.length === 0 || !medicationId}
|
||||
aria-busy={busy || undefined}
|
||||
>
|
||||
{busy && (
|
||||
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
|
||||
)}
|
||||
{t("medications.logIntake.submit")}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
{/* v1.16.4 — a real form so Enter in the dose / datetime fields
|
||||
submits; mirrors the intake-edit and dose-history-add dialogs. */}
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
if (busy || medications.length === 0 || !medicationId) return;
|
||||
void submit();
|
||||
}}
|
||||
className="space-y-4"
|
||||
>
|
||||
{medications.length === 0 ? (
|
||||
<p className="text-muted-foreground py-4 text-sm">
|
||||
{t("medications.logIntake.noMedications")}
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
<form
|
||||
id={formId}
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
if (busy || medications.length === 0 || !medicationId) return;
|
||||
void submit();
|
||||
}}
|
||||
className="space-y-4"
|
||||
>
|
||||
{medications.length === 0 ? (
|
||||
<p className="text-muted-foreground py-4 text-sm">
|
||||
{t("medications.logIntake.noMedications")}
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="log-intake-medication">
|
||||
{t("medications.logIntake.medicationLabel")}
|
||||
</Label>
|
||||
<NativeSelect
|
||||
id="log-intake-medication"
|
||||
value={medicationId}
|
||||
onChange={(e) => handleMedicationChange(e.target.value)}
|
||||
>
|
||||
{medications.map((m) => (
|
||||
<option key={m.id} value={m.id}>
|
||||
{m.dose ? `${m.name} — ${formatDose(m.dose, t)}` : m.name}
|
||||
</option>
|
||||
))}
|
||||
</NativeSelect>
|
||||
</div>
|
||||
|
||||
{slotTimes.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="log-intake-medication">
|
||||
{t("medications.logIntake.medicationLabel")}
|
||||
<Label htmlFor="log-intake-slot">
|
||||
{t("medications.logIntake.slotLabel")}
|
||||
</Label>
|
||||
<NativeSelect
|
||||
id="log-intake-medication"
|
||||
value={medicationId}
|
||||
onChange={(e) => handleMedicationChange(e.target.value)}
|
||||
id="log-intake-slot"
|
||||
value={slot}
|
||||
onChange={(e) => {
|
||||
setSlot(e.target.value);
|
||||
// The configured baseline tracks the slot's schedule
|
||||
// dose, so an untouched field re-prefills on change.
|
||||
setDoseOverride(null);
|
||||
}}
|
||||
>
|
||||
{medications.map((m) => (
|
||||
<option key={m.id} value={m.id}>
|
||||
{m.dose ? `${m.name} — ${formatDose(m.dose, t)}` : m.name}
|
||||
<option value={NO_SLOT}>
|
||||
{t("medications.logIntake.slotNone")}
|
||||
</option>
|
||||
{slotTimes.map((time) => (
|
||||
<option key={time} value={time}>
|
||||
{time}
|
||||
</option>
|
||||
))}
|
||||
</NativeSelect>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{slotTimes.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="log-intake-slot">
|
||||
{t("medications.logIntake.slotLabel")}
|
||||
</Label>
|
||||
<NativeSelect
|
||||
id="log-intake-slot"
|
||||
value={slot}
|
||||
onChange={(e) => {
|
||||
setSlot(e.target.value);
|
||||
// The configured baseline tracks the slot's schedule
|
||||
// dose, so an untouched field re-prefills on change.
|
||||
setDoseOverride(null);
|
||||
}}
|
||||
>
|
||||
<option value={NO_SLOT}>
|
||||
{t("medications.logIntake.slotNone")}
|
||||
</option>
|
||||
{slotTimes.map((time) => (
|
||||
<option key={time} value={time}>
|
||||
{time}
|
||||
</option>
|
||||
))}
|
||||
</NativeSelect>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="log-intake-dose">
|
||||
{t("medications.logIntake.doseLabel")}
|
||||
</Label>
|
||||
<Input
|
||||
id="log-intake-dose"
|
||||
value={dose}
|
||||
maxLength={50}
|
||||
onChange={(e) => setDoseOverride(e.target.value)}
|
||||
placeholder={configuredDose}
|
||||
autoComplete="off"
|
||||
disabled={skipped}
|
||||
/>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("medications.logIntake.doseHint")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="log-intake-taken-at">
|
||||
{t("medications.logIntake.takenAtLabel")}
|
||||
</Label>
|
||||
<DateTimeField
|
||||
id="log-intake-taken-at"
|
||||
value={takenAt}
|
||||
max={toDateTimeLocal(new Date())}
|
||||
onChange={(value) => setTakenAt(value)}
|
||||
disabled={skipped}
|
||||
/>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("medications.logIntake.takenAtHint")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<label
|
||||
htmlFor="log-intake-skipped"
|
||||
className="flex items-center justify-between gap-3"
|
||||
>
|
||||
<span className="text-sm font-medium">
|
||||
{t("medications.logIntake.skippedLabel")}
|
||||
</span>
|
||||
<Switch
|
||||
id="log-intake-skipped"
|
||||
checked={skipped}
|
||||
onCheckedChange={setSkipped}
|
||||
/>
|
||||
</label>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="log-intake-dose">
|
||||
{t("medications.logIntake.doseLabel")}
|
||||
</Label>
|
||||
<Input
|
||||
id="log-intake-dose"
|
||||
value={dose}
|
||||
maxLength={50}
|
||||
onChange={(e) => setDoseOverride(e.target.value)}
|
||||
placeholder={configuredDose}
|
||||
autoComplete="off"
|
||||
disabled={skipped}
|
||||
/>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("medications.logIntake.doseHint")}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => onOpenChange(false)}
|
||||
disabled={busy}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="log-intake-taken-at">
|
||||
{t("medications.logIntake.takenAtLabel")}
|
||||
</Label>
|
||||
<DateTimeField
|
||||
id="log-intake-taken-at"
|
||||
value={takenAt}
|
||||
max={toDateTimeLocal(new Date())}
|
||||
onChange={(value) => setTakenAt(value)}
|
||||
disabled={skipped}
|
||||
/>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("medications.logIntake.takenAtHint")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<label
|
||||
htmlFor="log-intake-skipped"
|
||||
className="flex items-center justify-between gap-3"
|
||||
>
|
||||
{t("medications.logIntake.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={busy || medications.length === 0 || !medicationId}
|
||||
aria-busy={busy || undefined}
|
||||
>
|
||||
{busy && (
|
||||
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
|
||||
)}
|
||||
{t("medications.logIntake.submit")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<span className="text-sm font-medium">
|
||||
{t("medications.logIntake.skippedLabel")}
|
||||
</span>
|
||||
<Switch
|
||||
id="log-intake-skipped"
|
||||
checked={skipped}
|
||||
onCheckedChange={setSkipped}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
</ResponsiveSheet>
|
||||
);
|
||||
}
|
||||
@@ -327,7 +327,7 @@ export function MedicationTable({
|
||||
)
|
||||
) : (
|
||||
<ChevronsUpDown
|
||||
className="text-muted-foreground/60 size-3.5"
|
||||
className="text-muted-foreground size-3.5"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -8,6 +8,16 @@ import { Loader2, Plus, Trash2 } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
@@ -113,6 +123,7 @@ export function SideEffectsSection({ medicationId }: SideEffectsSectionProps) {
|
||||
// into the `<ResponsiveSheet>` footer slot. The submit button keeps
|
||||
// its `<form>` association via the HTML `form` attribute.
|
||||
const [footerEl, setFooterEl] = useState<HTMLDivElement | null>(null);
|
||||
const [pendingDeleteId, setPendingDeleteId] = useState<string | null>(null);
|
||||
const formId = useId();
|
||||
|
||||
const listKey = queryKeys.medicationSideEffects(medicationId);
|
||||
@@ -330,14 +341,7 @@ export function SideEffectsSection({ medicationId }: SideEffectsSectionProps) {
|
||||
size="icon"
|
||||
className="text-muted-foreground hover:text-destructive size-11 shrink-0"
|
||||
aria-label={t("medications.sideEffects.deleteCta")}
|
||||
onClick={() => {
|
||||
if (
|
||||
typeof window === "undefined" ||
|
||||
window.confirm(t("medications.sideEffects.deleteConfirm"))
|
||||
) {
|
||||
deleteMutation.mutate(row.id);
|
||||
}
|
||||
}}
|
||||
onClick={() => setPendingDeleteId(row.id)}
|
||||
disabled={deleteMutation.isPending}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
@@ -348,6 +352,43 @@ export function SideEffectsSection({ medicationId }: SideEffectsSectionProps) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Themed confirm — replaces the native window.confirm so the delete
|
||||
gate matches every other destructive action in the app. */}
|
||||
<AlertDialog
|
||||
open={!!pendingDeleteId}
|
||||
onOpenChange={(o) => {
|
||||
if (!o) setPendingDeleteId(null);
|
||||
}}
|
||||
>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>
|
||||
{t("medications.sideEffects.deleteConfirmTitle")}
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{t("medications.sideEffects.deleteConfirmBody")}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={deleteMutation.isPending}>
|
||||
{t("common.cancel")}
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
if (pendingDeleteId) deleteMutation.mutate(pendingDeleteId);
|
||||
setPendingDeleteId(null);
|
||||
}}
|
||||
disabled={deleteMutation.isPending}
|
||||
aria-busy={deleteMutation.isPending || undefined}
|
||||
className="bg-destructive dark:bg-destructive/60 hover:bg-destructive/90 text-white"
|
||||
>
|
||||
{t("medications.sideEffects.deleteCta")}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
|
||||
<ResponsiveSheet
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
|
||||
@@ -19,14 +19,7 @@ import { useQueryClient } from "@tanstack/react-query";
|
||||
import { CheckCheck, Loader2 } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { formatDose } from "@/lib/medications/format-dose";
|
||||
import { formatTimeWindowRange } from "@/lib/time-window-format";
|
||||
@@ -65,49 +58,17 @@ export function TakeAllDueDialog({
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
<ResponsiveSheet
|
||||
open={open}
|
||||
onOpenChange={(next) => {
|
||||
// Refuse to close while the take loop is in flight.
|
||||
if (submitting && !next) return;
|
||||
onOpenChange(next);
|
||||
}}
|
||||
>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("medications.takeAllDue.dialogTitle")}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t("medications.takeAllDue.dialogDescription")}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<ul className="space-y-2" data-testid="take-all-due-list">
|
||||
{dueMedications.map((med) => (
|
||||
<li
|
||||
key={med.id}
|
||||
className="flex items-baseline justify-between gap-3 text-sm"
|
||||
>
|
||||
<span className="min-w-0 truncate">
|
||||
<span className="font-medium">{med.name}</span>
|
||||
{med.dose ? (
|
||||
<span className="text-muted-foreground">
|
||||
{" "}
|
||||
— {formatDose(med.dose, t)}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
{med.window ? (
|
||||
<span className="text-muted-foreground shrink-0 text-xs tabular-nums">
|
||||
{formatTimeWindowRange(
|
||||
med.window.start,
|
||||
med.window.end,
|
||||
locale,
|
||||
)}
|
||||
</span>
|
||||
) : null}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<DialogFooter>
|
||||
title={t("medications.takeAllDue.dialogTitle")}
|
||||
description={t("medications.takeAllDue.dialogDescription")}
|
||||
footer={
|
||||
<>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
@@ -136,8 +97,36 @@ export function TakeAllDueDialog({
|
||||
count: dueMedications.length,
|
||||
})}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<ul className="space-y-2" data-testid="take-all-due-list">
|
||||
{dueMedications.map((med) => (
|
||||
<li
|
||||
key={med.id}
|
||||
className="flex items-baseline justify-between gap-3 text-sm"
|
||||
>
|
||||
<span className="min-w-0 truncate">
|
||||
<span className="font-medium">{med.name}</span>
|
||||
{med.dose ? (
|
||||
<span className="text-muted-foreground">
|
||||
{" "}
|
||||
— {formatDose(med.dose, t)}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
{med.window ? (
|
||||
<span className="text-muted-foreground shrink-0 text-xs tabular-nums">
|
||||
{formatTimeWindowRange(
|
||||
med.window.start,
|
||||
med.window.end,
|
||||
locale,
|
||||
)}
|
||||
</span>
|
||||
) : null}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</ResponsiveSheet>
|
||||
);
|
||||
}
|
||||
@@ -183,7 +183,7 @@ export function TitrationTimeline({
|
||||
? "border-primary bg-primary/15 text-primary"
|
||||
: step.isPast
|
||||
? "border-border bg-muted text-muted-foreground"
|
||||
: "border-border/60 text-muted-foreground/70 border-dashed",
|
||||
: "border-border text-muted-foreground border-dashed",
|
||||
)}
|
||||
aria-hidden="true"
|
||||
>
|
||||
|
||||
@@ -170,7 +170,15 @@ export function SourceCardGrid() {
|
||||
<li key={item.slug}>
|
||||
<Button asChild variant="outline" size="sm" className="min-h-9">
|
||||
<Link
|
||||
href="/settings/integrations"
|
||||
// Anchor each chip to its provider card so the tap lands on
|
||||
// the card, not the top of the generic Integrations page.
|
||||
// Apple Health has no web card (it syncs from the iOS app),
|
||||
// so its chip stays on the page root.
|
||||
href={
|
||||
item.slug === "apple-health"
|
||||
? "/settings/integrations"
|
||||
: `/settings/integrations#${item.slug}`
|
||||
}
|
||||
data-testid={`source-more-${item.slug}`}
|
||||
>
|
||||
{t(item.labelKey)}
|
||||
|
||||
@@ -21,12 +21,14 @@
|
||||
* `connections-panel.tsx` so existing unit-test imports keep resolving.
|
||||
*/
|
||||
|
||||
import Link from "next/link";
|
||||
import { Link2, Send } from "lucide-react";
|
||||
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { SettingsCardHeader } from "@/components/settings/_card-header";
|
||||
import { ConnectionsPanel } from "@/components/settings/integrations/connections-panel";
|
||||
import { NotificationChannelsPanel } from "@/components/settings/integrations/notification-channels-panel";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
export {
|
||||
parseOAuthOutcome,
|
||||
@@ -65,6 +67,15 @@ export function IntegrationsSection() {
|
||||
title={t("settings.sections.integrations.channelsHeading")}
|
||||
description={t("settings.sections.integrations.channelsDescription")}
|
||||
/>
|
||||
{/* Names the per-event notification matrix so the three notification
|
||||
surfaces (matrix, reminder types, channels) cross-reference. */}
|
||||
<div>
|
||||
<Button asChild variant="outline" size="sm">
|
||||
<Link href="/notifications">
|
||||
{t("settings.sections.integrations.matrixLink")}
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
<NotificationChannelsPanel />
|
||||
</section>
|
||||
</div>
|
||||
|
||||
@@ -262,13 +262,29 @@ export function ConnectionsPanel() {
|
||||
|
||||
return (
|
||||
<div className="space-y-6" data-slot="connections-panel">
|
||||
<WithingsCard viewModel={withingsViewModel} />
|
||||
<WhoopCard viewModel={whoopViewModel} />
|
||||
<FitbitCard viewModel={fitbitViewModel} />
|
||||
<GoogleHealthCard viewModel={googleHealthViewModel} />
|
||||
<PolarCard enabled={isAuthenticated} viewModel={polarViewModel} />
|
||||
<OuraCard enabled={isAuthenticated} viewModel={ouraViewModel} />
|
||||
<NightscoutCard enabled={isAuthenticated} />
|
||||
{/* Per-provider anchors so deep-links (onboarding "more sources" chips,
|
||||
`/settings/integrations#oura`) scroll straight to the right card. */}
|
||||
<div id="withings" className="scroll-mt-28">
|
||||
<WithingsCard viewModel={withingsViewModel} />
|
||||
</div>
|
||||
<div id="whoop" className="scroll-mt-28">
|
||||
<WhoopCard viewModel={whoopViewModel} />
|
||||
</div>
|
||||
<div id="fitbit" className="scroll-mt-28">
|
||||
<FitbitCard viewModel={fitbitViewModel} />
|
||||
</div>
|
||||
<div id="google-health" className="scroll-mt-28">
|
||||
<GoogleHealthCard viewModel={googleHealthViewModel} />
|
||||
</div>
|
||||
<div id="polar" className="scroll-mt-28">
|
||||
<PolarCard enabled={isAuthenticated} viewModel={polarViewModel} />
|
||||
</div>
|
||||
<div id="oura" className="scroll-mt-28">
|
||||
<OuraCard enabled={isAuthenticated} viewModel={ouraViewModel} />
|
||||
</div>
|
||||
<div id="nightscout" className="scroll-mt-28">
|
||||
<NightscoutCard enabled={isAuthenticated} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -228,7 +228,7 @@ export function WithingsCard({
|
||||
<a
|
||||
href="/api/withings/connect"
|
||||
data-testid="withings-reconnect-banner"
|
||||
className="border-warning/30 bg-warning/10 text-warning-foreground hover:bg-warning/20 block rounded-md border px-3 py-2 text-sm transition-colors"
|
||||
className="border-warning/30 bg-warning/10 text-foreground hover:bg-warning/20 block rounded-md border px-3 py-2 text-sm transition-colors"
|
||||
>
|
||||
<span className="font-medium">
|
||||
{t("settings.integrations.withings.reconnect.banner.title")}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { Bell } from "lucide-react";
|
||||
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
@@ -7,6 +8,7 @@ import { useTranslations } from "@/lib/i18n/context";
|
||||
import { SettingsCardHeader } from "@/components/settings/_card-header";
|
||||
import { LowStockCard } from "@/components/settings/low-stock-card";
|
||||
import { MoodReminderCard } from "@/components/settings/mood-reminder-card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
/**
|
||||
* `<NotificationsSection>` — Settings → Notifications ("Benachrichtigungen").
|
||||
@@ -44,6 +46,24 @@ export function NotificationsSection() {
|
||||
|
||||
return (
|
||||
<div className="space-y-10">
|
||||
{/* Cross-links to the other two notification surfaces so this screen
|
||||
names them — the per-event matrix and the delivery channels. */}
|
||||
<div
|
||||
className="flex flex-wrap gap-2"
|
||||
data-slot="notifications-crosslinks"
|
||||
>
|
||||
<Button asChild variant="outline" size="sm">
|
||||
<Link href="/notifications">
|
||||
{t("settings.sections.notifications.matrixLink")}
|
||||
</Link>
|
||||
</Button>
|
||||
<Button asChild variant="outline" size="sm">
|
||||
<Link href="/settings/integrations#channels">
|
||||
{t("settings.sections.notifications.channelsLink")}
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Group 1 — Reminders ("what you receive"). Rendered only when at
|
||||
least one reminder-type card is visible, so a fully-disabled
|
||||
account never shows an empty heading. */}
|
||||
|
||||
@@ -204,7 +204,7 @@ function ShareLinksCard() {
|
||||
{t("common.cancel")}
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
className="bg-destructive dark:bg-destructive/60 hover:bg-destructive/90 text-white"
|
||||
onClick={() => revokeMutation.mutate(link.id)}
|
||||
>
|
||||
{t("settings.sharing.revoke")}
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import { PasswordInput } from "../password-input";
|
||||
|
||||
/**
|
||||
* PasswordInput bakes the password-manager autofill guard in by default so
|
||||
* BYOK API keys / webhook secrets / ntfy tokens (config secrets, not
|
||||
* credentials) are never offered up to Bitwarden / 1Password / LastPass.
|
||||
* Credential fields that pass a real autocomplete token keep manager
|
||||
* integration.
|
||||
*/
|
||||
function render(node: React.ReactNode) {
|
||||
return renderToStaticMarkup(
|
||||
<I18nProvider initialLocale="en">{node}</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("PasswordInput", () => {
|
||||
it("suppresses password managers by default for config secrets", () => {
|
||||
const html = render(<PasswordInput id="ntfy-token" />);
|
||||
expect(html).toMatch(/autocomplete="off"/i);
|
||||
expect(html).toContain("data-bwignore");
|
||||
expect(html).toContain("data-1p-ignore");
|
||||
expect(html).toContain('data-lpignore="true"');
|
||||
});
|
||||
|
||||
it("treats an explicit autoComplete=off as a config secret too", () => {
|
||||
const html = render(<PasswordInput id="tg-token" autoComplete="off" />);
|
||||
expect(html).toContain("data-bwignore");
|
||||
expect(html).toContain("data-1p-ignore");
|
||||
});
|
||||
|
||||
it("keeps manager integration for credential fields", () => {
|
||||
const html = render(
|
||||
<PasswordInput id="current-password" autoComplete="current-password" />,
|
||||
);
|
||||
expect(html).toMatch(/autocomplete="current-password"/i);
|
||||
expect(html).not.toContain("data-bwignore");
|
||||
expect(html).not.toContain("data-1p-ignore");
|
||||
expect(html).not.toContain("data-lpignore");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/**
|
||||
* Auth-resolving placeholder for a top-level page.
|
||||
*
|
||||
* Every module page renders while `useAuth()` / its first query settles. The
|
||||
* legacy pattern was a lone centered `Loader2` that then snapped into the full
|
||||
* layout — a reflow, and inconsistent with the skeleton-first surfaces
|
||||
* (documents, Insights tiles, dashboard hero). This reserves the page frame
|
||||
* instead: a `PageHeader`-shaped title/description block above a tile grid, so
|
||||
* the auth-resolving frame holds the same geometry the loaded page will.
|
||||
*
|
||||
* Presentational + `aria-hidden` (matching the old spinner, which announced
|
||||
* nothing); pass `label` to expose an `sr-only` status line for screen readers.
|
||||
*/
|
||||
export function PageAuthGate({
|
||||
className,
|
||||
label,
|
||||
tiles = 3,
|
||||
}: {
|
||||
className?: string;
|
||||
label?: string;
|
||||
/** Number of content tile skeletons below the header. */
|
||||
tiles?: number;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
data-slot="page-auth-gate"
|
||||
className={cn("space-y-6", className)}
|
||||
aria-hidden="true"
|
||||
>
|
||||
{label ? (
|
||||
<span className="sr-only" aria-live="polite" aria-hidden={false}>
|
||||
{label}
|
||||
</span>
|
||||
) : null}
|
||||
<div className="space-y-1.5">
|
||||
<Skeleton className="h-8 w-48 max-w-full rounded" />
|
||||
<Skeleton className="h-4 w-72 max-w-full rounded" />
|
||||
</div>
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{Array.from({ length: tiles }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-32 rounded-xl" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -22,14 +22,28 @@ import { useTranslations } from "@/lib/i18n/context";
|
||||
*/
|
||||
export function PasswordInput({
|
||||
className,
|
||||
autoComplete,
|
||||
...props
|
||||
}: React.ComponentProps<typeof Input>) {
|
||||
const [visible, setVisible] = useState(false);
|
||||
const { t } = useTranslations();
|
||||
|
||||
// Config/secret fields (BYOK API keys, webhook secrets, ntfy tokens) pass no
|
||||
// `autoComplete` — they are not credentials, so a password manager offering
|
||||
// to fill or *save* them is noise and risks clobbering the field. Suppress
|
||||
// Bitwarden / 1Password / LastPass by default for those. Credential fields
|
||||
// (login, register, password change) pass a real token
|
||||
// (`current-password` / `new-password` / `username`) and keep manager
|
||||
// integration; only an explicit `"off"` opts back into suppression.
|
||||
const suppressManagers = autoComplete == null || autoComplete === "off";
|
||||
return (
|
||||
<div className="relative">
|
||||
<Input
|
||||
{...props}
|
||||
autoComplete={autoComplete ?? "off"}
|
||||
data-bwignore={suppressManagers ? "" : undefined}
|
||||
data-1p-ignore={suppressManagers ? "" : undefined}
|
||||
data-lpignore={suppressManagers ? "true" : undefined}
|
||||
type={visible ? "text" : "password"}
|
||||
className={cn("pr-12", className)}
|
||||
/>
|
||||
|
||||
@@ -40,7 +40,7 @@ export function QueryErrorCard({
|
||||
className="flex flex-col items-center justify-center gap-3 px-6 py-10 text-center"
|
||||
>
|
||||
<AlertTriangle
|
||||
className="text-muted-foreground/60 h-8 w-8"
|
||||
className="text-muted-foreground h-8 w-8"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<p className="text-sm font-medium">{title ?? t("common.loadFailed")}</p>
|
||||
|
||||
Reference in new issue
Block a user