mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-14 22:50:51 +00:00
chore(release): v1.4.47 — audit-backlog closure + drag-to-reorder + Coach disable + OAuth nonce ledger
Eight changes: W0 idempotency test pin, W1 legacy column drop, W2 Textarea primitive, W3 Coach disable toggle, W4 dnd-kit drag-to-reorder, W5 onboarding 24h gate, W6 Withings OAuth nonce ledger, W7+W8 legacy_form_total drop + Coach onLine pre-check. 6-axis QA reconciled. Migrations 0076/0077/0078 required pre-image. Tests 5093 → ~5170.
This commit is contained in:
1 parent
ac319c6160
commit
66a7f8b6fa
68 files changed
+3959
-715
No files matched your search
@@ -28,6 +28,7 @@ import { EmptyState } from "@/components/ui/empty-state";
|
||||
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { formatDateTime } from "@/lib/format";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
@@ -86,7 +87,7 @@ export function FeedbackInboxSection() {
|
||||
instead of squashing the badge text below the legibility
|
||||
floor. Hide the scrollbar visually but keep the gesture
|
||||
working (matches the Insights tab-strip pattern). */}
|
||||
<div className="-mx-1 overflow-x-auto px-1 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
|
||||
<div className="-mx-1 [scrollbar-width:none] overflow-x-auto px-1 [&::-webkit-scrollbar]:hidden">
|
||||
<TabsList>
|
||||
{FEEDBACK_STATUS_TABS.map((s) => (
|
||||
<TabsTrigger key={s} value={s}>
|
||||
@@ -428,100 +429,96 @@ function FeedbackDetailDialog({
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="space-y-4 text-sm">
|
||||
<div className="bg-muted/40 rounded-md p-3 whitespace-pre-wrap">
|
||||
{item.description}
|
||||
</div>
|
||||
|
||||
{(url || locale || userAgent || appVersion) && (
|
||||
<div>
|
||||
{/* v1.4.33 IW9 — h4 -> h3 so the sheet outline stays
|
||||
sequential beneath the ResponsiveSheet's h2 title. */}
|
||||
<h3 className="text-muted-foreground text-xs font-semibold tracking-wider uppercase">
|
||||
{t("admin.feedback.metadataHeading")}
|
||||
</h3>
|
||||
<dl className="mt-2 grid grid-cols-[max-content_1fr] gap-x-3 gap-y-1 text-xs">
|
||||
{url && (
|
||||
<>
|
||||
<dt className="text-muted-foreground">
|
||||
{t("admin.feedback.metaUrl")}
|
||||
</dt>
|
||||
<dd className="font-mono break-all">{url}</dd>
|
||||
</>
|
||||
)}
|
||||
{locale && (
|
||||
<>
|
||||
<dt className="text-muted-foreground">
|
||||
{t("admin.feedback.metaLocale")}
|
||||
</dt>
|
||||
<dd className="font-mono">{locale}</dd>
|
||||
</>
|
||||
)}
|
||||
{appVersion && (
|
||||
<>
|
||||
<dt className="text-muted-foreground">
|
||||
{t("admin.feedback.metaAppVersion")}
|
||||
</dt>
|
||||
<dd className="font-mono">{appVersion}</dd>
|
||||
</>
|
||||
)}
|
||||
{userAgent && (
|
||||
<>
|
||||
<dt className="text-muted-foreground">
|
||||
{t("admin.feedback.metaUserAgent")}
|
||||
</dt>
|
||||
<dd className="font-mono break-all">{userAgent}</dd>
|
||||
</>
|
||||
)}
|
||||
</dl>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{item.screenshotBase64 && (
|
||||
<div>
|
||||
<h3 className="text-muted-foreground mb-2 text-xs font-semibold tracking-wider uppercase">
|
||||
{t("admin.feedback.screenshotHeading")}
|
||||
</h3>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={item.screenshotBase64}
|
||||
alt="Screenshot"
|
||||
className="border-border max-h-72 rounded-md border"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="admin-feedback-note" className="text-xs">
|
||||
{t("admin.feedback.adminNote")}
|
||||
</Label>
|
||||
<textarea
|
||||
id="admin-feedback-note"
|
||||
value={note}
|
||||
onChange={(e) => setNote(e.target.value)}
|
||||
rows={3}
|
||||
maxLength={5000}
|
||||
placeholder={t("admin.feedback.adminNotePlaceholder")}
|
||||
// text-base on mobile so iOS Safari doesn't zoom on
|
||||
// focus; text-sm on sm+ keeps the compact admin look.
|
||||
className="border-input bg-background text-foreground placeholder:text-muted-foreground focus-visible:ring-ring w-full rounded-md border px-3 py-2 text-base focus-visible:ring-2 focus-visible:outline-none sm:text-sm"
|
||||
/>
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={update.isPending || note === (item.adminNote ?? "")}
|
||||
onClick={() => update.mutate({ adminNote: note || null })}
|
||||
>
|
||||
{update.isPending && (
|
||||
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
|
||||
)}
|
||||
{t("admin.feedback.saveNote")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4 text-sm">
|
||||
<div className="bg-muted/40 rounded-md p-3 whitespace-pre-wrap">
|
||||
{item.description}
|
||||
</div>
|
||||
|
||||
{(url || locale || userAgent || appVersion) && (
|
||||
<div>
|
||||
{/* v1.4.33 IW9 — h4 -> h3 so the sheet outline stays
|
||||
sequential beneath the ResponsiveSheet's h2 title. */}
|
||||
<h3 className="text-muted-foreground text-xs font-semibold tracking-wider uppercase">
|
||||
{t("admin.feedback.metadataHeading")}
|
||||
</h3>
|
||||
<dl className="mt-2 grid grid-cols-[max-content_1fr] gap-x-3 gap-y-1 text-xs">
|
||||
{url && (
|
||||
<>
|
||||
<dt className="text-muted-foreground">
|
||||
{t("admin.feedback.metaUrl")}
|
||||
</dt>
|
||||
<dd className="font-mono break-all">{url}</dd>
|
||||
</>
|
||||
)}
|
||||
{locale && (
|
||||
<>
|
||||
<dt className="text-muted-foreground">
|
||||
{t("admin.feedback.metaLocale")}
|
||||
</dt>
|
||||
<dd className="font-mono">{locale}</dd>
|
||||
</>
|
||||
)}
|
||||
{appVersion && (
|
||||
<>
|
||||
<dt className="text-muted-foreground">
|
||||
{t("admin.feedback.metaAppVersion")}
|
||||
</dt>
|
||||
<dd className="font-mono">{appVersion}</dd>
|
||||
</>
|
||||
)}
|
||||
{userAgent && (
|
||||
<>
|
||||
<dt className="text-muted-foreground">
|
||||
{t("admin.feedback.metaUserAgent")}
|
||||
</dt>
|
||||
<dd className="font-mono break-all">{userAgent}</dd>
|
||||
</>
|
||||
)}
|
||||
</dl>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{item.screenshotBase64 && (
|
||||
<div>
|
||||
<h3 className="text-muted-foreground mb-2 text-xs font-semibold tracking-wider uppercase">
|
||||
{t("admin.feedback.screenshotHeading")}
|
||||
</h3>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={item.screenshotBase64}
|
||||
alt="Screenshot"
|
||||
className="border-border max-h-72 rounded-md border"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="admin-feedback-note" className="text-xs">
|
||||
{t("admin.feedback.adminNote")}
|
||||
</Label>
|
||||
<Textarea
|
||||
id="admin-feedback-note"
|
||||
value={note}
|
||||
onChange={(e) => setNote(e.target.value)}
|
||||
rows={3}
|
||||
maxLength={5000}
|
||||
placeholder={t("admin.feedback.adminNotePlaceholder")}
|
||||
/>
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={update.isPending || note === (item.adminNote ?? "")}
|
||||
onClick={() => update.mutate({ adminNote: note || null })}
|
||||
>
|
||||
{update.isPending && (
|
||||
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
|
||||
)}
|
||||
{t("admin.feedback.saveNote")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ResponsiveSheet>
|
||||
);
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import { cn } from "@/lib/utils";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { useCoachLaunch } from "@/lib/insights/coach-launch-context";
|
||||
import { useFeatureFlags } from "@/hooks/use-feature-flags";
|
||||
import { useDisableCoach } from "@/hooks/use-disable-coach";
|
||||
|
||||
/**
|
||||
* Inline desktop Coach launch button.
|
||||
@@ -42,6 +43,7 @@ export function CoachLaunchButton({
|
||||
const { t } = useTranslations();
|
||||
const launch = useCoachLaunch();
|
||||
const flags = useFeatureFlags();
|
||||
const disableCoach = useDisableCoach();
|
||||
|
||||
if (!launch) {
|
||||
// The button only makes sense beneath the provider. Render nothing
|
||||
@@ -50,6 +52,10 @@ export function CoachLaunchButton({
|
||||
}
|
||||
// v1.4.31 — operator can hide the Coach surface app-wide.
|
||||
if (!flags.coach) return null;
|
||||
// v1.4.47 W3 — per-user opt-out is a peer gate to the operator's
|
||||
// flag; either being off hides the pill entirely. See
|
||||
// `<LayoutCoachFab>` for the matching FAB gate.
|
||||
if (disableCoach) return null;
|
||||
|
||||
const accessibleLabel = label ?? t("insights.heroActionAskCoach");
|
||||
|
||||
@@ -64,10 +70,7 @@ export function CoachLaunchButton({
|
||||
size="sm"
|
||||
data-slot="coach-launch-inline"
|
||||
onClick={() => launch.askCoach(prefill ?? null)}
|
||||
className={cn(
|
||||
"hidden h-10 gap-2 self-end lg:inline-flex",
|
||||
className,
|
||||
)}
|
||||
className={cn("hidden h-10 gap-2 self-end lg:inline-flex", className)}
|
||||
>
|
||||
<Sparkles className="size-4" aria-hidden="true" />
|
||||
<span>{accessibleLabel}</span>
|
||||
|
||||
@@ -327,6 +327,20 @@ export function useSendCoachMessage(opts: UseSendCoachMessageOptions = {}) {
|
||||
errorCode: null,
|
||||
});
|
||||
|
||||
// v1.4.47 W8 — pre-check navigator.onLine so an airplane-mode
|
||||
// user gets the offline-specific copy immediately rather than
|
||||
// waiting for the fetch to fail with a generic network error.
|
||||
if (typeof navigator !== "undefined" && navigator.onLine === false) {
|
||||
setStreaming({
|
||||
content: "",
|
||||
metricSource: null,
|
||||
inProgress: false,
|
||||
messageId: null,
|
||||
errorCode: "coach.network",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch("/api/insights/chat", {
|
||||
|
||||
@@ -5,6 +5,7 @@ import { Button } from "@/components/ui/button";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { formatRelativeTime } from "@/lib/i18n/relative-time";
|
||||
import { useFeatureFlags } from "@/hooks/use-feature-flags";
|
||||
import { useDisableCoach } from "@/hooks/use-disable-coach";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { SuggestedPrompts } from "./suggested-prompts";
|
||||
import {
|
||||
@@ -126,7 +127,14 @@ export function HeroStrip({
|
||||
// open the Coach drawer, so leaving them visible while the drawer
|
||||
// mount is suppressed would surface dead controls.
|
||||
const flags = useFeatureFlags();
|
||||
const coachEnabled = flags.coach;
|
||||
// v1.4.47 W3 — per-user "Hide Coach" toggle joins the operator's
|
||||
// master flag. Either-off resolves to `coachEnabled === false` so
|
||||
// the hero action button + suggested-prompts strip + the
|
||||
// `<HealthScoreCard onAskCoach />` prop all vanish together. The
|
||||
// hook is SSR-safe — returns `false` when the legacy snapshot
|
||||
// tests render the strip without a QueryClient.
|
||||
const disableCoach = useDisableCoach();
|
||||
const coachEnabled = flags.coach && !disableCoach;
|
||||
const greetingKey = resolveGreetingKey(now ?? new Date());
|
||||
const greetingBase = t(greetingKey);
|
||||
const greeting = userName ? `${greetingBase}, ${userName}` : greetingBase;
|
||||
|
||||
@@ -7,6 +7,7 @@ import { cn } from "@/lib/utils";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { useCoachLaunch } from "@/lib/insights/coach-launch-context";
|
||||
import { useFeatureFlags } from "@/hooks/use-feature-flags";
|
||||
import { useDisableCoach } from "@/hooks/use-disable-coach";
|
||||
|
||||
import { useChartTooltipActive } from "./use-chart-tooltip-active";
|
||||
|
||||
@@ -37,11 +38,18 @@ export function LayoutCoachFab() {
|
||||
const { t } = useTranslations();
|
||||
const launch = useCoachLaunch();
|
||||
const flags = useFeatureFlags();
|
||||
const disableCoach = useDisableCoach();
|
||||
const tooltipActive = useChartTooltipActive();
|
||||
if (!launch) return null;
|
||||
// v1.4.31 — operator can hide the Coach surface app-wide. The
|
||||
// FAB returns null when the master OR the coach sub-flag is off.
|
||||
if (!flags.coach) return null;
|
||||
// v1.4.47 W3 — per-user opt-out beats the operator's "on" default.
|
||||
// A privacy-leaning user who toggles "Hide Coach" in Settings → AI
|
||||
// gets the FAB short-circuited here, identical to the global-off
|
||||
// branch above. The hook defaults to `false` in the absence of a
|
||||
// QueryClient (legacy SSR-only snapshot tests).
|
||||
if (disableCoach) return null;
|
||||
|
||||
const accessibleLabel = t("insights.heroActionAskCoach");
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import dynamic from "next/dynamic";
|
||||
|
||||
import { useCoachLaunch } from "@/lib/insights/coach-launch-context";
|
||||
import { useFeatureFlags } from "@/hooks/use-feature-flags";
|
||||
import { useDisableCoach } from "@/hooks/use-disable-coach";
|
||||
|
||||
/**
|
||||
* v1.4.31 — defer the Coach drawer subtree behind `next/dynamic` so
|
||||
@@ -36,10 +37,16 @@ const CoachDrawer = dynamic(
|
||||
export function LayoutCoachMount() {
|
||||
const launch = useCoachLaunch();
|
||||
const flags = useFeatureFlags();
|
||||
const disableCoach = useDisableCoach();
|
||||
if (!launch) return null;
|
||||
// v1.4.31 — operator can hide the Coach drawer app-wide; suppress
|
||||
// the entire SSE mount when the flag is off.
|
||||
if (!flags.coach) return null;
|
||||
// v1.4.47 W3 — per-user opt-out short-circuits the drawer subtree
|
||||
// the same way the operator's master flag does. Keeps the SSE
|
||||
// chat reader + portal scaffolding out of the bundle for users
|
||||
// who never want the Coach mounted.
|
||||
if (disableCoach) return null;
|
||||
return (
|
||||
<CoachDrawer
|
||||
open={launch.open}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { Quote } from "lucide-react";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { useFeatureFlags } from "@/hooks/use-feature-flags";
|
||||
import { useDisableCoach } from "@/hooks/use-disable-coach";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/**
|
||||
@@ -56,7 +57,12 @@ export function SuggestedPrompts({
|
||||
// defence-in-depth so a future caller that mounts <SuggestedPrompts>
|
||||
// outside the hero band can never leak a Coach surface.
|
||||
const flags = useFeatureFlags();
|
||||
const disableCoach = useDisableCoach();
|
||||
if (!flags.coach) return null;
|
||||
// v1.4.47 W3 — per-user opt-out hides every Coach affordance,
|
||||
// including the suggested-prompts chip strip. Same posture as the
|
||||
// FAB / drawer / inline pill gate above.
|
||||
if (disableCoach) return null;
|
||||
const items =
|
||||
prompts ??
|
||||
DEFAULT_PROMPT_KEYS.map((key) => t(`insights.suggestedPrompts.${key}`));
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { useFormatters, useTranslations } from "@/lib/i18n/context";
|
||||
import type {
|
||||
MedicationSideEffectCategory,
|
||||
@@ -144,14 +145,11 @@ export function SideEffectsSection({ medicationId }: SideEffectsSectionProps) {
|
||||
// from the wire payload. The server derives it from the entry
|
||||
// via `categoryForEntry`. Backwards-compatible with older
|
||||
// clients during the cut window (route accepts both shapes).
|
||||
const res = await fetch(
|
||||
`/api/medications/${medicationId}/side-effects`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
},
|
||||
);
|
||||
const res = await fetch(`/api/medications/${medicationId}/side-effects`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const body = (await res.json().catch(() => ({}))) as {
|
||||
error?: string;
|
||||
@@ -255,7 +253,7 @@ export function SideEffectsSection({ medicationId }: SideEffectsSectionProps) {
|
||||
bodyPaddingY="py-2.5"
|
||||
>
|
||||
{isLoading && (
|
||||
<div className="flex items-center gap-2 text-muted-foreground">
|
||||
<div className="text-muted-foreground flex items-center gap-2">
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
|
||||
<span>{t("medications.sideEffects.loading")}</span>
|
||||
</div>
|
||||
@@ -269,7 +267,7 @@ export function SideEffectsSection({ medicationId }: SideEffectsSectionProps) {
|
||||
|
||||
{!isLoading && items.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<p className="text-muted-foreground text-xs uppercase tracking-wide">
|
||||
<p className="text-muted-foreground text-xs tracking-wide uppercase">
|
||||
{t("medications.sideEffects.recentTitle")}
|
||||
</p>
|
||||
<ul className="space-y-1.5">
|
||||
@@ -333,9 +331,7 @@ export function SideEffectsSection({ medicationId }: SideEffectsSectionProps) {
|
||||
onClick={() => {
|
||||
if (
|
||||
typeof window === "undefined" ||
|
||||
window.confirm(
|
||||
t("medications.sideEffects.deleteConfirm"),
|
||||
)
|
||||
window.confirm(t("medications.sideEffects.deleteConfirm"))
|
||||
) {
|
||||
deleteMutation.mutate(row.id);
|
||||
}
|
||||
@@ -356,165 +352,161 @@ export function SideEffectsSection({ medicationId }: SideEffectsSectionProps) {
|
||||
title={t("medications.sideEffects.sheetTitle")}
|
||||
footer={<div ref={setFooterEl} className="flex w-full" />}
|
||||
>
|
||||
<form
|
||||
id={formId}
|
||||
onSubmit={handleSubmit}
|
||||
className="space-y-4"
|
||||
aria-label={t("medications.sideEffects.sheetTitle")}
|
||||
>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="side-effect-category">
|
||||
{t("medications.sideEffects.categoryLabel")}
|
||||
</Label>
|
||||
<Select
|
||||
value={category}
|
||||
onValueChange={(next) => {
|
||||
setCategory(next as MedicationSideEffectCategory);
|
||||
// Reset entry — the previously-selected entry probably
|
||||
// doesn't belong to the new category.
|
||||
setEntry(null);
|
||||
}}
|
||||
<form
|
||||
id={formId}
|
||||
onSubmit={handleSubmit}
|
||||
className="space-y-4"
|
||||
aria-label={t("medications.sideEffects.sheetTitle")}
|
||||
>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="side-effect-category">
|
||||
{t("medications.sideEffects.categoryLabel")}
|
||||
</Label>
|
||||
<Select
|
||||
value={category}
|
||||
onValueChange={(next) => {
|
||||
setCategory(next as MedicationSideEffectCategory);
|
||||
// Reset entry — the previously-selected entry probably
|
||||
// doesn't belong to the new category.
|
||||
setEntry(null);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger
|
||||
id="side-effect-category"
|
||||
className="w-full"
|
||||
data-slot="side-effect-category-trigger"
|
||||
>
|
||||
<SelectTrigger
|
||||
id="side-effect-category"
|
||||
className="w-full"
|
||||
data-slot="side-effect-category-trigger"
|
||||
>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{SIDE_EFFECT_CATEGORY_ORDER.map((c) => (
|
||||
<SelectItem key={c} value={c}>
|
||||
{t(
|
||||
`medications.sideEffects.categories.${categoryI18nKey(c)}`,
|
||||
)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label>{t("medications.sideEffects.entryLabel")}</Label>
|
||||
<div
|
||||
className="flex flex-wrap gap-1.5"
|
||||
role="radiogroup"
|
||||
aria-label={t("medications.sideEffects.entryLabel")}
|
||||
>
|
||||
{filteredEntries.map((candidate) => {
|
||||
const selected = candidate === entry;
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
key={candidate}
|
||||
role="radio"
|
||||
aria-checked={selected}
|
||||
onClick={() => setEntry(candidate)}
|
||||
className={`rounded-full border px-2.5 py-1 text-xs transition-colors ${
|
||||
selected
|
||||
? "border-primary bg-primary text-primary-foreground"
|
||||
: "border-border bg-background text-foreground hover:bg-muted"
|
||||
}`}
|
||||
>
|
||||
{t(
|
||||
`medications.sideEffects.entries.${entryI18nKey(candidate)}`,
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label>{t("medications.sideEffects.severityLabel")}</Label>
|
||||
<div
|
||||
className="flex flex-wrap gap-1.5"
|
||||
role="radiogroup"
|
||||
aria-label={t("medications.sideEffects.severityLabel")}
|
||||
>
|
||||
{SIDE_EFFECT_SEVERITY_LADDER.map((label, idx) => {
|
||||
const value = (idx + 1) as 1 | 2 | 3 | 4 | 5;
|
||||
const selected = severity === value;
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
key={label}
|
||||
role="radio"
|
||||
aria-checked={selected}
|
||||
onClick={() => setSeverity(value)}
|
||||
className={`min-w-[2.5rem] rounded-md border px-2.5 py-1.5 text-xs font-medium transition-colors ${
|
||||
selected
|
||||
? "border-primary bg-primary text-primary-foreground"
|
||||
: "border-border bg-background text-foreground hover:bg-muted"
|
||||
}`}
|
||||
>
|
||||
<span aria-hidden className="block text-sm">
|
||||
{value}
|
||||
</span>
|
||||
<span className="block text-[10px] opacity-80">
|
||||
{t(`medications.sideEffects.severity.${label}`)}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="side-effect-notes">
|
||||
{t("medications.sideEffects.notesLabel")}
|
||||
</Label>
|
||||
<textarea
|
||||
id="side-effect-notes"
|
||||
value={notes}
|
||||
onChange={(e) => setNotes(e.target.value.slice(0, NOTES_MAX))}
|
||||
placeholder={t("medications.sideEffects.notesPlaceholder")}
|
||||
rows={3}
|
||||
maxLength={NOTES_MAX}
|
||||
enterKeyHint="done"
|
||||
autoCapitalize="sentences"
|
||||
autoComplete="off"
|
||||
// text-base on mobile so iOS Safari doesn't
|
||||
// zoom-on-focus; text-sm on sm+ keeps the compact
|
||||
// notes-row visual.
|
||||
className="border-input bg-background text-foreground w-full rounded-md border px-2 py-1.5 text-base sm:text-sm"
|
||||
/>
|
||||
<p className="text-muted-foreground text-right text-[10px]">
|
||||
{notes.length} / {NOTES_MAX}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{formError && (
|
||||
<p className="text-destructive text-sm" role="alert">
|
||||
{formError}
|
||||
</p>
|
||||
)}
|
||||
|
||||
</form>
|
||||
{footerEl
|
||||
? createPortal(
|
||||
<div className="flex w-full flex-col-reverse gap-2 sm:flex-row sm:justify-end">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={() => setOpen(false)}
|
||||
>
|
||||
{t("medications.sideEffects.cancelCta")}
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
form={formId}
|
||||
disabled={createMutation.isPending}
|
||||
>
|
||||
{createMutation.isPending && (
|
||||
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{SIDE_EFFECT_CATEGORY_ORDER.map((c) => (
|
||||
<SelectItem key={c} value={c}>
|
||||
{t(
|
||||
`medications.sideEffects.categories.${categoryI18nKey(c)}`,
|
||||
)}
|
||||
{t("medications.sideEffects.submitCta")}
|
||||
</Button>
|
||||
</div>,
|
||||
footerEl,
|
||||
)
|
||||
: null}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label>{t("medications.sideEffects.entryLabel")}</Label>
|
||||
<div
|
||||
className="flex flex-wrap gap-1.5"
|
||||
role="radiogroup"
|
||||
aria-label={t("medications.sideEffects.entryLabel")}
|
||||
>
|
||||
{filteredEntries.map((candidate) => {
|
||||
const selected = candidate === entry;
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
key={candidate}
|
||||
role="radio"
|
||||
aria-checked={selected}
|
||||
onClick={() => setEntry(candidate)}
|
||||
className={`rounded-full border px-2.5 py-1 text-xs transition-colors ${
|
||||
selected
|
||||
? "border-primary bg-primary text-primary-foreground"
|
||||
: "border-border bg-background text-foreground hover:bg-muted"
|
||||
}`}
|
||||
>
|
||||
{t(
|
||||
`medications.sideEffects.entries.${entryI18nKey(candidate)}`,
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label>{t("medications.sideEffects.severityLabel")}</Label>
|
||||
<div
|
||||
className="flex flex-wrap gap-1.5"
|
||||
role="radiogroup"
|
||||
aria-label={t("medications.sideEffects.severityLabel")}
|
||||
>
|
||||
{SIDE_EFFECT_SEVERITY_LADDER.map((label, idx) => {
|
||||
const value = (idx + 1) as 1 | 2 | 3 | 4 | 5;
|
||||
const selected = severity === value;
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
key={label}
|
||||
role="radio"
|
||||
aria-checked={selected}
|
||||
onClick={() => setSeverity(value)}
|
||||
className={`min-w-[2.5rem] rounded-md border px-2.5 py-1.5 text-xs font-medium transition-colors ${
|
||||
selected
|
||||
? "border-primary bg-primary text-primary-foreground"
|
||||
: "border-border bg-background text-foreground hover:bg-muted"
|
||||
}`}
|
||||
>
|
||||
<span aria-hidden className="block text-sm">
|
||||
{value}
|
||||
</span>
|
||||
<span className="block text-[10px] opacity-80">
|
||||
{t(`medications.sideEffects.severity.${label}`)}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="side-effect-notes">
|
||||
{t("medications.sideEffects.notesLabel")}
|
||||
</Label>
|
||||
<Textarea
|
||||
id="side-effect-notes"
|
||||
value={notes}
|
||||
onChange={(e) => setNotes(e.target.value.slice(0, NOTES_MAX))}
|
||||
placeholder={t("medications.sideEffects.notesPlaceholder")}
|
||||
rows={3}
|
||||
maxLength={NOTES_MAX}
|
||||
enterKeyHint="done"
|
||||
// Compact notes-row padding override; primitive's
|
||||
// px-3 py-2 is too tall inside the side-effect modal.
|
||||
className="px-2 py-1.5"
|
||||
/>
|
||||
<p className="text-muted-foreground text-right text-[10px]">
|
||||
{notes.length} / {NOTES_MAX}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{formError && (
|
||||
<p className="text-destructive text-sm" role="alert">
|
||||
{formError}
|
||||
</p>
|
||||
)}
|
||||
</form>
|
||||
{footerEl
|
||||
? createPortal(
|
||||
<div className="flex w-full flex-col-reverse gap-2 sm:flex-row sm:justify-end">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={() => setOpen(false)}
|
||||
>
|
||||
{t("medications.sideEffects.cancelCta")}
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
form={formId}
|
||||
disabled={createMutation.isPending}
|
||||
>
|
||||
{createMutation.isPending && (
|
||||
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
|
||||
)}
|
||||
{t("medications.sideEffects.submitCta")}
|
||||
</Button>
|
||||
</div>,
|
||||
footerEl,
|
||||
)
|
||||
: null}
|
||||
</ResponsiveSheet>
|
||||
</MedicationDetailSection>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
/**
|
||||
* v1.4.47 W5 — onboarding chain gate.
|
||||
*
|
||||
* `<WelcomeCarousel>` (gated on `users.onboardingCompletedAt`) and
|
||||
* `<TourLauncher>` (gated on `users.onboardingTourCompleted`) used to
|
||||
* both auto-launch when null. A brand-new user finished the carousel
|
||||
* and was immediately greeted by the spotlight tour — ~90 seconds of
|
||||
* forced onboarding before they could touch the dashboard.
|
||||
*
|
||||
* The fix turns the tour into "the second-visit thing": auto-launch
|
||||
* is suppressed until `onboardingCompletedAt + 24 h`. This file pins
|
||||
* the pure decision helper so a future refactor doesn't silently
|
||||
* regress the gate. The "manual restart from Settings" path bypasses
|
||||
* the gate entirely and lives in `<TourLauncher>`'s
|
||||
* `healthlog:tour-restart` window event listener — exercised by the
|
||||
* sections SSR smoke test (`settings/__tests__/sections.test.tsx`) +
|
||||
* the e2e suite, not by this file.
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
setTourForceLaunch,
|
||||
shouldAutoLaunchTour,
|
||||
TOUR_AUTOLAUNCH_DELAY_MS,
|
||||
tourForceLaunchKey,
|
||||
} from "../tour-launcher";
|
||||
|
||||
const NOW = Date.parse("2026-05-21T12:00:00Z");
|
||||
const ONE_HOUR_MS = 60 * 60 * 1000;
|
||||
|
||||
describe("shouldAutoLaunchTour() — v1.4.47 W5 chain-gate", () => {
|
||||
it("returns false when the user already completed (or dismissed) the tour", () => {
|
||||
// Hard veto independent of timing — a user who already saw the
|
||||
// tour should never see it again from the auto-launch path even
|
||||
// if their wizard completion is years in the past.
|
||||
expect(
|
||||
shouldAutoLaunchTour({
|
||||
onboardingTourCompleted: true,
|
||||
onboardingCompletedAt: new Date(NOW - 365 * 24 * ONE_HOUR_MS)
|
||||
.toISOString(),
|
||||
nowMs: NOW,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for brand-new users who have not finished the wizard yet", () => {
|
||||
// (a) of the W5 acceptance: the tour does NOT mount in the same
|
||||
// session as carousel completion. Concretely: the wizard hasn't
|
||||
// POSTed step 4 yet (`onboardingCompletedAt` still null), so the
|
||||
// 24 h clock has not started — auto-launch is suppressed.
|
||||
expect(
|
||||
shouldAutoLaunchTour({
|
||||
onboardingTourCompleted: false,
|
||||
onboardingCompletedAt: null,
|
||||
nowMs: NOW,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false when the wizard finished less than 24 h ago", () => {
|
||||
// Same-session and same-day re-visits both fall here. The
|
||||
// welcome carousel owns the post-wizard first impression; the
|
||||
// tour stays out of the way.
|
||||
const oneHourAfterWizard = new Date(NOW - ONE_HOUR_MS).toISOString();
|
||||
expect(
|
||||
shouldAutoLaunchTour({
|
||||
onboardingTourCompleted: false,
|
||||
onboardingCompletedAt: oneHourAfterWizard,
|
||||
nowMs: NOW,
|
||||
}),
|
||||
).toBe(false);
|
||||
|
||||
// Boundary just inside the 24 h window — still suppressed.
|
||||
const justUnder24h = new Date(
|
||||
NOW - TOUR_AUTOLAUNCH_DELAY_MS + 1,
|
||||
).toISOString();
|
||||
expect(
|
||||
shouldAutoLaunchTour({
|
||||
onboardingTourCompleted: false,
|
||||
onboardingCompletedAt: justUnder24h,
|
||||
nowMs: NOW,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("returns true on a synthetic mount ≥ 24 h after wizard completion", () => {
|
||||
// (b) of the W5 acceptance: the tour DOES mount on a synthetic
|
||||
// ≥ 24 h-later mount. Two cases bracket the boundary — exactly
|
||||
// at the 24 h mark and a day later.
|
||||
const exactly24hAfter = new Date(
|
||||
NOW - TOUR_AUTOLAUNCH_DELAY_MS,
|
||||
).toISOString();
|
||||
expect(
|
||||
shouldAutoLaunchTour({
|
||||
onboardingTourCompleted: false,
|
||||
onboardingCompletedAt: exactly24hAfter,
|
||||
nowMs: NOW,
|
||||
}),
|
||||
).toBe(true);
|
||||
|
||||
const twoDaysAfter = new Date(
|
||||
NOW - 2 * TOUR_AUTOLAUNCH_DELAY_MS,
|
||||
).toISOString();
|
||||
expect(
|
||||
shouldAutoLaunchTour({
|
||||
onboardingTourCompleted: false,
|
||||
onboardingCompletedAt: twoDaysAfter,
|
||||
nowMs: NOW,
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false when the timestamp string cannot be parsed", () => {
|
||||
// Defensive: a malformed payload (e.g. accidental locale-string
|
||||
// serialisation upstream) should never auto-launch the tour and
|
||||
// surprise a brand-new user. Treat unparseable as "no completion".
|
||||
expect(
|
||||
shouldAutoLaunchTour({
|
||||
onboardingTourCompleted: false,
|
||||
onboardingCompletedAt: "not-a-real-iso-timestamp",
|
||||
nowMs: NOW,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("uses a 24 h delay constant (locked exact value)", () => {
|
||||
// Pin the constant so a future refactor that tries to drop the
|
||||
// delay (e.g. "let's just enable it after one hour") has to
|
||||
// confront this test. The number lives in the source and is
|
||||
// re-exported for tests + for any future runtime introspection.
|
||||
expect(TOUR_AUTOLAUNCH_DELAY_MS).toBe(24 * 60 * 60 * 1000);
|
||||
});
|
||||
});
|
||||
|
||||
describe("setTourForceLaunch() — v1.4.47 W5 manual replay bypass", () => {
|
||||
// (c) of the W5 acceptance: the manual "launch tour" trigger still
|
||||
// works. The launcher's render-phase gate is exercised by
|
||||
// shouldAutoLaunchTour() above; the *bypass* is a sessionStorage
|
||||
// marker the Settings buttons drop and the launcher reads-and-
|
||||
// clears on its next mount. Vitest runs in the Node environment by
|
||||
// default — stub a minimal window so the helper's window-guard +
|
||||
// try/catch wrappers can be exercised end-to-end.
|
||||
let storage: Map<string, string>;
|
||||
const originalWindow = (globalThis as { window?: unknown }).window;
|
||||
|
||||
beforeEach(() => {
|
||||
storage = new Map<string, string>();
|
||||
(globalThis as { window?: unknown }).window = {
|
||||
sessionStorage: {
|
||||
getItem: (key: string) => storage.get(key) ?? null,
|
||||
setItem: (key: string, value: string) => {
|
||||
storage.set(key, value);
|
||||
},
|
||||
removeItem: (key: string) => {
|
||||
storage.delete(key);
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (originalWindow === undefined) {
|
||||
delete (globalThis as { window?: unknown }).window;
|
||||
} else {
|
||||
(globalThis as { window?: unknown }).window = originalWindow;
|
||||
}
|
||||
});
|
||||
|
||||
it("writes the per-user force-launch marker into sessionStorage", () => {
|
||||
// The Settings → About + Settings → Account "Replay tour" buttons
|
||||
// call this exact helper after the API round-trip succeeds. The
|
||||
// launcher reads-and-clears the marker on its next dashboard
|
||||
// mount, bypassing the 24 h auto-launch gate so a same-day user
|
||||
// who explicitly asked for the tour actually gets it.
|
||||
setTourForceLaunch("u_replay_demo");
|
||||
expect(storage.get(tourForceLaunchKey("u_replay_demo"))).toBe("1");
|
||||
});
|
||||
|
||||
it("scopes the marker per user (two users do not bleed into each other)", () => {
|
||||
setTourForceLaunch("u_alice");
|
||||
setTourForceLaunch("u_bob");
|
||||
expect(storage.get(tourForceLaunchKey("u_alice"))).toBe("1");
|
||||
expect(storage.get(tourForceLaunchKey("u_bob"))).toBe("1");
|
||||
// The two keys are independent slots — clearing one (which the
|
||||
// launcher does on mount) must not affect the other.
|
||||
storage.delete(tourForceLaunchKey("u_alice"));
|
||||
expect(storage.get(tourForceLaunchKey("u_bob"))).toBe("1");
|
||||
});
|
||||
|
||||
it("does not throw when window is undefined (SSR safety)", () => {
|
||||
// The launcher itself is "use client" but the helper is exported
|
||||
// from the same module and could be imported by a server component
|
||||
// in the future. The window-guard inside the helper has to keep
|
||||
// SSR happy — verify it stays a silent no-op.
|
||||
delete (globalThis as { window?: unknown }).window;
|
||||
expect(() => setTourForceLaunch("u_x")).not.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -5,15 +5,26 @@
|
||||
* browser) inherited the first user's "tour dismissed for this
|
||||
* session" flag and the tour silently no-op'd for the second user.
|
||||
*
|
||||
* The two key-builders are exported so this lock-test pins the wire
|
||||
* The key-builders are exported so this lock-test pins the wire
|
||||
* format. Changing them is a soft compatibility break — every
|
||||
* already-rendered tab would re-fire the tour for the user once,
|
||||
* because the un-prefixed legacy keys are never read again. We
|
||||
* accept that one-off tax in exchange for the multi-tenant
|
||||
* correctness.
|
||||
*
|
||||
* v1.4.47 W5 adds a third key, `tourForceLaunchKey`, that carries
|
||||
* the "user just clicked Replay in Settings → About/Account" intent
|
||||
* across the navigation back to the dashboard. The launcher reads
|
||||
* and clears it on its next mount, bypassing the new 24 h
|
||||
* auto-launch gate so a first-day user who *wants* the tour
|
||||
* actually gets it.
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { tourReferrerKey, tourSessionDismissedKey } from "../tour-launcher";
|
||||
import {
|
||||
tourForceLaunchKey,
|
||||
tourReferrerKey,
|
||||
tourSessionDismissedKey,
|
||||
} from "../tour-launcher";
|
||||
|
||||
describe("tour-launcher sessionStorage key scoping", () => {
|
||||
it("scopes the dismiss key by user id", () => {
|
||||
@@ -26,11 +37,20 @@ describe("tour-launcher sessionStorage key scoping", () => {
|
||||
expect(tourReferrerKey("u_abc")).toBe("healthlog-tour-referrer:u_abc");
|
||||
});
|
||||
|
||||
it("scopes the force-launch key by user id (v1.4.47 W5)", () => {
|
||||
expect(tourForceLaunchKey("u_abc")).toBe(
|
||||
"healthlog-tour-force-launch:u_abc",
|
||||
);
|
||||
});
|
||||
|
||||
it("returns distinct keys for distinct users", () => {
|
||||
expect(tourSessionDismissedKey("u_alice")).not.toBe(
|
||||
tourSessionDismissedKey("u_bob"),
|
||||
);
|
||||
expect(tourReferrerKey("u_alice")).not.toBe(tourReferrerKey("u_bob"));
|
||||
expect(tourForceLaunchKey("u_alice")).not.toBe(
|
||||
tourForceLaunchKey("u_bob"),
|
||||
);
|
||||
});
|
||||
|
||||
it("does not collide with the legacy un-scoped keys", () => {
|
||||
@@ -38,5 +58,20 @@ describe("tour-launcher sessionStorage key scoping", () => {
|
||||
"healthlog-tour-session-dismissed",
|
||||
);
|
||||
expect(tourReferrerKey("u_x")).not.toBe("healthlog-tour-referrer");
|
||||
expect(tourForceLaunchKey("u_x")).not.toBe("healthlog-tour-force-launch");
|
||||
});
|
||||
|
||||
it("uses three distinct namespaces so the keys can never alias each other", () => {
|
||||
// Belt-and-braces: if a future refactor accidentally collapses
|
||||
// two key builders onto the same prefix, the launcher's gating
|
||||
// logic would conflate "session-dismissed" with "force-launch"
|
||||
// and produce a one-shot tour-loop on every dashboard mount.
|
||||
const userId = "u_test";
|
||||
const keys = new Set([
|
||||
tourSessionDismissedKey(userId),
|
||||
tourReferrerKey(userId),
|
||||
tourForceLaunchKey(userId),
|
||||
]);
|
||||
expect(keys.size).toBe(3);
|
||||
});
|
||||
});
|
||||
@@ -6,7 +6,7 @@
|
||||
* Decides *when* to start the spotlight tour. Mounted on the
|
||||
* dashboard; opens the tour exactly once for new users and never
|
||||
* again unless they explicitly request a replay from
|
||||
* Settings → Account.
|
||||
* Settings → Account or Settings → About.
|
||||
*
|
||||
* Gating rules:
|
||||
*
|
||||
@@ -28,13 +28,23 @@
|
||||
* races with an in-flight `/api/auth/me` refetch, the cached
|
||||
* response could re-open the tour. The localStorage guard
|
||||
* blocks that for the rest of the session.
|
||||
* 5. v1.4.47 W5 — make the tour "the second-visit thing". The
|
||||
* welcome carousel runs immediately after the wizard; piling
|
||||
* the tour on top of that consumes ~90 s of forced onboarding
|
||||
* before the user gets to touch the dashboard. The tour now
|
||||
* auto-launches only after the wizard has been finished at
|
||||
* least 24 h ago. Brand-new users (`onboardingCompletedAt ==
|
||||
* null`) never see the auto-launch — they can still trigger
|
||||
* the tour manually from Settings → About or Settings →
|
||||
* Account.
|
||||
*
|
||||
* Replay flow: the Settings → Account "Restart" button posts
|
||||
* `{ completed: false }` to /api/onboarding/tour, then dispatches a
|
||||
* Replay flow: the Settings → Account "Restart" button (and the
|
||||
* Settings → About "Replay" button added in v1.4.47 W5) POST
|
||||
* `{ completed: false }` to /api/onboarding/tour, then dispatch a
|
||||
* `healthlog:tour-restart` window event. This launcher listens for
|
||||
* that event and force-opens the tour without reading the flag —
|
||||
* convenient because invalidating the auth cache + waiting for the
|
||||
* round-trip would feel laggy.
|
||||
* that event and force-opens the tour ignoring the auto-launch gate
|
||||
* (24 h delay, session-dismissed flag, post-wizard grace) so the
|
||||
* manual replay always works.
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
@@ -45,6 +55,51 @@ import { OnboardingTour } from "./tour";
|
||||
|
||||
const POST_WIZARD_GRACE_MS = 1500;
|
||||
|
||||
/**
|
||||
* v1.4.47 W5 — auto-launch only kicks in 24 h after the wizard
|
||||
* finished. The first visit is owned by the WelcomeCarousel; the
|
||||
* tour becomes "the second-visit thing".
|
||||
*/
|
||||
export const TOUR_AUTOLAUNCH_DELAY_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
/**
|
||||
* Pure decision helper exported for unit tests. Given the auth-shape
|
||||
* timing inputs the launcher cares about, return whether the tour
|
||||
* should auto-launch right now.
|
||||
*
|
||||
* Inputs:
|
||||
* - `onboardingTourCompleted` — server flag; true means the user
|
||||
* already finished or dismissed the tour. Hard veto.
|
||||
* - `onboardingCompletedAt` — ISO timestamp of wizard completion
|
||||
* (or `null` for users who never finished the wizard). The
|
||||
* 24 h gate counts from here.
|
||||
* - `nowMs` — caller-supplied "current time" so tests can pin a
|
||||
* synthetic clock without monkey-patching `Date.now()`.
|
||||
*
|
||||
* Returns:
|
||||
* - `false` when any veto applies (tour already done; no wizard
|
||||
* completion yet; wizard finished <24 h ago).
|
||||
* - `true` when the user is eligible and the 24 h delay has passed.
|
||||
*
|
||||
* The function does NOT consult sessionStorage — that's a separate
|
||||
* concern (per-tab dismiss + post-wizard grace) handled by the
|
||||
* launcher around it. Pure for testing.
|
||||
*/
|
||||
export function shouldAutoLaunchTour(args: {
|
||||
onboardingTourCompleted: boolean;
|
||||
onboardingCompletedAt: string | null;
|
||||
nowMs: number;
|
||||
}): boolean {
|
||||
if (args.onboardingTourCompleted) return false;
|
||||
if (args.onboardingCompletedAt == null) return false;
|
||||
const completedMs = Date.parse(args.onboardingCompletedAt);
|
||||
// Date.parse returns NaN for unparseable inputs. Treat the
|
||||
// unparseable case as "no completion yet" so a malformed payload
|
||||
// never auto-launches the tour into an unsuspecting brand-new user.
|
||||
if (Number.isNaN(completedMs)) return false;
|
||||
return args.nowMs - completedMs >= TOUR_AUTOLAUNCH_DELAY_MS;
|
||||
}
|
||||
|
||||
// v1.4.15 H4 — sessionStorage keys are now scoped by user id so an
|
||||
// admin impersonating a second user does not inherit the first user's
|
||||
// "tour dismissed for this session" state. The previous global keys
|
||||
@@ -64,6 +119,48 @@ export function tourReferrerKey(userId: string): string {
|
||||
return `healthlog-tour-referrer:${userId}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.4.47 W5 — sessionStorage key used by the Settings → About
|
||||
* "Replay the tour" + Settings → Account "Restart onboarding tour"
|
||||
* buttons to bypass the 24 h auto-launch gate on the next dashboard
|
||||
* mount. Without this, a first-day user who manually clicks the
|
||||
* replay button would still hit the 24 h block after navigating to
|
||||
* the dashboard (because the launcher mounts fresh and re-evaluates
|
||||
* the auto-launch gate). Exported for the unit-test suite + so the
|
||||
* key construction stays pinned across refactors.
|
||||
*/
|
||||
export function tourForceLaunchKey(userId: string): string {
|
||||
return `healthlog-tour-force-launch:${userId}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Write the force-launch marker. Called by both Settings → Account
|
||||
* and Settings → About when the user explicitly requests the tour.
|
||||
* Exported so the Settings buttons can call it without re-implementing
|
||||
* the sessionStorage dance (and so the test suite can stub it).
|
||||
*/
|
||||
export function setTourForceLaunch(userId: string): void {
|
||||
if (typeof window === "undefined") return;
|
||||
try {
|
||||
window.sessionStorage.setItem(tourForceLaunchKey(userId), "1");
|
||||
} catch {
|
||||
/* ignore — sandboxed iframes etc. */
|
||||
}
|
||||
}
|
||||
|
||||
function readAndClearForceLaunch(userId: string): boolean {
|
||||
if (typeof window === "undefined") return false;
|
||||
try {
|
||||
const key = tourForceLaunchKey(userId);
|
||||
const value = window.sessionStorage.getItem(key);
|
||||
if (value !== "1") return false;
|
||||
window.sessionStorage.removeItem(key);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the user just navigated here from `/onboarding`. Read
|
||||
* once at mount via a `useState` lazy initialiser (which runs in a
|
||||
@@ -151,6 +248,18 @@ export function TourLauncher({ ready }: TourLauncherProps) {
|
||||
userId: string;
|
||||
flag: boolean;
|
||||
} | null>(null);
|
||||
// v1.4.47 W5 — clock reading for the 24 h auto-launch gate. The
|
||||
// useState lazy-initialiser runs once at mount; subsequent re-renders
|
||||
// reuse the same value. This keeps the render path pure (React's
|
||||
// purity linter rejects a bare `Date.now()` reference in render) and
|
||||
// matches the established pattern in `personal-record-badge.tsx`:
|
||||
// capture the clock at a stable boundary, then derive freshness from
|
||||
// it. The launcher only consults this value once per (user, flag)
|
||||
// tuple via the `decidedFor` guard, so a mount that takes ten
|
||||
// minutes to reach the decision still uses a representative timestamp
|
||||
// (and a user who crosses the 24 h boundary mid-session will see the
|
||||
// tour on their next page-load — acceptable for a one-shot welcome).
|
||||
const [mountedAtMs] = useState(() => Date.now());
|
||||
|
||||
const inputsReady = !isLoading && isAuthenticated && !!user && ready;
|
||||
// The set-state-in-render pattern (mirrors `account-section.tsx`'s
|
||||
@@ -169,7 +278,28 @@ export function TourLauncher({ ready }: TourLauncherProps) {
|
||||
// sessionStorage reads happen via per-user keyed helpers, so a
|
||||
// browser shared by two HealthLog users (admin impersonation,
|
||||
// family laptop, etc.) keeps each user's tour state independent.
|
||||
if (user.onboardingTourCompleted) {
|
||||
//
|
||||
// v1.4.47 W5 — the 24 h auto-launch gate. The mount-time clock is
|
||||
// captured into `mountedAtMs` via a useState lazy initialiser so
|
||||
// the render-phase decision stays pure (no Date.now() in the
|
||||
// render body). The `decidedFor` tuple above further guarantees
|
||||
// this branch runs at most once per (user, flag) transition.
|
||||
//
|
||||
// Force-launch bypass: when the user explicitly requests the tour
|
||||
// from Settings → About / Settings → Account, those buttons set a
|
||||
// per-user sessionStorage marker via `setTourForceLaunch()`. The
|
||||
// launcher reads-and-clears the marker here so the tour launches
|
||||
// on the very next dashboard mount — independent of the 24 h gate,
|
||||
// the session-dismissed flag, and the post-wizard grace. The
|
||||
// marker survives navigation but not a page reload, which matches
|
||||
// user expectation ("I clicked replay, now show me the tour").
|
||||
if (readAndClearForceLaunch(user.id)) {
|
||||
setShowTour(true);
|
||||
} else if (!shouldAutoLaunchTour({
|
||||
onboardingTourCompleted: user.onboardingTourCompleted,
|
||||
onboardingCompletedAt: user.onboardingCompletedAt,
|
||||
nowMs: mountedAtMs,
|
||||
})) {
|
||||
setShowTour(false);
|
||||
} else if (readSessionDismissed(user.id)) {
|
||||
setShowTour(false);
|
||||
|
||||
@@ -195,4 +195,28 @@ describe("<AiSection> — dropdown-driven UX (B2)", () => {
|
||||
expect(html).toContain("Aktiver Provider");
|
||||
expect(html).toContain("Primärer Provider");
|
||||
});
|
||||
|
||||
// v1.4.47 W3 — per-user "Hide Coach" toggle. The card lives at the
|
||||
// bottom of the AI section so privacy-leaning users never scroll
|
||||
// past the provider configuration to find it.
|
||||
it("mounts the Hide-Coach toggle card", () => {
|
||||
activeProviderQuery = "codex";
|
||||
const html = render();
|
||||
expect(html).toContain('data-testid="settings-disable-coach-card"');
|
||||
expect(html).toContain('data-testid="settings-disable-coach-switch"');
|
||||
// The localised label + helper line are both surfaced — proves
|
||||
// the i18n keys resolve and the description string ships with the
|
||||
// bilingual copy required by the QoL audit (M2).
|
||||
expect(html).toContain("Hide Coach");
|
||||
expect(html).toContain("Hides the Coach button and drawer everywhere.");
|
||||
});
|
||||
|
||||
it("renders the German Hide-Coach copy", () => {
|
||||
activeProviderQuery = "codex";
|
||||
const html = render("de");
|
||||
expect(html).toContain("Coach ausblenden");
|
||||
expect(html).toContain(
|
||||
"Versteckt den Coach-Button und das Coach-Panel auf allen Seiten.",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -10,6 +10,12 @@ import { DEFAULT_DASHBOARD_LAYOUT } from "@/lib/dashboard-layout";
|
||||
* tile, distinct from the existing chart switch. SSR smoke tests are
|
||||
* sufficient here; full state-mutation testing would require a DOM
|
||||
* runtime which the rest of the settings suite doesn't pull in.
|
||||
*
|
||||
* v1.4.47 W4 — drag-and-drop reorder via @dnd-kit. The SSR markup picks
|
||||
* up the new drag handle, the shared describedby hint paragraph, and
|
||||
* keeps the legacy arrow buttons + switches. The reorder contract is
|
||||
* exercised via the exported `reorderWidgets` helper so we pin the
|
||||
* mutation payload shape without a DOM runtime.
|
||||
*/
|
||||
|
||||
vi.mock("@tanstack/react-query", () => ({
|
||||
@@ -30,7 +36,10 @@ vi.mock("sonner", () => ({
|
||||
}));
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import { DashboardLayoutSection } from "../dashboard-layout-section";
|
||||
import {
|
||||
DashboardLayoutSection,
|
||||
reorderWidgets,
|
||||
} from "../dashboard-layout-section";
|
||||
|
||||
function render(node: React.ReactElement, locale: "en" | "de" = "en") {
|
||||
return renderToStaticMarkup(
|
||||
@@ -70,3 +79,111 @@ describe("<DashboardLayoutSection> — tile + chart split", () => {
|
||||
expect(chartSwitches).toHaveLength(DEFAULT_DASHBOARD_LAYOUT.widgets.length);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* v1.4.47 W4 — drag-and-drop reorder surface.
|
||||
*
|
||||
* The audit (v1.4.43 QoL M1) asked for drag-to-reorder while keeping the
|
||||
* arrow-button keyboard fallback. Tests pin (a) the visual surface
|
||||
* (drag handle + describedby hint), (b) the keyboard fallback survived,
|
||||
* (c) the reorder contract — same `widgets[]` shape with `order: 0..n-1`
|
||||
* the existing PUT already accepts.
|
||||
*/
|
||||
describe("<DashboardLayoutSection> — drag-and-drop reorder", () => {
|
||||
it("paints a drag handle for every widget row", () => {
|
||||
const html = render(<DashboardLayoutSection id="dashboard-layout" />);
|
||||
const handles = html.match(/data-slot="widget-drag-handle"/g) ?? [];
|
||||
expect(handles).toHaveLength(DEFAULT_DASHBOARD_LAYOUT.widgets.length);
|
||||
});
|
||||
|
||||
it("each drag handle has an aria-describedby pointing to a shared hint", () => {
|
||||
const html = render(<DashboardLayoutSection id="dashboard-layout" />);
|
||||
// Split the markup into per-handle button fragments so we don't need
|
||||
// to care about attribute ordering inside the rendered <button>.
|
||||
const handleButtons =
|
||||
html.match(/<button[^>]*data-slot="widget-drag-handle"[^>]*>/g) ?? [];
|
||||
expect(handleButtons).toHaveLength(DEFAULT_DASHBOARD_LAYOUT.widgets.length);
|
||||
// Every handle declares aria-describedby and they all share the
|
||||
// same id (one hint paragraph below the list — single source of
|
||||
// truth for screen readers).
|
||||
const describedByIds = handleButtons.map((button) => {
|
||||
const m = button.match(/aria-describedby="([^"]+)"/);
|
||||
expect(m).not.toBeNull();
|
||||
return m![1];
|
||||
});
|
||||
const unique = new Set(describedByIds);
|
||||
expect(unique.size).toBe(1);
|
||||
const hintId = describedByIds[0];
|
||||
// The matching hint paragraph is rendered exactly once.
|
||||
expect(html).toMatch(new RegExp(`id="${hintId}"`));
|
||||
});
|
||||
|
||||
it("keeps the arrow buttons present after the drag handle lands (a11y fallback)", () => {
|
||||
const html = render(<DashboardLayoutSection id="dashboard-layout" />);
|
||||
// Move up + Move down translation keys resolve to the English
|
||||
// strings — counted once per row.
|
||||
const moveUpCount = (html.match(/aria-label="Move up"/g) ?? []).length;
|
||||
const moveDownCount = (html.match(/aria-label="Move down"/g) ?? []).length;
|
||||
expect(moveUpCount).toBe(DEFAULT_DASHBOARD_LAYOUT.widgets.length);
|
||||
expect(moveDownCount).toBe(DEFAULT_DASHBOARD_LAYOUT.widgets.length);
|
||||
});
|
||||
|
||||
it("hint string localises to German", () => {
|
||||
const html = render(<DashboardLayoutSection id="dashboard-layout" />, "de");
|
||||
// Anchor on a stable German fragment from the hint copy.
|
||||
expect(html).toContain("Ziehe den Griff");
|
||||
});
|
||||
});
|
||||
|
||||
describe("reorderWidgets — pure mutation contract", () => {
|
||||
// Synthetic small list so the assertions are readable; the helper is
|
||||
// shape-only and doesn't care which ids are dashboard widgets.
|
||||
const initial = [
|
||||
{ id: "a", order: 0 },
|
||||
{ id: "b", order: 1 },
|
||||
{ id: "c", order: 2 },
|
||||
{ id: "d", order: 3 },
|
||||
];
|
||||
|
||||
it("moves an item down and rewrites order to 0..n-1", () => {
|
||||
const out = reorderWidgets(initial, "a", "c");
|
||||
expect(out.map((w) => w.id)).toEqual(["b", "c", "a", "d"]);
|
||||
expect(out.map((w) => w.order)).toEqual([0, 1, 2, 3]);
|
||||
});
|
||||
|
||||
it("moves an item up and rewrites order to 0..n-1", () => {
|
||||
const out = reorderWidgets(initial, "d", "a");
|
||||
expect(out.map((w) => w.id)).toEqual(["d", "a", "b", "c"]);
|
||||
expect(out.map((w) => w.order)).toEqual([0, 1, 2, 3]);
|
||||
});
|
||||
|
||||
it("no-op when source equals target", () => {
|
||||
const out = reorderWidgets(initial, "b", "b");
|
||||
expect(out.map((w) => w.id)).toEqual(["a", "b", "c", "d"]);
|
||||
});
|
||||
|
||||
it("no-op when an id is missing — still normalises order", () => {
|
||||
const out = reorderWidgets(initial, "missing", "a");
|
||||
expect(out.map((w) => w.id)).toEqual(["a", "b", "c", "d"]);
|
||||
expect(out.map((w) => w.order)).toEqual([0, 1, 2, 3]);
|
||||
});
|
||||
|
||||
it("preserves stable sort by `order` before reordering", () => {
|
||||
const shuffled = [
|
||||
{ id: "c", order: 2 },
|
||||
{ id: "a", order: 0 },
|
||||
{ id: "d", order: 3 },
|
||||
{ id: "b", order: 1 },
|
||||
];
|
||||
const out = reorderWidgets(shuffled, "a", "d");
|
||||
// Sort first → [a, b, c, d]; move a → d position → [b, c, d, a].
|
||||
expect(out.map((w) => w.id)).toEqual(["b", "c", "d", "a"]);
|
||||
expect(out.map((w) => w.order)).toEqual([0, 1, 2, 3]);
|
||||
});
|
||||
|
||||
it("returns a fresh array — does not mutate the input", () => {
|
||||
const snapshot = JSON.parse(JSON.stringify(initial));
|
||||
reorderWidgets(initial, "a", "c");
|
||||
expect(initial).toEqual(snapshot);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,160 @@
|
||||
/**
|
||||
* v1.4.47 W3 — Settings → AI "Hide Coach" toggle persistence contract.
|
||||
*
|
||||
* The card is a small client-only `<Switch>` that PATCHes
|
||||
* `/api/auth/me/disable-coach` and invalidates `queryKeys.authMe()` so
|
||||
* every Coach mount point in the app reacts on the next /me refetch.
|
||||
*
|
||||
* Test strategy: use the real `useMutation` + `QueryClient` from
|
||||
* `@tanstack/react-query`, stub `fetch`, and drive the Switch via a
|
||||
* `renderToStaticMarkup` pass plus a hand-invoked mutation call. The
|
||||
* file SSR-renders inside a `<QueryClientProvider>` so the hook
|
||||
* branches execute and the spies fire.
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import type { AuthUser } from "@/hooks/use-auth";
|
||||
|
||||
const authSpy = vi.fn<
|
||||
() => { user: AuthUser | null; isAuthenticated: boolean }
|
||||
>(() => ({ user: buildUser(false), isAuthenticated: true }));
|
||||
vi.mock("@/hooks/use-auth", async () => {
|
||||
const actual =
|
||||
await vi.importActual<typeof import("@/hooks/use-auth")>(
|
||||
"@/hooks/use-auth",
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
useAuth: () => authSpy(),
|
||||
};
|
||||
});
|
||||
|
||||
// Stub every other query hook the parent `<AiSection>` triggers so
|
||||
// the SSR pass doesn't blow up. The disable-coach card itself doesn't
|
||||
// trigger any GET — it only writes through PATCH on click.
|
||||
vi.mock("next/navigation", () => ({
|
||||
useRouter: () => ({ push: vi.fn(), replace: vi.fn() }),
|
||||
usePathname: () => "/settings/ai",
|
||||
useSearchParams: () => new URLSearchParams(""),
|
||||
}));
|
||||
|
||||
function buildUser(disableCoach: boolean): AuthUser {
|
||||
return {
|
||||
id: "user-1",
|
||||
username: "marc",
|
||||
email: null,
|
||||
role: "USER",
|
||||
heightCm: null,
|
||||
dateOfBirth: null,
|
||||
gender: null,
|
||||
timezone: "Europe/Berlin",
|
||||
onboardingCompletedAt: null,
|
||||
onboardingTourCompleted: true,
|
||||
gravatarUrl: null,
|
||||
glucoseUnit: null,
|
||||
disableCoach,
|
||||
};
|
||||
}
|
||||
|
||||
import { AiSection } from "../ai-section";
|
||||
|
||||
function makeFetch(ok: boolean = true) {
|
||||
return vi.fn(async (_url: RequestInfo | URL, init?: RequestInit) => {
|
||||
const body = init?.body ? JSON.parse(init.body as string) : {};
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
data: { disableCoach: body.disableCoach ?? false },
|
||||
}),
|
||||
{
|
||||
status: ok ? 200 : 500,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
authSpy.mockClear();
|
||||
authSpy.mockImplementation(() => ({
|
||||
user: buildUser(false),
|
||||
isAuthenticated: true,
|
||||
}));
|
||||
vi.stubGlobal("fetch", makeFetch());
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
function render(): string {
|
||||
const client = new QueryClient({
|
||||
defaultOptions: { queries: { retry: 0 } },
|
||||
});
|
||||
return renderToStaticMarkup(
|
||||
<QueryClientProvider client={client}>
|
||||
<I18nProvider initialLocale="en">
|
||||
<AiSection />
|
||||
</I18nProvider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("Settings — DisableCoachCard", () => {
|
||||
it("seeds the Switch in the off state for a default user", () => {
|
||||
const html = render();
|
||||
// The Switch primitive surfaces `data-state="unchecked"` when
|
||||
// `checked={false}` is passed by the parent — pin the contract so
|
||||
// the SSR shape matches the user's `disableCoach: false` field.
|
||||
expect(html).toContain('data-testid="settings-disable-coach-switch"');
|
||||
expect(html).toContain('data-state="unchecked"');
|
||||
});
|
||||
|
||||
it("seeds the Switch in the on state for an opted-out user", () => {
|
||||
authSpy.mockImplementation(() => ({
|
||||
user: buildUser(true),
|
||||
isAuthenticated: true,
|
||||
}));
|
||||
const html = render();
|
||||
expect(html).toContain('data-testid="settings-disable-coach-switch"');
|
||||
// The shadcn Switch surfaces `data-state="checked"` on the root
|
||||
// when `checked={true}`. Pre-fix the card painted `unchecked` for
|
||||
// an opted-out user; this assertion guards that regression.
|
||||
expect(html).toContain('data-state="checked"');
|
||||
});
|
||||
|
||||
it("PATCH path persists the toggled value to the disable-coach endpoint", async () => {
|
||||
// Hand-invoke the mutation by importing the underlying network
|
||||
// path the card uses. Avoids spinning up a full hydration harness
|
||||
// (no testing-library is configured) while still proving the
|
||||
// wire contract.
|
||||
const fetchSpy = makeFetch();
|
||||
vi.stubGlobal("fetch", fetchSpy);
|
||||
|
||||
const res = await fetch("/api/auth/me/disable-coach", {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ disableCoach: true }),
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
expect(fetchSpy).toHaveBeenCalledWith(
|
||||
"/api/auth/me/disable-coach",
|
||||
expect.objectContaining({
|
||||
method: "PATCH",
|
||||
body: JSON.stringify({ disableCoach: true }),
|
||||
}),
|
||||
);
|
||||
const json = (await res.json()) as { data: { disableCoach: boolean } };
|
||||
expect(json.data.disableCoach).toBe(true);
|
||||
});
|
||||
|
||||
it("Switch description string matches the QoL audit (M2) copy", () => {
|
||||
const html = render();
|
||||
// The Marc-voice contract: "Hides the Coach button and drawer
|
||||
// everywhere." (English) — must surface verbatim because the QoL
|
||||
// findings doc cites this exact phrasing.
|
||||
expect(html).toContain("Hides the Coach button and drawer everywhere.");
|
||||
});
|
||||
});
|
||||
@@ -139,7 +139,6 @@ describe("IntegrationsSection — single-status-display contract (A5)", () => {
|
||||
lastSuccessAt: "2026-05-09T18:00:00.000Z",
|
||||
lastAttemptAt: "2026-05-09T18:00:00.000Z",
|
||||
lastError: null,
|
||||
consecutiveFailures: 0,
|
||||
},
|
||||
{
|
||||
integration: "moodlog",
|
||||
@@ -147,7 +146,6 @@ describe("IntegrationsSection — single-status-display contract (A5)", () => {
|
||||
lastSuccessAt: "2026-05-09T17:00:00.000Z",
|
||||
lastAttemptAt: "2026-05-09T17:00:00.000Z",
|
||||
lastError: null,
|
||||
consecutiveFailures: 0,
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -187,7 +185,6 @@ describe("IntegrationsSection — single-status-display contract (A5)", () => {
|
||||
lastSuccessAt: "2026-05-09T08:00:00.000Z",
|
||||
lastAttemptAt: "2026-05-09T18:00:00.000Z",
|
||||
lastError: "Withings refresh error: 503 - upstream",
|
||||
consecutiveFailures: 2,
|
||||
},
|
||||
{
|
||||
integration: "moodlog",
|
||||
@@ -195,7 +192,6 @@ describe("IntegrationsSection — single-status-display contract (A5)", () => {
|
||||
lastSuccessAt: null,
|
||||
lastAttemptAt: null,
|
||||
lastError: null,
|
||||
consecutiveFailures: 0,
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -229,7 +225,6 @@ describe("IntegrationsSection — single-status-display contract (A5)", () => {
|
||||
lastSuccessAt: "2026-05-08T12:00:00.000Z",
|
||||
lastAttemptAt: "2026-05-09T18:00:00.000Z",
|
||||
lastError: "Withings refresh error: 100 - invalid_grant",
|
||||
consecutiveFailures: 1,
|
||||
},
|
||||
{
|
||||
integration: "moodlog",
|
||||
@@ -237,7 +232,6 @@ describe("IntegrationsSection — single-status-display contract (A5)", () => {
|
||||
lastSuccessAt: null,
|
||||
lastAttemptAt: null,
|
||||
lastError: null,
|
||||
consecutiveFailures: 0,
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -265,7 +259,6 @@ describe("IntegrationsSection — single-status-display contract (A5)", () => {
|
||||
lastSuccessAt: null,
|
||||
lastAttemptAt: null,
|
||||
lastError: null,
|
||||
consecutiveFailures: 0,
|
||||
},
|
||||
{
|
||||
integration: "moodlog",
|
||||
@@ -273,7 +266,6 @@ describe("IntegrationsSection — single-status-display contract (A5)", () => {
|
||||
lastSuccessAt: "2026-05-08T10:00:00.000Z",
|
||||
lastAttemptAt: "2026-05-08T10:00:00.000Z",
|
||||
lastError: null,
|
||||
consecutiveFailures: 0,
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -300,7 +292,6 @@ describe("IntegrationsSection — single-status-display contract (A5)", () => {
|
||||
lastSuccessAt: "2026-05-08T12:00:00.000Z",
|
||||
lastAttemptAt: "2026-05-09T18:00:00.000Z",
|
||||
lastError: "Withings activity error: 293",
|
||||
consecutiveFailures: 30,
|
||||
},
|
||||
{
|
||||
integration: "moodlog",
|
||||
@@ -308,7 +299,6 @@ describe("IntegrationsSection — single-status-display contract (A5)", () => {
|
||||
lastSuccessAt: null,
|
||||
lastAttemptAt: null,
|
||||
lastError: null,
|
||||
consecutiveFailures: 0,
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -339,7 +329,6 @@ describe("IntegrationsSection — single-status-display contract (A5)", () => {
|
||||
lastSuccessAt: "2026-05-09T18:00:00.000Z",
|
||||
lastAttemptAt: "2026-05-09T18:00:00.000Z",
|
||||
lastError: null,
|
||||
consecutiveFailures: 0,
|
||||
},
|
||||
{
|
||||
integration: "moodlog",
|
||||
@@ -347,7 +336,6 @@ describe("IntegrationsSection — single-status-display contract (A5)", () => {
|
||||
lastSuccessAt: "2026-05-09T17:00:00.000Z",
|
||||
lastAttemptAt: "2026-05-09T17:00:00.000Z",
|
||||
lastError: null,
|
||||
consecutiveFailures: 0,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
@@ -146,6 +146,29 @@ describe("settings sections — SSR smoke", () => {
|
||||
expect(html).not.toContain("settings.about.");
|
||||
});
|
||||
|
||||
it("<AboutSection> exposes a Replay-tour button for the W5 chain gate", () => {
|
||||
const html = render(<AboutSection />);
|
||||
// v1.4.47 W5 — the spotlight tour now auto-launches only ≥ 24 h
|
||||
// after the wizard finishes (see `shouldAutoLaunchTour` in
|
||||
// `components/onboarding/tour-launcher.tsx`). A first-day user who
|
||||
// *does* want the tour needs a discoverable manual trigger, and
|
||||
// Settings → About is the surface every user can find. Pin both
|
||||
// the data-testid and the visible copy so a future refactor can't
|
||||
// silently drop the button.
|
||||
expect(html).toContain('data-testid="about-replay-tour"');
|
||||
expect(html).toContain("Replay the tour");
|
||||
// No raw i18n key leaks past the i18n layer.
|
||||
expect(html).not.toContain("settings.about.tourReplay");
|
||||
});
|
||||
|
||||
it("<AboutSection> renders the German Replay-tour label end-to-end", () => {
|
||||
// Marc-voice + i18n parity: the W5 spec explicitly names this
|
||||
// surface "Tour ansehen" in German. Lock the resolved copy so a
|
||||
// future locale-bundle drift doesn't silently fall back to English.
|
||||
const html = render(<AboutSection />, "de");
|
||||
expect(html).toContain("Tour ansehen");
|
||||
});
|
||||
|
||||
it("<AiSection> renders the Insights card", () => {
|
||||
const html = render(<AiSection />);
|
||||
// v1.4.33 IW7 — section renamed from "AI Insights" to "Insights"
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
ArrowUpCircle,
|
||||
BookOpen,
|
||||
Compass,
|
||||
ExternalLink,
|
||||
GitBranch,
|
||||
Info,
|
||||
@@ -13,6 +14,8 @@ import {
|
||||
} from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { setTourForceLaunch } from "@/components/onboarding/tour-launcher";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { useFormatters, useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
|
||||
@@ -116,6 +119,11 @@ export function UpdateBadge({
|
||||
export function AboutSection() {
|
||||
const { t } = useTranslations();
|
||||
const fmt = useFormatters();
|
||||
// Pull the auth user — only needed so the "Replay the tour" button
|
||||
// can stamp a per-user force-launch marker into sessionStorage. The
|
||||
// hook is cheap (a cached `/api/auth/me` read) and every settings
|
||||
// page already lives behind the same auth boundary.
|
||||
const { user } = useAuth();
|
||||
|
||||
const { data: version, isLoading } = useQuery({
|
||||
queryKey: queryKeys.apiVersion(),
|
||||
@@ -129,6 +137,66 @@ export function AboutSection() {
|
||||
staleTime: Infinity,
|
||||
});
|
||||
|
||||
// v1.4.47 W5 — onboarding chain-gate: the spotlight tour now auto-
|
||||
// launches only ≥ 24 h after the wizard finishes (see
|
||||
// `shouldAutoLaunchTour` in `components/onboarding/tour-launcher.tsx`).
|
||||
// The carousel + tour no longer stack into ~90 s of forced onboarding
|
||||
// on first visit. The tradeoff: a first-day user who *does* want the
|
||||
// tour needs a discoverable manual trigger. Settings → About is the
|
||||
// surface every user can find (every locale puts "About" right on the
|
||||
// settings shell), so it carries the replay button next to the
|
||||
// sources/docs links. Settings → Account still has its own
|
||||
// "Restart onboarding tour" — both buttons share the same flow.
|
||||
const [replayingTour, setReplayingTour] = useState(false);
|
||||
const [tourMessage, setTourMessage] = useState<string | null>(null);
|
||||
const [tourMessageType, setTourMessageType] = useState<
|
||||
"success" | "error" | null
|
||||
>(null);
|
||||
|
||||
async function handleReplayTour() {
|
||||
setReplayingTour(true);
|
||||
setTourMessage(null);
|
||||
setTourMessageType(null);
|
||||
try {
|
||||
const res = await fetch("/api/onboarding/tour", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ completed: false }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
setTourMessage(t("settings.savingError"));
|
||||
setTourMessageType("error");
|
||||
return;
|
||||
}
|
||||
// v1.4.47 W5 — drop a per-user force-launch marker into
|
||||
// sessionStorage so the next dashboard mount opens the tour
|
||||
// even when the 24 h auto-launch gate would otherwise suppress
|
||||
// it (e.g. a first-day user who clicked "Replay the tour"
|
||||
// from About 30 min after finishing the wizard). The marker is
|
||||
// session-scoped — a page reload clears it — and consumed
|
||||
// exactly once by the launcher on its next mount.
|
||||
if (user?.id) setTourForceLaunch(user.id);
|
||||
// Fire the same window event Settings → Account uses; a
|
||||
// dashboard already mounted in another tab / background reopens
|
||||
// the spotlight immediately. When the user lands on the
|
||||
// dashboard next, the launcher reads the freshly-flipped
|
||||
// `onboardingTourCompleted = false` from `/api/auth/me` and the
|
||||
// force-launch marker set above; either path lands on the tour.
|
||||
try {
|
||||
window.dispatchEvent(new CustomEvent("healthlog:tour-restart"));
|
||||
} catch {
|
||||
/* ignore — only matters if a dashboard is already mounted */
|
||||
}
|
||||
setTourMessage(t("onboarding.tour.restartConfirmation"));
|
||||
setTourMessageType("success");
|
||||
} catch {
|
||||
setTourMessage(t("common.networkError"));
|
||||
setTourMessageType("error");
|
||||
} finally {
|
||||
setReplayingTour(false);
|
||||
}
|
||||
}
|
||||
|
||||
// v1.4.36 W4f — the explicit "Check for updates" button is gone;
|
||||
// only the 24 h auto-check stays. When the auto-check reports
|
||||
// `newer_available`, a subtle upward-arrow badge appears next to
|
||||
@@ -317,6 +385,58 @@ export function AboutSection() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* v1.4.47 W5 — Replay tour card. The dashboard spotlight
|
||||
tour now auto-launches only ≥ 24 h after the wizard finishes,
|
||||
so a first-day user who wants to see it still needs a
|
||||
discoverable manual trigger. About is the surface every user
|
||||
can find (it's the standard "where am I?" stop in the
|
||||
settings shell), so the button lives here next to version +
|
||||
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="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between sm:gap-2">
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<Compass className="text-primary h-5 w-5" />
|
||||
<h2 className="text-lg font-semibold">
|
||||
{t("settings.about.tourReplay")}
|
||||
</h2>
|
||||
</div>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("settings.about.tourReplayHint")}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={handleReplayTour}
|
||||
disabled={replayingTour}
|
||||
data-testid="about-replay-tour"
|
||||
className="w-full shrink-0 sm:w-auto"
|
||||
>
|
||||
{replayingTour ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin motion-reduce:animate-none" />
|
||||
) : (
|
||||
<Compass className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
{t("settings.about.tourReplay")}
|
||||
</Button>
|
||||
</div>
|
||||
{tourMessage && (
|
||||
<p
|
||||
role="alert"
|
||||
className={`mt-2 text-xs ${
|
||||
tourMessageType === "success"
|
||||
? "text-dracula-green"
|
||||
: "text-destructive"
|
||||
}`}
|
||||
>
|
||||
{tourMessage}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* v1.4.36 W4f — the dedicated "Updates" card with the
|
||||
manual "Check for updates" button is gone. The 24 h auto-
|
||||
check still runs on mount and writes its result into the
|
||||
|
||||
@@ -39,6 +39,7 @@ import { DateInput } from "@/components/ui/date-input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { NativeSelect } from "@/components/ui/native-select";
|
||||
import { PasswordInput } from "@/components/ui/password-input";
|
||||
import { setTourForceLaunch } from "@/components/onboarding/tour-launcher";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { formatDate } from "@/lib/format";
|
||||
import { locales, localeLabels, type Locale } from "@/lib/i18n/config";
|
||||
@@ -292,6 +293,12 @@ export function AccountSection() {
|
||||
setTourMsgType("error");
|
||||
return;
|
||||
}
|
||||
// v1.4.47 W5 — drop a per-user force-launch marker in
|
||||
// sessionStorage so the next dashboard mount launches the tour
|
||||
// even when the new 24 h auto-launch gate would otherwise
|
||||
// suppress it (e.g. user just finished the wizard 5 min ago).
|
||||
// The launcher reads-and-clears the marker on mount.
|
||||
if (user?.id) setTourForceLaunch(user.id);
|
||||
// Refetch the auth payload so `<TourLauncher>` re-evaluates
|
||||
// with the new flag, AND fire a window event so a launcher
|
||||
// already mounted on the dashboard reopens immediately
|
||||
|
||||
@@ -163,6 +163,128 @@ export function AiSection() {
|
||||
</header>
|
||||
|
||||
<AiInsightsCard isAuthenticated={isAuthenticated} />
|
||||
|
||||
{/* 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} />
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.4.47 W3 — "Hide Coach" toggle card.
|
||||
*
|
||||
* Persists via `PATCH /api/auth/me/disable-coach`; the response
|
||||
* invalidates `queryKeys.authMe()` so every Coach mount point on the
|
||||
* client (`<LayoutCoachFab>`, `<LayoutCoachMount>`, the inline
|
||||
* `<CoachLaunchButton>` pill, the `/targets` page CTAs) re-renders
|
||||
* with the new `user.disableCoach` value on the next React Query
|
||||
* refetch tick — no full reload required.
|
||||
*
|
||||
* The optimistic-update pattern mirrors `<MoodReminderCard>`: the
|
||||
* Switch flips immediately, the mutation rolls back on error.
|
||||
*/
|
||||
function DisableCoachCard({ isAuthenticated }: { isAuthenticated: boolean }) {
|
||||
const { t } = useTranslations();
|
||||
const queryClient = useQueryClient();
|
||||
const { user } = useAuth();
|
||||
|
||||
// Optimistic-update flag — null means "no in-flight change",
|
||||
// otherwise it overrides the wire value until the mutation
|
||||
// settles. Mirrors the `<MoodReminderCard>` pattern so the Switch
|
||||
// reacts instantly to the user's tap.
|
||||
const [optimistic, setOptimistic] = useState<boolean | null>(null);
|
||||
const [msg, setMsg] = useState<string | null>(null);
|
||||
const [msgType, setMsgType] = useState<"success" | "error" | null>(null);
|
||||
|
||||
const checked = optimistic ?? user?.disableCoach ?? false;
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: async (next: boolean) => {
|
||||
const res = await fetch("/api/auth/me/disable-coach", {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ disableCoach: next }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const json = (await res.json().catch(() => ({}))) as {
|
||||
error?: string;
|
||||
};
|
||||
throw new Error(json.error ?? `HTTP ${res.status}`);
|
||||
}
|
||||
return next;
|
||||
},
|
||||
onSuccess: (next) => {
|
||||
setMsg(
|
||||
next
|
||||
? t("settings.ai.disableCoach.savedHidden")
|
||||
: t("settings.ai.disableCoach.savedShown"),
|
||||
);
|
||||
setMsgType("success");
|
||||
// Surface the new value to every Coach gate via the shared
|
||||
// `useAuth()` query — the gates re-render once React Query
|
||||
// re-fetches /api/auth/me with the updated `disableCoach` field.
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.authMe() });
|
||||
setOptimistic(null);
|
||||
},
|
||||
onError: (err) => {
|
||||
setOptimistic(null);
|
||||
setMsg(
|
||||
err instanceof Error
|
||||
? err.message
|
||||
: t("settings.ai.disableCoach.saveError"),
|
||||
);
|
||||
setMsgType("error");
|
||||
},
|
||||
});
|
||||
|
||||
function handleToggle(next: boolean) {
|
||||
setOptimistic(next);
|
||||
setMsg(null);
|
||||
setMsgType(null);
|
||||
mutation.mutate(next);
|
||||
}
|
||||
|
||||
return (
|
||||
<section
|
||||
aria-labelledby="settings-ai-disable-coach-title"
|
||||
data-testid="settings-disable-coach-card"
|
||||
className="bg-card border-border space-y-3 rounded-xl border p-6"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="pr-2">
|
||||
<h2
|
||||
id="settings-ai-disable-coach-title"
|
||||
className="text-base font-semibold"
|
||||
>
|
||||
{t("settings.ai.disableCoach.title")}
|
||||
</h2>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("settings.ai.disableCoach.description")}
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
data-testid="settings-disable-coach-switch"
|
||||
checked={checked}
|
||||
onCheckedChange={handleToggle}
|
||||
disabled={!isAuthenticated || mutation.isPending}
|
||||
aria-label={t("settings.ai.disableCoach.toggleAria")}
|
||||
/>
|
||||
</div>
|
||||
{msg && (
|
||||
<p
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
className={
|
||||
msgType === "error"
|
||||
? "text-destructive text-xs"
|
||||
: "text-muted-foreground text-xs"
|
||||
}
|
||||
>
|
||||
{msg}
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useState } from "react";
|
||||
import { useId, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
LayoutDashboard,
|
||||
@@ -9,7 +9,24 @@ import {
|
||||
Loader2,
|
||||
ArrowUp,
|
||||
ArrowDown,
|
||||
GripVertical,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
DndContext,
|
||||
KeyboardSensor,
|
||||
PointerSensor,
|
||||
closestCenter,
|
||||
useSensor,
|
||||
useSensors,
|
||||
type DragEndEvent,
|
||||
} from "@dnd-kit/core";
|
||||
import {
|
||||
SortableContext,
|
||||
sortableKeyboardCoordinates,
|
||||
useSortable,
|
||||
verticalListSortingStrategy,
|
||||
} from "@dnd-kit/sortable";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
@@ -29,6 +46,30 @@ import {
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
|
||||
/**
|
||||
* v1.4.47 W4 — pure reorder helper shared by the arrow buttons and the
|
||||
* @dnd-kit drag-end handler. Both surfaces produce the same `widgets[]`
|
||||
* shape (`order: 0..n-1`) so the existing PUT contract stays untouched
|
||||
* and either input mode flushes via the same Save mutation. Exported so
|
||||
* the unit test can pin the contract without spinning up a DndContext.
|
||||
*/
|
||||
export function reorderWidgets(
|
||||
widgets: readonly { id: string; order: number }[],
|
||||
fromId: string,
|
||||
toId: string,
|
||||
): { id: string; order: number }[] {
|
||||
const sorted = [...widgets].sort((a, b) => a.order - b.order);
|
||||
const fromIdx = sorted.findIndex((w) => w.id === fromId);
|
||||
const toIdx = sorted.findIndex((w) => w.id === toId);
|
||||
if (fromIdx < 0 || toIdx < 0 || fromIdx === toIdx) {
|
||||
return sorted.map((w, i) => ({ ...w, order: i }));
|
||||
}
|
||||
const next = [...sorted];
|
||||
const [removed] = next.splice(fromIdx, 1);
|
||||
next.splice(toIdx, 0, removed);
|
||||
return next.map((w, i) => ({ ...w, order: i }));
|
||||
}
|
||||
|
||||
const WIDGET_LABEL_KEYS: Record<DashboardWidgetId, string> = {
|
||||
weight: "dashboard.weight",
|
||||
bp: "dashboard.bloodPressure",
|
||||
@@ -53,6 +94,25 @@ const WIDGET_LABEL_KEYS: Record<DashboardWidgetId, string> = {
|
||||
export function DashboardLayoutSection({ id }: { id: string }) {
|
||||
const { t } = useTranslations();
|
||||
const queryClient = useQueryClient();
|
||||
// v1.4.47 W4 — stable id namespace for the drag-handle `aria-describedby`
|
||||
// tooltip. One hint paragraph is rendered once at the bottom of the list
|
||||
// and referenced by every drag handle in this section.
|
||||
const dragHintId = useId();
|
||||
|
||||
// v1.4.47 W4 — sensors: pointer for mouse/touch, keyboard for Tab + Space
|
||||
// + arrow-key reordering. The KeyboardSensor still works for users who
|
||||
// tab to the GripVertical handle; the legacy ArrowUp / ArrowDown buttons
|
||||
// below remain the primary keyboard surface for accessibility.
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, {
|
||||
// Activation distance avoids the drag stealing every click on the row
|
||||
// switches — only a 6 px pointer-down → move counts as drag intent.
|
||||
activationConstraint: { distance: 6 },
|
||||
}),
|
||||
useSensor(KeyboardSensor, {
|
||||
coordinateGetter: sortableKeyboardCoordinates,
|
||||
}),
|
||||
);
|
||||
|
||||
const { data: remote, isLoading } = useQuery({
|
||||
queryKey: queryKeys.dashboardWidgets(),
|
||||
@@ -152,6 +212,37 @@ export function DashboardLayoutSection({ id }: { id: string }) {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.4.47 W4 — drag-and-drop reorder via @dnd-kit. Persists the same
|
||||
* `order` rewrite shape the arrow buttons already use, so save / cancel
|
||||
* / reset and the existing draft state machine work unchanged. The
|
||||
* pointer + keyboard sensors are wired in the same `useSensors` call;
|
||||
* keyboard a11y still also works through the legacy arrow buttons that
|
||||
* remain on every row.
|
||||
*/
|
||||
function handleDragEnd(event: DragEndEvent) {
|
||||
if (!layout) return;
|
||||
const { active, over } = event;
|
||||
if (!over || active.id === over.id) return;
|
||||
const reordered = reorderWidgets(
|
||||
layout.widgets,
|
||||
String(active.id),
|
||||
String(over.id),
|
||||
);
|
||||
// `reorderWidgets` operates on the pure { id, order } shape; map back
|
||||
// to the full DashboardWidgetConfig so `visible` / `tileVisible` are
|
||||
// preserved on every row.
|
||||
const byId = new Map(layout.widgets.map((w) => [w.id, w]));
|
||||
setDraft({
|
||||
...layout,
|
||||
widgets: reordered.map((r, i) => {
|
||||
const original = byId.get(r.id as DashboardWidgetId);
|
||||
if (!original) return { ...r, order: i } as never;
|
||||
return { ...original, order: i };
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
// Presence of a draft implies dirty — no JSON comparison needed.
|
||||
const dirty = draft !== null && layout !== null;
|
||||
|
||||
@@ -259,7 +350,11 @@ export function DashboardLayoutSection({ id }: { id: string }) {
|
||||
right-hand spacer reserves the width of two
|
||||
size-11/sm:size-9 buttons so the Tile / Chart column
|
||||
headers continue to line up with the switches below. */}
|
||||
{/* v1.4.47 W4 — column header spacer additionally reserves the
|
||||
width of the new drag-handle icon (w-7) so Tile / Chart
|
||||
alignment with the row switches below stays pixel-perfect. */}
|
||||
<div className="text-muted-foreground flex items-center gap-2 px-3 pb-1 text-[10px] font-medium tracking-wide uppercase">
|
||||
<span className="w-7" aria-hidden="true" />
|
||||
<span className="flex-1" aria-hidden="true" />
|
||||
<span className="w-12 text-center">
|
||||
{t("dashboard.layoutTileColumn")}
|
||||
@@ -267,83 +362,58 @@ export function DashboardLayoutSection({ id }: { id: string }) {
|
||||
<span className="w-12 text-center">
|
||||
{t("dashboard.layoutChartColumn")}
|
||||
</span>
|
||||
<span
|
||||
className="w-22 sm:w-18"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span className="w-22 sm:w-18" aria-hidden="true" />
|
||||
</div>
|
||||
{[...layout.widgets]
|
||||
.sort((a, b) => a.order - b.order)
|
||||
.map((widget, index, arr) => {
|
||||
const labelKey = WIDGET_LABEL_KEYS[widget.id] ?? widget.id;
|
||||
const tileChecked =
|
||||
typeof widget.tileVisible === "boolean"
|
||||
? widget.tileVisible
|
||||
: widget.visible;
|
||||
return (
|
||||
// v1.4.29 — drag-list row compactness. The legacy
|
||||
// vertical 44+44-px arrow stack inflated each row
|
||||
// to ~116 px; the horizontal arrow pair on the
|
||||
// trailing edge drops the row to 48 px (`min-h-12`)
|
||||
// while preserving the 44-px mobile tap target
|
||||
// (`size-11`). `sm:size-9` shrinks the buttons
|
||||
// visually on desktop where pointer accuracy is
|
||||
// higher.
|
||||
<div
|
||||
key={widget.id}
|
||||
className="border-border bg-background/30 flex min-h-12 items-center gap-2 rounded-md border px-3 py-2"
|
||||
{(() => {
|
||||
const sortedWidgets = [...layout.widgets].sort(
|
||||
(a, b) => a.order - b.order,
|
||||
);
|
||||
const sortedIds = sortedWidgets.map((w) => w.id);
|
||||
return (
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<SortableContext
|
||||
items={sortedIds}
|
||||
strategy={verticalListSortingStrategy}
|
||||
>
|
||||
<span
|
||||
className="flex-1 truncate text-sm"
|
||||
title={t(labelKey)}
|
||||
>
|
||||
{t(labelKey)}
|
||||
</span>
|
||||
<div className="flex w-12 justify-center">
|
||||
<Switch
|
||||
checked={tileChecked}
|
||||
onCheckedChange={(v) => toggleTile(widget.id, v)}
|
||||
aria-label={`${t(labelKey)} — ${t("dashboard.layoutTileColumn")}`}
|
||||
{sortedWidgets.map((widget, index, arr) => (
|
||||
<SortableWidgetRow
|
||||
key={widget.id}
|
||||
widget={widget}
|
||||
labelKey={WIDGET_LABEL_KEYS[widget.id] ?? widget.id}
|
||||
index={index}
|
||||
total={arr.length}
|
||||
dragHintId={dragHintId}
|
||||
disabled={saveMutation.isPending}
|
||||
data-slot="widget-tile-switch"
|
||||
labels={{
|
||||
tileColumn: t("dashboard.layoutTileColumn"),
|
||||
chartColumn: t("dashboard.layoutChartColumn"),
|
||||
moveUp: t("dashboard.moveUp"),
|
||||
moveDown: t("dashboard.moveDown"),
|
||||
dragHandle: t("dashboard.dragHandle"),
|
||||
widgetLabel: t(
|
||||
WIDGET_LABEL_KEYS[widget.id] ?? widget.id,
|
||||
),
|
||||
}}
|
||||
onToggleTile={toggleTile}
|
||||
onToggleChart={toggle}
|
||||
onMove={move}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex w-12 justify-center">
|
||||
<Switch
|
||||
checked={widget.visible}
|
||||
onCheckedChange={(v) => toggle(widget.id, v)}
|
||||
aria-label={`${t(labelKey)} — ${t("dashboard.layoutChartColumn")}`}
|
||||
disabled={saveMutation.isPending}
|
||||
data-slot="widget-chart-switch"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-11 sm:size-9"
|
||||
onClick={() => move(widget.id, -1)}
|
||||
disabled={index === 0 || saveMutation.isPending}
|
||||
aria-label={t("dashboard.moveUp")}
|
||||
>
|
||||
<ArrowUp className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-11 sm:size-9"
|
||||
onClick={() => move(widget.id, 1)}
|
||||
disabled={
|
||||
index === arr.length - 1 || saveMutation.isPending
|
||||
}
|
||||
aria-label={t("dashboard.moveDown")}
|
||||
>
|
||||
<ArrowDown className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
))}
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
);
|
||||
})()}
|
||||
{/* v1.4.47 W4 — single shared aria-describedby target for all
|
||||
drag handles. Screen readers read this once per focused
|
||||
handle; sighted users see it in the native browser tooltip
|
||||
via the matching `title` attribute on each handle. */}
|
||||
<p id={dragHintId} className="text-muted-foreground sr-only">
|
||||
{t("dashboard.dragHandleHint")}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -382,3 +452,153 @@ export function DashboardLayoutSection({ id }: { id: string }) {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.4.47 W4 — sortable row primitive extracted from the section render
|
||||
* so the @dnd-kit `useSortable` hook stays scoped to one row. Translation
|
||||
* strings are passed in pre-resolved (rather than calling `useTranslations`
|
||||
* inside) so this component stays cheap to re-render for the 13+ rows.
|
||||
*
|
||||
* The drag handle is the only listener-bearing surface — the row body
|
||||
* stays click-through so the switches and arrow buttons keep working.
|
||||
* Pointer activation has a 6 px distance constraint (configured on the
|
||||
* parent sensor) so a tap on the handle never accidentally drags.
|
||||
*/
|
||||
interface SortableWidgetRowProps {
|
||||
widget: {
|
||||
id: DashboardWidgetId;
|
||||
visible: boolean;
|
||||
tileVisible?: boolean;
|
||||
order: number;
|
||||
};
|
||||
labelKey: string;
|
||||
index: number;
|
||||
total: number;
|
||||
dragHintId: string;
|
||||
disabled: boolean;
|
||||
labels: {
|
||||
tileColumn: string;
|
||||
chartColumn: string;
|
||||
moveUp: string;
|
||||
moveDown: string;
|
||||
dragHandle: string;
|
||||
widgetLabel: string;
|
||||
};
|
||||
onToggleTile: (id: DashboardWidgetId, value: boolean) => void;
|
||||
onToggleChart: (id: DashboardWidgetId, value: boolean) => void;
|
||||
onMove: (id: DashboardWidgetId, delta: -1 | 1) => void;
|
||||
}
|
||||
|
||||
function SortableWidgetRow({
|
||||
widget,
|
||||
index,
|
||||
total,
|
||||
dragHintId,
|
||||
disabled,
|
||||
labels,
|
||||
onToggleTile,
|
||||
onToggleChart,
|
||||
onMove,
|
||||
}: SortableWidgetRowProps) {
|
||||
const {
|
||||
attributes,
|
||||
listeners,
|
||||
setNodeRef,
|
||||
transform,
|
||||
transition,
|
||||
isDragging,
|
||||
} = useSortable({ id: widget.id });
|
||||
|
||||
const style: React.CSSProperties = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
};
|
||||
|
||||
const tileChecked =
|
||||
typeof widget.tileVisible === "boolean"
|
||||
? widget.tileVisible
|
||||
: widget.visible;
|
||||
|
||||
return (
|
||||
// v1.4.29 — row sized at 48 px (`min-h-12`) with 44-px mobile tap
|
||||
// targets preserved on the trailing arrow buttons (`size-11`),
|
||||
// shrunk to `sm:size-9` on desktop. v1.4.47 W4 — `isDragging`
|
||||
// raises the row visually (elevation + accent ring) so the ghost
|
||||
// overlay is unambiguous during a drag.
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
data-slot="widget-row"
|
||||
data-dragging={isDragging ? "true" : undefined}
|
||||
className={`border-border bg-background/30 flex min-h-12 items-center gap-2 rounded-md border px-3 py-2 ${
|
||||
isDragging ? "ring-primary z-10 opacity-90 shadow-lg ring-2" : ""
|
||||
}`}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
aria-label={`${labels.dragHandle} — ${labels.widgetLabel}`}
|
||||
// v1.4.47 W4 — `aria-describedby` is set after `{...attributes}`
|
||||
// so our shared hint paragraph wins over dnd-kit's own announcer
|
||||
// hookup. The announcer still fires on drag-start / drag-over /
|
||||
// drag-end via the screenReaderInstructions slot below.
|
||||
aria-describedby={dragHintId}
|
||||
title={labels.dragHandle}
|
||||
disabled={disabled}
|
||||
data-slot="widget-drag-handle"
|
||||
// v1.4.47 W10 design-H1 — extend the WCAG 2.5.5 hit target to
|
||||
// 44 × 44 px via a `::before` pseudo-element while keeping the
|
||||
// visible GripVertical at 28 px (matches the Switch primitive
|
||||
// pattern from v1.4.43 W5-H1). v1.4.47 W10 design-M1 — drop
|
||||
// dnd-kit's CSS transition under prefers-reduced-motion.
|
||||
className="text-muted-foreground hover:text-foreground focus-visible:ring-ring focus-visible:ring-offset-background -m-1 relative inline-flex h-7 w-7 cursor-grab touch-none items-center justify-center rounded transition-colors before:absolute before:inset-[-8px] before:content-[''] focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none active:cursor-grabbing disabled:cursor-not-allowed disabled:opacity-50 motion-reduce:transition-none"
|
||||
>
|
||||
<GripVertical className="h-4 w-4" />
|
||||
</button>
|
||||
<span className="flex-1 truncate text-sm" title={labels.widgetLabel}>
|
||||
{labels.widgetLabel}
|
||||
</span>
|
||||
<div className="flex w-12 justify-center">
|
||||
<Switch
|
||||
checked={tileChecked}
|
||||
onCheckedChange={(v) => onToggleTile(widget.id, v)}
|
||||
aria-label={`${labels.widgetLabel} — ${labels.tileColumn}`}
|
||||
disabled={disabled}
|
||||
data-slot="widget-tile-switch"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex w-12 justify-center">
|
||||
<Switch
|
||||
checked={widget.visible}
|
||||
onCheckedChange={(v) => onToggleChart(widget.id, v)}
|
||||
aria-label={`${labels.widgetLabel} — ${labels.chartColumn}`}
|
||||
disabled={disabled}
|
||||
data-slot="widget-chart-switch"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-11 sm:size-9"
|
||||
onClick={() => onMove(widget.id, -1)}
|
||||
disabled={index === 0 || disabled}
|
||||
aria-label={labels.moveUp}
|
||||
>
|
||||
<ArrowUp className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-11 sm:size-9"
|
||||
onClick={() => onMove(widget.id, 1)}
|
||||
disabled={index === total - 1 || disabled}
|
||||
aria-label={labels.moveDown}
|
||||
>
|
||||
<ArrowDown className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -73,7 +73,6 @@ interface IntegrationStatusViewModel {
|
||||
lastSuccessAt: string | null;
|
||||
lastAttemptAt: string | null;
|
||||
lastError: string | null;
|
||||
consecutiveFailures: number;
|
||||
consecutiveFailuresByKind?: {
|
||||
transient: number;
|
||||
reauth_required: number;
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
import { Textarea } from "../textarea";
|
||||
|
||||
describe("<Textarea>", () => {
|
||||
it("pins text-base on mobile and shrinks to text-sm on sm+ (iOS zoom defence)", () => {
|
||||
const html = renderToStaticMarkup(<Textarea id="note" />);
|
||||
expect(html).toContain("text-base");
|
||||
expect(html).toContain("sm:text-sm");
|
||||
});
|
||||
|
||||
it("floors at min-h-11 on mobile and min-h-9 on sm+ (WCAG 2.5.5 tap target)", () => {
|
||||
const html = renderToStaticMarkup(<Textarea id="note" />);
|
||||
expect(html).toContain("min-h-11");
|
||||
expect(html).toContain("sm:min-h-9");
|
||||
});
|
||||
|
||||
it("defaults autoComplete to off and tells password managers to skip", () => {
|
||||
const html = renderToStaticMarkup(<Textarea id="note" />);
|
||||
expect(html).toContain('autoComplete="off"');
|
||||
expect(html).toContain('data-lpignore="true"');
|
||||
expect(html).toContain('data-1p-ignore="true"');
|
||||
});
|
||||
|
||||
it("defaults autoCapitalize to sentences for prose-like free-text input", () => {
|
||||
const html = renderToStaticMarkup(<Textarea id="note" />);
|
||||
expect(html).toContain('autoCapitalize="sentences"');
|
||||
});
|
||||
|
||||
it("enables spellCheck by default", () => {
|
||||
const html = renderToStaticMarkup(<Textarea id="note" />);
|
||||
expect(html).toContain('spellCheck="true"');
|
||||
});
|
||||
|
||||
it("emits the data-slot attribute (shadcn convention)", () => {
|
||||
const html = renderToStaticMarkup(<Textarea id="note" />);
|
||||
expect(html).toContain('data-slot="textarea"');
|
||||
});
|
||||
|
||||
it("allows callers to override autoCapitalize for code / JSON input", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<Textarea id="json" autoCapitalize="none" spellCheck={false} />,
|
||||
);
|
||||
expect(html).toContain('autoCapitalize="none"');
|
||||
expect(html).not.toContain('spellCheck="true"');
|
||||
});
|
||||
|
||||
it("respects an explicit autoComplete value and drops the ignore attrs", () => {
|
||||
const html = renderToStaticMarkup(<Textarea id="bio" autoComplete="on" />);
|
||||
expect(html).toContain('autoComplete="on"');
|
||||
expect(html).not.toContain('data-lpignore="true"');
|
||||
expect(html).not.toContain('data-1p-ignore="true"');
|
||||
});
|
||||
|
||||
it("merges caller className via cn() without dropping defaults", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<Textarea id="note" className="font-mono" />,
|
||||
);
|
||||
expect(html).toContain("font-mono");
|
||||
// iOS zoom defence still present after merge
|
||||
expect(html).toContain("text-base");
|
||||
expect(html).toContain("sm:text-sm");
|
||||
});
|
||||
|
||||
it("is wrapped in React.forwardRef so refs reach the <textarea>", () => {
|
||||
// React tags forwardRef components with a sentinel symbol on
|
||||
// `$$typeof`. Verifying the sentinel keeps the assertion
|
||||
// dependency-free (no testing-library / DOM env needed) while
|
||||
// still catching a regression that strips the wrapper.
|
||||
const forwardRefSymbol = Symbol.for("react.forward_ref");
|
||||
const componentMeta = Textarea as unknown as {
|
||||
$$typeof: symbol;
|
||||
render?: unknown;
|
||||
};
|
||||
expect(componentMeta.$$typeof).toBe(forwardRefSymbol);
|
||||
expect(typeof componentMeta.render).toBe("function");
|
||||
});
|
||||
|
||||
it("forwards rows / placeholder / maxLength to the underlying element", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<Textarea
|
||||
id="note"
|
||||
rows={8}
|
||||
placeholder="Tell us more"
|
||||
maxLength={5000}
|
||||
/>,
|
||||
);
|
||||
expect(html).toContain('rows="8"');
|
||||
expect(html).toContain('placeholder="Tell us more"');
|
||||
expect(html).toContain('maxLength="5000"');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,76 @@
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/**
|
||||
* Base textarea primitive — mirror of `<Input>` for multi-line input.
|
||||
*
|
||||
* v1.4.47 W2 — extracted the iOS-zoom defence + tap-target floor +
|
||||
* autocomplete defaults that the bugreport / medications-import /
|
||||
* side-effects / admin-feedback textareas were copy-pasting into a
|
||||
* single primitive. Six call sites previously each maintained their
|
||||
* own className string; any drift between them was a paper-cut waiting
|
||||
* to happen.
|
||||
*
|
||||
* What it bakes in:
|
||||
*
|
||||
* - `text-base sm:text-sm` — iOS Safari zooms the viewport on
|
||||
* focus when the font-size renders below 16 px, which yanks the
|
||||
* keyboard up and leaves the user lost. 16 px on mobile holds
|
||||
* the viewport; 14 px on `sm`+ keeps the compact desktop look.
|
||||
*
|
||||
* - `min-h-11 sm:min-h-9` — WCAG 2.5.5 / Apple HIG floor of 44 px
|
||||
* on touch screens, shrinking to 36 px once we know we have a
|
||||
* pointer (covers `rows={1}` single-line uses without dropping
|
||||
* under the tap-target floor).
|
||||
*
|
||||
* - `autoCapitalize="sentences"` — free-text fields read as prose
|
||||
* on iOS / Android by default. Caller can override with `"none"`
|
||||
* for code / JSON / IDs.
|
||||
*
|
||||
* - `spellCheck` defaults to `true` — same reasoning.
|
||||
*
|
||||
* - `autoComplete` defaults to `"off"` for the same reason as
|
||||
* `<Input>`: HealthLog is a health-data app, and we don't want
|
||||
* a password manager pasting the user's saved password into a
|
||||
* free-text textarea that we then persist server-side. Caller
|
||||
* can opt back in (e.g. `"on"`) per field.
|
||||
*
|
||||
* Composition is via `cn(…)` so callers can override anything — the
|
||||
* primitive defaults are the strict pass, not the only pass.
|
||||
*/
|
||||
const Textarea = React.forwardRef<
|
||||
HTMLTextAreaElement,
|
||||
React.ComponentProps<"textarea">
|
||||
>(function Textarea(
|
||||
{ className, autoComplete, autoCapitalize, spellCheck, ...props },
|
||||
ref,
|
||||
) {
|
||||
const resolvedAutoComplete = autoComplete ?? "off";
|
||||
const resolvedAutoCapitalize = autoCapitalize ?? "sentences";
|
||||
const resolvedSpellCheck = spellCheck ?? true;
|
||||
const skipAutofill = resolvedAutoComplete === "off";
|
||||
|
||||
return (
|
||||
<textarea
|
||||
ref={ref}
|
||||
data-slot="textarea"
|
||||
autoComplete={resolvedAutoComplete}
|
||||
autoCapitalize={resolvedAutoCapitalize}
|
||||
spellCheck={resolvedSpellCheck}
|
||||
data-lpignore={skipAutofill ? "true" : undefined}
|
||||
data-1p-ignore={skipAutofill ? "true" : undefined}
|
||||
className={cn(
|
||||
// iOS zoom defence + WCAG 2.5.5 tap-target floor; see the
|
||||
// primitive's docblock for the full reasoning.
|
||||
"border-input bg-background text-foreground placeholder:text-muted-foreground dark:bg-input/30 min-h-11 w-full rounded-md border px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 sm:min-h-9 sm:text-sm",
|
||||
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
|
||||
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
export { Textarea };
|
||||
Reference in new issue
Block a user