mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-07 12:22:03 +00:00
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:
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) => {
|
||||
|
||||
Reference in new issue
Block a user