mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-13 22:32:04 +00:00
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:
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/,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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")}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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")}
|
||||
|
||||
@@ -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")}
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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")}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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")}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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")}
|
||||
|
||||
Reference in new issue
Block a user