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:
Marc-André Bombeck committed 2026-07-09 00:23:13 +02:00
1 parent 96c99f4dfa
commit 23ded20306
28 files changed
+465 -458

No files matched your search

+2 -6
View File
@@ -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
+2 -6
View File
@@ -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 (
+4 -1
View File
@@ -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>
+8 -1
View File
@@ -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";
+2 -6
View File
@@ -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
View File
@@ -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>
);
+2 -6
View File
@@ -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 -6
View File
@@ -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 />;
+2 -6
View File
@@ -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 (
+3 -6
View File
@@ -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 (
+2 -14
View File
@@ -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 -6
View File
@@ -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 (
+3 -6
View File
@@ -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");
});
});
+1 -1
View File
@@ -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>
+4 -5
View File
@@ -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>
);
}
+100 -100
View File
@@ -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")}
+1 -1
View File
@@ -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")}
+49
View File
@@ -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>
);
}