mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 05:42:19 +00:00
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:
1 parent
d01d1d1dc9
commit
8ee7aaac44
53 files changed
+1049
-1017
No files matched your search
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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")) {
|
||||
|
||||
@@ -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")}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in new issue
Block a user