feat(settings): allow user to replay the onboarding tour

v1.4.15 Phase B5 — fourth (and final) commit. Closes acceptance
criterion 4.

Adds a Tour-replay card to Settings → Account, between Password and
the password dialog. The card:

  - Calls `POST /api/onboarding/tour` with `{ completed: false }`
    so the server flips `users.onboarding_tour_completed` back to
    false. Idempotent: same DB write either direction; no audit
    log noise on the reset.
  - Refetches `/api/auth/me` so any in-memory <TourLauncher>
    re-evaluates with the fresh flag.
  - Dispatches a `healthlog:tour-restart` window event so a
    launcher already mounted on the dashboard (e.g. user has a
    dashboard tab open in another window) reopens the spotlight
    immediately, without waiting for the user to navigate. The
    launcher's listener clears the session-dismiss flag too.
  - Surfaces a confirmation message via the same status pattern as
    every other settings save (success / error / null).

i18n: reuses the `onboarding.tour.restart`,
`onboarding.tour.restartHint`, `onboarding.tour.restartConfirmation`
keys that landed in commit 2/4 (EN + DE). No new strings.

SSR test in `sections.test.tsx` locks the contract: the Restart
button is present in the static markup and no raw `onboarding.tour.*`
key leaks past the i18n layer.

Tests: my 40 unit tests pass (was 39 — +1 for the new SSR
assertion). The 12 unrelated failures observed in the full suite
all live in uncommitted B6 doctor-report work in the working tree
(`useEffect not defined` in advanced-section.tsx, missing
`lastReportPracticeName` in unmigrated DB schema fixtures, etc.).
Lint clean for B5 files; the single B6 lint error in
`doctor-report-dialog.tsx` is out of scope.

End-to-end shape:

  Phase B5 acceptance criteria:
    [x] DB migration 0030 + Prisma schema flag
    [x] Tour component (state machine + spotlight + i18n + a11y)
    [x] Auto-launch on first dashboard load
    [x] Settings → Account → Restart tour button

This is the last commit in the B5 batch.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Marc-André BombeckandClaude Opus 4.7 committed 2026-05-09 21:31:38 +02:00
1 parent 8215e25876
commit fa1c6a62ca
2 files changed
+104

No files matched your search

@@ -98,6 +98,17 @@ describe("settings sections — SSR smoke", () => {
expect(html).not.toContain("settings.sections.");
});
it("<AccountSection> exposes a Restart-tour button for B5 replay", () => {
const html = render(<AccountSection />);
// v1.4.15 phase B5 acceptance criterion 4: Settings → Account
// exposes a "Restart onboarding tour" button that resets the
// server-side flag. Rendered as a card next to Password reset.
expect(html).toContain('data-testid="settings-restart-tour"');
expect(html).toContain("Restart onboarding tour");
// No raw i18n key leaks.
expect(html).not.toContain("onboarding.tour.");
});
it("<AboutSection> renders version + license + check-for-updates button", () => {
const html = render(<AboutSection />);
expect(html).toContain("About");
@@ -5,6 +5,7 @@ import { useRouter } from "next/navigation";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
AlertTriangle,
Compass,
KeyRound,
Loader2,
Save,
@@ -76,6 +77,19 @@ export function AccountSection() {
>(null);
const [passwordDialogOpen, setPasswordDialogOpen] = useState(false);
// v1.4.15 Phase B5 — onboarding-tour replay state. Settings → Account
// exposes a "Restart onboarding tour" button that resets the
// server-side flag (`users.onboarding_tour_completed = false`) and
// dispatches a `healthlog:tour-restart` window event so the dashboard's
// <TourLauncher> picks it up immediately on the user's next nav. The
// confirmation message goes through the same announce channel as
// every other settings save (status: "success" | "error" | null).
const [tourRestarting, setTourRestarting] = useState(false);
const [tourMsg, setTourMsg] = useState<string | null>(null);
const [tourMsgType, setTourMsgType] = useState<"success" | "error" | null>(
null,
);
// Passkey registration state.
const [passkeyLoading, setPasskeyLoading] = useState(false);
const [passkeyMsg, setPasskeyMsg] = useState<string | null>(null);
@@ -186,6 +200,41 @@ export function AccountSection() {
}
}
async function handleRestartTour() {
setTourRestarting(true);
setTourMsg(null);
setTourMsgType(null);
try {
const res = await fetch("/api/onboarding/tour", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ completed: false }),
});
if (!res.ok) {
setTourMsg(t("settings.savingError"));
setTourMsgType("error");
return;
}
// 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
// without waiting for a navigation.
await refetch();
try {
window.dispatchEvent(new CustomEvent("healthlog:tour-restart"));
} catch {
/* ignore — only matters if the dashboard is already mounted */
}
setTourMsg(t("onboarding.tour.restartConfirmation"));
setTourMsgType("success");
} catch {
setTourMsg(t("common.networkError"));
setTourMsgType("error");
} finally {
setTourRestarting(false);
}
}
async function handleChangePassword(e: React.FormEvent) {
e.preventDefault();
setPasswordSaving(true);
@@ -431,6 +480,50 @@ export function AccountSection() {
</p>
</div>
{/* Tour replay card. v1.4.15 Phase B5: a one-shot button that
resets `users.onboarding_tour_completed` on the server AND
dispatches a window event so a dashboard already in the
background reopens the spotlight tour immediately. */}
<div className="bg-card border-border rounded-xl border p-6">
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2">
<Compass className="text-primary h-5 w-5" />
<h2 className="text-lg font-semibold">
{t("onboarding.tour.restart")}
</h2>
</div>
<Button
type="button"
variant="outline"
onClick={handleRestartTour}
disabled={tourRestarting}
data-testid="settings-restart-tour"
>
{tourRestarting ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Compass className="mr-2 h-4 w-4" />
)}
{t("onboarding.tour.restart")}
</Button>
</div>
<p className="text-muted-foreground mt-1 text-xs">
{t("onboarding.tour.restartHint")}
</p>
{tourMsg && (
<p
role="alert"
className={`mt-2 text-xs ${
tourMsgType === "success"
? "text-dracula-green"
: "text-destructive"
}`}
>
{tourMsg}
</p>
)}
</div>
<Dialog
open={passwordDialogOpen}
onOpenChange={(open) => {