chore(release): v1.6.0

Medication editor overhaul, route of administration, today-tile read-flip.

- Route-of-administration column (ORAL | INJECTION | OTHER) decoupled from
  treatmentClass; injection-site picker surfaces for any injection.
  Migration 0088 backfills ORAL via a constant column default.
- One-time injection (oneShot + deliveryForm = INJECTION) editor path.
- Profile-photo upload card on Settings → Account.
- Today-tile read-flip: /api/medications/intake?scope=today and
  /api/dashboard/summary gate through the canonical recurrence engine,
  matching the reminder worker for bi-weekly, rolling, RRULE, and one-time
  cadences.
- Measurement note cap 25 → 200; dashboard range colors on semantic tokens;
  admin sidebar icons + shared-helper consolidation; API-tokens multi-language
  snippets restored.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Marc-André BombeckandClaude Opus 4.7 committed 2026-05-30 00:10:12 +02:00
1 parent d01d1d1dc9
commit 8ee7aaac44
53 files changed
+1049 -1017

No files matched your search

+29
View File
@@ -532,3 +532,32 @@ export function useSystemStatus() {
},
});
}
/** Helpful-rate tint shared by the AI-quality + Coach-feedback tables.
* Green ≥80 %, yellow ≥50 %, orange below. */
export function helpfulRateColour(rate: number): string {
if (rate >= 0.8) return "text-dracula-green";
if (rate >= 0.5) return "text-dracula-yellow";
return "text-dracula-orange";
}
export interface PublicVersion {
version: string;
buildSha: string | null;
builtAt: string | null;
offlineGeoEnabled?: boolean;
}
/** Shared `/api/version` reader — the system-status section + overview
* summary both surface app version / build SHA / offline-geo state. */
export function usePublicVersion() {
return useQuery({
queryKey: queryKeys.publicVersion(),
queryFn: async () => {
const res = await fetch("/api/version");
if (!res.ok) throw new Error("Failed to load version");
return (await res.json()).data as PublicVersion;
},
staleTime: 5 * 60_000,
});
}
+7 -5
View File
@@ -17,18 +17,20 @@ import * as React from "react";
import Link from "next/link";
import { usePathname } from "next/navigation";
import {
AlertTriangle,
Bell,
Database,
FileText,
Info,
KeyRound,
Inbox,
MessageSquare,
Plug,
Radio,
ScrollText,
Server,
Settings,
ShieldAlert,
SlidersHorizontal,
Sparkles,
Users,
type LucideIcon,
@@ -65,12 +67,12 @@ export const ADMIN_SECTIONS: readonly AdminSection[] = [
{
slug: "services",
titleKey: "admin.section.services.title",
icon: Plug,
icon: Radio,
},
{
slug: "integrations",
titleKey: "admin.section.integrations.title",
icon: AlertTriangle,
icon: Plug,
},
{
slug: "ai-quality",
@@ -80,12 +82,12 @@ export const ADMIN_SECTIONS: readonly AdminSection[] = [
{
slug: "assistant",
titleKey: "admin.section.assistant.title",
icon: Sparkles,
icon: SlidersHorizontal,
},
{
slug: "coach-feedback",
titleKey: "admin.section.coach-feedback.title",
icon: Sparkles,
icon: MessageSquare,
},
{
slug: "feedback",
+1 -6
View File
@@ -23,6 +23,7 @@ import { useQuery } from "@tanstack/react-query";
import { Sparkles, Loader2 } from "lucide-react";
import { useTranslations, useFormatters } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import { helpfulRateColour } from "./_shared";
interface FeedbackBucket {
severity: string;
@@ -53,12 +54,6 @@ const SEVERITY_TINT: Record<string, string> = {
urgent: "text-dracula-red",
};
function helpfulRateColour(rate: number): string {
if (rate >= 0.8) return "text-dracula-green";
if (rate >= 0.5) return "text-dracula-yellow";
return "text-dracula-orange";
}
export function AiQualitySection() {
const { t } = useTranslations();
const fmt = useFormatters();
@@ -23,6 +23,7 @@ import { Loader2, Sparkles } from "lucide-react";
import { useTranslations, useFormatters } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import { helpfulRateColour } from "./_shared";
interface CoachFeedbackBucket {
promptVersion: string;
@@ -45,12 +46,6 @@ interface CoachFeedbackResponse {
error?: string | null;
}
function helpfulRateColour(rate: number): string {
if (rate >= 0.8) return "text-dracula-green";
if (rate >= 0.5) return "text-dracula-yellow";
return "text-dracula-orange";
}
export function CoachFeedbackSection() {
const { t } = useTranslations();
const fmt = useFormatters();
@@ -148,6 +148,10 @@ export function HostMetricsChart() {
[data?.samples],
);
// Total physical memory (latest sample) — lets the tooltip pair the
// percentage with an absolute "X.X GiB / Y.Y GiB" label. 0 ⇒ omit.
const memTotalGiB = (data?.meta.memTotalBytes ?? 0) / 1_073_741_824;
// Loading skeleton: same footprint as the chart so the panel doesn't
// jump when data lands. Animated only when motion isn't reduced.
if (isLoading) {
@@ -282,6 +286,13 @@ export function HostMetricsChart() {
return [fmt.number(value, 2), name];
}
if (name === t("admin.hostMetrics.memUsedPercent")) {
if (memTotalGiB > 0) {
const usedGiB = (value / 100) * memTotalGiB;
return [
`${fmt.number(value, 1)} % · ${fmt.number(usedGiB, 1)} / ${fmt.number(memTotalGiB, 1)} GiB`,
name,
];
}
return [`${fmt.number(value, 1)} %`, name];
}
if (name === t("admin.hostMetrics.diskBusyPercent")) {
+11 -1
View File
@@ -1,6 +1,6 @@
"use client";
import { Bell, Globe, Key, MessageCircle } from "lucide-react";
import { Bell, Globe, Key, MessageCircle, Smile } from "lucide-react";
import { useTranslations } from "@/lib/i18n/context";
import { SettingsToggle, useAdminSettings, useUpdateSettings } from "./_shared";
@@ -49,6 +49,16 @@ export function ServicesSection() {
}
disabled={updateSettings.isPending}
/>
<SettingsToggle
label="moodLog"
description={t("admin.moodLogGlobal")}
icon={Smile}
checked={settings?.moodLogGlobal ?? true}
onCheckedChange={(checked) =>
updateSettings.mutate({ moodLogGlobal: checked })
}
disabled={updateSettings.isPending}
/>
<SettingsToggle
label="API"
description={t("admin.apiGlobal")}
+2 -23
View File
@@ -18,31 +18,10 @@ import {
Server,
Users,
} from "lucide-react";
import { useQuery } from "@tanstack/react-query";
import { Button } from "@/components/ui/button";
import { formatDateTime } from "@/lib/format";
import { useFormatters, useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import { StatusItem, useSystemStatus } from "./_shared";
interface VersionResponse {
version: string;
buildSha: string | null;
builtAt: string | null;
offlineGeoEnabled?: boolean;
}
function useOfflineGeoState() {
return useQuery({
queryKey: queryKeys.publicVersion(),
queryFn: async () => {
const res = await fetch("/api/version");
if (!res.ok) throw new Error("Failed to load version");
return (await res.json()).data as VersionResponse;
},
staleTime: 5 * 60_000,
});
}
import { StatusItem, usePublicVersion, useSystemStatus } from "./_shared";
// v1.4.16 phase B3: Recharts is ~108 KiB Brotli — defer-load the host
// metrics chart so the system-status page only ships it when an admin
@@ -66,7 +45,7 @@ export function SystemStatusSection() {
const { t } = useTranslations();
const fmt = useFormatters();
const { data: status, isError, refetch, isFetching } = useSystemStatus();
const { data: version } = useOfflineGeoState();
const { data: version } = usePublicVersion();
return (
<div className="space-y-6">
+2 -23
View File
@@ -22,35 +22,14 @@ import {
Server,
Tag,
} from "lucide-react";
import { useQuery } from "@tanstack/react-query";
import { formatDateTime } from "@/lib/format";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import { StatusItem, useSystemStatus } from "./_shared";
interface VersionResponse {
version: string;
buildSha: string | null;
builtAt: string | null;
offlineGeoEnabled?: boolean;
}
function useVersion() {
return useQuery({
queryKey: queryKeys.publicVersion(),
queryFn: async () => {
const res = await fetch("/api/version");
if (!res.ok) throw new Error("Failed to load version");
return (await res.json()).data as VersionResponse;
},
staleTime: 5 * 60_000,
});
}
import { StatusItem, usePublicVersion, useSystemStatus } from "./_shared";
export function SystemStatusSummary() {
const { t } = useTranslations();
const { data: status, isError } = useSystemStatus();
const { data: version } = useVersion();
const { data: version } = usePublicVersion();
return (
<section
@@ -24,8 +24,9 @@ import { Loader2, MoreHorizontal, Plus, RotateCcw } from "lucide-react";
import { toast } from "sonner";
import { useTranslations } from "@/lib/i18n/context";
import { invalidateKeys, measurementDependentKeys } from "@/lib/query-keys";
import { MEASUREMENT_NOTES_MAX_LENGTH } from "@/lib/validations/measurement";
const MAX_COMMENT_LENGTH = 25;
const MAX_COMMENT_LENGTH = MEASUREMENT_NOTES_MAX_LENGTH;
const MEASUREMENT_TYPES = [
{
@@ -60,6 +60,7 @@ import { formatDateOrRelative, formatDateTime } from "@/lib/format";
import { useTranslations, useFormatters } from "@/lib/i18n/context";
import { CUMULATIVE_DAY_SUM_TYPES } from "@/lib/measurements/cumulative-day-sum";
import { invalidateKeys, measurementDependentKeys } from "@/lib/query-keys";
import { MEASUREMENT_NOTES_MAX_LENGTH } from "@/lib/validations/measurement";
import { DateTimeInput } from "@/components/ui/date-input";
import {
MEASUREMENT_TYPE_LABEL_KEYS as TYPE_LABEL_KEYS,
@@ -105,11 +106,14 @@ interface MeasurementListProps {
}
const PAGE_SIZE = 25;
const MAX_COMMENT_LENGTH = 25;
// Input cap mirrors the server Zod bound. The list preview truncates at
// a shorter width so a long note does not stretch a list row.
const MAX_COMMENT_LENGTH = MEASUREMENT_NOTES_MAX_LENGTH;
const COMMENT_PREVIEW_LENGTH = 40;
function truncateComment(comment: string): string {
if (comment.length <= MAX_COMMENT_LENGTH) return comment;
return `${comment.slice(0, MAX_COMMENT_LENGTH - 1)}…`;
if (comment.length <= COMMENT_PREVIEW_LENGTH) return comment;
return `${comment.slice(0, COMMENT_PREVIEW_LENGTH - 1)}…`;
}
function toDateTimeLocalValue(isoString: string): string {
@@ -26,35 +26,6 @@ vi.mock("@/lib/i18n/context", () => ({
}),
}));
// `<DropdownMenu>` wraps a Radix portal that `renderToStaticMarkup`
// does not materialise while closed; mock the primitives to
// passthroughs so the trigger + menu items are reachable in static
// markup and the `onSelect` wiring is assertable (G-1 §4).
vi.mock("@/components/ui/dropdown-menu", () => ({
DropdownMenu: ({ children }: { children: React.ReactNode }) => (
<div data-slot="mock-dropdown">{children}</div>
),
DropdownMenuTrigger: ({ children }: { children: React.ReactNode }) => (
<div data-slot="mock-dropdown-trigger">{children}</div>
),
DropdownMenuContent: ({ children }: { children: React.ReactNode }) => (
<div data-slot="mock-dropdown-content">{children}</div>
),
DropdownMenuItem: ({
children,
onSelect,
...rest
}: {
children: React.ReactNode;
onSelect?: () => void;
[key: string]: unknown;
}) => (
<button onClick={onSelect} {...rest}>
{children}
</button>
),
}));
import { MedicationDetailHeader } from "@/components/medications/medication-detail-header";
function render(props: {
@@ -109,20 +80,22 @@ describe("MedicationDetailHeader (D-3 §9.1)", () => {
expect(headingIndex).toBeLessThan(buttonIndex);
});
it("renders the two-option Bearbeiten picker (G-1 §4)", () => {
it("renders the edit button beside the advanced-settings gear (v1.6.0)", () => {
const html = render({ active: true });
expect(html).toContain("medications.detail.edit.planOption");
expect(html).toContain("medications.detail.edit.advancedOption");
expect(html).toContain('data-slot="medication-detail-edit-plan"');
expect(html).toContain('data-slot="medication-detail-edit-advanced"');
expect(html).toContain("common.edit");
expect(html).toContain('data-slot="medication-detail-edit-button"');
expect(html).toContain('data-slot="medication-detail-advanced-button"');
expect(html).toContain('aria-label="medications.detail.edit.advancedOption"');
});
it("orders Plan bearbeiten above Erweiterte Einstellungen", () => {
it("orders the primary edit button before the advanced gear", () => {
const html = render({ active: true });
const plan = html.indexOf("medications.detail.edit.planOption");
const advanced = html.indexOf("medications.detail.edit.advancedOption");
expect(plan).toBeGreaterThan(-1);
expect(advanced).toBeGreaterThan(plan);
const edit = html.indexOf('data-slot="medication-detail-edit-button"');
const advanced = html.indexOf(
'data-slot="medication-detail-advanced-button"',
);
expect(edit).toBeGreaterThan(-1);
expect(advanced).toBeGreaterThan(edit);
});
it("status pill text always renders (no colour-only state)", () => {
@@ -63,7 +63,7 @@ export function AdvancedSettingsSheet({
open={open}
onOpenChange={onOpenChange}
title={t("medications.detail.advanced.title")}
className="sm:max-w-lg"
contentWidth="lg"
bodyClassName="gap-6"
>
<div
@@ -18,16 +18,10 @@
* destructive zone, not on the header (E-2 M-1).
*/
import { ChevronDown, Pencil } from "lucide-react";
import { Pencil, Settings2 } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { useTranslations } from "@/lib/i18n/context";
export interface MedicationDetailHeaderProps {
@@ -36,13 +30,15 @@ export interface MedicationDetailHeaderProps {
active: boolean;
endsOn?: string | null;
/**
* v1.5.6 G-1 §4 — "Plan bearbeiten" opens the wizard (mode="edit",
* Step 1). Fired from the Bearbeiten dropdown's first item.
* v1.6.0 — the single "Bearbeiten" button opens the unified editor
* (mode="edit"). The two-option dropdown the v1.5.6 header carried
* collapsed to one direct button so editing is a single tap.
*/
onEditPlan: () => void;
/**
* v1.5.6 G-1 §4 — "Erweiterte Einstellungen" opens the
* `<AdvancedSettingsSheet>`. Fired from the dropdown's second item.
* v1.6.0 — the gear button opens the `<AdvancedSettingsSheet>`
* (notifications / API tokens / Phasen / destructive zone). Kept as
* a discreet secondary affordance beside the primary edit button.
*/
onOpenAdvanced: () => void;
}
@@ -109,34 +105,28 @@ export function MedicationDetailHeader({
</Badge>
</div>
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
size="sm"
className="min-h-11 sm:min-h-9"
data-slot="medication-detail-edit-button"
>
<Pencil aria-hidden="true" className="h-4 w-4" />
<span>{t("common.edit")}</span>
<ChevronDown aria-hidden="true" className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onSelect={onEditPlan}
data-slot="medication-detail-edit-plan"
>
{t("medications.detail.edit.planOption")}
</DropdownMenuItem>
<DropdownMenuItem
onSelect={onOpenAdvanced}
data-slot="medication-detail-edit-advanced"
>
{t("medications.detail.edit.advancedOption")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
className="min-h-11 sm:min-h-9"
onClick={onEditPlan}
data-slot="medication-detail-edit-button"
>
<Pencil aria-hidden="true" className="h-4 w-4" />
<span>{t("common.edit")}</span>
</Button>
<Button
variant="ghost"
size="icon"
className="min-h-11 min-w-11 sm:min-h-9 sm:min-w-9"
onClick={onOpenAdvanced}
aria-label={t("medications.detail.edit.advancedOption")}
data-slot="medication-detail-advanced-button"
>
<Settings2 aria-hidden="true" className="h-4 w-4" />
</Button>
</div>
</div>
);
}
@@ -22,6 +22,14 @@ import { toast } from "sonner";
import { Copy, KeyRound } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { useTranslations } from "@/lib/i18n/context";
import {
invalidateKeys,
@@ -34,6 +42,8 @@ export interface ApiTokensRowProps {
medicationName: string;
}
type ExampleType = "curl" | "wget" | "fetch" | "powershell";
interface ApiEndpointStatus {
enabled: boolean;
activeTokenCount: number;
@@ -47,6 +57,7 @@ export function ApiTokensRow({
const queryClient = useQueryClient();
const [mintedToken, setMintedToken] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
const [exampleType, setExampleType] = useState<ExampleType>("curl");
// v1.5.5 F-1 H-2 — single source of truth for the per-medication
// api-endpoint key. The earlier inline `useMemo` minted the same
@@ -74,6 +85,52 @@ export function ApiTokensRow({
typeof window !== "undefined" ? window.location.origin : "https://...";
const endpoint = `${baseUrl}/api/ingest/medication`;
// The minted token is wired straight into the snippets the moment it
// exists; before that the templates carry a `YOUR_TOKEN` placeholder.
// The idempotency key uses an `intake-` prefix so a re-POST of the
// same logical intake collapses to one row.
const tokenForExample = mintedToken ?? "YOUR_TOKEN";
const examplePayload = `{"medicationName":"${medicationName}","idempotencyKey":"intake-202602191230"}`;
const exampleMap: Record<ExampleType, { label: string; value: string }> = {
curl: {
label: "cURL",
value: `curl -X POST ${endpoint} \\
-H "Authorization: Bearer ${tokenForExample}" \\
-H "Content-Type: application/json" \\
-d '${examplePayload}'`,
},
wget: {
label: "wget",
value: `wget --method=POST "${endpoint}" \\
--header="Authorization: Bearer ${tokenForExample}" \\
--header="Content-Type: application/json" \\
--body-data='${examplePayload}' \\
-O -`,
},
fetch: {
label: "JavaScript fetch",
value: `await fetch("${endpoint}", {
method: "POST",
headers: {
"Authorization": "Bearer ${tokenForExample}",
"Content-Type": "application/json"
},
body: JSON.stringify({
medicationName: "${medicationName}",
idempotencyKey: "intake-" + Date.now()
})
});`,
},
powershell: {
label: "PowerShell",
value: `Invoke-RestMethod -Method Post -Uri "${endpoint}" \`
-Headers @{ Authorization = "Bearer ${tokenForExample}" } \`
-ContentType "application/json" \`
-Body '${examplePayload}'`,
},
};
const selectedExample = exampleMap[exampleType];
async function copy(text: string, label: string) {
try {
await navigator.clipboard.writeText(text);
@@ -199,6 +256,47 @@ export function ApiTokensRow({
</Button>
</div>
)}
{/* v1.6.0 — multi-language request snippets (restored from the
v1.5.4 endpoint dialog). The minted token is interpolated
live; until then the snippet carries a `YOUR_TOKEN` stand-in. */}
<div className="space-y-1.5 pt-1" data-slot="api-tokens-examples">
<div className="flex items-center justify-between gap-2">
<Label className="text-xs font-medium">
{t("medications.requestExample")}
</Label>
<Select
value={exampleType}
onValueChange={(value) => setExampleType(value as ExampleType)}
>
<SelectTrigger size="sm" className="w-[170px]">
<SelectValue />
</SelectTrigger>
<SelectContent align="end">
<SelectItem value="curl">cURL</SelectItem>
<SelectItem value="wget">wget</SelectItem>
<SelectItem value="fetch">JavaScript fetch</SelectItem>
<SelectItem value="powershell">PowerShell</SelectItem>
</SelectContent>
</Select>
</div>
<div className="relative">
<pre className="bg-muted rounded-lg p-3 pr-10 font-mono text-xs break-all whitespace-pre-wrap">
{selectedExample.value}
</pre>
<Button
variant="ghost"
size="icon"
className="absolute top-1 right-1 h-7 w-7"
onClick={() =>
void copy(selectedExample.value, t("common.copied"))
}
aria-label={t("common.copy")}
>
<Copy aria-hidden="true" className="h-3 w-3" />
</Button>
</div>
</div>
</div>
);
}
@@ -1,266 +0,0 @@
"use client";
/**
* v1.5.5 D-3 §9.2 — Today's-dose card.
*
* Two buttons: `[Genommen]` posts a confirmed-taken intake;
* `[Übersprungen]` posts a skipped one. The wire shape is the one the
* intake POST already understands:
*
* `{ scheduledFor: <today-iso>, takenAt: <now-iso>, skipped: boolean }`
*
* (The C-E2-1 fix dropped the "Verschoben / DEFERRED" third branch that
* an earlier draft proposed but the schema never carried.)
*
* Optimistic update: the moment the request fires, the card flips into
* read-only `Heute genommen um HH:MM` (or its skipped twin). On error
* the read-only state rolls back and an inline destructive note
* surfaces. The polite live region (the parent's `<Toaster>`) carries
* the toast announcement; this card additionally persists state
* visibly in the same paint as the toast (E-4 C-1).
*
* Paused state: when the medication is inactive both buttons render
* `aria-disabled` + visually muted with a single helper line.
*
* Cache: invalidates the bundle via `medicationDependentKeys` so every
* downstream tile (inline compliance chart, dashboard rollup tile)
* picks the new event up in one tick.
*/
import { useMemo, useState } from "react";
import { useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { Check, Loader2, SkipForward } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import { useTranslations, useFormatters } from "@/lib/i18n/context";
import { invalidateKeys, medicationDependentKeys } from "@/lib/query-keys";
export interface TodaysDoseCardProps {
medicationId: string;
active: boolean;
oneShot: boolean;
/**
* Server-side "due today" decision. The detail page derives it from
* the medication's cadence + the cached intake list and passes it
* in so the card stays presentation-only.
*/
scheduledForToday: boolean;
/**
* If the user already logged today's intake, the time the row
* captured. The card uses it to render the read-only state
* immediately on mount (rather than waiting for the optimistic
* branch to populate it).
*/
alreadyTakenAt?: string | null;
alreadySkipped?: boolean;
}
type Optimistic =
| { kind: "idle" }
| { kind: "submitting"; skipped: boolean }
| { kind: "done"; at: string; skipped: boolean }
| { kind: "error"; message: string };
export function TodaysDoseCard({
medicationId,
active,
oneShot,
scheduledForToday,
alreadyTakenAt,
alreadySkipped,
}: TodaysDoseCardProps) {
const { t } = useTranslations();
const formatters = useFormatters();
const queryClient = useQueryClient();
const initialState: Optimistic = alreadyTakenAt
? { kind: "done", at: alreadyTakenAt, skipped: false }
: alreadySkipped
? { kind: "done", at: new Date().toISOString(), skipped: true }
: { kind: "idle" };
const [state, setState] = useState<Optimistic>(initialState);
const liveLabel = useMemo(() => {
if (state.kind === "done") {
const timeLabel = formatters.dateTime(state.at);
if (state.skipped) {
return t("medications.detail.today.recordedSkipped", {
time: timeLabel,
});
}
return oneShot
? t("medications.detail.today.recordedOneShot", { time: timeLabel })
: t("medications.detail.today.recordedTaken", { time: timeLabel });
}
return null;
}, [formatters, oneShot, state, t]);
async function submit(skipped: boolean) {
if (state.kind === "submitting") return;
setState({ kind: "submitting", skipped });
const now = new Date();
const body = {
scheduledFor: now.toISOString(),
takenAt: skipped ? null : now.toISOString(),
skipped,
};
try {
const res = await fetch(`/api/medications/${medicationId}/intake`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
const json = await res.json();
if (!res.ok) {
const message = json.error ?? t("medications.detail.today.error");
setState({ kind: "error", message });
return;
}
setState({ kind: "done", at: now.toISOString(), skipped });
await invalidateKeys(queryClient, medicationDependentKeys);
toast.success(
skipped
? t("medications.detail.today.toastSkipped")
: t("medications.detail.today.toastTaken"),
);
} catch {
setState({
kind: "error",
message: t("medications.detail.today.error"),
});
}
}
// Paused / inactive state: both buttons sit muted with one helper.
if (!active) {
return (
<Card
className="p-5 sm:p-6 space-y-4"
data-slot="todays-dose-card"
data-state="paused"
>
<p
className="text-muted-foreground text-sm"
data-slot="todays-dose-paused"
>
{t("medications.detail.today.pausedHint")}
</p>
<div className="flex flex-wrap gap-2">
<Button
variant="outline"
aria-disabled="true"
tabIndex={-1}
className="min-h-11 sm:min-h-9 opacity-60"
>
<Check aria-hidden="true" className="mr-2 h-4 w-4" />
{t("medications.detail.today.taken")}
</Button>
<Button
variant="outline"
aria-disabled="true"
tabIndex={-1}
className="min-h-11 sm:min-h-9 opacity-60"
>
<SkipForward aria-hidden="true" className="mr-2 h-4 w-4" />
{t("medications.detail.today.skipped")}
</Button>
</div>
</Card>
);
}
// Read-only post-log state — switches in the same paint as the toast
// so the user sees the dose persisted without a refresh round-trip.
if (state.kind === "done") {
return (
<Card
className="p-5 sm:p-6 space-y-2"
data-slot="todays-dose-card"
data-state="done"
role="status"
aria-live="polite"
>
<p className="text-foreground text-sm font-medium">{liveLabel}</p>
</Card>
);
}
// Empty: nothing scheduled for today (recurring path only).
if (!scheduledForToday && !oneShot) {
return (
<Card
className="p-5 sm:p-6"
data-slot="todays-dose-card"
data-state="empty"
>
<p className="text-muted-foreground text-sm">
{t("medications.detail.today.noneScheduled")}
</p>
</Card>
);
}
const submitting = state.kind === "submitting";
return (
<Card
className="p-5 sm:p-6 space-y-4"
data-slot="todays-dose-card"
data-state="idle"
>
<div
className="flex flex-wrap gap-2"
role="group"
aria-label={t("medications.detail.today.groupLabel")}
>
<Button
onClick={() => void submit(false)}
disabled={submitting}
aria-busy={submitting && !state.skipped ? true : undefined}
className="min-h-11 sm:min-h-9"
data-slot="todays-dose-taken"
>
{submitting && !state.skipped ? (
<Loader2
aria-hidden="true"
className="mr-2 h-4 w-4 animate-spin motion-reduce:animate-none"
/>
) : (
<Check aria-hidden="true" className="mr-2 h-4 w-4" />
)}
{t("medications.detail.today.taken")}
</Button>
<Button
variant="outline"
onClick={() => void submit(true)}
disabled={submitting}
aria-busy={submitting && state.skipped ? true : undefined}
className="min-h-11 sm:min-h-9"
data-slot="todays-dose-skipped"
>
{submitting && state.skipped ? (
<Loader2
aria-hidden="true"
className="mr-2 h-4 w-4 animate-spin motion-reduce:animate-none"
/>
) : (
<SkipForward aria-hidden="true" className="mr-2 h-4 w-4" />
)}
{t("medications.detail.today.skipped")}
</Button>
</div>
{state.kind === "error" && (
<p
className="text-destructive text-sm"
role="alert"
data-slot="todays-dose-error"
>
{state.message}
</p>
)}
</Card>
);
}
@@ -338,11 +338,14 @@ function WizardDialogShell({
title={headerTitle}
hideHeader
showCloseButton
// v1.5.5 D-3 §8 — desktop shell widens from `sm:max-w-md`
// (448 px) to the 560 px ceiling the rest of the v1.5
// surfaces use; mobile bottom-sheet keeps the `40dvh` floor
// so the sheet never collapses to a stub on short screens.
className="sm:max-w-[560px] min-h-[40dvh] md:min-h-0"
// v1.6.0 — the unified editor widens to the 2xl ceiling on
// desktop so the sectioned form (Identity / Plan / Reminders /
// Specific) reads comfortably without the cramped 560 px
// column the v1.5 wizard used. The mobile bottom-sheet keeps
// the `40dvh` floor so the sheet never collapses to a stub on
// short screens.
contentWidth="2xl"
className="min-h-[40dvh] md:min-h-0"
bodyClassName="gap-0 p-0"
footer={
<div className="flex w-full items-center justify-between gap-2">
@@ -72,7 +72,16 @@ export function Step2Class({ payload, applyPartial }: StepProps) {
name="wizard-class"
value={row}
checked={selected}
onChange={() => applyPartial({ treatmentRow: row })}
onChange={() =>
applyPartial(
// The GLP-1 row is always injected; nudge the
// delivery form so the common case needs no extra
// tap. The user can still flip it on Step 3.
row === "glp1"
? { treatmentRow: row, deliveryForm: "INJECTION" }
: { treatmentRow: row },
)
}
className="sr-only"
aria-label={label}
/>
@@ -1,5 +1,8 @@
"use client";
import { useState } from "react";
import { ChevronDown, ChevronUp } from "lucide-react";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
@@ -9,6 +12,10 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Button } from "@/components/ui/button";
import { InjectionSitePicker } from "@/components/medications/injection-site-picker";
import type { InjectionSiteKey } from "@/lib/medications/injection-sites";
import type { MedicationDeliveryForm } from "@/lib/validations/medication";
import { useTranslations } from "@/lib/i18n/context";
import type { StepProps } from "./Step1Name";
@@ -33,47 +40,155 @@ const DOSE_UNIT_KEYS = [
"other",
] as const;
const DELIVERY_FORM_KEYS: readonly MedicationDeliveryForm[] = [
"ORAL",
"INJECTION",
"OTHER",
];
export function Step3Dose({ payload, applyPartial }: StepProps) {
const { t } = useTranslations();
// The injection-site rotation map is a visual aide on the editor: the
// chosen site is persisted per intake-event (no medication-level
// column), so the picker selection here is a local preview the user
// collapses when not needed.
const [siteOpen, setSiteOpen] = useState(false);
const [previewSite, setPreviewSite] = useState<InjectionSiteKey | null>(null);
const isInjection = payload.deliveryForm === "INJECTION";
return (
<div className="grid grid-cols-2 gap-3" data-slot="wizard-step3">
<div className="space-y-1.5">
<Label htmlFor="wizard-dose-amount" className="text-sm">
{t("medications.wizard.steps.step3.amountLabel")}
</Label>
<Input
id="wizard-dose-amount"
type="text"
inputMode="decimal"
value={payload.doseAmount}
onChange={(e) => applyPartial({ doseAmount: e.target.value })}
placeholder={t("medications.wizard.steps.step3.amountPlaceholder")}
maxLength={20}
autoComplete="off"
enterKeyHint="next"
className="h-11"
/>
<div className="space-y-6" data-slot="wizard-step3">
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<Label htmlFor="wizard-dose-amount" className="text-sm">
{t("medications.wizard.steps.step3.amountLabel")}
</Label>
<Input
id="wizard-dose-amount"
type="text"
inputMode="decimal"
value={payload.doseAmount}
onChange={(e) => applyPartial({ doseAmount: e.target.value })}
placeholder={t("medications.wizard.steps.step3.amountPlaceholder")}
maxLength={20}
autoComplete="off"
enterKeyHint="next"
className="h-11"
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="wizard-dose-unit" className="text-sm">
{t("medications.wizard.steps.step3.unitLabel")}
</Label>
<Select
value={payload.doseUnit}
onValueChange={(v) => applyPartial({ doseUnit: v })}
>
<SelectTrigger id="wizard-dose-unit" className="h-11 w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
{DOSE_UNIT_KEYS.map((u) => (
<SelectItem key={u} value={u}>
{t(`medications.wizard.steps.step3.unit.${u}`)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="space-y-1.5">
<Label htmlFor="wizard-dose-unit" className="text-sm">
{t("medications.wizard.steps.step3.unitLabel")}
</Label>
<Select
value={payload.doseUnit}
onValueChange={(v) => applyPartial({ doseUnit: v })}
{/* v1.6.0 — route of administration + inventory. Decoupled from
the Step 2 treatment row so an INJECTION delivery surfaces the
rotation map for any class, not only GLP-1. */}
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<Label htmlFor="wizard-delivery-form" className="text-sm">
{t("medications.wizard.steps.step3.deliveryFormLabel")}
</Label>
<Select
value={payload.deliveryForm}
onValueChange={(v) =>
applyPartial({ deliveryForm: v as MedicationDeliveryForm })
}
>
<SelectTrigger id="wizard-delivery-form" className="h-11 w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
{DELIVERY_FORM_KEYS.map((d) => (
<SelectItem key={d} value={d}>
{t(`medications.wizard.steps.step3.deliveryForm.${d}`)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label htmlFor="wizard-doses-per-unit" className="text-sm">
{t("medications.wizard.steps.step3.dosesPerUnitLabel")}
</Label>
<Input
id="wizard-doses-per-unit"
type="text"
inputMode="numeric"
value={payload.dosesPerUnit}
onChange={(e) =>
applyPartial({
dosesPerUnit: e.target.value.replace(/[^0-9]/g, "").slice(0, 3),
})
}
placeholder={t(
"medications.wizard.steps.step3.dosesPerUnitPlaceholder",
)}
maxLength={3}
autoComplete="off"
className="h-11"
/>
<p className="text-muted-foreground text-xs">
{t("medications.wizard.steps.step3.dosesPerUnitHint")}
</p>
</div>
</div>
{/* v1.6.0 — collapsible injection-site rotation map. Shown only for
INJECTION delivery; the per-dose site is logged at intake time
so this surface is an optional rotation aide, not a persisted
medication field. */}
{isInjection && (
<div
className="border-border/60 rounded-lg border"
data-slot="wizard-injection-site"
>
<SelectTrigger id="wizard-dose-unit" className="h-11 w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
{DOSE_UNIT_KEYS.map((u) => (
<SelectItem key={u} value={u}>
{t(`medications.wizard.steps.step3.unit.${u}`)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<Button
type="button"
variant="ghost"
className="h-11 w-full justify-between px-3"
aria-expanded={siteOpen}
onClick={() => setSiteOpen((prev) => !prev)}
>
<span className="text-sm font-medium">
{t("medications.wizard.steps.step3.injectionSiteToggle")}
</span>
{siteOpen ? (
<ChevronUp aria-hidden="true" className="h-4 w-4" />
) : (
<ChevronDown aria-hidden="true" className="h-4 w-4" />
)}
</Button>
{siteOpen && (
<div className="space-y-2 px-3 pb-4">
<p className="text-muted-foreground text-xs">
{t("medications.wizard.steps.step3.injectionSiteHint")}
</p>
<InjectionSitePicker
value={previewSite}
onChange={setPreviewSite}
/>
</div>
)}
</div>
)}
</div>
);
}
@@ -61,6 +61,7 @@ import {
import {
type MedicationTreatmentClass,
type MedicationCategoryValue,
type MedicationDeliveryForm,
} from "@/lib/validations/medication";
// ────────────────────────────────────────────────────────────────────
@@ -193,6 +194,19 @@ export interface WizardPayload {
doseAmount: string;
doseUnit: string;
treatmentRow: WizardTreatmentRow | null;
/**
* v1.6.0 — route of administration. Drives the injection-site
* rotation preview (shown when `INJECTION`) and, paired with
* `mode === "oneShot"`, the one-time-injection shape. Defaults to
* `ORAL`; the GLP-1 treatment row nudges it to `INJECTION` on
* selection so the common case needs no extra tap.
*/
deliveryForm: MedicationDeliveryForm;
/**
* v1.6.0 — doses per pen / vial for inventory math. Empty string =
* inventory tracking off (maps to `undefined` / NULL on the wire).
*/
dosesPerUnit: string;
/** Set in Step 5 — mirrors `schedules[activeScheduleIndex].mode`. */
mode: "oneShot" | "recurring" | null;
/** Mirrors `schedules[activeScheduleIndex].cadence`. */
@@ -237,6 +251,8 @@ export function emptyWizardPayload(): WizardPayload {
doseAmount: "",
doseUnit: "mg",
treatmentRow: null,
deliveryForm: "ORAL",
dosesPerUnit: "",
mode: draft.mode,
cadence: draft.cadence,
subControls: draft.subControls,
@@ -443,6 +459,10 @@ export interface CreateMedicationBody {
dose: string;
category: MedicationCategoryValue;
treatmentClass: MedicationTreatmentClass;
/** v1.6.0 — route of administration (ORAL | INJECTION | OTHER). */
deliveryForm: MedicationDeliveryForm;
/** v1.6.0 — doses per pen / vial. Omitted when inventory is off. */
dosesPerUnit?: number;
/**
* v1.5.5 D-3 §10 invariant 16 — the wizard owns the create-time
* default for `notificationsEnabled` but never overwrites it on
@@ -571,11 +591,17 @@ export function buildCreateBody(
? [committed.schedules[committed.activeScheduleIndex] ?? committed.schedules[0]]
: committed.schedules;
const parsedDosesPerUnit = Number.parseInt(committed.dosesPerUnit, 10);
const body: CreateMedicationBody = {
name: committed.name.trim(),
dose,
category: mapping.category,
treatmentClass: mapping.treatmentClass,
deliveryForm: committed.deliveryForm,
...(Number.isFinite(parsedDosesPerUnit) &&
parsedDosesPerUnit >= 1 && {
dosesPerUnit: parsedDosesPerUnit,
}),
// v1.5.5 D-3 §10 invariant 16 — `notificationsEnabled` only on
// create. Edits never round-trip the wizard's default because the
// detail page's notifications switch is the single source of
@@ -777,6 +803,10 @@ export interface MedicationPayload {
dose: string;
category: string;
treatmentClass?: string;
/** v1.6.0 — route of administration. Defaults to ORAL when absent. */
deliveryForm?: string;
/** v1.6.0 — doses per pen / vial. NULL = inventory tracking off. */
dosesPerUnit?: number | null;
notificationsEnabled: boolean;
startsOn: Date | null;
endsOn: Date | null;
@@ -795,6 +825,11 @@ export interface MedicationPayload {
}>;
}
/** Coerce a persisted `deliveryForm` string onto the closed enum. */
function normaliseDeliveryForm(value: string | undefined): MedicationDeliveryForm {
return value === "INJECTION" || value === "OTHER" ? value : "ORAL";
}
const DOSE_EXPR_RE = /^\s*(\d+(?:[.,]\d+)?)\s*(.*)$/;
/** Try to split "5 mg" into ["5", "mg"]; falls back to ["", input]. */
@@ -866,6 +901,11 @@ export function hydrateWizardPayload(initial: MedicationPayload): WizardPayload
doseAmount: parsedDose.amount,
doseUnit: parsedDose.unit || base.doseUnit,
treatmentRow: rowFromTreatment(initial.treatmentClass, initial.category),
deliveryForm: normaliseDeliveryForm(initial.deliveryForm),
dosesPerUnit:
typeof initial.dosesPerUnit === "number" && initial.dosesPerUnit >= 1
? String(initial.dosesPerUnit)
: "",
mode: first.mode,
cadence: first.cadence,
subControls: first.subControls,
+174 -1
View File
@@ -1,11 +1,12 @@
"use client";
import { useEffect, useState } from "react";
import { useEffect, useRef, useState } from "react";
import { useRouter } from "next/navigation";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
AlertTriangle,
Compass,
ImageUp,
KeyRound,
Loader2,
Save,
@@ -25,6 +26,11 @@ import {
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import {
Avatar,
AvatarFallback,
AvatarImage,
} from "@/components/ui/avatar";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
@@ -376,6 +382,9 @@ export function AccountSection() {
</header>
{/* Profile card */}
{/* Profile photo card */}
<AvatarSection />
<div className="bg-card border-border rounded-xl border p-6">
<div className="mb-4 flex items-center gap-2">
<User className="text-primary h-5 w-5" />
@@ -955,3 +964,167 @@ function PasskeyListSection({ isAuthenticated }: { isAuthenticated: boolean }) {
</div>
);
}
// Accepted upload types + byte cap mirror the server contract in
// `src/app/api/user/avatar/route.ts` so the client rejects an obvious
// bad file before the round-trip. The server still re-validates by
// magic-byte sniff — this is a UX shortcut, not the security boundary.
const ACCEPTED_AVATAR_TYPES = ["image/jpeg", "image/png", "image/webp"];
const AVATAR_MAX_UPLOAD_BYTES = 2 * 1024 * 1024;
function getAvatarInitials(name: string): string {
return name
.split(/[\s._-]+/)
.slice(0, 2)
.map((s) => s[0]?.toUpperCase() ?? "")
.join("");
}
function AvatarSection() {
const { t } = useTranslations();
const { user, refetch } = useAuth();
const fileInputRef = useRef<HTMLInputElement>(null);
const [msg, setMsg] = useState<string | null>(null);
const [msgType, setMsgType] = useState<"success" | "error" | null>(null);
const upload = useMutation({
mutationFn: async (file: File) => {
const body = new FormData();
body.append("file", file);
const res = await fetch("/api/user/avatar", { method: "POST", body });
const json = await res.json().catch(() => ({}));
if (!res.ok) {
if (res.status === 415) throw new Error(t("settings.avatar.invalidType"));
if (res.status === 413) throw new Error(t("settings.avatar.tooLarge"));
throw new Error(json.error || t("settings.avatar.error"));
}
},
onSuccess: async () => {
setMsgType("success");
setMsg(t("settings.avatar.uploaded"));
await refetch();
},
onError: (err: Error) => {
setMsgType("error");
setMsg(err.message);
},
});
const remove = useMutation({
mutationFn: async () => {
const res = await fetch("/api/user/avatar", { method: "DELETE" });
if (!res.ok) {
const json = await res.json().catch(() => ({}));
throw new Error(json.error || t("settings.avatar.error"));
}
},
onSuccess: async () => {
setMsgType("success");
setMsg(t("settings.avatar.removed"));
await refetch();
},
onError: (err: Error) => {
setMsgType("error");
setMsg(err.message);
},
});
function handleFile(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0];
// Reset so re-selecting the same file still fires `onChange`.
e.target.value = "";
if (!file) return;
setMsg(null);
setMsgType(null);
if (!ACCEPTED_AVATAR_TYPES.includes(file.type)) {
setMsgType("error");
setMsg(t("settings.avatar.invalidType"));
return;
}
if (file.size > AVATAR_MAX_UPLOAD_BYTES) {
setMsgType("error");
setMsg(t("settings.avatar.tooLarge"));
return;
}
upload.mutate(file);
}
if (!user) return null;
const avatarUrl = user.avatarUrl ?? null;
const busy = upload.isPending || remove.isPending;
return (
<div className="bg-card border-border rounded-xl border p-6">
<div className="mb-4 flex items-center gap-2">
<ImageUp className="text-primary h-5 w-5" />
<h2 className="text-lg font-semibold">{t("settings.avatar.title")}</h2>
</div>
<p className="text-muted-foreground mb-4 text-sm">
{t("settings.avatar.description")}
</p>
<div className="flex items-center gap-4">
<Avatar className="size-16">
{avatarUrl && <AvatarImage src={avatarUrl} alt={user.username} />}
<AvatarFallback className="text-base">
{getAvatarInitials(user.username)}
</AvatarFallback>
</Avatar>
<div className="flex flex-wrap gap-2">
<input
ref={fileInputRef}
type="file"
accept="image/jpeg,image/png,image/webp"
className="hidden"
onChange={handleFile}
/>
<Button
variant="outline"
disabled={busy}
onClick={() => fileInputRef.current?.click()}
>
{upload.isPending ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin motion-reduce:animate-none" />
) : (
<ImageUp className="mr-2 h-4 w-4" />
)}
{avatarUrl
? t("settings.avatar.replace")
: t("settings.avatar.upload")}
</Button>
{avatarUrl && (
<Button
variant="ghost"
className="text-destructive"
disabled={busy}
onClick={() => {
setMsg(null);
setMsgType(null);
remove.mutate();
}}
>
{remove.isPending ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin motion-reduce:animate-none" />
) : (
<Trash2 className="mr-2 h-4 w-4" />
)}
{t("settings.avatar.remove")}
</Button>
)}
</div>
</div>
<p className="text-muted-foreground mt-3 text-xs">
{t("settings.avatar.hint")}
</p>
{msg && (
<p
role="alert"
className={`mt-3 text-sm ${
msgType === "success" ? "text-dracula-green" : "text-destructive"
}`}
>
{msg}
</p>
)}
</div>
);
}
+15 -6
View File
@@ -38,6 +38,7 @@ import { useAuth } from "@/hooks/use-auth";
import { formatDateTime } from "@/lib/format";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import { toast } from "sonner";
interface InsightsSettings {
codexStatus: string;
@@ -1796,12 +1797,20 @@ function RuntimeActionsRow({
async function togglePrivacy() {
const next = privacyMode === "raw" ? "aggregated" : "raw";
await fetch("/api/insights/settings", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ privacyMode: next }),
});
onPrivacyChanged();
// Privacy mode is a sensitive control: only reflect the new mode
// once the server confirms it. A swallowed 4xx/5xx would leave the
// UI showing a setting the server never accepted.
try {
const res = await fetch("/api/insights/settings", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ privacyMode: next }),
});
if (!res.ok) throw new Error("request failed");
onPrivacyChanged();
} catch {
toast.error(t("settings.savingError"));
}
}
const lastInsightLine = lastInsightAt
+24 -1
View File
@@ -58,9 +58,31 @@ export interface ResponsiveSheetProps {
* consumer renders its own close affordance inside the header.
*/
showCloseButton?: boolean;
/**
* Desktop (Dialog branch) content width. The bottom-sheet branch
* always spans the viewport, so this prop only affects the `md+`
* Dialog. Defaults to `"md"` (448 px) to preserve every existing
* consumer; pass `"4xl"` for the wide medication editor's
* two-column layout. The class is the only width utility applied —
* the prior `cn("sm:max-w-md", className)` ordering let a
* consumer-passed width silently collide with the hardcoded
* default, so the width now resolves from this single source.
*/
contentWidth?: "md" | "lg" | "2xl" | "3xl" | "4xl";
children: React.ReactNode;
}
const CONTENT_WIDTH_CLASS: Record<
NonNullable<ResponsiveSheetProps["contentWidth"]>,
string
> = {
md: "sm:max-w-md",
lg: "sm:max-w-lg",
"2xl": "sm:max-w-2xl",
"3xl": "sm:max-w-3xl",
"4xl": "sm:max-w-4xl",
};
/**
* v1.4.27 R3d MB1 — shared mount primitive that flips between a
* bottom-anchored `<Sheet>` on narrow viewports and the existing
@@ -89,6 +111,7 @@ export function ResponsiveSheet({
className,
bodyClassName,
showCloseButton = true,
contentWidth = "md",
children,
}: ResponsiveSheetProps) {
const isMobile = useIsMobile();
@@ -160,7 +183,7 @@ export function ResponsiveSheet({
showCloseButton={showCloseButton}
data-slot="responsive-sheet-content"
data-variant="dialog"
className={cn("sm:max-w-md", className)}
className={cn(CONTENT_WIDTH_CLASS[contentWidth], className)}
>
{hideHeader ? (
<DialogHeader className="sr-only">