feat(medications,coach,admin,perf): era corrections, a truthful weekly status and one card language everywhere

The dashboard snapshot fires the moment login succeeds instead of
waiting for the page chunk (start ~670 to ~200 ms throttled). Guided
follow-ups are personalised from the user's snapshot and an answer gets
the coach's streamed reaction before the next question. Archived
schedule eras can be corrected through a superseding revision that
keeps the original as an audit trail, era writes serialise per
medication, and duplicate dose times collapse in validation. A rolling
medication whose next dose is tomorrow can no longer read as heavily
overdue — the card status derives strictly from the served next-due —
and the adherence bars keep a fixed geometry in every state. The
server-wide assistant key gains its admin card, the one duplicated
coach preference collapses onto a single owner, twenty-seven admin and
settings card headers move onto the shared pattern with sixty
containers normalised, and the remaining hydration warnings are gone.
This commit is contained in:
Marc-André Bombeck committed 2026-06-11 13:57:15 +02:00
1 parent a4b3d2fcd0
commit 40f7196c20
115 files changed
+3014 -1084

No files matched your search

@@ -24,10 +24,12 @@ vi.mock("next/navigation", () => ({
// The shell gates its own frame on the confirmed ADMIN role — a
// non-admin (or a still-loading auth state) must not see the section
// nav while AuthShell's redirect effect is catching up.
const mockUserRef = { value: { role: "ADMIN", username: "op" } as {
role: string;
username: string;
} | null };
const mockUserRef = {
value: { role: "ADMIN", username: "op" } as {
role: string;
username: string;
} | null,
};
vi.mock("@/hooks/use-auth", () => ({
useAuth: () => ({ user: mockUserRef.value }),
}));
@@ -374,9 +374,7 @@ describe("carrierShortLabel — DACH carrier folding (v1.4.27 B3)", () => {
});
it("folds Telefónica + O2 variants down to 'O2'", () => {
expect(
carrierShortLabel("Telefónica Germany GmbH & Co. OHG"),
).toBe("O2");
expect(carrierShortLabel("Telefónica Germany GmbH & Co. OHG")).toBe("O2");
expect(carrierShortLabel("Telefonica Germany Online Services")).toBe("O2");
expect(carrierShortLabel("O2 Deutschland")).toBe("O2");
});
@@ -384,6 +382,8 @@ describe("carrierShortLabel — DACH carrier folding (v1.4.27 B3)", () => {
it("returns the raw organisation string for unknown carriers", () => {
expect(carrierShortLabel("Google LLC")).toBe("Google LLC");
expect(carrierShortLabel("Amazon.com, Inc.")).toBe("Amazon.com, Inc.");
expect(carrierShortLabel("Hetzner Online GmbH")).toBe("Hetzner Online GmbH");
expect(carrierShortLabel("Hetzner Online GmbH")).toBe(
"Hetzner Online GmbH",
);
});
});
@@ -120,9 +120,12 @@ describe("UserManagementSection — mobile responsive", () => {
expect(html).toMatch(/class="[^"]*\bp-4\b[^"]*sm:p-6/);
});
it("stacks header rows on mobile so filter pills don't collide with title", () => {
it("wraps the filter pills below the title on narrow viewports", () => {
const html = render();
// Header carries `flex-col` at base + `sm:flex-row` at small+.
expect(html).toMatch(/class="[^"]*\bflex-col\b[^"]*sm:flex-row/);
// The shared SettingsCardHeader title row wraps, so the status slot
// (filter pills) reflows under the title instead of colliding with it.
expect(html).toMatch(
/class="[^"]*\bflex-wrap\b[^"]*items-start justify-between/,
);
});
});
+13
View File
@@ -5,6 +5,7 @@ import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { Cpu, Fingerprint, Globe, KeyRound } from "lucide-react";
import { Switch } from "@/components/ui/switch";
import { Badge } from "@/components/ui/badge";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import { PasswordInput as SharedPasswordInput } from "@/components/ui/password-input";
@@ -160,6 +161,18 @@ export const FEEDBACK_STATUS_TABS: FeedbackStatusType[] = [
"ARCHIVED",
];
/** Shared green "configured" pill — the GlitchTip, Umami, bug-report,
* and Web-Push VAPID cards all surface the same state; one component
* keeps the tint and copy in lockstep. */
export function ConfiguredBadge() {
const { t } = useTranslations();
return (
<Badge className="border-dracula-green/30 bg-dracula-green/15 text-dracula-green">
{t("admin.configured")}
</Badge>
);
}
export function StatusItem({
icon: Icon,
label,
+3 -3
View File
@@ -261,9 +261,9 @@ export function AdminShell({ active, children }: AdminShellProps) {
aria-current={isActive ? "page" : undefined}
className={cn(
// v1.4.25 W8 — chip strip is the primary mobile-admin
// navigation surface. Pad to WCAG 2.5.5 44 px so the
// chips can be tapped without zoom on a Pixel-5.
"flex min-h-11 items-center gap-2 rounded-full border px-3 py-1.5 text-sm font-medium whitespace-nowrap transition-colors",
// navigation surface. Pad to WCAG 2.5.5 44 px so the
// chips can be tapped without zoom on a Pixel-5.
"flex min-h-11 items-center gap-2 rounded-full border px-3 py-1.5 text-sm font-medium whitespace-nowrap transition-colors",
isActive
? "border-primary/40 bg-primary/10 text-primary"
: "border-border text-foreground hover:bg-accent",
+23 -26
View File
@@ -21,6 +21,7 @@
import { useQuery } from "@tanstack/react-query";
import { Sparkles, Loader2 } from "lucide-react";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { useTranslations, useFormatters } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import { apiGet } from "@/lib/api/api-fetch";
@@ -66,7 +67,7 @@ export function AiQualitySection() {
if (query.isLoading) {
return (
<div className="bg-card border-border flex items-center gap-2 rounded-xl border p-6">
<div className="bg-card border-border flex items-center gap-2 rounded-xl border p-4 sm:p-6">
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
<span className="text-muted-foreground text-sm">
{t("admin.aiQuality.loading")}
@@ -90,14 +91,12 @@ export function AiQualitySection() {
if (!summary || summary.buckets.length === 0) {
return (
<div className="bg-card border-border rounded-xl border p-6">
<div className="flex items-center gap-2">
<Sparkles className="text-dracula-purple h-5 w-5" />
<div className="text-lg font-semibold">
{t("admin.aiQuality.title")}
</div>
</div>
<p className="text-muted-foreground mt-3 text-sm">
<div className="bg-card border-border rounded-xl border p-4 sm:p-6">
<SettingsCardHeader
icon={Sparkles}
title={t("admin.aiQuality.title")}
/>
<p className="text-muted-foreground mt-3 pl-7 text-sm">
{t("admin.aiQuality.empty")}
</p>
</div>
@@ -105,23 +104,21 @@ export function AiQualitySection() {
}
return (
<div className="bg-card border-border space-y-4 rounded-xl border p-6">
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2">
<Sparkles className="text-dracula-purple h-5 w-5" />
<div className="text-lg font-semibold">
{t("admin.aiQuality.title")}
</div>
</div>
<p className="text-muted-foreground text-xs">
{t("admin.aiQuality.windowLabel", {
days: String(summary.windowDays),
})}
{" · "}
{t("admin.aiQuality.generatedAtLabel")}{" "}
{fmt.dateTime(summary.generatedAt)}
</p>
</div>
<div className="bg-card border-border space-y-4 rounded-xl border p-4 sm:p-6">
<SettingsCardHeader
icon={Sparkles}
title={t("admin.aiQuality.title")}
status={
<p className="text-muted-foreground text-xs">
{t("admin.aiQuality.windowLabel", {
days: String(summary.windowDays),
})}
{" · "}
{t("admin.aiQuality.generatedAtLabel")}{" "}
{fmt.dateTime(summary.generatedAt)}
</p>
}
/>
<div className="overflow-x-auto">
<table className="w-full text-sm" data-slot="ai-quality-table">
@@ -0,0 +1,188 @@
"use client";
import { useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { KeyRound, Loader2 } from "lucide-react";
import { toast } from "sonner";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import { apiGet, apiPut } from "@/lib/api/api-fetch";
import { PasswordInput } from "./_shared";
/**
* v1.16.6 — operator panel for the server-wide AI key.
*
* The backing endpoint (`/api/admin/ai-settings`, admin-cookie-only) has
* existed since the `admin-openai` chain fallback shipped, but it never had
* an operator UI — configuring the shared key took a manual API call. This
* card is its home, beside the assistant feature flags.
*
* Semantics worth keeping visible to the operator:
* - A user's own key (BYOK / Codex OAuth / local) always wins; the server
* key sits at the END of the provider chain as the shared fallback.
* - Egress over the server key is consent-gated per user
* (`ConsentRequiredError` in `src/lib/ai/consent-guard.ts`) — setting a
* key here never silently forwards anyone's health data.
* - The base URL is locked server-side to HTTPS + a hostname allowlist
* (`ADMIN_AI_BASE_URL_ALLOWLIST` extends it).
*/
interface AiServerKeyResponse {
hasKey: boolean;
keyPreview: string | null;
model: string;
baseUrl: string;
}
export function AiServerKeySection() {
const { t } = useTranslations();
const queryClient = useQueryClient();
const { data: settings } = useQuery({
queryKey: queryKeys.adminAiServerKey(),
queryFn: () => apiGet<AiServerKeyResponse>("/api/admin/ai-settings"),
});
const [keyDraft, setKeyDraft] = useState("");
const [modelDraft, setModelDraft] = useState<string | null>(null);
const [baseUrlDraft, setBaseUrlDraft] = useState<string | null>(null);
const modelValue = modelDraft ?? settings?.model ?? "";
const baseUrlValue = baseUrlDraft ?? settings?.baseUrl ?? "";
const configured = settings?.hasKey ?? false;
const save = useMutation({
mutationFn: (payload: Record<string, unknown>) =>
apiPut<AiServerKeyResponse>("/api/admin/ai-settings", payload),
onSuccess: () => {
setKeyDraft("");
setModelDraft(null);
setBaseUrlDraft(null);
queryClient.invalidateQueries({
queryKey: queryKeys.adminAiServerKey(),
});
toast.success(t("admin.aiServerKey.saved"));
},
onError: (error: unknown) => {
toast.error(
error instanceof Error ? error.message : t("admin.aiServerKey.error"),
);
},
});
function handleSave() {
const payload: Record<string, unknown> = {
model: modelValue,
baseUrl: baseUrlValue,
};
if (keyDraft.trim().length > 0) {
payload.apiKey = keyDraft.trim();
}
save.mutate(payload);
}
function handleRemove() {
save.mutate({ apiKey: "" });
}
return (
<div
data-slot="admin-ai-server-key"
className="bg-card border-border rounded-xl border p-4 sm:p-6"
>
<SettingsCardHeader
icon={KeyRound}
title={t("admin.aiServerKey.title")}
description={
<>
<p>{t("admin.aiServerKey.description")}</p>
<p>{t("admin.aiServerKey.byokHint")}</p>
<p>{t("admin.aiServerKey.consentHint")}</p>
</>
}
status={
<Badge variant={configured ? "default" : "outline"}>
{configured
? t("admin.aiServerKey.statusConfigured")
: t("admin.aiServerKey.statusNotConfigured")}
</Badge>
}
/>
<div className="mt-4 space-y-4 pl-7">
<div className="space-y-1.5">
<Label htmlFor="admin-ai-server-key-input">
{t("admin.aiServerKey.keyLabel")}
</Label>
<PasswordInput
id="admin-ai-server-key-input"
value={keyDraft}
onChange={(e) => setKeyDraft(e.target.value)}
placeholder={
configured && settings?.keyPreview
? t("admin.aiServerKey.keyPlaceholderSet", {
preview: settings.keyPreview,
})
: t("admin.aiServerKey.keyPlaceholder")
}
autoComplete="off"
/>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-1.5">
<Label htmlFor="admin-ai-server-key-model">
{t("admin.aiServerKey.modelLabel")}
</Label>
<Input
id="admin-ai-server-key-model"
value={modelValue}
onChange={(e) => setModelDraft(e.target.value)}
placeholder="gpt-4o"
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="admin-ai-server-key-base-url">
{t("admin.aiServerKey.baseUrlLabel")}
</Label>
<Input
id="admin-ai-server-key-base-url"
value={baseUrlValue}
onChange={(e) => setBaseUrlDraft(e.target.value)}
placeholder="https://api.openai.com/v1"
/>
</div>
</div>
<div className="flex flex-wrap items-center gap-2">
<Button
onClick={handleSave}
disabled={save.isPending}
data-slot="admin-ai-server-key-save"
>
{save.isPending && (
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
)}
{t("admin.aiServerKey.save")}
</Button>
{configured && (
<Button
variant="outline"
onClick={handleRemove}
disabled={save.isPending}
data-slot="admin-ai-server-key-remove"
>
{t("admin.aiServerKey.remove")}
</Button>
)}
</div>
</div>
</div>
);
}
@@ -11,6 +11,7 @@ import {
TooltipTrigger,
} from "@/components/ui/tooltip";
import { formatDate, formatDateTime } from "@/lib/format";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import { type ApiTokenInfo } from "./_shared";
@@ -157,10 +158,11 @@ export function ApiTokenOverviewSection() {
return (
<div className="bg-card border-border overflow-hidden rounded-xl border p-4 sm:p-6">
<div className="flex items-center gap-2">
<Key className="text-muted-foreground h-5 w-5" />
<div className="text-lg font-semibold">{t("admin.apiTokens")}</div>
</div>
<SettingsCardHeader
icon={Key}
title={t("admin.apiTokens")}
description={t("admin.apiTokensDescription")}
/>
<div className="mt-4">
{isLoading ? (
@@ -46,6 +46,7 @@ import {
DialogTitle,
} from "@/components/ui/dialog";
import { formatDateTime } from "@/lib/format";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import type { WideEvent, LogLevel } from "@/lib/logging/types";
@@ -104,7 +105,9 @@ export function AppLogPreviewSection() {
const { data, isLoading, refetch, isFetching } = useQuery({
queryKey: queryKeys.adminAppLogs(traceId, actionFilter, level, range),
queryFn: async () => {
return apiGet<AppLogsResponse>(`/api/admin/app-logs?${params.toString()}`);
return apiGet<AppLogsResponse>(
`/api/admin/app-logs?${params.toString()}`,
);
},
refetchInterval: 30_000,
});
@@ -112,31 +115,25 @@ export function AppLogPreviewSection() {
const events = data?.events ?? [];
return (
<div className="bg-card border-border space-y-4 rounded-xl border p-6">
<header className="flex items-start justify-between gap-3">
<div className="flex items-start gap-2">
<FileText className="text-muted-foreground mt-0.5 h-5 w-5" />
<div>
<h2 className="text-lg font-semibold">
{t("admin.section.app-logs.title")}
</h2>
<p className="text-muted-foreground mt-1 text-xs">
{t("admin.section.app-logs.processNote")}
</p>
</div>
</div>
<Button
variant="ghost"
size="sm"
onClick={() => refetch()}
disabled={isFetching}
aria-label={t("admin.section.app-logs.refresh")}
>
<RefreshCw
className={`h-4 w-4 ${isFetching ? "animate-spin" : ""} motion-reduce:animate-none`}
/>
</Button>
</header>
<div className="bg-card border-border space-y-4 rounded-xl border p-4 sm:p-6">
<SettingsCardHeader
icon={FileText}
title={t("admin.section.app-logs.title")}
description={t("admin.section.app-logs.processNote")}
status={
<Button
variant="ghost"
size="sm"
onClick={() => refetch()}
disabled={isFetching}
aria-label={t("admin.section.app-logs.refresh")}
>
<RefreshCw
className={`h-4 w-4 ${isFetching ? "animate-spin" : ""} motion-reduce:animate-none`}
/>
</Button>
}
/>
<div className="grid gap-2 md:grid-cols-2 lg:grid-cols-4">
<Input
+73 -71
View File
@@ -4,6 +4,7 @@ import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { Sparkles } from "lucide-react";
import { toast } from "sonner";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import { SettingsToggle } from "./_shared";
@@ -49,7 +50,9 @@ function useAssistantFlags() {
return useQuery({
queryKey: queryKeys.adminAssistantFlags(),
queryFn: async () => {
return apiGet<AssistantFlagsResponse>("/api/admin/settings/assistant-flags");
return apiGet<AssistantFlagsResponse>(
"/api/admin/settings/assistant-flags",
);
},
});
}
@@ -61,13 +64,13 @@ function useUpdateAssistantFlags() {
mutationFn: async (
patch: Partial<AssistantFlagsResponse["raw"]>,
): Promise<AssistantFlagsResponse> => {
return apiPut<AssistantFlagsResponse>("/api/admin/settings/assistant-flags", patch);
return apiPut<AssistantFlagsResponse>(
"/api/admin/settings/assistant-flags",
patch,
);
},
onSuccess: (data) => {
client.setQueryData(
queryKeys.adminAssistantFlags(),
data,
);
client.setQueryData(queryKeys.adminAssistantFlags(), data);
// Bust the runtime `/api/feature-flags` cache so the operator
// sees the toggled surface react within the same session.
client.invalidateQueries({ queryKey: queryKeys.featureFlags() });
@@ -96,73 +99,72 @@ export function AssistantSection() {
const disabledSubs = !masterOn || mutation.isPending;
return (
<div className="bg-card border-border space-y-6 rounded-xl border p-6">
<div className="flex items-center gap-2">
<Sparkles className="text-muted-foreground h-5 w-5" />
<div className="text-lg font-semibold">{t("admin.assistant.title")}</div>
</div>
<p className="text-muted-foreground pl-7 text-sm">
{t("admin.assistant.description")}
</p>
<SettingsToggle
label={t("admin.assistant.master.title")}
description={t("admin.assistant.master.description")}
checked={raw?.assistantEnabled ?? true}
onCheckedChange={(checked) =>
mutation.mutate({ assistantEnabled: checked })
}
disabled={mutation.isPending}
<div className="bg-card border-border rounded-xl border p-4 sm:p-6">
<SettingsCardHeader
icon={Sparkles}
title={t("admin.assistant.title")}
description={t("admin.assistant.description")}
/>
<div className="mt-4 space-y-4 pl-7">
<SettingsToggle
label={t("admin.assistant.master.title")}
description={t("admin.assistant.master.description")}
checked={raw?.assistantEnabled ?? true}
onCheckedChange={(checked) =>
mutation.mutate({ assistantEnabled: checked })
}
disabled={mutation.isPending}
/>
<div className="border-border space-y-4 border-t pt-4">
<SettingsToggle
label={t("admin.assistant.coach.title")}
description={t("admin.assistant.coach.description")}
checked={raw?.assistantCoachEnabled ?? true}
onCheckedChange={(checked) =>
mutation.mutate({ assistantCoachEnabled: checked })
}
disabled={disabledSubs}
/>
<SettingsToggle
label={t("admin.assistant.briefing.title")}
description={t("admin.assistant.briefing.description")}
checked={raw?.assistantBriefingEnabled ?? true}
onCheckedChange={(checked) =>
mutation.mutate({ assistantBriefingEnabled: checked })
}
disabled={disabledSubs}
/>
<SettingsToggle
label={t("admin.assistant.insightStatus.title")}
description={t("admin.assistant.insightStatus.description")}
checked={raw?.assistantInsightStatusEnabled ?? true}
onCheckedChange={(checked) =>
mutation.mutate({ assistantInsightStatusEnabled: checked })
}
disabled={disabledSubs}
/>
<SettingsToggle
label={t("admin.assistant.correlations.title")}
description={t("admin.assistant.correlations.description")}
checked={raw?.assistantCorrelationsEnabled ?? true}
onCheckedChange={(checked) =>
mutation.mutate({ assistantCorrelationsEnabled: checked })
}
disabled={disabledSubs}
/>
<SettingsToggle
label={t("admin.assistant.healthScoreExplainer.title")}
description={t("admin.assistant.healthScoreExplainer.description")}
checked={raw?.assistantHealthScoreExplainerEnabled ?? true}
onCheckedChange={(checked) =>
mutation.mutate({
assistantHealthScoreExplainerEnabled: checked,
})
}
disabled={disabledSubs}
/>
<div className="border-border space-y-4 border-t pt-4">
<SettingsToggle
label={t("admin.assistant.coach.title")}
description={t("admin.assistant.coach.description")}
checked={raw?.assistantCoachEnabled ?? true}
onCheckedChange={(checked) =>
mutation.mutate({ assistantCoachEnabled: checked })
}
disabled={disabledSubs}
/>
<SettingsToggle
label={t("admin.assistant.briefing.title")}
description={t("admin.assistant.briefing.description")}
checked={raw?.assistantBriefingEnabled ?? true}
onCheckedChange={(checked) =>
mutation.mutate({ assistantBriefingEnabled: checked })
}
disabled={disabledSubs}
/>
<SettingsToggle
label={t("admin.assistant.insightStatus.title")}
description={t("admin.assistant.insightStatus.description")}
checked={raw?.assistantInsightStatusEnabled ?? true}
onCheckedChange={(checked) =>
mutation.mutate({ assistantInsightStatusEnabled: checked })
}
disabled={disabledSubs}
/>
<SettingsToggle
label={t("admin.assistant.correlations.title")}
description={t("admin.assistant.correlations.description")}
checked={raw?.assistantCorrelationsEnabled ?? true}
onCheckedChange={(checked) =>
mutation.mutate({ assistantCorrelationsEnabled: checked })
}
disabled={disabledSubs}
/>
<SettingsToggle
label={t("admin.assistant.healthScoreExplainer.title")}
description={t("admin.assistant.healthScoreExplainer.description")}
checked={raw?.assistantHealthScoreExplainerEnabled ?? true}
onCheckedChange={(checked) =>
mutation.mutate({
assistantHealthScoreExplainerEnabled: checked,
})
}
disabled={disabledSubs}
/>
</div>
</div>
</div>
);
+26 -25
View File
@@ -40,6 +40,7 @@ import { EmptyState } from "@/components/ui/empty-state";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { formatDateTime } from "@/lib/format";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { useFormatters, useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import type { BackupRow, BackupsList } from "@/types/backups";
@@ -331,34 +332,34 @@ export function BackupsSection() {
}
return (
<div className="bg-card border-border rounded-xl border p-6">
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2">
<Database className="text-muted-foreground h-5 w-5" />
<div className="text-lg font-semibold">
{t("admin.section.backups.title")}
</div>
{data && (
<div className="bg-card border-border rounded-xl border p-4 sm:p-6">
<SettingsCardHeader
icon={Database}
title={t("admin.section.backups.title")}
titleAccessory={
data ? (
<Badge variant="secondary" className="text-xs">
{rows.length}
</Badge>
)}
</div>
<Button
size="sm"
disabled={runBackup.isPending}
onClick={() => runBackup.mutate()}
className="min-h-11"
>
{runBackup.isPending ? (
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
) : (
<PlayCircle className="h-3.5 w-3.5" />
)}
{t("admin.section.backups.runNow")}
</Button>
</div>
<p className="text-muted-foreground mt-1 text-xs">
) : null
}
status={
<Button
size="sm"
disabled={runBackup.isPending}
onClick={() => runBackup.mutate()}
className="min-h-11"
>
{runBackup.isPending ? (
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
) : (
<PlayCircle className="h-3.5 w-3.5" />
)}
{t("admin.section.backups.runNow")}
</Button>
}
/>
<p className="text-muted-foreground mt-1 pl-7 text-xs">
{t("admin.section.backups.description")}{" "}
{/* External docs link — Phase E will publish the matching page on
the docs site. `noopener noreferrer` because this leaves the
+15 -22
View File
@@ -2,13 +2,18 @@
import { useState } from "react";
import { Bug, Loader2 } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import { useTranslations } from "@/lib/i18n/context";
import { PasswordInput, useAdminSettings, useUpdateSettings } from "./_shared";
import {
ConfiguredBadge,
PasswordInput,
useAdminSettings,
useUpdateSettings,
} from "./_shared";
export function BugReportSection() {
const { t } = useTranslations();
@@ -45,27 +50,15 @@ export function BugReportSection() {
}
return (
<div className="bg-card border-border rounded-xl border p-6">
<div className="flex flex-wrap items-start justify-between gap-2">
<div className="flex items-center gap-2">
<Bug className="text-muted-foreground h-5 w-5" />
<h2 className="text-lg font-semibold">
{t("admin.bugReportGithub")}
</h2>
</div>
<div className="flex flex-wrap items-center gap-2">
{configured && (
<Badge className="border-dracula-green/30 bg-dracula-green/15 text-dracula-green">
{t("admin.configured")}
</Badge>
)}
</div>
</div>
<p className="text-muted-foreground mt-1 text-xs">
{t("admin.bugReportGithubDescription")}
</p>
<div className="bg-card border-border rounded-xl border p-4 sm:p-6">
<SettingsCardHeader
icon={Bug}
title={t("admin.bugReportGithub")}
description={t("admin.bugReportGithubDescription")}
status={configured ? <ConfiguredBadge /> : null}
/>
<div className="bg-muted/40 mt-4 flex items-center justify-between gap-3 rounded-lg p-3">
<div className="bg-muted/40 mt-4 ml-7 flex items-center justify-between gap-3 rounded-lg p-3">
<div>
<p className="text-sm font-medium">
{t("admin.bugReportEnabledLabel")}
+18 -19
View File
@@ -21,6 +21,7 @@
import { useQuery } from "@tanstack/react-query";
import { Loader2, Sparkles } from "lucide-react";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { useTranslations, useFormatters } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import { apiGet } from "@/lib/api/api-fetch";
@@ -69,25 +70,23 @@ export function CoachFeedbackSection() {
// /admin/coach-feedback. Mirrors the canonical structure used by
// <SystemStatusSection> (header outside the fetch-state branch).
return (
<div className="bg-card border-border space-y-4 rounded-xl border p-6">
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="flex items-center gap-2">
<Sparkles className="text-dracula-purple h-5 w-5" />
<div className="text-lg font-semibold">
{t("admin.coachFeedback.title")}
</div>
</div>
{hasData && summary && (
<p className="text-muted-foreground text-xs">
{t("admin.coachFeedback.windowLabel", {
days: String(summary.windowDays),
})}
{" · "}
{t("admin.coachFeedback.generatedAtLabel")}{" "}
{fmt.dateTime(summary.generatedAt)}
</p>
)}
</div>
<div className="bg-card border-border space-y-4 rounded-xl border p-4 sm:p-6">
<SettingsCardHeader
icon={Sparkles}
title={t("admin.coachFeedback.title")}
status={
hasData && summary ? (
<p className="text-muted-foreground text-xs">
{t("admin.coachFeedback.windowLabel", {
days: String(summary.windowDays),
})}
{" · "}
{t("admin.coachFeedback.generatedAtLabel")}{" "}
{fmt.dateTime(summary.generatedAt)}
</p>
) : null
}
/>
{query.isLoading ? (
<div className="flex items-center gap-2">
+1 -1
View File
@@ -59,7 +59,7 @@ export function DangerZoneSection() {
});
return (
<div className="bg-destructive/5 border-destructive/30 rounded-xl border p-6">
<div className="bg-destructive/5 border-destructive/30 rounded-xl border p-4 sm:p-6">
{/* v1.4.19 A8 / F-08: page header `admin.section.danger-zone.*`
already names the section, so the card-level title was a
duplicate of the page title. The destructive icon stays so
@@ -54,7 +54,9 @@ export function FeedbackInboxSection() {
const { data, isLoading } = useQuery({
queryKey: queryKeys.adminFeedback(activeStatus),
queryFn: async () => {
return apiGet<FeedbackListResponse>(`/api/admin/feedback?status=${activeStatus}&limit=100`);
return apiGet<FeedbackListResponse>(
`/api/admin/feedback?status=${activeStatus}&limit=100`,
);
},
});
@@ -65,7 +67,7 @@ export function FeedbackInboxSection() {
}
return (
<div className="bg-card border-border rounded-xl border p-6">
<div className="bg-card border-border rounded-xl border p-4 sm:p-6">
{/* v1.4.19 A8 / F-08 + F-20: this is the only card on
`/admin/feedback`, so the page header already provides the
title + description — the card-level repetition was just
@@ -287,7 +289,9 @@ function FeedbackDetailDialog({
const publish = useMutation({
mutationFn: async () => {
return apiPost<{ issueUrl: string }>(`/api/admin/feedback/${item.id}/github`);
return apiPost<{ issueUrl: string }>(
`/api/admin/feedback/${item.id}/github`,
);
},
onSuccess: (data) => {
setIssueUrl(data.issueUrl);
@@ -2,6 +2,7 @@
import { useMemo } from "react";
import { Settings } from "lucide-react";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { useTranslations } from "@/lib/i18n/context";
import { NativeSelect } from "@/components/ui/native-select";
import { SettingsToggle, useAdminSettings, useUpdateSettings } from "./_shared";
@@ -25,12 +26,13 @@ export function GeneralSettingsSection() {
const currentDefaultTz = settings?.defaultUserTimezone ?? "";
return (
<div className="bg-card border-border rounded-xl border p-6">
<div className="flex items-center gap-2">
<Settings className="text-muted-foreground h-5 w-5" />
<div className="text-lg font-semibold">{t("admin.appSettings")}</div>
</div>
<div className="mt-4 space-y-4">
<div className="bg-card border-border rounded-xl border p-4 sm:p-6">
<SettingsCardHeader
icon={Settings}
title={t("admin.appSettings")}
description={t("admin.appSettingsDescription")}
/>
<div className="mt-4 space-y-4 pl-7">
<SettingsToggle
label={t("admin.registrationEnabled")}
description={t("admin.registrationEnabledDescription")}
+10 -19
View File
@@ -4,7 +4,7 @@ import { useState } from "react";
import { useMutation } from "@tanstack/react-query";
import { AlertTriangle, Loader2 } from "lucide-react";
import { toast } from "sonner";
import { Badge } from "@/components/ui/badge";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
@@ -13,6 +13,7 @@ import {
SettingsToggle,
useAdminSettings,
useUpdateSettings,
ConfiguredBadge,
} from "./_shared";
import { apiPost } from "@/lib/api/api-fetch";
@@ -68,25 +69,15 @@ export function GlitchtipSection() {
}
return (
<div className="bg-card border-border rounded-xl border p-6">
<div className="flex flex-wrap items-start justify-between gap-2">
<div className="flex items-center gap-2">
<AlertTriangle className="text-muted-foreground h-5 w-5" />
<h2 className="text-lg font-semibold">{t("admin.glitchtipTitle")}</h2>
</div>
<div className="flex flex-wrap items-center gap-2">
{configured && (
<Badge className="border-dracula-green/30 bg-dracula-green/15 text-dracula-green">
{t("admin.configured")}
</Badge>
)}
</div>
</div>
<p className="text-muted-foreground mt-1 text-xs">
{t("admin.glitchtipDescription")}
</p>
<div className="bg-card border-border rounded-xl border p-4 sm:p-6">
<SettingsCardHeader
icon={AlertTriangle}
title={t("admin.glitchtipTitle")}
description={t("admin.glitchtipDescription")}
status={configured ? <ConfiguredBadge /> : null}
/>
<div className="mt-4 space-y-3">
<div className="mt-4 space-y-3 pl-7">
<SettingsToggle
label={t("admin.glitchtipEnabled")}
icon={AlertTriangle}
+77 -74
View File
@@ -314,12 +314,15 @@ export function InviteTokensSection() {
<div className="flex flex-col gap-4 p-4 sm:flex-row sm:items-start sm:justify-between sm:p-6">
<div className="min-w-0">
<div className="flex items-center gap-2">
<Ticket className="text-muted-foreground h-5 w-5" aria-hidden="true" />
<Ticket
className="text-muted-foreground h-5 w-5"
aria-hidden="true"
/>
<h2 id="admin-invites-title" className="text-lg font-semibold">
{t("admin.invites.title")}
</h2>
</div>
<p className="text-muted-foreground mt-1 pl-7 text-sm">
<p className="text-muted-foreground mt-1 pl-7 text-xs">
{t("admin.invites.description")}
</p>
<p className="text-muted-foreground/80 mt-1 flex items-center gap-1.5 pl-7 text-xs">
@@ -559,79 +562,79 @@ export function InviteTokensSection() {
}}
className="space-y-4"
>
<div className="space-y-5">
<fieldset>
<legend className="text-sm font-medium">
{t("admin.invites.ttlLegend")}
</legend>
<div
className="mt-2 grid grid-cols-3 gap-2"
role="radiogroup"
>
{TTL_CHOICES.map((days) => (
<button
key={days}
type="button"
role="radio"
aria-checked={ttlDays === days}
onClick={() => setTtlDays(days)}
data-testid={`admin-invites-ttl-${days}`}
className={cn(
"min-h-11 rounded-lg border px-3 py-2 text-sm font-medium transition-colors",
ttlDays === days
? "border-dracula-purple/50 bg-dracula-purple/10 text-dracula-purple"
: "border-border text-foreground hover:bg-accent",
)}
>
{t("admin.invites.createDays", { days })}
</button>
))}
</div>
</fieldset>
<div className="space-y-1.5">
<Label htmlFor="admin-invites-max-uses">
{t("admin.invites.maxUsesLabel")}
</Label>
<Input
id="admin-invites-max-uses"
type="number"
inputMode="numeric"
min={1}
max={MAX_USES_CAP}
step={1}
value={maxUses}
onChange={(e) => setMaxUses(e.target.value)}
onBlur={() => {
// Echo the submit-time clamp (1–50, integer) into
// the field so the admin sees what will be minted.
setMaxUses(String(clampMaxUses(maxUses)));
}}
className="w-24 tabular-nums"
data-testid="admin-invites-max-uses"
/>
<p className="text-muted-foreground text-xs">
{t("admin.invites.maxUsesHint")}
</p>
</div>
</div>
<DialogFooter>
<Button
type="submit"
disabled={create.isPending}
aria-busy={create.isPending || undefined}
data-testid="admin-invites-submit-create"
>
{create.isPending ? (
<Loader2
className="size-4 animate-spin motion-reduce:animate-none"
aria-hidden="true"
<div className="space-y-5">
<fieldset>
<legend className="text-sm font-medium">
{t("admin.invites.ttlLegend")}
</legend>
<div
className="mt-2 grid grid-cols-3 gap-2"
role="radiogroup"
>
{TTL_CHOICES.map((days) => (
<button
key={days}
type="button"
role="radio"
aria-checked={ttlDays === days}
onClick={() => setTtlDays(days)}
data-testid={`admin-invites-ttl-${days}`}
className={cn(
"min-h-11 rounded-lg border px-3 py-2 text-sm font-medium transition-colors",
ttlDays === days
? "border-dracula-purple/50 bg-dracula-purple/10 text-dracula-purple"
: "border-border text-foreground hover:bg-accent",
)}
>
{t("admin.invites.createDays", { days })}
</button>
))}
</div>
</fieldset>
<div className="space-y-1.5">
<Label htmlFor="admin-invites-max-uses">
{t("admin.invites.maxUsesLabel")}
</Label>
<Input
id="admin-invites-max-uses"
type="number"
inputMode="numeric"
min={1}
max={MAX_USES_CAP}
step={1}
value={maxUses}
onChange={(e) => setMaxUses(e.target.value)}
onBlur={() => {
// Echo the submit-time clamp (1–50, integer) into
// the field so the admin sees what will be minted.
setMaxUses(String(clampMaxUses(maxUses)));
}}
className="w-24 tabular-nums"
data-testid="admin-invites-max-uses"
/>
) : (
<Ticket className="size-4" aria-hidden="true" />
)}
{t("admin.invites.createConfirm")}
</Button>
</DialogFooter>
<p className="text-muted-foreground text-xs">
{t("admin.invites.maxUsesHint")}
</p>
</div>
</div>
<DialogFooter>
<Button
type="submit"
disabled={create.isPending}
aria-busy={create.isPending || undefined}
data-testid="admin-invites-submit-create"
>
{create.isPending ? (
<Loader2
className="size-4 animate-spin motion-reduce:animate-none"
aria-hidden="true"
/>
) : (
<Ticket className="size-4" aria-hidden="true" />
)}
{t("admin.invites.createConfirm")}
</Button>
</DialogFooter>
</form>
</>
) : (
@@ -21,6 +21,7 @@ import {
SelectValue,
} from "@/components/ui/select";
import { formatDateTime } from "@/lib/format";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import { toCSV } from "@/lib/export";
@@ -131,7 +132,9 @@ export function LoginOverviewSection() {
if (filter === "failed") {
params.set("action", "auth.login.failed");
}
return apiGet<AuditLogResponse>(`/api/admin/audit-log?${params.toString()}`);
return apiGet<AuditLogResponse>(
`/api/admin/audit-log?${params.toString()}`,
);
},
});
@@ -193,11 +196,12 @@ export function LoginOverviewSection() {
}
return (
<div className="bg-card border-border rounded-xl border p-6">
<div className="flex items-center gap-2">
<ScrollText className="text-muted-foreground h-5 w-5" />
<div className="text-lg font-semibold">{t("admin.loginOverview")}</div>
</div>
<div className="bg-card border-border rounded-xl border p-4 sm:p-6">
<SettingsCardHeader
icon={ScrollText}
title={t("admin.loginOverview")}
description={t("admin.loginOverviewDescription")}
/>
<div className="mt-4 space-y-3">
{/* Quick-filter pills. Failed-only is the most common
+18 -19
View File
@@ -20,6 +20,7 @@ import {
} from "lucide-react";
import { EmptyState } from "@/components/ui/empty-state";
import { formatDateTime } from "@/lib/format";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import { type AdminAuditEntry, useAuthActionLabels } from "./_shared";
@@ -38,7 +39,9 @@ export function RecentAuditPreview() {
const { data, isLoading, isError } = useQuery({
queryKey: queryKeys.adminAuditOverview(),
queryFn: async () => {
return apiGet<AuditLogResponse>(`/api/admin/audit-log?limit=${PREVIEW_LIMIT}&filter=auth`);
return apiGet<AuditLogResponse>(
`/api/admin/audit-log?limit=${PREVIEW_LIMIT}&filter=auth`,
);
},
// Avoid hitting the DB on every page focus; the overview is the
// type of page admins keep open and re-render frequently.
@@ -52,26 +55,22 @@ export function RecentAuditPreview() {
return (
<section
aria-labelledby="admin-overview-audit-heading"
className="bg-card border-border rounded-xl border p-6"
className="bg-card border-border rounded-xl border p-4 sm:p-6"
>
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2">
<ScrollText className="text-muted-foreground h-5 w-5" aria-hidden="true" />
<h2
id="admin-overview-audit-heading"
className="text-lg font-semibold"
<SettingsCardHeader
icon={ScrollText}
titleId="admin-overview-audit-heading"
title={t("admin.overview.auditTitle")}
status={
<Link
href="/admin/login-overview"
className="text-primary hover:text-primary/80 inline-flex items-center gap-1 text-sm"
>
{t("admin.overview.auditTitle")}
</h2>
</div>
<Link
href="/admin/login-overview"
className="text-primary hover:text-primary/80 inline-flex items-center gap-1 text-sm"
>
{t("admin.overview.auditViewAll")}
<ArrowRight className="h-3.5 w-3.5" aria-hidden="true" />
</Link>
</div>
{t("admin.overview.auditViewAll")}
<ArrowRight className="h-3.5 w-3.5" aria-hidden="true" />
</Link>
}
/>
<div className="mt-4">
{isLoading ? (
+11 -14
View File
@@ -8,6 +8,7 @@ import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { useTranslations } from "@/lib/i18n/context";
import { useAdminSettings, useUpdateSettings } from "./_shared";
import { apiPost } from "@/lib/api/api-fetch";
@@ -91,21 +92,17 @@ export function RemindersSection() {
});
return (
<div className="bg-card border-border rounded-xl border p-6">
<div className="flex items-center gap-2">
{/* v1.4.22 D / F-58 — icon swap so the section header matches
the admin-shell nav tile (Bell). Clock now lives on the
timing chips inline instead of competing as the page icon. */}
<Bell className="text-muted-foreground h-5 w-5" />
<div className="text-lg font-semibold">
{t("admin.medicationReminders")}
</div>
</div>
<p className="text-muted-foreground mt-1 pl-7 text-xs">
{t("admin.medicationRemindersDescription")}
</p>
<div className="bg-card border-border rounded-xl border p-4 sm:p-6">
{/* v1.4.22 D / F-58 — icon swap so the section header matches
the admin-shell nav tile (Bell). Clock now lives on the
timing chips inline instead of competing as the page icon. */}
<SettingsCardHeader
icon={Bell}
title={t("admin.medicationReminders")}
description={t("admin.medicationRemindersDescription")}
/>
<div className="mt-4 space-y-3">
<div className="mt-4 space-y-3 pl-7">
<div className="grid gap-3 sm:grid-cols-2">
<div className="space-y-1.5">
<Label htmlFor="admin-reminder-late" className="text-xs">
+8 -9
View File
@@ -1,6 +1,7 @@
"use client";
import { Bell, Globe, Key, MessageCircle, Smile } from "lucide-react";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { useTranslations } from "@/lib/i18n/context";
import { SettingsToggle, useAdminSettings, useUpdateSettings } from "./_shared";
@@ -10,15 +11,13 @@ export function ServicesSection() {
const updateSettings = useUpdateSettings();
return (
<div className="bg-card border-border rounded-xl border p-6">
<div className="flex items-center gap-2">
<Globe className="text-muted-foreground h-5 w-5" />
<div className="text-lg font-semibold">{t("admin.servicesGlobal")}</div>
</div>
<p className="text-muted-foreground mt-1 pl-7 text-xs">
{t("admin.servicesGlobalDescription")}
</p>
<div className="mt-4 space-y-3">
<div className="bg-card border-border rounded-xl border p-4 sm:p-6">
<SettingsCardHeader
icon={Globe}
title={t("admin.servicesGlobal")}
description={t("admin.servicesGlobalDescription")}
/>
<div className="mt-4 space-y-3 pl-7">
<SettingsToggle
label="Telegram"
description={t("admin.telegramGlobal")}
@@ -21,6 +21,7 @@ import {
import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
import { formatDateTime } from "@/lib/format";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { useFormatters, useTranslations } from "@/lib/i18n/context";
import { StatusItem, usePublicVersion, useSystemStatus } from "./_shared";
@@ -51,13 +52,14 @@ export function SystemStatusSection() {
return (
<div className="space-y-6">
<HostMetricsChart />
<div className="bg-card border-border rounded-xl border p-6">
<div className="flex items-center gap-2">
<Server className="text-muted-foreground h-5 w-5" />
<div className="text-lg font-semibold">{t("admin.systemStatus")}</div>
</div>
<div className="bg-card border-border rounded-xl border p-4 sm:p-6">
<SettingsCardHeader
icon={Server}
title={t("admin.systemStatus")}
description={t("admin.systemStatusDescription")}
/>
{status ? (
<div className="mt-4 grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<div className="mt-4 grid gap-4 pl-7 sm:grid-cols-2 lg:grid-cols-4">
<StatusItem
icon={Database}
label={t("admin.database")}
+8 -11
View File
@@ -23,6 +23,7 @@ import {
Tag,
} from "lucide-react";
import { formatDateTime } from "@/lib/format";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { useTranslations } from "@/lib/i18n/context";
import { StatusItem, usePublicVersion, useSystemStatus } from "./_shared";
@@ -34,20 +35,16 @@ export function SystemStatusSummary() {
return (
<section
aria-labelledby="admin-overview-snapshot-heading"
className="bg-card border-border rounded-xl border p-6"
className="bg-card border-border rounded-xl border p-4 sm:p-6"
>
<div className="flex items-center gap-2">
<Server className="text-muted-foreground h-5 w-5" aria-hidden="true" />
<h2
id="admin-overview-snapshot-heading"
className="text-lg font-semibold"
>
{t("admin.overview.snapshotTitle")}
</h2>
</div>
<SettingsCardHeader
icon={Server}
titleId="admin-overview-snapshot-heading"
title={t("admin.overview.snapshotTitle")}
/>
{status ? (
<div className="mt-4 grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
<div className="mt-4 grid gap-3 pl-7 sm:grid-cols-2 lg:grid-cols-3">
<StatusItem
icon={Tag}
label={t("admin.overview.snapshotVersion")}
+10 -19
View File
@@ -4,7 +4,7 @@ import { useState } from "react";
import { useMutation } from "@tanstack/react-query";
import { Activity, Loader2 } from "lucide-react";
import { toast } from "sonner";
import { Badge } from "@/components/ui/badge";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
@@ -13,6 +13,7 @@ import {
SettingsToggle,
useAdminSettings,
useUpdateSettings,
ConfiguredBadge,
} from "./_shared";
import { apiPost } from "@/lib/api/api-fetch";
@@ -71,25 +72,15 @@ export function UmamiSection() {
}
return (
<div className="bg-card border-border rounded-xl border p-6">
<div className="flex flex-wrap items-start justify-between gap-2">
<div className="flex items-center gap-2">
<Activity className="text-muted-foreground h-5 w-5" />
<h2 className="text-lg font-semibold">{t("admin.umamiTitle")}</h2>
</div>
<div className="flex flex-wrap items-center gap-2">
{configured && (
<Badge className="border-dracula-green/30 bg-dracula-green/15 text-dracula-green">
{t("admin.configured")}
</Badge>
)}
</div>
</div>
<p className="text-muted-foreground mt-1 text-xs">
{t("admin.umamiDescription")}
</p>
<div className="bg-card border-border rounded-xl border p-4 sm:p-6">
<SettingsCardHeader
icon={Activity}
title={t("admin.umamiTitle")}
description={t("admin.umamiDescription")}
status={configured ? <ConfiguredBadge /> : null}
/>
<div className="mt-4 space-y-3">
<div className="mt-4 space-y-3 pl-7">
<SettingsToggle
label={t("admin.umamiEnabled")}
icon={Activity}
@@ -22,6 +22,7 @@ import {
} from "@/components/ui/alert-dialog";
import { useAuth } from "@/hooks/use-auth";
import { formatDate } from "@/lib/format";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import { type AdminUser, PasswordInput } from "./_shared";
@@ -220,18 +221,12 @@ export function UserManagementSection() {
return (
<div className="bg-card border-border rounded-xl border p-4 sm:p-6">
{/* Header: stacks vertically on mobile so the filter pills get
their own row instead of overlapping the title. The audit
flagged this as CRITICAL #2 — at 393px the filter row was
colliding with the count Badge and the user-count digit was
floating into the title's gap. */}
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between sm:gap-2">
<div className="flex items-center gap-2">
<Users className="text-muted-foreground h-5 w-5" />
<div className="text-lg font-semibold">
{t("admin.userManagement")}
</div>
{filteredUsers && (
<SettingsCardHeader
icon={Users}
title={t("admin.userManagement")}
description={t("admin.userManagementDescription")}
titleAccessory={
filteredUsers ? (
<Badge variant="secondary" className="text-xs">
{filteredUsers.length}
{filter !== "all" &&
@@ -242,24 +237,25 @@ export function UserManagementSection() {
</span>
)}
</Badge>
)}
</div>
{/* Filters mirror the Settings-style horizontal pills */}
<div className="flex flex-wrap items-center gap-1.5">
{(["all", "admin", "user"] as const).map((value) => (
<Button
key={value}
variant={filter === value ? "default" : "ghost"}
size="sm"
className="min-h-11 min-w-11 px-3 text-xs"
onClick={() => setFilter(value)}
aria-pressed={filter === value}
>
{t(`admin.section.users.filter.${value}`)}
</Button>
))}
</div>
</div>
) : null
}
status={
<div className="flex flex-wrap items-center gap-1.5">
{(["all", "admin", "user"] as const).map((value) => (
<Button
key={value}
variant={filter === value ? "default" : "ghost"}
size="sm"
className="min-h-11 min-w-11 px-3 text-xs"
onClick={() => setFilter(value)}
aria-pressed={filter === value}
>
{t(`admin.section.users.filter.${value}`)}
</Button>
))}
</div>
}
/>
{filteredUsers ? (
filteredUsers.length === 0 ? (
+39 -35
View File
@@ -16,9 +16,16 @@
* (`/admin`), which gates on `requireAdmin()`.
*/
import { ArrowUpCircle, CheckCircle2, Clock, GitCommit, Tag } from "lucide-react";
import {
ArrowUpCircle,
CheckCircle2,
Clock,
GitCommit,
Tag,
} from "lucide-react";
import { useQuery } from "@tanstack/react-query";
import { formatDateTime } from "@/lib/format";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import { ApiError, apiGet } from "@/lib/api/api-fetch";
@@ -77,42 +84,39 @@ export function VersionTileSection() {
return (
<section
aria-labelledby="admin-overview-version-heading"
className="bg-card border-border rounded-xl border p-6"
className="bg-card border-border rounded-xl border p-4 sm:p-6"
>
<div className="flex flex-wrap items-center gap-2">
<Tag className="text-muted-foreground h-5 w-5" aria-hidden="true" />
<h2
id="admin-overview-version-heading"
className="text-lg font-semibold"
>
{t("admin.overview.versionTileTitle")}
</h2>
<SettingsCardHeader
icon={Tag}
titleId="admin-overview-version-heading"
title={t("admin.overview.versionTileTitle")}
status={
newer ? (
<a
href={
(update.status === "newer_available" && update.html_url) ||
"https://github.com/MBombeck/HealthLog/releases/latest"
}
target="_blank"
rel="noopener noreferrer"
className="bg-dracula-yellow/15 text-dracula-yellow border-dracula-yellow/30 inline-flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-xs font-medium transition-opacity hover:opacity-80"
>
<ArrowUpCircle className="h-3.5 w-3.5" aria-hidden="true" />
{t("admin.overview.versionTileUpdateAvailable", {
tag:
update.status === "newer_available" ? update.latest_tag : "",
})}
</a>
) : upToDate ? (
<span className="text-dracula-green inline-flex items-center gap-1.5 text-xs font-medium">
<CheckCircle2 className="h-3.5 w-3.5" aria-hidden="true" />
{t("admin.overview.versionTileUpToDate")}
</span>
) : null
}
/>
{newer ? (
<a
href={
(update.status === "newer_available" && update.html_url) ||
"https://github.com/MBombeck/HealthLog/releases/latest"
}
target="_blank"
rel="noopener noreferrer"
className="bg-dracula-yellow/15 text-dracula-yellow border-dracula-yellow/30 ml-auto inline-flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-xs font-medium transition-opacity hover:opacity-80"
>
<ArrowUpCircle className="h-3.5 w-3.5" aria-hidden="true" />
{t("admin.overview.versionTileUpdateAvailable", {
tag:
update.status === "newer_available" ? update.latest_tag : "",
})}
</a>
) : upToDate ? (
<span className="text-dracula-green ml-auto inline-flex items-center gap-1.5 text-xs font-medium">
<CheckCircle2 className="h-3.5 w-3.5" aria-hidden="true" />
{t("admin.overview.versionTileUpToDate")}
</span>
) : null}
</div>
<div className="mt-4 grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
<div className="mt-4 grid gap-3 pl-7 sm:grid-cols-2 lg:grid-cols-3">
<StatusItem
icon={Tag}
label={t("admin.overview.versionTileVersion")}
+15 -22
View File
@@ -2,12 +2,17 @@
import { useState } from "react";
import { BellRing, Loader2 } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { useTranslations } from "@/lib/i18n/context";
import { PasswordInput, useAdminSettings, useUpdateSettings } from "./_shared";
import {
ConfiguredBadge,
PasswordInput,
useAdminSettings,
useUpdateSettings,
} from "./_shared";
export function WebPushVapidSection() {
const { t } = useTranslations();
@@ -48,27 +53,15 @@ export function WebPushVapidSection() {
}
return (
<div className="bg-card border-border rounded-xl border p-6">
<div className="flex flex-wrap items-start justify-between gap-2">
<div className="flex items-center gap-2">
<BellRing className="text-muted-foreground h-5 w-5" />
<h2 className="text-lg font-semibold">
{t("admin.webPushVapidTitle")}
</h2>
</div>
<div className="flex flex-wrap items-center gap-2">
{configured && (
<Badge className="border-dracula-green/30 bg-dracula-green/15 text-dracula-green">
{t("admin.configured")}
</Badge>
)}
</div>
</div>
<p className="text-muted-foreground mt-1 text-xs">
{t("admin.webPushVapidDescription")}
</p>
<div className="bg-card border-border rounded-xl border p-4 sm:p-6">
<SettingsCardHeader
icon={BellRing}
title={t("admin.webPushVapidTitle")}
description={t("admin.webPushVapidDescription")}
status={configured ? <ConfiguredBadge /> : null}
/>
<div className="mt-4 grid gap-3 sm:grid-cols-2">
<div className="mt-4 grid gap-3 pl-7 sm:grid-cols-2">
<div className="space-y-1.5 sm:col-span-2">
<Label htmlFor="admin-web-push-public-key" className="text-xs">
{t("admin.webPushVapidPublicKey")}
@@ -203,6 +203,58 @@ describe("guidedReducer — adoption settling", () => {
});
});
describe("guidedReducer — reaction-then-adopt sequencing (v1.16.6)", () => {
// With a provider, the turn streams a Coach reaction and the flow
// waits in `answered` until the adopt offer settles; the settle of
// the CURRENT outcome is what advances the sequence.
it("ADOPTION_SETTLED for the current outcome advances to the next question", () => {
const state = run([
{ type: "START", questions: QUESTIONS },
{ type: "ANSWER_SUBMITTED", answer: "Hypertension." },
{ type: "ADOPTION_SETTLED", index: 0, adoption: "adopted" },
]);
if (state.phase !== "asking") throw new Error("unreachable");
expect(state.index).toBe(1);
expect(state.outcomes[0].adoption).toBe("adopted");
});
it("ADOPTION_SETTLED on the last answer lands on the summary", () => {
const state = run([
{ type: "START", questions: ["only one"] },
{ type: "ANSWER_SUBMITTED", answer: "An answer." },
{ type: "ADOPTION_SETTLED", index: 0, adoption: "declined" },
]);
expect(state.phase).toBe("summary");
if (state.phase !== "summary") throw new Error("unreachable");
expect(state.outcomes[0].adoption).toBe("declined");
});
it("a late settle for an older outcome does not advance", () => {
// Silent (provider-less) flow already advanced via TURN_COMPLETE;
// question 2 is answered and streaming when outcome 0 settles.
const state = run([
{ type: "START", questions: QUESTIONS },
{ type: "ANSWER_SUBMITTED", answer: "Hypertension." },
{ type: "TURN_COMPLETE" },
{ type: "ANSWER_SUBMITTED", answer: "Sleep." },
{ type: "ADOPTION_SETTLED", index: 0, adoption: "adopted" },
]);
if (state.phase !== "answered") throw new Error("unreachable");
expect(state.index).toBe(1);
expect(state.outcomes[0].adoption).toBe("adopted");
});
it("the silent flow still advances on TURN_COMPLETE alone", () => {
const state = run([
{ type: "START", questions: QUESTIONS },
{ type: "ANSWER_SUBMITTED", answer: "Hypertension." },
{ type: "TURN_COMPLETE" },
]);
if (state.phase !== "asking") throw new Error("unreachable");
expect(state.index).toBe(1);
});
});
describe("deriveThreadItems", () => {
it("idle and done contribute nothing", () => {
expect(deriveThreadItems(GUIDED_IDLE)).toEqual([]);
@@ -204,9 +204,12 @@ export function CoachConversation({
dismissQuestions.mutate(guidedQuestion);
}
setInputValue("");
await send.send({
// v1.16.6 — hand the question to the turn so the Coach reaction is
// contextual (the question bubble itself is never persisted).
const resolvedId = await send.send({
conversationId: currentConversationId ?? undefined,
message: trimmed,
guidedQuestion: guidedQuestion ?? undefined,
});
if (guidedQuestion !== null && guidedIndex !== null) {
setPendingAdopt({
@@ -214,9 +217,15 @@ export function CoachConversation({
answer: trimmed,
guidedIndex,
});
// The assistant turn is done — advance to the next question (or
// the closing summary).
dispatchGuided({ type: "TURN_COMPLETE" });
// v1.16.6 — sequence per answer: answer → Coach reaction
// (streamed above) → adopt offer → next question. A resolved id
// means the reaction landed; the machine then advances when the
// adopt offer settles (ADOPTION_SETTLED). A null id is the
// provider-less / errored turn — no reaction is coming, so keep
// the original silent flow and advance immediately.
if (resolvedId === null) {
dispatchGuided({ type: "TURN_COMPLETE" });
}
}
}
@@ -17,10 +17,16 @@
*
* idle ──START──▶ asking ──ANSWER_SUBMITTED──▶ answered
* │ │ ▲ │
* LATER SKIP└──────TURN_COMPLETE────────┘
* │ │ (next question, or…)
* ▼ ├──EXIT/SKIP-on-last──▶ summary (answers exist)
* done ◀────────────┴──────────────────────▶ done (none answered)
* LATER SKIP└── TURN_COMPLETE ──────────┤
* │ │ (provider-less: advance │
* │ │ right after the send) │
* │ │ ▲ │
* │ └── ADOPTION_SETTLED ─────────┘
* │ │ (current outcome: Coach reaction has
* │ │ streamed, the adopt offer settled —
* ▼ │ NOW the next question may appear)
* done ◀────────────┴──EXIT/SKIP-on-last──▶ summary (answers exist)
* ──▶ done (none answered)
*
* Server contract stays additive-free: answering dismisses that one
* question (existing DELETE), "skip" / "later" leave questions pending
@@ -140,11 +146,7 @@ export function guidedReducer(
case "TURN_COMPLETE": {
if (state.phase !== "answered") return state;
const next = state.index + 1;
if (next < state.questions.length) {
return { ...state, phase: "asking", index: next };
}
return finish(state);
return advance(state);
}
case "SKIP": {
@@ -174,11 +176,38 @@ export function guidedReducer(
? { ...o, adoption: event.adoption }
: o,
);
// v1.16.6 — when a provider reacted to the answer the sequence
// waits in `answered` until the adopt offer settles, so the
// transcript reads answer → Coach reaction → adopt outcome →
// next question. Settling the CURRENT outcome is that signal;
// a late settle for an older outcome (provider-less flow, where
// TURN_COMPLETE already advanced) only updates the tally.
if (state.phase === "answered" && event.index === state.index) {
return advance({ ...state, outcomes });
}
return { ...state, outcomes };
}
}
}
/** Move past the current question: next one, or close the sequence. */
function advance(state: {
questions: string[];
index: number;
outcomes: GuidedOutcome[];
}): GuidedState {
const next = state.index + 1;
if (next < state.questions.length) {
return {
phase: "asking",
questions: state.questions,
index: next,
outcomes: state.outcomes,
};
}
return finish(state);
}
/** Count of answers actually written into the self-context. */
export function countAdopted(outcomes: GuidedOutcome[]): number {
return outcomes.filter((o) => o.adoption === "adopted").length;
@@ -236,6 +236,12 @@ export interface SendCoachMessageParams {
* back to all-source last30days, matching the legacy behaviour.
*/
scope?: CoachScope;
/**
* v1.16.6 — guided clarifying-questions flow: the pending question
* this message answers. Server-side prompt context only; never
* persisted with the message.
*/
guidedQuestion?: string;
}
export interface UseSendCoachMessageOptions {
@@ -286,7 +292,9 @@ export function useSendCoachMessage(opts: UseSendCoachMessageOptions = {}) {
}, []);
const send = useCallback(
async (params: SendCoachMessageParams): Promise<void> => {
async (
params: SendCoachMessageParams,
): Promise<string | null> => {
// Cancel any prior in-flight request.
abortRef.current?.abort();
const controller = new AbortController();
@@ -322,7 +330,7 @@ export function useSendCoachMessage(opts: UseSendCoachMessageOptions = {}) {
messageId: null,
errorCode: "coach.network",
});
return;
return null;
}
// apiFetchRaw: the chat POST streams SSE — the envelope helpers
@@ -341,11 +349,12 @@ export function useSendCoachMessage(opts: UseSendCoachMessageOptions = {}) {
prefill: params.prefill,
locale: params.locale,
scope: params.scope,
guidedQuestion: params.guidedQuestion,
}),
signal: controller.signal,
});
} catch (err) {
if ((err as Error).name === "AbortError") return;
if ((err as Error).name === "AbortError") return null;
setStreaming({
content: "",
metricSource: null,
@@ -353,7 +362,7 @@ export function useSendCoachMessage(opts: UseSendCoachMessageOptions = {}) {
messageId: null,
errorCode: "coach.network",
});
return;
return null;
}
if (!response.body) {
@@ -364,7 +373,7 @@ export function useSendCoachMessage(opts: UseSendCoachMessageOptions = {}) {
messageId: null,
errorCode: `coach.http.${response.status}`,
});
return;
return null;
}
// Even on `!response.ok`, the route may have emitted a structured
@@ -405,7 +414,7 @@ export function useSendCoachMessage(opts: UseSendCoachMessageOptions = {}) {
// the bubble left the error standing next to an empty thread.
// The content-equality dedupe in <MessageThread> still
// suppresses the optimistic copy if a persisted twin lands.
return;
return null;
}
const reader = response.body.getReader();
@@ -495,6 +504,7 @@ export function useSendCoachMessage(opts: UseSendCoachMessageOptions = {}) {
if (resolvedConversationId || !lastError) {
setOptimisticUser(null);
}
return resolvedConversationId;
},
[queryClient],
);
@@ -470,3 +470,74 @@ describe("medication card body — shared shell + dose-state presentation", () =
expect(html).toContain("min-h-[2.75rem] space-y-3.5 text-sm");
});
});
describe("compliance bars — fixed geometry (v1.16.6)", () => {
/**
* The two percentage rows must keep an IDENTICAL, fixed geometry no
* matter the rates, labels, or any status colouring around them: each
* label line is pinned to one text line (`h-5` + `truncate`) and each
* bar to `h-2`. A regression here lets a wrapping label or a
* status-dependent caption make the two bars sit at unequal heights
* with stray space below (the v1.16.4 card report).
*/
function geometrySignature(html: string): {
labelRows: number;
bars: number;
truncated: number;
} {
return {
labelRows: (html.match(/flex h-5 items-center justify-between/g) ?? [])
.length,
bars: (html.match(/data-slot="progress"/g) ?? []).length,
truncated: (html.match(/truncate/g) ?? []).length,
};
}
it("pins both rows to the same fixed-height markup regardless of rates", () => {
const low = render(
<MedicationComplianceBars rate7={0} rate30={13} streak={0} />,
);
const high = render(
<MedicationComplianceBars rate7={100} rate30={100} streak={9} />,
);
const wide = render(
<MedicationComplianceBars
rate7={50}
rate30={50}
streak={0}
shortDays={90}
longDays={365}
/>,
);
for (const html of [low, high, wide]) {
const sig = geometrySignature(html);
expect(sig.labelRows).toBe(2);
expect(sig.bars).toBe(2);
expect(sig.truncated).toBe(2);
// Both bars carry the same fixed track height.
expect(html.match(/data-slot="progress"[^>]*h-2/g) ?? []).toHaveLength(2);
}
});
it("rate / streak changes alter text only, never the geometry classes", () => {
const a = render(
<MedicationComplianceBars rate7={0} rate30={0} streak={0} />,
);
const b = render(
<MedicationComplianceBars rate7={100} rate30={42} streak={0} />,
);
// Strip text nodes; the remaining tag/class skeleton must be identical
// between a 0% and a 100% card (transform style varies with the value
// by design, so it is normalised too).
const skeleton = (html: string) =>
html
.replace(/>[^<]+</g, "><")
.replace(/transform:[^;"]+/g, "transform:X")
.replace(/aria-valuenow="\d+"/g, 'aria-valuenow="N"')
.replace(/data-state="[^"]*"/g, 'data-state="S"')
.replace(/data-value="[^"]*"/g, 'data-value="V"')
.replace(/data-max="[^"]*"/g, 'data-max="M"');
expect(skeleton(a)).toBe(skeleton(b));
});
});
@@ -61,27 +61,35 @@ export function MedicationComplianceBars({
const shortPct = fmt.number(Math.round(rate7));
const longPct = fmt.number(Math.round(rate30));
// v1.16.6 — both rows carry an identical, FIXED geometry: the label line
// is pinned to one text line (`h-5` + `truncate`) and the bar to `h-2`,
// so neither a long locale label wrapping, nor the `?` trigger, nor any
// status-coloured caption can make the two rows differ in height. Status
// is colour / text only — never geometry; the two bars on a card (and
// across a grid row) always align.
return (
<div className="space-y-2.5">
<div className="space-y-1.5">
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground flex items-center gap-1">
{shortLabel}
<div className="flex h-5 items-center justify-between text-sm">
<span className="text-muted-foreground flex min-w-0 items-center gap-1">
<span className="truncate">{shortLabel}</span>
{/* One `?` for the whole block — both rows measure the same
thing over different windows, so a second trigger on the
long row would only add noise. */}
<ComplianceInfoTip />
</span>
<span className="font-medium">{shortPct}%</span>
<span className="shrink-0 font-medium">{shortPct}%</span>
</div>
{/* aria-label so the bar has an accessible name. */}
<Progress value={rate7} className="h-2" aria-label={shortLabel} />
</div>
<div className="space-y-1.5">
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">{longLabel}</span>
<span className="font-medium">{longPct}%</span>
<div className="flex h-5 items-center justify-between text-sm">
<span className="text-muted-foreground min-w-0 truncate">
{longLabel}
</span>
<span className="shrink-0 font-medium">{longPct}%</span>
</div>
<Progress value={rate30} className="h-2" aria-label={longLabel} />
</div>
@@ -121,14 +129,14 @@ export function MedicationComplianceSkeleton() {
return (
<div className="space-y-2.5" aria-hidden>
<div className="space-y-1.5">
<div className="flex items-center justify-between">
<div className="flex h-5 items-center justify-between">
<span className="bg-muted h-4 w-20 rounded" />
<span className="bg-muted h-4 w-9 rounded" />
</div>
<div className="bg-muted h-2 rounded" />
</div>
<div className="space-y-1.5">
<div className="flex items-center justify-between">
<div className="flex h-5 items-center justify-between">
<span className="bg-muted h-4 w-20 rounded" />
<span className="bg-muted h-4 w-9 rounded" />
</div>
@@ -318,6 +318,16 @@ export function Glp1MedicationCard({
active: medication.active,
lastTakenAt: medication.lastTakenAt,
todayEventCount: medication.todayEventCount ?? 0,
// v1.16.6 — gate the pill on the server display-due so a rolling
// cadence whose next injection is days away can never paint an overdue
// pill today. `undefined` (older payloads / fixtures) keeps legacy
// behaviour; `next` is already the parsed-and-validated `nextDueAt`.
nextDue:
medication.nextDueAt === undefined
? undefined
: next
? { at: next.date, overdue: medication.nextDueOverdue === true }
: null,
});
// v1.12.3 — slot instant of the injection this card is showing (the
@@ -251,6 +251,15 @@ export function MedicationCard({
active: medication.active,
lastTakenAt: medication.lastTakenAt,
todayEventCount: medication.todayEventCount ?? 0,
// v1.16.6 — gate the pill on the server display-due so a rolling
// cadence whose next dose is tomorrow can never paint an overdue pill
// today. `undefined` (older payloads / fixtures) keeps legacy behaviour.
nextDue:
medication.nextDueAt === undefined
? undefined
: nextAt !== undefined
? { at: new Date(nextAt), overdue: medication.nextDueOverdue === true }
: null,
});
// v1.12.3 — the slot instant of the dose this card is currently showing
@@ -14,6 +14,8 @@
* the chip + delete affordance, ARCHIVED eras get neither.
* 4. No revisions: no toggle, the add-era CTA still renders.
* 5. `eraTimes` dedupes/sorts across entries and drops non-arrays.
* 6. v1.16.6: every era row (MANUAL and ARCHIVED) carries the edit
* pencil; only MANUAL rows carry delete.
*/
import { describe, expect, it } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
@@ -130,9 +132,13 @@ describe("ScheduleHistoryTimeline", () => {
expect(html).toContain("Manuell ergänzt");
expect(html).toContain("Ära löschen");
// Exactly ONE manual chip + ONE delete button — the archived era
// renders read-only.
// renders without delete.
expect(html.match(/zeitplan-history-manual-chip/g)).toHaveLength(1);
expect(html.match(/zeitplan-history-delete/g)).toHaveLength(1);
// BOTH eras carry the edit pencil: a MANUAL era edits in place, an
// ARCHIVED era is corrected via a superseding revision.
expect(html.match(/zeitplan-history-edit/g)).toHaveLength(2);
expect(html).toContain("Ära bearbeiten");
expect(html).toContain("Frühere Ären ausblenden");
});
@@ -17,6 +17,11 @@
* - MANUAL eras carry a tinted chip and a delete affordance
* (confirmed via AlertDialog); write-path archives are immutable
* and render without one.
* - v1.16.6: every era row carries an edit pencil. The dialog opens
* prefilled like the add flow; a MANUAL era PATCHes in place, an
* ARCHIVED era shows a quiet hint that the change corrects the
* recorded history (the server mints a superseding MANUAL row and
* keeps the original as the audit record).
*
* The "Bis" date is the day the NEXT plan took over (exclusive bound) —
* the dialog copy says so, and adjacency with the live plan is the
@@ -25,7 +30,14 @@
*/
import { useMemo, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { ChevronDown, History, Loader2, Plus, Trash2 } from "lucide-react";
import {
ChevronDown,
History,
Loader2,
Pencil,
Plus,
Trash2,
} from "lucide-react";
import { toast } from "sonner";
import {
@@ -50,7 +62,7 @@ import {
import { DateInput } from "@/components/ui/date-input";
import { Label } from "@/components/ui/label";
import { TimesOfDayChips } from "@/components/medications/scheduling/TimesOfDayChips";
import { apiDelete, apiGet, apiPost } from "@/lib/api/api-fetch";
import { apiDelete, apiGet, apiPatch, apiPost } from "@/lib/api/api-fetch";
import { useFormatters, useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import { cn } from "@/lib/utils";
@@ -103,6 +115,9 @@ export function ScheduleHistoryTimeline({
const queryClient = useQueryClient();
const [expanded, setExpanded] = useState(defaultExpanded);
const [addOpen, setAddOpen] = useState(false);
const [editTarget, setEditTarget] = useState<ScheduleRevisionRow | null>(
null,
);
const [deleteTarget, setDeleteTarget] = useState<ScheduleRevisionRow | null>(
null,
);
@@ -206,22 +221,37 @@ export function ScheduleHistoryTimeline({
{fmt.date(revision.validUntil)}
</p>
</div>
{revision.source === "MANUAL" && (
<div className="flex shrink-0 items-center">
<Button
type="button"
variant="ghost"
size="icon"
className="text-muted-foreground hover:text-dracula-red size-8 shrink-0"
className="text-muted-foreground hover:text-foreground size-8"
aria-label={t(
"medications.detail.zeitplan.history.deleteAria",
"medications.detail.zeitplan.history.editAria",
)}
disabled={remove.isPending}
onClick={() => setDeleteTarget(revision)}
data-slot="zeitplan-history-delete"
onClick={() => setEditTarget(revision)}
data-slot="zeitplan-history-edit"
>
<Trash2 className="size-4" aria-hidden="true" />
<Pencil className="size-4" aria-hidden="true" />
</Button>
)}
{revision.source === "MANUAL" && (
<Button
type="button"
variant="ghost"
size="icon"
className="text-muted-foreground hover:text-dracula-red size-8"
aria-label={t(
"medications.detail.zeitplan.history.deleteAria",
)}
disabled={remove.isPending}
onClick={() => setDeleteTarget(revision)}
data-slot="zeitplan-history-delete"
>
<Trash2 className="size-4" aria-hidden="true" />
</Button>
)}
</div>
</div>
</TimelineRow>
);
@@ -266,11 +296,18 @@ export function ScheduleHistoryTimeline({
</Button>
</div>
<AddEraDialog
<EraDialog
key={editTarget?.id ?? "add"}
medicationId={medicationId}
open={addOpen}
onOpenChange={setAddOpen}
onCreated={() => setExpanded(true)}
target={editTarget}
open={addOpen || editTarget !== null}
onOpenChange={(next) => {
if (!next) {
setAddOpen(false);
setEditTarget(null);
}
}}
onSaved={() => setExpanded(true)}
/>
<AlertDialog
@@ -343,37 +380,58 @@ function TimelineRow({
);
}
/** ISO instant → the local calendar date a `DateInput` understands. */
function toLocalDateInput(iso: string): string {
const d = new Date(iso);
const pad = (n: number) => String(n).padStart(2, "0");
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
}
/**
* "Frühere Ära ergänzen" — collects daily dose times + the Von/Bis date
* pair and POSTs a MANUAL schedule revision. Client-side guards mirror
* the server validation (times present, Von before Bis); the server
* stays authoritative for overlap / before-current-plan and its message
* surfaces through the error toast.
* "Frühere Ära ergänzen" / "Ära bearbeiten" — collects daily dose times
* + the Von/Bis date pair. Without a `target` it POSTs a MANUAL
* revision; with one it PATCHes the era (a MANUAL era updates in place,
* an ARCHIVED era gets a superseding correction — the dialog shows a
* quiet hint that the recorded history is being corrected). The parent
* remounts the dialog via `key` per target, so the prefill is a plain
* lazy state init. Client-side guards mirror the server validation
* (times present, Von before Bis); the server stays authoritative for
* overlap / before-current-plan and its message surfaces through the
* error toast.
*/
function AddEraDialog({
function EraDialog({
medicationId,
target,
open,
onOpenChange,
onCreated,
onSaved,
}: {
medicationId: string;
/** Era being edited; null = the add flow. */
target: ScheduleRevisionRow | null;
open: boolean;
onOpenChange: (open: boolean) => void;
onCreated: () => void;
onSaved: () => void;
}) {
const { t } = useTranslations();
const queryClient = useQueryClient();
const [times, setTimes] = useState<string[]>([]);
const [fromDate, setFromDate] = useState("");
const [untilDate, setUntilDate] = useState("");
const [times, setTimes] = useState<string[]>(() =>
target ? eraTimes(target.entries) : [],
);
const [fromDate, setFromDate] = useState(() =>
target ? toLocalDateInput(target.validFrom) : "",
);
const [untilDate, setUntilDate] = useState(() =>
target ? toLocalDateInput(target.validUntil) : "",
);
const [validationKey, setValidationKey] = useState<
"timesMissing" | "rangeMissing" | "rangeOrder" | null
>(null);
function reset() {
setTimes([]);
setFromDate("");
setUntilDate("");
setTimes(target ? eraTimes(target.entries) : []);
setFromDate(target ? toLocalDateInput(target.validFrom) : "");
setUntilDate(target ? toLocalDateInput(target.validUntil) : "");
setValidationKey(null);
}
@@ -383,21 +441,34 @@ function AddEraDialog({
validUntil: string;
timesOfDay: string[];
}) =>
apiPost<ScheduleRevisionRow>(
`/api/medications/${medicationId}/schedule-revisions`,
input,
),
target
? apiPatch<ScheduleRevisionRow>(
`/api/medications/${medicationId}/schedule-revisions/${target.id}`,
input,
)
: apiPost<ScheduleRevisionRow>(
`/api/medications/${medicationId}/schedule-revisions`,
input,
),
onSuccess: () => {
toast.success(t("medications.detail.zeitplan.history.createdToast"));
toast.success(
target
? t("medications.detail.zeitplan.history.updatedToast")
: t("medications.detail.zeitplan.history.createdToast"),
);
queryClient.invalidateQueries({
queryKey: queryKeys.medicationDetail(medicationId),
});
onOpenChange(false);
reset();
onCreated();
onSaved();
},
onError: () => {
toast.error(t("medications.detail.zeitplan.history.createError"));
toast.error(
target
? t("medications.detail.zeitplan.history.updateError")
: t("medications.detail.zeitplan.history.createError"),
);
},
});
@@ -437,12 +508,24 @@ function AddEraDialog({
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>
{t("medications.detail.zeitplan.history.addEraTitle")}
{target
? t("medications.detail.zeitplan.history.editEraTitle")
: t("medications.detail.zeitplan.history.addEraTitle")}
</DialogTitle>
<DialogDescription>
{t("medications.detail.zeitplan.history.addEraDescription")}
{target
? t("medications.detail.zeitplan.history.editEraDescription")
: t("medications.detail.zeitplan.history.addEraDescription")}
</DialogDescription>
</DialogHeader>
{target?.source === "ARCHIVED" && (
<p
className="text-muted-foreground text-xs"
data-slot="zeitplan-history-archived-hint"
>
{t("medications.detail.zeitplan.history.archivedEditHint")}
</p>
)}
<form
onSubmit={(e) => {
e.preventDefault();
+29 -1
View File
@@ -1,6 +1,11 @@
"use client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import {
QueryClient,
QueryClientProvider,
useQueryClient,
} from "@tanstack/react-query";
import { usePathname } from "next/navigation";
import {
createContext,
useCallback,
@@ -14,6 +19,8 @@ import type { Locale } from "@/lib/i18n/config";
import { Toaster } from "@/components/ui/sonner";
import { AppSettingsProvider } from "@/components/app-settings-provider";
import { VersionPoller } from "@/components/version-poller";
import { isDashboardSnapshotEnabled } from "@/lib/dashboard/snapshot-flag";
import { prefetchDashboardSnapshot } from "@/lib/queries/use-dashboard-snapshot";
// ── Theme Context ────────────────────────────────────
@@ -109,6 +116,26 @@ function ThemeProvider({ children }: { children: ReactNode }) {
);
}
// ── Dashboard snapshot preloader ─────────────────────
//
// v1.16.6 first-load waterfall fix: fire the snapshot fetch the moment
// the router commits to "/" instead of waiting for the dashboard page
// chunk to download + mount (~450 ms later on a 4G / 4x-CPU profile).
// The proxy has already enforced auth + onboarding for "/" before any
// client code runs here, so the prefetch cannot fire for an
// unauthenticated visitor; a racing edge case just yields a swallowed
// 401 prefetch and the mounted cell re-resolves normally.
function DashboardSnapshotPreloader() {
const queryClient = useQueryClient();
const pathname = usePathname();
useEffect(() => {
if (pathname === "/" && isDashboardSnapshotEnabled()) {
prefetchDashboardSnapshot(queryClient);
}
}, [pathname, queryClient]);
return null;
}
// ── Root Providers ───────────────────────────────────
export function Providers({
@@ -143,6 +170,7 @@ export function Providers({
initialMessages={initialMessages}
>
<AppSettingsProvider>
<DashboardSnapshotPreloader />
{children}
<Toaster position="bottom-right" richColors />
<VersionPoller />
@@ -121,7 +121,9 @@ describe("<UpdateBadge> — a11y contract", () => {
ariaLabel="A newer version is available: v1.4.99"
/>,
);
expect(html).toContain('aria-label="A newer version is available: v1.4.99"');
expect(html).toContain(
'aria-label="A newer version is available: v1.4.99"',
);
expect(html).toContain("min-h-11");
expect(html).toContain("min-w-11");
expect(html).toContain("focus-visible:ring-ring");
@@ -140,7 +142,9 @@ describe("<UpdateBadge> — a11y contract", () => {
/>,
);
expect(html).toContain('role="img"');
expect(html).toContain('aria-label="A newer version is available: v1.4.99"');
expect(html).toContain(
'aria-label="A newer version is available: v1.4.99"',
);
expect(html).toContain("min-h-11");
expect(html).toContain("min-w-11");
});
@@ -44,9 +44,9 @@ describe("resolveInitialTimezone — v1.4.37 silent auto-seed", () => {
});
it("respects any non-Berlin stored value — explicit user choice wins over browser detection", () => {
expect(
resolveInitialTimezone("America/Los_Angeles", "Europe/Berlin"),
).toBe("America/Los_Angeles");
expect(resolveInitialTimezone("America/Los_Angeles", "Europe/Berlin")).toBe(
"America/Los_Angeles",
);
});
it("respects a non-Berlin stored value even when the browser reports a different non-Berlin zone", () => {
@@ -30,9 +30,7 @@ vi.mock("@tanstack/react-query", () => ({
}));
vi.mock("@/components/medications/ResearchModeAcknowledgmentDialog", () => ({
ResearchModeAcknowledgmentDialog: () => (
<div data-slot="mock-ack-dialog" />
),
ResearchModeAcknowledgmentDialog: () => <div data-slot="mock-ack-dialog" />,
}));
import { AdvancedSection } from "../advanced-section";
@@ -137,9 +137,7 @@ describe("<AdvancedSection> → Research Mode card", () => {
expect(html).toContain('data-slot="settings-research-mode-reprompt"');
expect(html).toContain("Disclaimer updated");
expect(html).toContain(
'data-slot="settings-research-mode-reprompt-cta"',
);
expect(html).toContain('data-slot="settings-research-mode-reprompt-cta"');
expect(html).toContain("Re-acknowledge disclaimer");
// Status line reflects the stale state without claiming
// "acknowledged on …" — that copy would mis-represent that the
@@ -290,17 +290,19 @@ describe("<DashboardLayoutSection> — iOS-pin-only ids hidden from web (v1.11.2
const html = render(<DashboardLayoutSection id="dashboard-layout" />);
// The pin-only ids reuse `measurements.type*` labels that resolve to
// distinctive English strings; none should appear as a toggle aria-label.
const pinOnlyLabels: Record<(typeof IOS_PIN_ONLY_WIDGET_IDS)[number], string> =
{
cardioRecovery: "Cardio recovery",
sixMinuteWalk: "Six-minute walk distance",
stairAscentSpeed: "Stair ascent speed",
stairDescentSpeed: "Stair descent speed",
breathingDisturbances: "Breathing disturbances",
wristTemperature: "Wrist temperature",
falls: "Falls",
walkingSteadiness: "Walking steadiness",
};
const pinOnlyLabels: Record<
(typeof IOS_PIN_ONLY_WIDGET_IDS)[number],
string
> = {
cardioRecovery: "Cardio recovery",
sixMinuteWalk: "Six-minute walk distance",
stairAscentSpeed: "Stair ascent speed",
stairDescentSpeed: "Stair descent speed",
breathingDisturbances: "Breathing disturbances",
wristTemperature: "Wrist temperature",
falls: "Falls",
walkingSteadiness: "Walking steadiness",
};
for (const id of IOS_PIN_ONLY_WIDGET_IDS) {
// The widget label drives the switch aria-label; if the row were
// rendered the localised label would show up in the markup.
@@ -27,11 +27,7 @@ vi.mock("next/navigation", () => ({
}));
import { I18nProvider } from "@/lib/i18n/context";
import {
ImportPanel,
EXAMPLE_IMPORT,
parseImportJson,
} from "../import-panel";
import { ImportPanel, EXAMPLE_IMPORT, parseImportJson } from "../import-panel";
function render(node: React.ReactElement, locale: "en" | "de" = "en") {
const queryClient = new QueryClient({
@@ -61,11 +61,12 @@ beforeEach(() => {
}));
vi.stubGlobal(
"fetch",
vi.fn(async () =>
new Response(JSON.stringify({ data: DEFAULT_INSIGHTS_LAYOUT }), {
status: 200,
headers: { "Content-Type": "application/json" },
}),
vi.fn(
async () =>
new Response(JSON.stringify({ data: DEFAULT_INSIGHTS_LAYOUT }), {
status: 200,
headers: { "Content-Type": "application/json" },
}),
),
);
});
@@ -113,7 +113,9 @@ describe("Settings — TimeFormatSelect", () => {
isAuthenticated: true,
}));
const html = render();
expect(html).toMatch(/<option[^>]*selected[^>]*value="H24"|<option[^>]*value="H24"[^>]*selected/);
expect(html).toMatch(
/<option[^>]*selected[^>]*value="H24"|<option[^>]*value="H24"[^>]*selected/,
);
});
it("defaults to AUTO when the user has no stored preference", () => {
@@ -122,14 +124,14 @@ describe("Settings — TimeFormatSelect", () => {
isAuthenticated: true,
}));
const html = render();
expect(html).toMatch(/<option[^>]*selected[^>]*value="AUTO"|<option[^>]*value="AUTO"[^>]*selected/);
expect(html).toMatch(
/<option[^>]*selected[^>]*value="AUTO"|<option[^>]*value="AUTO"[^>]*selected/,
);
});
it("disables the select when unauthenticated", () => {
const html = render(false);
const select = html.match(
/<select[^>]*settings-time-format-select[^>]*>/,
);
const select = html.match(/<select[^>]*settings-time-format-select[^>]*>/);
expect(select).not.toBeNull();
expect(select![0]).toContain("disabled");
});
+1 -1
View File
@@ -184,7 +184,7 @@ export function AboutMeSection({
<section
aria-labelledby="settings-ai-about-me-title"
data-testid="settings-about-me-card"
className="bg-card border-border space-y-5 rounded-xl border p-6"
className="bg-card border-border space-y-5 rounded-xl border p-4 sm:p-6"
>
<div className="flex flex-wrap items-start justify-between gap-3">
<SettingsCardHeader
+4 -7
View File
@@ -234,10 +234,7 @@ export function AboutSection() {
className="space-y-6"
>
<header className="space-y-1">
<h1
id="settings-section-about-title"
className="sr-only"
>
<h1 id="settings-section-about-title" className="sr-only">
{t("settings.sections.about.title")}
</h1>
<p className="text-muted-foreground text-sm">
@@ -249,7 +246,7 @@ export function AboutSection() {
stacked the license under the version inside its own boxed Badge,
which read as a separate field even though the two values belong
on the same row. */}
<div className="bg-card border-border rounded-xl border p-6">
<div className="bg-card border-border rounded-xl border p-4 sm:p-6">
<SettingsCardHeader icon={Info} title="HealthLog" className="mb-4" />
{isLoading || !version ? (
@@ -319,7 +316,7 @@ export function AboutSection() {
About surface, so the buttons floated alone with no heading
telling the user what the section was for. */}
{version && (
<div className="bg-card border-border rounded-xl border p-6">
<div className="bg-card border-border rounded-xl border p-4 sm:p-6">
<SettingsCardHeader
icon={BookOpen}
title={t("settings.about.linksHeading")}
@@ -368,7 +365,7 @@ export function AboutSection() {
links. Mirrors the stack-on-mobile / right-align-on-desktop
contract used by the Account section's "Restart onboarding
tour" card so the two surfaces feel consistent. */}
<div className="bg-card border-border rounded-xl border p-6">
<div className="bg-card border-border rounded-xl border p-4 sm:p-6">
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between sm:gap-2">
<div className="flex items-start gap-2">
<Compass className="text-muted-foreground mt-0.5 h-5 w-5 shrink-0" />
+6 -9
View File
@@ -414,10 +414,7 @@ export function AccountSection() {
className="space-y-6"
>
<header className="space-y-1">
<h1
id="settings-section-account-title"
className="sr-only"
>
<h1 id="settings-section-account-title" className="sr-only">
{t("settings.sections.account.title")}
</h1>
<p className="text-muted-foreground text-sm">
@@ -429,7 +426,7 @@ export function AccountSection() {
{/* Profile photo card */}
<AvatarSection />
<div className="bg-card border-border rounded-xl border p-6">
<div className="bg-card border-border rounded-xl border p-4 sm:p-6">
<SettingsCardHeader
icon={User}
title={t("settings.profile")}
@@ -648,7 +645,7 @@ export function AccountSection() {
<CycleTrackingCard isAuthenticated={isAuthenticated} />
{/* Passkeys card */}
<div className="bg-card border-border rounded-xl border p-6">
<div className="bg-card border-border rounded-xl border p-4 sm:p-6">
<SettingsCardHeader
icon={Shield}
title={t("settings.passkeys")}
@@ -692,7 +689,7 @@ export function AccountSection() {
(the German "Passwort ändern" already pushes it; the tour
card's "Restart onboarding tour" actually broke through the
right border by ~48 px). */}
<div className="bg-card border-border rounded-xl border p-6">
<div className="bg-card border-border rounded-xl border p-4 sm:p-6">
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between sm:gap-2">
<div className="flex items-start gap-2">
<Shield className="text-muted-foreground mt-0.5 h-5 w-5 shrink-0" />
@@ -722,7 +719,7 @@ export function AccountSection() {
background reopens the spotlight tour immediately. v1.4.19
A6 — same stack-on-mobile / right-align-on-desktop contract
as the password card so both action surfaces look identical. */}
<div className="bg-card border-border rounded-xl border p-6">
<div className="bg-card border-border rounded-xl border p-4 sm:p-6">
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between sm:gap-2">
<div className="flex items-start gap-2">
<Compass className="text-muted-foreground mt-0.5 h-5 w-5 shrink-0" />
@@ -1190,7 +1187,7 @@ function AvatarSection() {
const busy = upload.isPending || remove.isPending;
return (
<div className="bg-card border-border rounded-xl border p-6">
<div className="bg-card border-border rounded-xl border p-4 sm:p-6">
<SettingsCardHeader
icon={ImageUp}
title={t("settings.avatar.title")}
+10 -16
View File
@@ -19,6 +19,7 @@ import { Button } from "@/components/ui/button";
import { Switch } from "@/components/ui/switch";
import { ResearchModeAcknowledgmentDialog } from "@/components/medications/ResearchModeAcknowledgmentDialog";
import { formatDateTime } from "@/lib/format";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import {
@@ -56,10 +57,7 @@ export function AdvancedSection() {
className="space-y-6"
>
<header className="space-y-1">
<h1
id="settings-section-advanced-title"
className="sr-only"
>
<h1 id="settings-section-advanced-title" className="sr-only">
{t("settings.sections.advanced.title")}
</h1>
<p className="text-muted-foreground text-sm">
@@ -151,18 +149,14 @@ function ResearchModeCard() {
return (
<div
className="bg-card border-border rounded-xl border p-6"
className="bg-card border-border rounded-xl border p-4 sm:p-6"
data-slot="settings-research-mode-card"
>
<div className="flex items-center gap-2">
<BookOpenCheck className="text-muted-foreground h-5 w-5" />
<h2 className="text-lg font-semibold">
{t("settings.researchMode.sectionTitle")}
</h2>
</div>
<p className="text-muted-foreground mt-1 pl-7 text-xs">
{t("settings.researchMode.subtitle")}
</p>
<SettingsCardHeader
icon={BookOpenCheck}
title={t("settings.researchMode.sectionTitle")}
description={t("settings.researchMode.subtitle")}
/>
{showRePrompt && (
<div
@@ -270,7 +264,7 @@ function DataResetCard() {
return (
<div
className="bg-card border-border rounded-xl border p-6"
className="bg-card border-border rounded-xl border p-4 sm:p-6"
data-slot="settings-data-reset-card"
>
{/* v1.4.43 QoL (L5) — dropped the `AlertTriangle` icon and
@@ -418,7 +412,7 @@ function AccountDeleteCard() {
return (
<div
className="bg-card border-border rounded-xl border p-6"
className="bg-card border-border rounded-xl border p-4 sm:p-6"
data-slot="settings-account-delete-card"
>
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between sm:gap-2">
+18 -10
View File
@@ -16,23 +16,31 @@ import { DisableCoachCard } from "@/components/settings/ai/disable-coach-card";
import { CoachMemorySection } from "@/components/settings/coach-memory-section";
import { CoachPrefsSection } from "@/components/settings/coach-prefs-section";
import { useAuth } from "@/hooks/use-auth";
import { useMounted } from "@/hooks/use-mounted";
import { useTranslations } from "@/lib/i18n/context";
export function AiSection() {
const { t } = useTranslations();
const { isAuthenticated, user } = useAuth();
// v1.16.6 — the auth query can resolve before this boundary
// hydrates; every `disabled={!isAuthenticated}` binding in the child
// cards would then disagree with the SSR HTML (React #418). Gate the
// prop on `useMounted()` so the hydration render matches the
// server-rendered "not yet authenticated" state and the cards enable
// on the first client re-render.
const mounted = useMounted();
const authed = mounted && isAuthenticated;
// v1.11.2 — the Coach-memory panel rides the same gate as the rest of
// the Coach surface: when the user has hidden the Coach there is no
// assistant to remember anything, so the memory controls hide too.
const coachEnabled = !user?.disableCoach;
// (`mounted`-gated like `authed`: SSR renders the panels, so the
// hydration render must too, even when the user disabled the Coach.)
const coachEnabled = !mounted || !user?.disableCoach;
return (
<section aria-labelledby="settings-section-ai-title" className="space-y-6">
<header className="space-y-1">
<h1
id="settings-section-ai-title"
className="sr-only"
>
<h1 id="settings-section-ai-title" className="sr-only">
{t("settings.sections.ai.title")}
</h1>
<p className="text-muted-foreground text-sm">
@@ -40,27 +48,27 @@ export function AiSection() {
</p>
</header>
<AiInsightsCard isAuthenticated={isAuthenticated} />
<AiInsightsCard isAuthenticated={authed} />
{/* v1.4.47 W3 — per-user Coach opt-out. Lives below the provider
card so users who want the surface gone never have to scroll
through provider configuration before finding the toggle. */}
<DisableCoachCard isAuthenticated={isAuthenticated} />
<DisableCoachCard isAuthenticated={authed} />
{/* v1.16.1 — Coach preferences (tone, verbosity, data scope)
moved here from the in-chat sheet; the chat header gear
deep-links to this section. Gated on the Coach being
enabled. */}
{coachEnabled && <CoachPrefsSection isAuthenticated={isAuthenticated} />}
{coachEnabled && <CoachPrefsSection isAuthenticated={authed} />}
{/* v1.11.2 — "What the Coach remembers": durable-fact review +
forget controls. Gated on the Coach being enabled. */}
{coachEnabled && <CoachMemorySection isAuthenticated={isAuthenticated} />}
{coachEnabled && <CoachMemorySection isAuthenticated={authed} />}
{/* v1.15.20 — user-authored "about me" context for the Coach +
daily briefing. Not gated on the Coach toggle: the briefing
reads it too. */}
<AboutMeSection isAuthenticated={isAuthenticated} />
<AboutMeSection isAuthenticated={authed} />
</section>
);
}
@@ -119,7 +119,7 @@ export function AiInsightsCard({
}
return (
<div className="bg-card border-border space-y-4 rounded-xl border p-6">
<div className="bg-card border-border space-y-4 rounded-xl border p-4 sm:p-6">
{/* The card used to render an icon-only header row (Sparkles +
status badges, no title), which left the tile unanchored next
to its titled siblings. It now follows the shared
@@ -26,6 +26,7 @@ import { Switch } from "@/components/ui/switch";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { apiPatch } from "@/lib/api/api-fetch";
import { useAuth } from "@/hooks/use-auth";
import { useMounted } from "@/hooks/use-mounted";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
@@ -74,7 +75,12 @@ export function DisableCoachCard({
}, 3000);
}
const checked = optimistic ?? user?.disableCoach ?? false;
// `mounted`-gated: `user` comes from the auth query, which can
// resolve before this boundary hydrates — the Switch state must
// match the unchecked SSR HTML during hydration (React #418) and
// pick up the wire value on the first client re-render.
const mounted = useMounted();
const checked = mounted ? (optimistic ?? user?.disableCoach ?? false) : false;
const mutation = useMutation({
mutationFn: async (next: boolean) => {
@@ -122,7 +128,7 @@ export function DisableCoachCard({
<section
aria-labelledby="settings-ai-disable-coach-title"
data-testid="settings-disable-coach-card"
className="bg-card border-border rounded-xl border p-6"
className="bg-card border-border rounded-xl border p-4 sm:p-6"
>
<SettingsCardHeader
icon={MessageCircleOff}
+3 -6
View File
@@ -42,10 +42,7 @@ export function ApiSection() {
return (
<section aria-labelledby="settings-section-api-title" className="space-y-6">
<header className="space-y-1">
<h1
id="settings-section-api-title"
className="sr-only"
>
<h1 id="settings-section-api-title" className="sr-only">
{t("settings.sections.api.title")}
</h1>
<p className="text-muted-foreground text-sm">
@@ -78,7 +75,7 @@ function ApiEndpointsCard() {
];
return (
<div className="bg-card border-border rounded-xl border p-6">
<div className="bg-card border-border rounded-xl border p-4 sm:p-6">
<SettingsCardHeader
icon={Key}
title={t("settings.apiEndpointsTitle")}
@@ -241,7 +238,7 @@ function ApiTokensCard() {
}, null);
return (
<div className="bg-card border-border rounded-xl border p-6">
<div className="bg-card border-border rounded-xl border p-4 sm:p-6">
<SettingsCardHeader
icon={Key}
title={t("settings.apiTokens")}
@@ -20,7 +20,7 @@
*/
import { useMemo } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Loader2, Trash2 } from "lucide-react";
import { Brain, Loader2, Trash2 } from "lucide-react";
import {
AlertDialog,
@@ -35,6 +35,7 @@ import {
} from "@/components/ui/alert-dialog";
import { Button } from "@/components/ui/button";
import { formatDateOrRelative } from "@/lib/format";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import { toast } from "sonner";
@@ -136,70 +137,65 @@ export function CoachMemorySection({
<section
aria-labelledby="settings-ai-coach-memory-title"
data-testid="settings-coach-memory-card"
className="bg-card border-border space-y-4 rounded-xl border p-6"
className="bg-card border-border space-y-4 rounded-xl border p-4 sm:p-6"
>
<div className="flex items-start justify-between gap-4">
<div className="pr-2">
<h2
id="settings-ai-coach-memory-title"
className="text-base font-semibold"
>
{t("settings.ai.coachMemory.title")}
</h2>
<p className="text-muted-foreground mt-1 text-xs">
{t("settings.ai.coachMemory.description")}
</p>
</div>
{facts.length > 0 && (
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
type="button"
variant="outline"
size="sm"
data-testid="settings-coach-memory-forget-all"
disabled={!isAuthenticated || forgetAll.isPending}
>
{forgetAll.isPending ? (
<Loader2
className="size-4 animate-spin motion-reduce:animate-none"
aria-hidden
/>
) : (
<Trash2 className="size-4" aria-hidden />
)}
{t("settings.ai.coachMemory.forgetAll")}
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{t("settings.ai.coachMemory.forgetAllConfirmTitle")}
</AlertDialogTitle>
<AlertDialogDescription>
{t("settings.ai.coachMemory.forgetAllConfirmBody")}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>
{t("settings.ai.coachMemory.cancel")}
</AlertDialogCancel>
<AlertDialogAction
data-testid="settings-coach-memory-forget-all-confirm"
disabled={forgetAll.isPending}
aria-busy={forgetAll.isPending || undefined}
onClick={() => forgetAll.mutate()}
<SettingsCardHeader
icon={Brain}
titleId="settings-ai-coach-memory-title"
title={t("settings.ai.coachMemory.title")}
description={t("settings.ai.coachMemory.description")}
status={
facts.length > 0 ? (
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
type="button"
variant="outline"
size="sm"
data-testid="settings-coach-memory-forget-all"
disabled={!isAuthenticated || forgetAll.isPending}
>
{forgetAll.isPending && (
<Loader2 className="mr-1 size-3.5 animate-spin motion-reduce:animate-none" />
{forgetAll.isPending ? (
<Loader2
className="size-4 animate-spin motion-reduce:animate-none"
aria-hidden
/>
) : (
<Trash2 className="size-4" aria-hidden />
)}
{t("settings.ai.coachMemory.forgetAllConfirmAction")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)}
</div>
{t("settings.ai.coachMemory.forgetAll")}
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{t("settings.ai.coachMemory.forgetAllConfirmTitle")}
</AlertDialogTitle>
<AlertDialogDescription>
{t("settings.ai.coachMemory.forgetAllConfirmBody")}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>
{t("settings.ai.coachMemory.cancel")}
</AlertDialogCancel>
<AlertDialogAction
data-testid="settings-coach-memory-forget-all-confirm"
disabled={forgetAll.isPending}
aria-busy={forgetAll.isPending || undefined}
onClick={() => forgetAll.mutate()}
>
{forgetAll.isPending && (
<Loader2 className="mr-1 size-3.5 animate-spin motion-reduce:animate-none" />
)}
{t("settings.ai.coachMemory.forgetAllConfirmAction")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
) : null
}
/>
{query.isError && (
<p
+1 -1
View File
@@ -172,7 +172,7 @@ export function CoachNudgeCard({
return (
<section
aria-labelledby="settings-coach-nudge-title"
className="bg-card rounded-lg border p-4 sm:p-6"
className="bg-card border-border rounded-xl border p-4 sm:p-6"
data-testid="settings-coach-nudge-card"
>
<SettingsCardHeader
+41 -131
View File
@@ -19,17 +19,10 @@
* contradict each other.
*/
import { useState } from "react";
import { Loader2 } from "lucide-react";
import Link from "next/link";
import { Loader2, SlidersHorizontal } from "lucide-react";
import { toast } from "sonner";
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
@@ -41,14 +34,11 @@ import {
SelectValue,
} from "@/components/ui/select";
import { Skeleton } from "@/components/ui/skeleton";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { useTranslations } from "@/lib/i18n/context";
import { useCoachPrefs, useSaveCoachPrefs } from "@/hooks/use-coach-prefs";
import {
DEFAULT_COACH_CLUSTERS,
DEFAULT_COACH_PREFS,
coachDataClusterEnum,
type CoachDataCluster,
type CoachDefaultWindow,
type CoachExcludeMetric,
type CoachPrefs,
type CoachTone,
@@ -76,15 +66,6 @@ const CONTEXT_OPTIONS: CoachExcludeMetric[] = [
"anthropometrics",
];
const DEFAULT_WINDOW_OPTIONS: CoachDefaultWindow[] = [
"last7days",
"last30days",
"last90days",
"allTime",
];
const CLUSTER_OPTIONS: CoachDataCluster[] = coachDataClusterEnum.options;
export interface CoachPrefsSectionProps {
isAuthenticated: boolean;
}
@@ -122,36 +103,22 @@ export function CoachPrefsSection({ isAuthenticated }: CoachPrefsSectionProps) {
}));
}
function toggleCluster(cluster: CoachDataCluster, next: boolean) {
setDraft((prev) => {
const current = prev.dataClusters ?? Array.from(DEFAULT_COACH_CLUSTERS);
const updated = next
? Array.from(new Set([...current, cluster]))
: current.filter((c) => c !== cluster);
return { ...prev, dataClusters: updated };
});
}
const enabledClusters = new Set<CoachDataCluster>(
draft.dataClusters ?? DEFAULT_COACH_CLUSTERS,
);
const isLoading = isAuthenticated && !persisted;
return (
<Card data-slot="coach-prefs-section">
<CardHeader>
<CardTitle className="text-lg font-semibold">
{t("insights.coach.settingsTitle")}
</CardTitle>
<CardDescription>
{t("insights.coach.settingsDescription")}
</CardDescription>
</CardHeader>
<div
className="bg-card border-border rounded-xl border p-4 sm:p-6"
data-slot="coach-prefs-section"
>
<SettingsCardHeader
icon={SlidersHorizontal}
title={t("insights.coach.settingsTitle")}
description={t("insights.coach.settingsDescription")}
/>
{isLoading ? (
<CardContent
<div
data-slot="coach-prefs-skeleton"
className="flex flex-col gap-5"
className="mt-4 flex flex-col gap-5 pl-7"
>
<div className="flex flex-col gap-2">
<Skeleton className="h-3 w-20" />
@@ -166,9 +133,9 @@ export function CoachPrefsSection({ isAuthenticated }: CoachPrefsSectionProps) {
<Skeleton className="h-3 w-3/4" />
<Skeleton className="h-44 w-full" />
</div>
</CardContent>
</div>
) : (
<CardContent className="flex flex-col gap-5">
<div className="mt-4 flex flex-col gap-5 pl-7">
<div className="grid grid-cols-1 gap-5 sm:grid-cols-2">
{/* Tone */}
<div className="flex flex-col gap-2">
@@ -181,7 +148,10 @@ export function CoachPrefsSection({ isAuthenticated }: CoachPrefsSectionProps) {
setDraft((prev) => ({ ...prev, tone: value as CoachTone }))
}
>
<SelectTrigger id="coach-prefs-tone" data-slot="coach-prefs-tone">
<SelectTrigger
id="coach-prefs-tone"
data-slot="coach-prefs-tone"
>
<SelectValue />
</SelectTrigger>
<SelectContent>
@@ -236,84 +206,24 @@ export function CoachPrefsSection({ isAuthenticated }: CoachPrefsSectionProps) {
</div>
</div>
{/* Default analysis window */}
<div className="flex flex-col gap-2">
<Label
htmlFor="coach-prefs-default-window"
className="text-xs font-medium"
{/* Data clusters + analysis window live on ONE owner: the
sources rail inside the chat ("What I draw on"). This card
used to render its own copies of both controls; two writable
surfaces for the same persisted fields is exactly the
redundancy the rail was built to end. A pointer replaces
them. */}
<p
data-slot="coach-prefs-sources-pointer"
className="text-muted-foreground border-border/60 rounded-md border border-dashed px-3 py-2 text-[11px] leading-relaxed"
>
{t("insights.coach.settingsSourcesPointer")}{" "}
<Link
href="/insights/coach"
className="text-foreground underline underline-offset-2"
>
{t("insights.coach.settingsDefaultWindowLabel")}
</Label>
<p className="text-muted-foreground text-[11px] leading-relaxed">
{t("insights.coach.settingsDefaultWindowHint")}
</p>
<Select
value={draft.defaultWindow}
onValueChange={(value) =>
setDraft((prev) => ({
...prev,
defaultWindow: value as CoachDefaultWindow,
}))
}
>
<SelectTrigger
id="coach-prefs-default-window"
data-slot="coach-prefs-default-window"
>
<SelectValue />
</SelectTrigger>
<SelectContent>
{DEFAULT_WINDOW_OPTIONS.map((w) => (
<SelectItem key={w} value={w}>
{t(`insights.coach.window.${w}`)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Clustered, opt-in data sources */}
<div className="flex flex-col gap-2">
<Label className="text-xs font-medium">
{t("insights.coach.settingsClustersLabel")}
</Label>
<p className="text-muted-foreground text-[11px] leading-relaxed">
{t("insights.coach.settingsClustersHint")}
</p>
<ul
data-slot="coach-prefs-cluster-list"
className="border-border/60 divide-border/50 mt-1 flex flex-col divide-y rounded-md border"
>
{CLUSTER_OPTIONS.map((cluster) => {
const id = `coach-prefs-cluster-${cluster}`;
const checked = enabledClusters.has(cluster);
return (
<li
key={cluster}
className="flex items-center justify-between gap-3 px-3 py-2"
>
<div className="flex min-w-0 flex-col">
<Label
htmlFor={id}
className="cursor-pointer text-xs font-medium"
>
{t(`insights.coach.cluster.${cluster}.label`)}
</Label>
<span className="text-muted-foreground text-[11px] leading-snug">
{t(`insights.coach.cluster.${cluster}.hint`)}
</span>
</div>
<Switch
id={id}
data-slot={`coach-prefs-cluster-${cluster}`}
checked={checked}
onCheckedChange={(next) => toggleCluster(cluster, next)}
/>
</li>
);
})}
</ul>
</div>
{t("insights.coach.settingsSourcesPointerLink")}
</Link>
</p>
{/* Exclude metrics — checked = excluded. */}
<div className="flex flex-col gap-2">
@@ -386,9 +296,9 @@ export function CoachPrefsSection({ isAuthenticated }: CoachPrefsSectionProps) {
})}
</ul>
</div>
</CardContent>
</div>
)}
<CardFooter className="justify-end">
<div className="mt-4 flex justify-end pl-7">
<Button
type="button"
size="sm"
@@ -404,7 +314,7 @@ export function CoachPrefsSection({ isAuthenticated }: CoachPrefsSectionProps) {
) : null}
{t("insights.coach.settingsSave")}
</Button>
</CardFooter>
</Card>
</div>
</div>
);
}
@@ -59,7 +59,7 @@ export function CycleTrackingCard({
const enabled = data?.cycleTrackingEnabled ?? false;
return (
<div className="bg-card rounded-lg border p-4 sm:p-6">
<div className="bg-card border-border rounded-xl border p-4 sm:p-6">
<SettingsCardHeader
icon={CalendarHeart}
title={t("settings.cycleTracking.title")}
@@ -294,7 +294,7 @@ export function DashboardLayoutSection({ id }: { id: string }) {
return (
<div
id={id}
className="bg-card border-border scroll-mt-28 space-y-4 rounded-xl border p-6"
className="bg-card border-border scroll-mt-28 space-y-4 rounded-xl border p-4 sm:p-6"
>
{/* v1.4.19 A6 — title / action row uses the same stack-on-mobile,
right-align-on-desktop contract as Account → Password +
@@ -12,10 +12,7 @@ export function DashboardSection() {
className="space-y-6"
>
<header className="space-y-1">
<h1
id="settings-section-dashboard-title"
className="sr-only"
>
<h1 id="settings-section-dashboard-title" className="sr-only">
{t("settings.sections.dashboard.title")}
</h1>
<p className="text-muted-foreground text-sm">
+6 -6
View File
@@ -79,10 +79,7 @@ export function ExportSection() {
className="space-y-6"
>
<header className="space-y-1">
<h1
id="settings-section-export-title"
className="sr-only"
>
<h1 id="settings-section-export-title" className="sr-only">
{t("settings.sections.export.title")}
</h1>
<p className="text-muted-foreground text-sm">
@@ -158,7 +155,7 @@ function ExportCardShell({
return (
<div
data-testid={testId}
className={`bg-card border-border flex h-full flex-col rounded-xl border p-6${outerClassName ? ` ${outerClassName}` : ""}`}
className={`bg-card border-border flex h-full flex-col rounded-xl border p-4 sm:p-6${outerClassName ? ` ${outerClassName}` : ""}`}
>
<div className="flex items-start justify-between gap-3">
<div className="flex items-center gap-2">
@@ -505,7 +502,10 @@ function CycleExportCard() {
const prefsQuery = useQuery({
queryKey: queryKeys.cyclePrefs(),
queryFn: async (): Promise<{ cycleTrackingEnabled: boolean }> => {
return apiGet<{ cycleTrackingEnabled: boolean }>("/api/auth/me/cycle-prefs", { credentials: "include" });
return apiGet<{ cycleTrackingEnabled: boolean }>(
"/api/auth/me/cycle-prefs",
{ credentials: "include" },
);
},
staleTime: 5 * 60 * 1000,
});
+9 -3
View File
@@ -93,10 +93,13 @@ function ImportCardShell({
return (
<div
data-testid={testId}
className="bg-card border-border flex h-full flex-col rounded-xl border p-6"
className="bg-card border-border flex h-full flex-col rounded-xl border p-4 sm:p-6"
>
<div className="flex items-center gap-2">
<Icon className="text-muted-foreground h-5 w-5 shrink-0" aria-hidden="true" />
<Icon
className="text-muted-foreground h-5 w-5 shrink-0"
aria-hidden="true"
/>
<h3 className="text-base font-semibold">{title}</h3>
</div>
<p className="text-muted-foreground mt-1 text-xs">{description}</p>
@@ -144,7 +147,10 @@ function AppleHealthImportCard() {
return 2000;
},
queryFn: async (): Promise<JobStatus> => {
return apiGet<JobStatus>(`/api/import/apple-health-export/${jobId}/status`, { credentials: "include" });
return apiGet<JobStatus>(
`/api/import/apple-health-export/${jobId}/status`,
{ credentials: "include" },
);
},
});
@@ -47,7 +47,9 @@ export function InjectionSitesCard({
const mutation = useMutation({
mutationFn: async (next: InjectionSiteKey[]) => {
await apiPatch("/api/auth/me/injection-site-prefs", { globalExcludedInjectionSites: next });
await apiPatch("/api/auth/me/injection-site-prefs", {
globalExcludedInjectionSites: next,
});
},
onSuccess: () => {
void queryClient.invalidateQueries({
@@ -125,12 +125,12 @@ export function InsightsPillOrderSection({ id }: { id?: string }) {
const saveMutation = useMutation({
mutationFn: async (tiles: InsightsTileConfig[]) => {
return apiPut<InsightsLayout>("/api/insights/layout", {
version: 2,
// Preserve the overview section order/visibility verbatim — this
// surface owns the PILL order only.
sections: layout.sections,
tiles,
});
version: 2,
// Preserve the overview section order/visibility verbatim — this
// surface owns the PILL order only.
sections: layout.sections,
tiles,
});
},
onSuccess: (saved) => {
queryClient.setQueryData(queryKeys.insightsLayout(), saved);
+1 -4
View File
@@ -24,10 +24,7 @@ export function InsightsSection() {
className="space-y-6"
>
<header className="space-y-1">
<h1
id="settings-section-insights-title"
className="sr-only"
>
<h1 id="settings-section-insights-title" className="sr-only">
{t("settings.sections.insights.title")}
</h1>
<p className="text-muted-foreground text-sm">
@@ -106,12 +106,9 @@ export function IntegrationStatusPill({
// a reauth won't fix" vs "tried to keep going for 24h but the mismatch
// persists, manual intervention required" — distinct from the red
// reconnect pill (`error`).
const connectedChipClass =
"border-success/30 bg-success/15 text-success";
const warningChipClass =
"border-warning/30 bg-warning/15 text-warning";
const parkedChipClass =
"border-warning/30 bg-warning/15 text-warning";
const connectedChipClass = "border-success/30 bg-success/15 text-success";
const warningChipClass = "border-warning/30 bg-warning/15 text-warning";
const parkedChipClass = "border-warning/30 bg-warning/15 text-warning";
let label: string;
let icon: React.ReactNode;
@@ -99,10 +99,7 @@ export function IntegrationsSection() {
className="space-y-6"
>
<header className="space-y-1">
<h1
id="settings-section-integrations-title"
className="sr-only"
>
<h1 id="settings-section-integrations-title" className="sr-only">
{t("settings.sections.integrations.title")}
</h1>
<p className="text-muted-foreground text-sm">
@@ -9,7 +9,14 @@
import { useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { HeartPulse, Link2, Loader2, RefreshCw, Save, Unlink } from "lucide-react";
import {
HeartPulse,
Link2,
Loader2,
RefreshCw,
Save,
Unlink,
} from "lucide-react";
import {
AlertDialog,
@@ -182,7 +189,7 @@ export function FitbitCard({
return (
<div
data-testid="fitbit-card"
className="bg-card border-border rounded-xl border p-6"
className="bg-card border-border rounded-xl border p-4 sm:p-6"
>
<SettingsCardHeader
icon={HeartPulse}
@@ -2,7 +2,14 @@
import { useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { Activity, Link2, Loader2, RefreshCw, Save, Unlink } from "lucide-react";
import {
Activity,
Link2,
Loader2,
RefreshCw,
Save,
Unlink,
} from "lucide-react";
import {
AlertDialog,
@@ -174,7 +181,7 @@ export function WhoopCard({
: null;
return (
<div className="bg-card border-border rounded-xl border p-6">
<div className="bg-card border-border rounded-xl border p-4 sm:p-6">
<SettingsCardHeader
icon={Activity}
title={t("settings.whoop")}
@@ -189,7 +189,7 @@ export function WithingsCard({
: null;
return (
<div className="bg-card border-border rounded-xl border p-6">
<div className="bg-card border-border rounded-xl border p-4 sm:p-6">
<SettingsCardHeader
icon={Link2}
title={t("settings.withings")}
@@ -107,7 +107,9 @@ export function MoodReminderCard({
: t("notifications.moodReminder.disabledToast"),
);
setMsgType("success");
await queryClient.invalidateQueries({ queryKey: queryKeys.userProfile() });
await queryClient.invalidateQueries({
queryKey: queryKeys.userProfile(),
});
setOptimistic(null);
scheduleClear();
} else {
@@ -155,7 +157,7 @@ export function MoodReminderCard({
return (
<section
aria-labelledby="settings-mood-reminder-title"
className="bg-card rounded-lg border p-4 sm:p-6"
className="bg-card border-border rounded-xl border p-4 sm:p-6"
>
<SettingsCardHeader
icon={SmilePlus}
@@ -180,10 +182,7 @@ export function MoodReminderCard({
/>
{enabled && (
<div className="mt-4 flex min-h-11 items-center gap-3 pl-7">
<label
htmlFor="mood-reminder-hour"
className="text-sm font-medium"
>
<label htmlFor="mood-reminder-hour" className="text-sm font-medium">
{t("notifications.moodReminder.hourLabel")}
</label>
<NativeSelect
@@ -32,6 +32,7 @@ import {
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { useAuth } from "@/hooks/use-auth";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { useFormatters, useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import { apiGet, apiPost } from "@/lib/api/api-fetch";
@@ -113,7 +114,7 @@ export function NotificationStatusCard() {
if (isLoading) {
return (
<div className="bg-card border-border rounded-xl border p-6">
<div className="bg-card border-border rounded-xl border p-4 sm:p-6">
<Loader2 className="text-muted-foreground h-4 w-4 animate-spin motion-reduce:animate-none" />
</div>
);
@@ -128,37 +129,30 @@ export function NotificationStatusCard() {
if (channels.length === 0) {
return (
<div
className="bg-card border-border rounded-xl border p-6"
className="bg-card border-border rounded-xl border p-4 sm:p-6"
data-testid="notification-status-empty"
>
<div className="flex items-center gap-2">
<Bell className="text-muted-foreground h-5 w-5" />
<h2 className="text-lg font-semibold">
{t("settings.notificationStatus.title")}
</h2>
</div>
<p className="text-muted-foreground mt-1 pl-7 text-sm">
{t("settings.notificationStatus.emptyDescription")}
</p>
<SettingsCardHeader
icon={Bell}
title={t("settings.notificationStatus.title")}
description={t("settings.notificationStatus.emptyDescription")}
/>
</div>
);
}
return (
<div className="bg-card border-border rounded-xl border p-6">
<header className="mb-4">
<div className="flex items-center gap-2">
<Bell className="text-muted-foreground h-5 w-5" />
<h2 className="text-lg font-semibold">
{t("settings.notificationStatus.title")}
</h2>
</div>
<p className="text-muted-foreground pl-7 text-xs">
{t("settings.notificationStatus.description")}
</p>
</header>
<div className="bg-card border-border rounded-xl border p-4 sm:p-6">
<SettingsCardHeader
icon={Bell}
title={t("settings.notificationStatus.title")}
description={t("settings.notificationStatus.description")}
/>
<ul className="space-y-3" data-testid="notification-status-list">
<ul
className="mt-4 space-y-3 pl-7"
data-testid="notification-status-list"
>
{channels.map((ch) => (
<ChannelRow
key={ch.id}
@@ -74,10 +74,7 @@ export function NotificationsSection() {
</ol>
</nav>
<header className="space-y-1">
<h1
id="settings-section-notifications-title"
className="sr-only"
>
<h1 id="settings-section-notifications-title" className="sr-only">
{t("settings.sections.notifications.title")}
</h1>
<p className="text-muted-foreground text-sm">
+1 -1
View File
@@ -82,7 +82,7 @@ export function NtfyCard({ isAuthenticated }: { isAuthenticated: boolean }) {
});
return (
<div className="bg-card border-border rounded-xl border p-6">
<div className="bg-card border-border rounded-xl border p-4 sm:p-6">
<SettingsCardHeader
icon={Bell}
title={t("settings.ntfy")}
@@ -70,10 +70,7 @@ export function SectionPlaceholder({ slug }: SectionPlaceholderProps) {
className="space-y-6"
>
<header className="space-y-1">
<h1
id={`settings-section-${slug}-title`}
className="sr-only"
>
<h1 id={`settings-section-${slug}-title`} className="sr-only">
{sectionTitle}
</h1>
<p className="text-muted-foreground text-sm">{sectionDescription}</p>
+19 -27
View File
@@ -42,6 +42,7 @@ import {
import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import {
@@ -285,10 +286,7 @@ export function SourcesSection() {
className="space-y-6"
>
<header className="space-y-1">
<h1
id="settings-section-sources-title"
className="sr-only"
>
<h1 id="settings-section-sources-title" className="sr-only">
{t("settings.sections.sources.title")}
</h1>
<p className="text-muted-foreground text-sm">
@@ -308,29 +306,23 @@ export function SourcesSection() {
</p>
</header>
<div className="bg-card border-border space-y-4 rounded-xl border p-6">
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-3">
<div className="flex items-center gap-2">
<Layers className="text-muted-foreground h-5 w-5" />
<h2 className="text-lg font-semibold">
{t("settings.sections.sources.cardTitle")}
</h2>
</div>
<Button
variant="ghost"
size="sm"
onClick={() => resetMutation.mutate()}
disabled={resetMutation.isPending || saveMutation.isPending}
className="self-end sm:self-auto"
>
<RotateCcw className="h-3.5 w-3.5" />
{t("settings.sections.sources.resetDefaults")}
</Button>
</div>
<p className="text-muted-foreground pl-7 text-xs">
{t("settings.sections.sources.help")}
</p>
<div className="bg-card border-border space-y-4 rounded-xl border p-4 sm:p-6">
<SettingsCardHeader
icon={Layers}
title={t("settings.sections.sources.cardTitle")}
description={t("settings.sections.sources.help")}
status={
<Button
variant="ghost"
size="sm"
onClick={() => resetMutation.mutate()}
disabled={resetMutation.isPending || saveMutation.isPending}
>
<RotateCcw className="h-3.5 w-3.5" />
{t("settings.sections.sources.resetDefaults")}
</Button>
}
/>
{isLoading || !priority ? (
<SourcesSkeletonList />
+1 -1
View File
@@ -87,7 +87,7 @@ export function TelegramCard({
}
return (
<div className="bg-card border-border rounded-xl border p-6">
<div className="bg-card border-border rounded-xl border p-4 sm:p-6">
<SettingsCardHeader
icon={MessageCircle}
title={t("settings.telegram")}
@@ -117,7 +117,7 @@ export function ThresholdsEditorSection({ id }: { id: string }) {
return (
<div
id={id}
className="bg-card border-border scroll-mt-28 space-y-4 rounded-xl border p-6"
className="bg-card border-border scroll-mt-28 space-y-4 rounded-xl border p-4 sm:p-6"
>
{/* v1.4.19 A8 / F-07: page header `settings.sections.thresholds.*`
already provides the title + description for this route, so the
@@ -39,10 +39,7 @@ export function ThresholdsSection() {
className="space-y-6"
>
<header className="space-y-1">
<h1
id="settings-section-thresholds-title"
className="sr-only"
>
<h1 id="settings-section-thresholds-title" className="sr-only">
{t("settings.sections.thresholds.title")}
</h1>
<p className="text-muted-foreground text-sm">
@@ -63,8 +63,7 @@ export function TimeFormatSelect({
}, 3000);
}
const value: TimeFormatPreference =
optimistic ?? user?.timeFormat ?? "AUTO";
const value: TimeFormatPreference = optimistic ?? user?.timeFormat ?? "AUTO";
const mutation = useMutation({
mutationFn: async (next: TimeFormatPreference) => {
@@ -94,9 +93,7 @@ export function TimeFormatSelect({
onError: (err) => {
setOptimistic(null);
setMsg(
err instanceof Error
? err.message
: t("settings.timeFormat.saveError"),
err instanceof Error ? err.message : t("settings.timeFormat.saveError"),
);
setMsgType("error");
scheduleClear();
+1 -1
View File
@@ -200,7 +200,7 @@ export function WebPushCard() {
);
return (
<div className="bg-card border-border rounded-xl border p-6">
<div className="bg-card border-border rounded-xl border p-4 sm:p-6">
<SettingsCardHeader
icon={BellRing}
title={t("settings.webPush")}