mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 21:32:18 +00:00
fix(pages): replace bare auth-gate spinners with a layout-reserving skeleton
Introduce a shared PageAuthGate (header + tile skeleton) and adopt it on the top-level pages that flashed a lone centered spinner while auth resolved: mood, measurements, achievements, illness, mental-wellbeing, checkups, cycle, and the labs / medications / custom-metrics / illness detail routes. The auth-resolving frame now reserves the page geometry instead of snapping in after a spinner.
This commit is contained in:
1 parent
96c99f4dfa
commit
23ded20306
28 files changed
+465
-458
No files matched your search
@@ -13,7 +13,6 @@ import {
|
||||
Heart,
|
||||
KeyRound,
|
||||
Languages,
|
||||
Loader2,
|
||||
Lock,
|
||||
LogIn,
|
||||
Moon,
|
||||
@@ -33,6 +32,7 @@ import { Progress } from "@/components/ui/progress";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { EmptyState } from "@/components/ui/empty-state";
|
||||
import { PageHeader } from "@/components/ui/page-header";
|
||||
import { PageAuthGate } from "@/components/ui/page-auth-gate";
|
||||
import { QueryErrorCard } from "@/components/ui/query-error-card";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
@@ -277,11 +277,7 @@ export default function AchievementsPage() {
|
||||
});
|
||||
|
||||
if (authLoading || (achievementsEnabled && isLoading)) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-20">
|
||||
<Loader2 className="text-primary h-6 w-6 animate-spin motion-reduce:animate-none" />
|
||||
</div>
|
||||
);
|
||||
return <PageAuthGate label={t("common.loading")} />;
|
||||
}
|
||||
|
||||
// v1.18.0 — module off ⇒ the surface disappears entirely. The nav entry
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback } from "react";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { useMounted } from "@/hooks/use-mounted";
|
||||
import { usePullToRefresh } from "@/hooks/use-pull-to-refresh";
|
||||
import { PullToRefreshIndicator } from "@/components/ui/pull-to-refresh-indicator";
|
||||
import { PageAuthGate } from "@/components/ui/page-auth-gate";
|
||||
import { VorsorgeSection } from "@/components/measurement-reminders/vorsorge-section";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
|
||||
@@ -35,11 +35,7 @@ export default function CheckupsPage() {
|
||||
const pull = usePullToRefresh({ onRefresh: refresh });
|
||||
|
||||
if (!mounted || isLoading) {
|
||||
return (
|
||||
<div className="flex min-h-[40vh] items-center justify-center">
|
||||
<Loader2 className="text-muted-foreground h-6 w-6 animate-spin motion-reduce:animate-none" />
|
||||
</div>
|
||||
);
|
||||
return <PageAuthGate />;
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -136,7 +136,10 @@ function CoachConversationsBody() {
|
||||
// Surface the sibling Plans ledger — otherwise it is reachable
|
||||
// only from the composer's `+` menu.
|
||||
<Button asChild variant="outline" size="sm">
|
||||
<Link href="/coach/plans" data-slot="coach-conversations-plans-link">
|
||||
<Link
|
||||
href="/coach/plans"
|
||||
data-slot="coach-conversations-plans-link"
|
||||
>
|
||||
<Target className="size-4" aria-hidden="true" />
|
||||
{t("coach.plans.title")}
|
||||
</Link>
|
||||
|
||||
@@ -3,7 +3,14 @@
|
||||
import { useEffect, useMemo } from "react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Check, Loader2, MessagesSquare, Target, Trash2, X } from "lucide-react";
|
||||
import {
|
||||
Check,
|
||||
Loader2,
|
||||
MessagesSquare,
|
||||
Target,
|
||||
Trash2,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { PageHeader } from "@/components/ui/page-header";
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
*/
|
||||
import { use, useEffect } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { PageAuthGate } from "@/components/ui/page-auth-gate";
|
||||
|
||||
import { CustomMetricDetail } from "@/components/custom-metrics/custom-metric-detail";
|
||||
import { BackLink } from "@/components/ui/back-link";
|
||||
@@ -35,11 +35,7 @@ export default function CustomMetricDetailPage({
|
||||
}, [isLoading, isAuthenticated, router]);
|
||||
|
||||
if (!mounted || isLoading) {
|
||||
return (
|
||||
<div className="flex h-64 items-center justify-center">
|
||||
<Loader2 className="text-primary h-8 w-8 animate-spin motion-reduce:animate-none" />
|
||||
</div>
|
||||
);
|
||||
return <PageAuthGate label={t("common.loading")} />;
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
+3
-13
@@ -2,9 +2,9 @@
|
||||
|
||||
import { Suspense, useEffect } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Loader2 } from "lucide-react";
|
||||
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { PageAuthGate } from "@/components/ui/page-auth-gate";
|
||||
import { CycleView } from "@/components/cycle/cycle-view";
|
||||
|
||||
/**
|
||||
@@ -36,24 +36,14 @@ export default function CyclePage() {
|
||||
}, [isLoading, isAuthenticated, enabled, router]);
|
||||
|
||||
if (isLoading || !isAuthenticated || !enabled) {
|
||||
return (
|
||||
<div className="flex h-64 items-center justify-center">
|
||||
<Loader2 className="text-primary h-8 w-8 animate-spin motion-reduce:animate-none" />
|
||||
</div>
|
||||
);
|
||||
return <PageAuthGate />;
|
||||
}
|
||||
|
||||
// `<CycleView>` reads `useSearchParams` for the `?tab=` deep-link; wrap it in
|
||||
// a Suspense boundary so the client-search-params bailout never de-opts the
|
||||
// build (Next's `missing-suspense-with-csr-bailout`).
|
||||
return (
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="flex h-64 items-center justify-center">
|
||||
<Loader2 className="text-primary h-8 w-8 animate-spin motion-reduce:animate-none" />
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Suspense fallback={<PageAuthGate />}>
|
||||
<CycleView />
|
||||
</Suspense>
|
||||
);
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
*/
|
||||
import { use, useEffect } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { PageAuthGate } from "@/components/ui/page-auth-gate";
|
||||
|
||||
import { IllnessEpisodeDetail } from "@/components/illness/illness-episode-detail";
|
||||
import { BackLink } from "@/components/ui/back-link";
|
||||
@@ -40,11 +40,7 @@ export default function IllnessEpisodePage({
|
||||
}, [isLoading, isAuthenticated, enabled, router]);
|
||||
|
||||
if (!mounted || isLoading || !isAuthenticated || !enabled) {
|
||||
return (
|
||||
<div className="flex h-64 items-center justify-center">
|
||||
<Loader2 className="text-primary h-8 w-8 animate-spin motion-reduce:animate-none" />
|
||||
</div>
|
||||
);
|
||||
return <PageAuthGate label={t("common.loading")} />;
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Loader2 } from "lucide-react";
|
||||
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { PageAuthGate } from "@/components/ui/page-auth-gate";
|
||||
import { IllnessView } from "@/components/illness/illness-view";
|
||||
|
||||
/**
|
||||
@@ -35,11 +35,7 @@ export default function IllnessPage() {
|
||||
}, [isLoading, isAuthenticated, enabled, router]);
|
||||
|
||||
if (isLoading || !isAuthenticated || !enabled) {
|
||||
return (
|
||||
<div className="flex h-64 items-center justify-center">
|
||||
<Loader2 className="text-primary h-8 w-8 animate-spin motion-reduce:animate-none" />
|
||||
</div>
|
||||
);
|
||||
return <PageAuthGate />;
|
||||
}
|
||||
|
||||
return <IllnessView />;
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
*/
|
||||
import { use, useEffect } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { PageAuthGate } from "@/components/ui/page-auth-gate";
|
||||
|
||||
import { LabBiomarkerDetail } from "@/components/labs/lab-biomarker-detail";
|
||||
import { BackLink } from "@/components/ui/back-link";
|
||||
@@ -35,11 +35,7 @@ export default function LabBiomarkerDetailPage({
|
||||
}, [isLoading, isAuthenticated, router]);
|
||||
|
||||
if (!mounted || isLoading) {
|
||||
return (
|
||||
<div className="flex h-64 items-center justify-center">
|
||||
<Loader2 className="text-primary h-8 w-8 animate-spin motion-reduce:animate-none" />
|
||||
</div>
|
||||
);
|
||||
return <PageAuthGate label={t("common.loading")} />;
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -14,9 +14,10 @@ import { CustomMetricList } from "@/components/custom-metrics/custom-metric-list
|
||||
import { MEASUREMENT_TYPE_LABEL_KEYS } from "@/components/measurements/measurement-list-meta";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { PageHeader } from "@/components/ui/page-header";
|
||||
import { PageAuthGate } from "@/components/ui/page-auth-gate";
|
||||
import { PullToRefreshIndicator } from "@/components/ui/pull-to-refresh-indicator";
|
||||
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
|
||||
import { Plus, Loader2 } from "lucide-react";
|
||||
import { Plus } from "lucide-react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
|
||||
@@ -116,11 +117,7 @@ export default function MeasurementsPage() {
|
||||
// SSR HTML when this boundary hydrates after `/api/auth/me` settled
|
||||
// (React #418 family); see `useMounted`.
|
||||
if (!mounted || isLoading) {
|
||||
return (
|
||||
<div className="flex h-64 items-center justify-center">
|
||||
<Loader2 className="text-primary h-8 w-8 animate-spin motion-reduce:animate-none" />
|
||||
</div>
|
||||
);
|
||||
return <PageAuthGate label={t("common.loading")} />;
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
import { use, useEffect } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { PageAuthGate } from "@/components/ui/page-auth-gate";
|
||||
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
@@ -68,19 +68,7 @@ export default function MedicationDetailPage({
|
||||
});
|
||||
|
||||
if (authLoading || isLoading || !isAuthenticated) {
|
||||
return (
|
||||
<div
|
||||
className="flex h-64 items-center justify-center"
|
||||
role="status"
|
||||
aria-busy="true"
|
||||
aria-live="polite"
|
||||
>
|
||||
<Loader2
|
||||
aria-hidden="true"
|
||||
className="text-primary h-8 w-8 animate-spin motion-reduce:animate-none"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
return <PageAuthGate label={t("common.loading")} />;
|
||||
}
|
||||
|
||||
if (isError || !medication) {
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Loader2 } from "lucide-react";
|
||||
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { PageAuthGate } from "@/components/ui/page-auth-gate";
|
||||
import { MentalWellbeing } from "@/components/mental-health/mental-wellbeing";
|
||||
|
||||
/**
|
||||
@@ -40,11 +40,7 @@ export default function MentalWellbeingPage() {
|
||||
}, [isLoading, isAuthenticated, enabled, router]);
|
||||
|
||||
if (isLoading || !isAuthenticated || !enabled) {
|
||||
return (
|
||||
<div className="flex h-64 items-center justify-center">
|
||||
<Loader2 className="text-primary h-8 w-8 animate-spin motion-reduce:animate-none" />
|
||||
</div>
|
||||
);
|
||||
return <PageAuthGate />;
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -10,9 +10,10 @@ import { MoodForm } from "@/components/mood/mood-form";
|
||||
import { MoodList } from "@/components/mood/mood-list";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { PageHeader } from "@/components/ui/page-header";
|
||||
import { PageAuthGate } from "@/components/ui/page-auth-gate";
|
||||
import { PullToRefreshIndicator } from "@/components/ui/pull-to-refresh-indicator";
|
||||
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
|
||||
import { Plus, Loader2, Wrench } from "lucide-react";
|
||||
import { Plus, Wrench } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect } from "react";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
@@ -54,11 +55,7 @@ export default function MoodPage() {
|
||||
// and React logged hydration error #418 — same fix as the measurements
|
||||
// page; see `useMounted`.
|
||||
if (!mounted || isLoading) {
|
||||
return (
|
||||
<div className="flex h-64 items-center justify-center">
|
||||
<Loader2 className="text-primary h-8 w-8 animate-spin motion-reduce:animate-none" />
|
||||
</div>
|
||||
);
|
||||
return <PageAuthGate label={t("common.loading")} />;
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -23,7 +23,11 @@ type QueryState = {
|
||||
};
|
||||
|
||||
let authState: AuthState = { isAuthenticated: true, isLoading: false };
|
||||
let queryState: QueryState = { data: undefined, isLoading: false, isError: false };
|
||||
let queryState: QueryState = {
|
||||
data: undefined,
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
};
|
||||
|
||||
vi.mock("@/hooks/use-auth", () => ({
|
||||
useAuth: () => authState,
|
||||
@@ -120,8 +124,6 @@ describe("NotificationsPage", () => {
|
||||
eventTypes: ["MEDICATION_REMINDER"],
|
||||
};
|
||||
const html = render();
|
||||
expect(html).toContain(
|
||||
"won’t receive notifications",
|
||||
);
|
||||
expect(html).toContain("won’t receive notifications");
|
||||
});
|
||||
});
|
||||
@@ -1665,7 +1665,7 @@ export default function DashboardPageClient() {
|
||||
href={href}
|
||||
data-slot="dashboard-tile-link"
|
||||
data-tile-id={entry.id}
|
||||
className="group focus-visible:ring-ring/50 flex min-w-0 rounded-xl transition-[box-shadow] hover:ring-2 hover:ring-ring/30 focus-visible:ring-2 focus-visible:outline-none"
|
||||
className="group focus-visible:ring-ring/50 hover:ring-ring/30 flex min-w-0 rounded-xl transition-[box-shadow] hover:ring-2 focus-visible:ring-2 focus-visible:outline-none"
|
||||
>
|
||||
{cell}
|
||||
</Link>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useMemo, useState } from "react";
|
||||
import dynamic from "next/dynamic";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import {
|
||||
@@ -108,11 +108,10 @@ export function CycleView() {
|
||||
|
||||
// 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; a `?tab=` deep-link change still switches too.
|
||||
// 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);
|
||||
useEffect(() => {
|
||||
setTab(initialTab);
|
||||
}, [initialTab]);
|
||||
|
||||
const today = useMemo(() => shiftToday(0), []);
|
||||
const from = useMemo(() => shiftToday(-90), []);
|
||||
|
||||
@@ -224,97 +224,97 @@ export function LedgerAddDialog({
|
||||
</>
|
||||
}
|
||||
>
|
||||
{/* v1.16.4 — a real form so Enter in the datetime field submits;
|
||||
{/* 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>
|
||||
|
||||
<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>
|
||||
)}
|
||||
<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>
|
||||
</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>
|
||||
</>
|
||||
}
|
||||
<label
|
||||
htmlFor="ledger-add-skipped"
|
||||
className="flex items-center justify-between gap-3"
|
||||
>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("medications.detail.verlauf.nudge.body", {
|
||||
<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>
|
||||
)}
|
||||
)}
|
||||
</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 dark:bg-destructive/60 text-white hover:bg-destructive/90"
|
||||
className="bg-destructive dark:bg-destructive/60 hover:bg-destructive/90 text-white"
|
||||
>
|
||||
{t("medications.detail.intake.deleteRow.confirmAction")}
|
||||
</AlertDialogAction>
|
||||
|
||||
@@ -168,64 +168,64 @@ function IntakeEditDialogBody({
|
||||
</>
|
||||
}
|
||||
>
|
||||
{/* v1.16.4 — a real form so Enter in the datetime field submits;
|
||||
{/* 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();
|
||||
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>
|
||||
</form>
|
||||
<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 dark:bg-destructive/60 text-white 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 dark:bg-destructive/60 text-white hover:bg-destructive/90"
|
||||
className="bg-destructive dark:bg-destructive/60 hover:bg-destructive/90 text-white"
|
||||
>
|
||||
{t("medications.detail.intake.deleteRow.confirmAction")}
|
||||
</AlertDialogAction>
|
||||
|
||||
@@ -190,52 +190,52 @@ export function IntakeImportDialog({
|
||||
}
|
||||
>
|
||||
<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"
|
||||
<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"
|
||||
/>
|
||||
{result && (
|
||||
<p
|
||||
className={`text-sm ${resultType === "success" ? "text-success" : "text-destructive"}`}
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
>
|
||||
{result}
|
||||
{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>
|
||||
</ResponsiveSheet>
|
||||
);
|
||||
}
|
||||
@@ -111,62 +111,62 @@ export function LogInjectionSiteDialog({
|
||||
}
|
||||
>
|
||||
{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
|
||||
<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>
|
||||
</>
|
||||
)}
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@@ -231,117 +231,117 @@ export function LogIntakeDialog({
|
||||
</>
|
||||
}
|
||||
>
|
||||
{/* v1.16.4 — a real form so Enter in the dose / datetime fields
|
||||
{/* v1.16.4 — a real form so Enter in the dose / datetime fields
|
||||
submits; mirrors the intake-edit and dose-history-add dialogs. */}
|
||||
<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">
|
||||
<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>
|
||||
)}
|
||||
</form>
|
||||
|
||||
<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>
|
||||
)}
|
||||
</form>
|
||||
</ResponsiveSheet>
|
||||
);
|
||||
}
|
||||
@@ -381,7 +381,7 @@ export function SideEffectsSection({ medicationId }: SideEffectsSectionProps) {
|
||||
}}
|
||||
disabled={deleteMutation.isPending}
|
||||
aria-busy={deleteMutation.isPending || undefined}
|
||||
className="bg-destructive dark:bg-destructive/60 text-white hover:bg-destructive/90"
|
||||
className="bg-destructive dark:bg-destructive/60 hover:bg-destructive/90 text-white"
|
||||
>
|
||||
{t("medications.sideEffects.deleteCta")}
|
||||
</AlertDialogAction>
|
||||
|
||||
@@ -101,32 +101,32 @@ export function TakeAllDueDialog({
|
||||
}
|
||||
>
|
||||
<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,
|
||||
)}
|
||||
{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}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -48,7 +48,10 @@ export function NotificationsSection() {
|
||||
<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">
|
||||
<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")}
|
||||
|
||||
@@ -204,7 +204,7 @@ function ShareLinksCard() {
|
||||
{t("common.cancel")}
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
className="bg-destructive dark:bg-destructive/60 text-white 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,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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user