mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-14 22:50:51 +00:00
The two consoles used the same primitives and still read as two systems, because everything without a primitive had drifted: the header-to-body step existed in three competing techniques at once (`mt-4` on the first body child, `space-y-4` on the card, `mb-4` on the header), the body started either at the card edge or 28 px in, and both shells hand-rolled the page title instead of calling `PageHeader`. Move the decisions into the primitives so a call site cannot re-open them: - `SettingsCard` is gap-based like `Card` (`flex flex-col gap-4`). The header-to-body step is the container's, not a margin somebody remembers. A dense card still overrides the gap on the card itself. - The body starts at the card edge. `pl-7` is gone — off the spacing scale, and it mis-aligned action rows against their own forms wherever two neighbouring cards disagreed about it. - The card header's description slot loses its `space-y-1`. That was the licence call sites used to stack explainer paragraphs into a muted `text-xs` slot; the slot holds one sentence. - New `SettingsCardActions`: one action row, trailing edge, last thing in the card. `align="start"` spells the one exception, a card whose only actions are links out. - New `StatTile` replaces four hand-rolled label/value boxes in Admin. - `SectionHeading` moves from `insights/` to `ui/`. It is the heading above a group of cards, which is what Settings needs; Settings had been using a card header with no card, which reads as a card that failed to paint. Both shells now render their heading through `PageHeader`, which gained `headingAs="div"` for the second copy each shell paints (mobile above the nav strip, desktop inside the grid) so only one is a real `<h1>`. The page explainer is muted everywhere: the primitive said foreground, the standards and every hand-rolled copy said muted, so pages disagreed with each other. Medications, Vorsorge, the labs / custom-metric / illness detail pages and the clinician view drop their hand-rolled title blocks too, including the mid-word truncation on the medications subtitle. Tests pin the parts that can be checked exactly: the card's gap, the description slot's missing rhythm, the action row's two alignments, the `<h1>`-free shells, and a sweep asserting `pl-7` stays gone.
344 lines
11 KiB
TypeScript
344 lines
11 KiB
TypeScript
"use client";
|
|
|
|
/**
|
|
* v1.15.20 — Settings → Notifications opt-out for the proactive Coach
|
|
* nudge (the daily 05:15 cron that points the user at /coach
|
|
* when a deterministic trigger fires). Default ON; the toggle writes
|
|
* `notificationPrefs.coach.nudgesEnabled` through the roaming
|
|
* notification-prefs blob so web + iOS share one source of truth.
|
|
*
|
|
* v1.16.5 — per-group toggles (medication / vitals / routine) plus the
|
|
* frequency pref (weekly / biweekly) underneath the master switch. All
|
|
* of it rides the same `coach` sub-object of the prefs blob; the cron
|
|
* resolves it via `resolveCoachNudgePrefs`.
|
|
*
|
|
* Mirrors `<MoodReminderCard>` (optimistic switch, auto-clearing inline
|
|
* status line, NativeSelect for the enum pref).
|
|
*/
|
|
import { useEffect, useRef, useState } from "react";
|
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
|
import { MessageCircleHeart } from "lucide-react";
|
|
|
|
import { Switch } from "@/components/ui/switch";
|
|
import { NativeSelect } from "@/components/ui/native-select";
|
|
import { SettingsCard } from "@/components/settings/settings-card";
|
|
import { SettingsCardHeader } from "@/components/settings/_card-header";
|
|
import { useTranslations } from "@/lib/i18n/context";
|
|
import { queryKeys } from "@/lib/query-keys";
|
|
import { apiFetchRaw, apiGet } from "@/lib/api/api-fetch";
|
|
import { readUpdatedAtToken, withBaseToken } from "@/lib/api/optimistic-token";
|
|
|
|
interface CoachPrefsShape {
|
|
nudgesEnabled: boolean;
|
|
nudgeMedication: boolean;
|
|
nudgeVitals: boolean;
|
|
nudgeRoutine: boolean;
|
|
nudgeFrequency: "weekly" | "biweekly";
|
|
ambientSuggestions: boolean;
|
|
nudgeAiComposed: boolean;
|
|
}
|
|
|
|
interface NotificationPrefsShape {
|
|
coach: CoachPrefsShape;
|
|
// v1.32.22 (R5b) — optimistic-concurrency token, a sibling key on the
|
|
// resolved prefs response, echoed on every PATCH from this card.
|
|
updatedAt?: string;
|
|
}
|
|
|
|
const COACH_PREF_DEFAULTS: CoachPrefsShape = {
|
|
nudgesEnabled: true,
|
|
nudgeMedication: true,
|
|
nudgeVitals: true,
|
|
nudgeRoutine: true,
|
|
nudgeFrequency: "weekly",
|
|
ambientSuggestions: true,
|
|
nudgeAiComposed: false,
|
|
};
|
|
|
|
const GROUP_FIELDS = [
|
|
"nudgeMedication",
|
|
"nudgeVitals",
|
|
"nudgeRoutine",
|
|
] as const;
|
|
type GroupField = (typeof GROUP_FIELDS)[number];
|
|
|
|
const GROUP_I18N: Record<GroupField, { label: string; desc: string }> = {
|
|
nudgeMedication: {
|
|
label: "notifications.coachNudge.groupMedication",
|
|
desc: "notifications.coachNudge.groupMedicationDesc",
|
|
},
|
|
nudgeVitals: {
|
|
label: "notifications.coachNudge.groupVitals",
|
|
desc: "notifications.coachNudge.groupVitalsDesc",
|
|
},
|
|
nudgeRoutine: {
|
|
label: "notifications.coachNudge.groupRoutine",
|
|
desc: "notifications.coachNudge.groupRoutineDesc",
|
|
},
|
|
};
|
|
|
|
export function CoachNudgeCard({
|
|
isAuthenticated,
|
|
}: {
|
|
isAuthenticated: boolean;
|
|
}) {
|
|
const { t } = useTranslations();
|
|
const queryClient = useQueryClient();
|
|
const [optimistic, setOptimistic] = useState<Partial<CoachPrefsShape>>({});
|
|
const [saving, setSaving] = useState(false);
|
|
const [msg, setMsg] = useState<string | null>(null);
|
|
const [msgType, setMsgType] = useState<"success" | "error" | null>(null);
|
|
|
|
const clearTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
|
|
useEffect(() => {
|
|
return () => {
|
|
if (clearTimerRef.current !== null) {
|
|
clearTimeout(clearTimerRef.current);
|
|
clearTimerRef.current = null;
|
|
}
|
|
};
|
|
}, []);
|
|
|
|
function scheduleClear() {
|
|
if (clearTimerRef.current !== null) {
|
|
clearTimeout(clearTimerRef.current);
|
|
}
|
|
clearTimerRef.current = setTimeout(() => {
|
|
clearTimerRef.current = null;
|
|
setMsg(null);
|
|
setMsgType(null);
|
|
}, 3000);
|
|
}
|
|
|
|
const { data: prefs } = useQuery({
|
|
queryKey: queryKeys.authNotificationPrefs(),
|
|
queryFn: async () => {
|
|
return apiGet<NotificationPrefsShape>("/api/auth/me/notification-prefs");
|
|
},
|
|
enabled: isAuthenticated,
|
|
});
|
|
|
|
const resolved: CoachPrefsShape = {
|
|
...COACH_PREF_DEFAULTS,
|
|
...(prefs?.coach ?? {}),
|
|
...optimistic,
|
|
};
|
|
const enabled = resolved.nudgesEnabled;
|
|
|
|
/**
|
|
* One PATCH path for every knob on the card. `toast` carries the
|
|
* success line for the master toggle; the sub-prefs pass null and
|
|
* stay silent on success — the control itself shows the new state.
|
|
*/
|
|
async function patchCoach(
|
|
partial: Partial<CoachPrefsShape>,
|
|
toast: string | null,
|
|
) {
|
|
setOptimistic((prev) => ({ ...prev, ...partial }));
|
|
setSaving(true);
|
|
setMsg(null);
|
|
setMsgType(null);
|
|
if (clearTimerRef.current !== null) {
|
|
clearTimeout(clearTimerRef.current);
|
|
clearTimerRef.current = null;
|
|
}
|
|
|
|
// v1.32.22 (R5b) — echo the base token so a concurrent notification-prefs
|
|
// write (the iOS client, another web card) 409s instead of reverting a
|
|
// sibling category through the server-side deep-merge.
|
|
const res = await apiFetchRaw("/api/auth/me/notification-prefs", {
|
|
method: "PATCH",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify(
|
|
withBaseToken(
|
|
{ coach: partial },
|
|
readUpdatedAtToken(queryClient, queryKeys.authNotificationPrefs()),
|
|
),
|
|
),
|
|
});
|
|
|
|
if (res.ok) {
|
|
if (toast) {
|
|
setMsg(toast);
|
|
setMsgType("success");
|
|
scheduleClear();
|
|
}
|
|
await queryClient.invalidateQueries({
|
|
queryKey: queryKeys.authNotificationPrefs(),
|
|
});
|
|
setOptimistic({});
|
|
} else if (res.status === 409) {
|
|
// The prefs advanced since they were loaded. Revert the optimistic knob
|
|
// and refetch (the active query reseeds a fresh token), then nudge — the
|
|
// change did not persist, so ask the user to re-apply it.
|
|
setOptimistic({});
|
|
await queryClient.invalidateQueries({
|
|
queryKey: queryKeys.authNotificationPrefs(),
|
|
});
|
|
setMsg(t("common.conflictReloaded"));
|
|
setMsgType("error");
|
|
scheduleClear();
|
|
} else {
|
|
setOptimistic({});
|
|
setMsg(t("notifications.coachNudge.saveError"));
|
|
setMsgType("error");
|
|
scheduleClear();
|
|
}
|
|
setSaving(false);
|
|
}
|
|
|
|
async function handleToggle(next: boolean) {
|
|
await patchCoach(
|
|
{ nudgesEnabled: next },
|
|
next
|
|
? t("notifications.coachNudge.enabledToast")
|
|
: t("notifications.coachNudge.disabledToast"),
|
|
);
|
|
}
|
|
|
|
return (
|
|
<SettingsCard
|
|
as="section"
|
|
aria-labelledby="settings-coach-nudge-title"
|
|
data-testid="settings-coach-nudge-card"
|
|
>
|
|
<SettingsCardHeader
|
|
icon={MessageCircleHeart}
|
|
title={t("notifications.coachNudge.title")}
|
|
titleId="settings-coach-nudge-title"
|
|
description={t("notifications.coachNudge.description")}
|
|
status={
|
|
<label className="flex min-h-11 items-center gap-3">
|
|
<Switch
|
|
checked={enabled}
|
|
onCheckedChange={handleToggle}
|
|
disabled={!isAuthenticated || saving}
|
|
aria-label={t("notifications.coachNudge.toggleAria")}
|
|
/>
|
|
<span className="text-muted-foreground text-xs">
|
|
{enabled
|
|
? t("notifications.coachNudge.statusOn")
|
|
: t("notifications.coachNudge.statusOff")}
|
|
</span>
|
|
</label>
|
|
}
|
|
/>
|
|
{enabled && (
|
|
<div className="space-y-3">
|
|
<p className="text-sm font-medium">
|
|
{t("notifications.coachNudge.groupsLabel")}
|
|
</p>
|
|
{GROUP_FIELDS.map((field) => (
|
|
<div
|
|
key={field}
|
|
className="flex min-h-11 items-center justify-between gap-3"
|
|
data-testid={`coach-nudge-group-${field}`}
|
|
>
|
|
<div className="min-w-0">
|
|
<p className="text-sm">{t(GROUP_I18N[field].label)}</p>
|
|
<p className="text-muted-foreground text-xs">
|
|
{t(GROUP_I18N[field].desc)}
|
|
</p>
|
|
</div>
|
|
<Switch
|
|
checked={resolved[field]}
|
|
onCheckedChange={(next) => patchCoach({ [field]: next }, null)}
|
|
disabled={!isAuthenticated || saving}
|
|
aria-label={t(GROUP_I18N[field].label)}
|
|
/>
|
|
</div>
|
|
))}
|
|
<div className="flex min-h-11 items-center gap-3">
|
|
<label
|
|
htmlFor="coach-nudge-frequency"
|
|
className="text-sm font-medium"
|
|
>
|
|
{t("notifications.coachNudge.frequencyLabel")}
|
|
</label>
|
|
<NativeSelect
|
|
id="coach-nudge-frequency"
|
|
className="w-auto"
|
|
value={resolved.nudgeFrequency}
|
|
disabled={!isAuthenticated || saving}
|
|
onChange={(e) =>
|
|
patchCoach(
|
|
{
|
|
nudgeFrequency: e.target.value as "weekly" | "biweekly",
|
|
},
|
|
null,
|
|
)
|
|
}
|
|
aria-label={t("notifications.coachNudge.frequencyAria")}
|
|
>
|
|
<option value="weekly">
|
|
{t("notifications.coachNudge.frequencyWeekly")}
|
|
</option>
|
|
<option value="biweekly">
|
|
{t("notifications.coachNudge.frequencyBiweekly")}
|
|
</option>
|
|
</NativeSelect>
|
|
</div>
|
|
{/* v1.25.0 — opt-in for AI-composed nudge copy. Off by default: the
|
|
deterministic warm template is the baseline and the fail-closed
|
|
fallback; this only lets the model phrase the nudge when a
|
|
provider is healthy. */}
|
|
<div className="flex min-h-11 items-center justify-between gap-3">
|
|
<div className="min-w-0">
|
|
<p className="text-sm">
|
|
{t("notifications.coachNudge.aiComposedLabel")}
|
|
</p>
|
|
<p className="text-muted-foreground text-xs">
|
|
{t("notifications.coachNudge.aiComposedDesc")}
|
|
</p>
|
|
</div>
|
|
<Switch
|
|
checked={resolved.nudgeAiComposed}
|
|
onCheckedChange={(next) =>
|
|
patchCoach({ nudgeAiComposed: next }, null)
|
|
}
|
|
disabled={!isAuthenticated || saving}
|
|
aria-label={t("notifications.coachNudge.aiComposedAria")}
|
|
/>
|
|
</div>
|
|
</div>
|
|
)}
|
|
{/* v1.25.0 — proactive ambient SUGGESTIONS opt-out. Independent of the
|
|
push-nudge master switch above (a user can keep nudges but silence
|
|
the example prompts), so it sits outside the `enabled` gate and rides
|
|
the same `coach` prefs blob. */}
|
|
<div className="border-border/60 flex min-h-11 items-center justify-between gap-3 border-t pt-4">
|
|
<div className="min-w-0">
|
|
<p className="text-sm">
|
|
{t("notifications.coachNudge.suggestionsLabel")}
|
|
</p>
|
|
<p className="text-muted-foreground text-xs">
|
|
{t("notifications.coachNudge.suggestionsDesc")}
|
|
</p>
|
|
</div>
|
|
<Switch
|
|
checked={resolved.ambientSuggestions}
|
|
onCheckedChange={(next) =>
|
|
patchCoach({ ambientSuggestions: next }, null)
|
|
}
|
|
disabled={!isAuthenticated || saving}
|
|
aria-label={t("notifications.coachNudge.suggestionsAria")}
|
|
/>
|
|
</div>
|
|
{msg && (
|
|
<p
|
|
role="status"
|
|
aria-live="polite"
|
|
className={
|
|
msgType === "error"
|
|
? "text-destructive mt-3 text-sm"
|
|
: "text-muted-foreground mt-3 text-sm"
|
|
}
|
|
>
|
|
{msg}
|
|
</p>
|
|
)}
|
|
</SettingsCard>
|
|
);
|
|
}
|