diff --git a/docs/configuration.it.md b/docs/configuration.it.md index da64ce5..82fffc6 100644 --- a/docs/configuration.it.md +++ b/docs/configuration.it.md @@ -538,9 +538,9 @@ riuscito, si riprova domani", registrato a livello debug sul server e per il resto silenzioso. Le scritture hanno effetto al **ciclo di poll successivo**, senza restart, perché lo -scheduler rilegge la configurazione a ogni passaggio. Un database nuovo viene -inizializzato con GitHub, Cloudflare e Anthropic così la dashboard è subito utile; la -tua lista non viene più sovrascritta in seguito. +scheduler rilegge la configurazione a ogni passaggio. Un database nuovo parte +senza provider: il wizard di primo avvio chiede quali seguire e manda una notifica di +prova reale (roadmap 4.5). ### 3.3 Variabili d'ambiente diff --git a/docs/configuration.md b/docs/configuration.md index 37a77dd..17f265f 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -520,9 +520,9 @@ just falls back to "last check didn't go through, retrying tomorrow", logged at debug level on the server and otherwise silent. Writes take effect on the **next poll cycle**, with no restart, because the -scheduler re-reads its configuration every pass. A fresh database is seeded with -GitHub, Cloudflare and Anthropic so the dashboard is useful immediately; your own -list is never overwritten afterwards. +scheduler re-reads its configuration every pass. A fresh database starts with +no providers: the first-run wizard asks which ones to watch and sends a real test +notification (roadmap 4.5). ### 3.3 Environment variables diff --git a/package-lock.json b/package-lock.json index fea3564..d900851 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "isitdown", - "version": "2.26.0", + "version": "2.27.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "isitdown", - "version": "2.26.0", + "version": "2.27.0", "license": "MIT", "dependencies": { "express": "^5.2.1", diff --git a/package.json b/package.json index 5ec2181..461d7f2 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "isitdown", - "version": "2.26.0", + "version": "2.27.0", "private": true, "description": "Polls the public status pages of third-party providers and notifies on state transitions", "license": "MIT", diff --git a/src/ui/db/seed.ts b/src/ui/db/seed.ts index 27a2466..d2433f9 100644 --- a/src/ui/db/seed.ts +++ b/src/ui/db/seed.ts @@ -3,7 +3,9 @@ import type { DatabaseSync } from "node:sqlite"; /** * First-boot defaults, so a fresh container shows a working dashboard instead of * an empty grid. Providers are seeded only when the table is empty: an operator's - * own list is never overwritten. + * own list is never overwritten. The server turns that off (`providers: false`): + * a real installation starts empty and the first-run wizard (roadmap 4.5) asks + * which providers to watch. * * Channels are seeded disabled and carry the *name* of the environment variable * holding each credential — never a value. That is the whole secret model of the @@ -239,11 +241,11 @@ const DEFAULT_CHANNELS = [ { id: "webpush", config: {} }, ]; -export function seedDefaults(db: DatabaseSync): void { +export function seedDefaults(db: DatabaseSync, { providers = true }: { providers?: boolean } = {}): void { // One commit for every default row, not one fsync per insert. db.exec("BEGIN"); try { - insertDefaults(db); + insertDefaults(db, providers); db.exec("COMMIT"); } catch (error) { db.exec("ROLLBACK"); @@ -251,9 +253,9 @@ export function seedDefaults(db: DatabaseSync): void { } } -function insertDefaults(db: DatabaseSync): void { +function insertDefaults(db: DatabaseSync, providers: boolean): void { const [existing] = db.prepare("SELECT COUNT(*) AS n FROM services").all() as { n: number }[]; - if ((existing?.n ?? 0) === 0) { + if (providers && (existing?.n ?? 0) === 0) { const insert = db.prepare( "INSERT INTO services (id, name, adapter, base_url, options, enabled, created_at) VALUES (?, ?, ?, ?, NULL, 1, ?)", ); diff --git a/src/ui/dbConfigSource.ts b/src/ui/dbConfigSource.ts index 23c6f6e..f520f25 100644 --- a/src/ui/dbConfigSource.ts +++ b/src/ui/dbConfigSource.ts @@ -130,6 +130,13 @@ const settingsSchema = z.object({ * hundred of them wants compact at any width. */ density: z.enum(["comfortable", "compact"]).catch("comfortable"), + /** + * First-run wizard and guided tour — roadmap 4.5 and 4.6. Instance + * preferences, not browser ones: an installation is set up once, and a + * second browser must not walk the operator through it again. + */ + wizardDone: booleanSetting.catch(false), + tourSeen: booleanSetting.catch(false), /** * When the operator last said "I have seen it" on the Overview's "while you * were away" summary — roadmap 4.4. An instance preference rather than a diff --git a/src/ui/routes/preferences.routes.ts b/src/ui/routes/preferences.routes.ts index 72f57eb..d3ccc1f 100644 --- a/src/ui/routes/preferences.routes.ts +++ b/src/ui/routes/preferences.routes.ts @@ -48,6 +48,8 @@ export function preferencesRoutes(runtime: UiRuntimeCore): Router { .optional(), mapView: z.enum(["off", "map", "globe"]).optional(), density: z.enum(["comfortable", "compact"]).optional(), + wizardDone: z.boolean().optional(), + tourSeen: z.boolean().optional(), // Validated against the runtime's own zone table rather than a hand-kept // list: "is this a zone?" is a question Intl can answer exactly, and a // preference that stores a name nothing can format is a dashboard of @@ -72,6 +74,8 @@ export function preferencesRoutes(runtime: UiRuntimeCore): Router { notificationLocale: settings.notificationLocale, mapView: settings.mapView, density: settings.density, + wizardDone: settings.wizardDone, + tourSeen: settings.tourSeen, timeZone: settings.timeZone, lastSeenAt: settings.lastSeenAt === "" ? null : settings.lastSeenAt, }; diff --git a/src/ui/runtime.ts b/src/ui/runtime.ts index ba38303..5138f3e 100644 --- a/src/ui/runtime.ts +++ b/src/ui/runtime.ts @@ -58,6 +58,8 @@ export interface UiRuntimeOptions { dbPath: string; env: NodeJS.ProcessEnv; logger?: Logger | undefined; + /** Seed the three starter providers into an empty database. Defaults on; the server turns it off. */ + seedProviders?: boolean | undefined; } /** @@ -188,7 +190,7 @@ export async function buildUiRuntime(options: UiRuntimeOptions): Promise> | undefined; try { - runtime = await buildUiRuntime({ dbPath: DB_PATH, env: process.env, logger }); + runtime = await buildUiRuntime({ dbPath: DB_PATH, env: process.env, logger, seedProviders: false }); } catch (error) { logger.error("isitdown ui failed to start", { error: error instanceof Error ? error.message : String(error), diff --git a/src/ui/web/App.tsx b/src/ui/web/App.tsx index f9cce2d..fa49f52 100644 --- a/src/ui/web/App.tsx +++ b/src/ui/web/App.tsx @@ -1,6 +1,8 @@ import { Outlet, useLocation, useParams } from "react-router"; import { useTranslation } from "react-i18next"; import { CommandPalette } from "@/components/CommandPalette.tsx"; +import { GuidedTour } from "@/components/GuidedTour.tsx"; +import { SetupWizard } from "@/components/SetupWizard.tsx"; import { DashboardActions } from "@/components/dashboards/DashboardActions.tsx"; import { Rail } from "@/components/Rail.tsx"; import { Header } from "@/components/Header.tsx"; @@ -81,6 +83,9 @@ export function App() { shortcut has to answer on every screen, and a palette mounted inside a view would take its own key listener away with it on navigation. */} + {/* Roadmaps 4.5 and 4.6: first-run chrome, in the shell for the palette's reason. */} + + {/* Roadmap 4.2. The named dashboards' dialogs and Undo notice, in the shell for the palette's reason: rail, header and ⌘K all ask for them. */} diff --git a/src/ui/web/components/CommandPalette.test.tsx b/src/ui/web/components/CommandPalette.test.tsx index 9387e8e..d955723 100644 --- a/src/ui/web/components/CommandPalette.test.tsx +++ b/src/ui/web/components/CommandPalette.test.tsx @@ -129,4 +129,92 @@ describe("CommandPalette", () => { expect(await screen.findByRole("option", { name: i18n.t("action.poll-now") })).toBeInTheDocument(); expect(await screen.findByRole("option", { name: i18n.t("nav.overview") })).toBeInTheDocument(); }); + + describe("actions — roadmap 4.7", () => { + const config = { + services: [{ id: "github", name: "GitHub", adapter: "statuspage", baseUrl: "https://example.test", enabled: true }], + channels: [ + { id: "telegram", enabled: true, fields: [], locale: "", template: "" }, + { id: "slack", enabled: false, fields: [], locale: "", template: "" }, + ], + }; + const calls = (): unknown[][] => (globalThis.fetch as unknown as { mock: { calls: unknown[][] } }).mock.calls; + + it("mutes a provider: action, provider, duration", async () => { + const user = userEvent.setup(); + renderWithProviders(, { status, config }); + await open(user); + + await user.click(await screen.findByRole("option", { name: i18n.t("palette.mute") })); + await user.click(await screen.findByRole("option", { name: "GitHub" })); + await user.click(await screen.findByRole("option", { name: i18n.t("mute.for-120") })); + + await waitFor(() => { + const patch = calls().find((call) => String(call[0]) === "/config/services/github"); + expect(patch).toBeDefined(); + expect(JSON.parse(String((patch?.[1] as RequestInit).body)).mutedUntil).toEqual(expect.any(String)); + }); + expect(await screen.findByRole("status")).toHaveTextContent(i18n.t("toast.service.muted", { name: "GitHub" })); + }); + + it("steps back from a second page on Backspace in an empty input", async () => { + const user = userEvent.setup(); + renderWithProviders(, { status, config }); + await open(user); + + await user.click(await screen.findByRole("option", { name: i18n.t("palette.mute") })); + // A click on a row takes focus off the input in jsdom; the keyboard path + // (Enter on the row) leaves it there, which is the case this is about. + await user.click(screen.getByPlaceholderText(i18n.t("palette.pick-provider"))); + await user.keyboard("{Backspace}"); + + expect(await screen.findByRole("option", { name: i18n.t("action.poll-now") })).toBeInTheDocument(); + }); + + it("jumps to the latest incident", async () => { + const incidents = { + active: [], + counts: {}, + page: { + page: 1, + pageSize: 1, + total: 1, + items: [{ providerId: "github", incidentId: "i1", name: "Down", impact: "major", status: "investigating", startedAt: "2026-10-20T10:00:00Z", updatedAt: "2026-10-20T10:00:00Z", resolvedAt: null }], + }, + }; + const user = userEvent.setup(); + renderWithProviders(, { status, config, incidents }); + await open(user); + + await user.click(await screen.findByRole("option", { name: i18n.t("palette.last-incident") })); + + await waitFor(() => expect(window.location.hash).toBe("#/incidents/github/i1")); + }); + + it("writes the typed text as a fleet-wide marker", async () => { + const user = userEvent.setup(); + renderWithProviders(, { status, config }); + await open(user); + + await user.click(await screen.findByRole("option", { name: i18n.t("palette.annotate") })); + await user.type(screen.getByPlaceholderText(i18n.t("palette.annotate-placeholder")), "deploy 4.2"); + await user.keyboard("{Enter}"); + + await waitFor(() => { + const post = calls().find((call) => String(call[0]) === "/annotations" && (call[1] as RequestInit).method === "POST"); + expect(JSON.parse(String((post?.[1] as RequestInit).body))).toMatchObject({ label: "deploy 4.2" }); + }); + }); + + it("offers only enabled channels for a test notification", async () => { + const user = userEvent.setup(); + renderWithProviders(, { status, config }); + await open(user); + + await user.click(await screen.findByRole("option", { name: i18n.t("palette.send-test") })); + + expect(await screen.findByRole("option", { name: i18n.t("channel.name.telegram") })).toBeInTheDocument(); + expect(screen.queryByRole("option", { name: i18n.t("channel.name.slack") })).not.toBeInTheDocument(); + }); + }); }); diff --git a/src/ui/web/components/CommandPalette.tsx b/src/ui/web/components/CommandPalette.tsx index 8753c09..8b5ce93 100644 --- a/src/ui/web/components/CommandPalette.tsx +++ b/src/ui/web/components/CommandPalette.tsx @@ -1,7 +1,23 @@ import { useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; -import { useNavigate } from "react-router"; -import { Activity, LayoutGrid, MonitorCog, Plus, RefreshCw, Server, Tv, Upload } from "lucide-react"; +import { generatePath, useNavigate } from "react-router"; +import { + Activity, + BellOff, + BellRing, + FileText, + LayoutGrid, + MessageSquarePlus, + MonitorCog, + Plus, + RefreshCw, + Send, + Server, + Stethoscope, + Tv, + Upload, + History, +} from "lucide-react"; import { CommandDialog, CommandEmpty, @@ -12,7 +28,18 @@ import { CommandSeparator, } from "@/components/ui/command.tsx"; import { NAV_ROUTES, ROUTE_PATHS } from "../../routePaths.ts"; -import { useDashboards, usePollNow, useStatusChrome } from "@/hooks/queries.ts"; +import { + useAnnotationEdits, + useConfigChrome, + useDashboards, + usePollNow, + useServiceMutations, + useStatusChrome, +} from "@/hooks/queries.ts"; +import { AdapterDebugDialog } from "@/components/settings/AdapterDebugDialog.tsx"; +import * as api from "@/lib/api.ts"; +import { isMuted } from "@/lib/mute.ts"; +import type { ServiceDefinition } from "@/lib/types.ts"; import { dashboardCommand, dashboardPath, MAX_DASHBOARDS } from "@/components/dashboards/DashboardActions.tsx"; import { useTheme } from "@/hooks/useTheme.tsx"; @@ -41,6 +68,35 @@ export function openCommandPalette(): void { window.dispatchEvent(new Event(PALETTE_EVENT)); } +/** The durations the Mute action offers — the same four the Settings menu does. */ +const MUTE_DURATIONS: { minutes: number; labelKey: string }[] = [ + { minutes: 30, labelKey: "mute.for-30" }, + { minutes: 120, labelKey: "mute.for-120" }, + { minutes: 480, labelKey: "mute.for-480" }, + { minutes: 1440, labelKey: "mute.for-1440" }, +]; + +/** + * Roadmap 4.7. An action that needs a target asks for it on a second page of + * the same palette, so the keyboard path is "action, provider, Enter" and + * Backspace on an empty input steps back. + */ +type Page = + | { kind: "root" } + | { kind: "provider"; action: "mute" | "diagnose" } + | { kind: "duration"; providerId: string } + | { kind: "annotate" } + | { kind: "channel" }; + +const ROOT: Page = { kind: "root" }; + +interface Notice { + text: string; + tone: "ok" | "error"; +} + +const failure = (error: unknown): string => (error instanceof Error ? error.message : String(error)); + export function CommandPalette() { const { t } = useTranslation(); const navigate = useNavigate(); @@ -50,7 +106,27 @@ export function CommandPalette() { // escape the view's error boundary and take the console down with it. With no // data the palette still opens, with its views and its two actions. const { data: status } = useStatusChrome(); + const { data: config } = useConfigChrome(); + const { patch } = useServiceMutations(); + const { add: addAnnotation } = useAnnotationEdits(); const [open, setOpen] = useState(false); + const [page, setPage] = useState(ROOT); + const [search, setSearch] = useState(""); + // Where an action's answer goes: the palette is not a Settings page, so there + // is no toast stack to land in, and a closed palette would swallow it. + const [notice, setNotice] = useState(null); + const [diagnosing, setDiagnosing] = useState(null); + + const goTo = (next: Page): void => { + setPage(next); + setSearch(""); + setNotice(null); + }; + + // A palette reopened later starts from the top, not from a half-done action. + useEffect(() => { + if (!open) goTo(ROOT); + }, [open]); useEffect(() => { const onKeyDown = (event: KeyboardEvent): void => { @@ -83,18 +159,168 @@ export function CommandPalette() { const providers = (status?.providers ?? []).filter((provider) => provider.enabled); const { data: dashboards = [] } = useDashboards(); + const channels = (config?.channels ?? []).filter((channel) => channel.enabled); + const channelName = (id: string): string => t(`channel.name.${id}`, { defaultValue: id }); + + /** An answer the operator reads in place: the palette stays open on the top page. */ + const answer = (next: Notice): void => { + setPage(ROOT); + setSearch(""); + setNotice(next); + }; + + const setMute = (providerId: string, minutes: number | null): void => { + const name = providers.find((provider) => provider.id === providerId)?.name ?? providerId; + patch.mutate( + { + id: providerId, + patch: { + mutedUntil: minutes === null ? null : new Date(Date.now() + minutes * 60_000).toISOString(), + }, + }, + { + onSuccess: () => + answer({ text: t(minutes === null ? "toast.service.unmuted" : "toast.service.muted", { name }), tone: "ok" }), + onError: (error) => answer({ text: t("toast.failed", { error: failure(error) }), tone: "error" }), + }, + ); + }; + + const jumpToLastIncident = (): void => { + api.getIncidents({ pageSize: 1 }).then( + ({ page: { items } }) => { + const latest = items[0]; + if (latest === undefined) { + answer({ text: t("palette.no-incident"), tone: "error" }); + return; + } + setOpen(false); + void navigate(generatePath(ROUTE_PATHS.incidentDetail, { + providerId: latest.providerId, + incidentId: latest.incidentId, + })); + }, + (error: unknown) => answer({ text: t("toast.failed", { error: failure(error) }), tone: "error" }), + ); + }; + + const addMarker = (label: string): void => { + addAnnotation.mutate( + { at: new Date().toISOString(), label, colour: "accent" }, + { + onSuccess: () => answer({ text: t("palette.marker-added"), tone: "ok" }), + onError: (error) => answer({ text: t("toast.failed", { error: failure(error) }), tone: "error" }), + }, + ); + }; + + const sendTest = (channelId: string): void => { + api.testChannel(channelId).then( + (result) => + answer( + result.ok + ? { text: t("channel.test-ok"), tone: "ok" } + : { text: t("channel.test-failed", { error: result.error ?? "" }), tone: "error" }, + ), + (error: unknown) => answer({ text: t("channel.test-failed", { error: failure(error) }), tone: "error" }), + ); + }; + + const diagnose = (providerId: string): void => { + const service = config?.services.find((entry) => entry.id === providerId); + if (service === undefined) return; + setOpen(false); + setDiagnosing(service); + }; + + const placeholders: Record = { + root: "palette.placeholder", + provider: "palette.pick-provider", + duration: "palette.pick-duration", + annotate: "palette.annotate-placeholder", + channel: "palette.pick-channel", + }; return ( + <> - + { + // Backspace on an empty input is "back", as it is in every + // two-step picker; anywhere else it is just a Backspace. + if (event.key === "Backspace" && search === "" && page.kind !== "root") { + event.preventDefault(); + goTo(ROOT); + } + }} + /> {t("palette.empty")} + {page.kind === "provider" && ( + + {providers.map((provider) => { + const muted = page.action === "mute" && isMuted(provider.mutedUntil); + return ( + { + if (page.action === "diagnose") diagnose(provider.id); + else if (muted) setMute(provider.id, null); + else goTo({ kind: "duration", providerId: provider.id }); + }} + > + {muted ? + ); + })} + + )} + + {page.kind === "duration" && ( + + {MUTE_DURATIONS.map(({ minutes, labelKey }) => ( + setMute(page.providerId, minutes)}> + + ))} + + )} + + {page.kind === "channel" && ( + + {channels.map((channel) => ( + sendTest(channel.id)}> + + ))} + + )} + + {page.kind === "annotate" && search.trim() !== "" && ( + + {/* The input is the marker's text, so cmdk's own filter must not hide + the one item that submits it. */} + addMarker(search.trim())}> + + + )} + + {page.kind === "root" && (<> {NAV_ROUTES.map((route) => ( + {/* Roadmap 4.7. Each is one step from the keyboard; the four that + need a target ask for it on the next page. */} + goTo({ kind: "provider", action: "mute" })}> + + goTo({ kind: "provider", action: "diagnose" })}> + + + + goTo({ kind: "annotate" })}> + + goTo({ kind: "channel" })} + > + + {/* No `month=`: the server's default is the current one, and a month + still running is reported as far as today. */} + window.location.assign("/export/monthly.md"))} + > + + )} + {notice !== null && ( +

+ {notice.text} +

+ )}
+ {diagnosing !== null && ( + { + if (!next) setDiagnosing(null); + }} + /> + )} + ); } diff --git a/src/ui/web/components/DashboardGrid.tsx b/src/ui/web/components/DashboardGrid.tsx index df88763..94c7f13 100644 --- a/src/ui/web/components/DashboardGrid.tsx +++ b/src/ui/web/components/DashboardGrid.tsx @@ -182,6 +182,7 @@ function GridCell({ role={editing ? "listitem" : undefined} data-slot="widget-cell" data-widget={widget.type} + data-tour="tile" // Position only: animating the size too would stretch every chart and // line of text inside for the length of the glide. Decided at mount, // because motion only builds a cell's projection then — and remounting diff --git a/src/ui/web/components/GuidedTour.test.tsx b/src/ui/web/components/GuidedTour.test.tsx new file mode 100644 index 0000000..e3426c1 --- /dev/null +++ b/src/ui/web/components/GuidedTour.test.tsx @@ -0,0 +1,59 @@ +import { act, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import i18n from "@/lib/i18n.ts"; +import { renderWithProviders } from "@/test/harness.tsx"; +import { GuidedTour, startGuidedTour } from "./GuidedTour.tsx"; + +afterEach(() => { + vi.unstubAllGlobals(); + document.body.innerHTML = ""; +}); + +const preferences = { theme: "system", uiLocale: "en", notificationLocale: "en", mapView: "off", density: "comfortable", timeZone: "auto", lastSeenAt: null, wizardDone: false, tourSeen: false }; + +/** jsdom lays nothing out, so an anchor needs a size to count as drawn. */ +const anchor = (name: string): void => { + const element = document.createElement("div"); + element.dataset["tour"] = name; + element.getBoundingClientRect = () => ({ width: 100, height: 40, top: 10, left: 10, right: 110, bottom: 50, x: 10, y: 10, toJSON: () => ({}) }); + document.body.append(element); +}; + +describe("GuidedTour — roadmap 4.6", () => { + it("does not start by itself before the wizard has handed over", () => { + anchor("rail"); + renderWithProviders(, { preferences }); + expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); + }); + + it("runs from the review button, one sentence per step, and can be skipped", async () => { + const user = userEvent.setup(); + anchor("rail"); + anchor("nav-incidents"); + renderWithProviders(, { preferences }); + + act(() => startGuidedTour()); + expect(await screen.findByText(i18n.t("tour.rail"))).toBeInTheDocument(); + + await user.click(screen.getByRole("button", { name: i18n.t("tour.skip") })); + expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); + const calls = (globalThis.fetch as unknown as { mock: { calls: unknown[][] } }).mock.calls; + expect(calls.some((call) => String(call[0]) === "/api/preferences" && String((call[1] as RequestInit).body).includes("tourSeen"))).toBe(true); + }); + + it("skips a step whose anchor is not on the page", async () => { + anchor("rail"); + anchor("nav-incidents"); + const user = userEvent.setup(); + // On the Overview already, so the missing tile is skipped rather than + // navigated to — leaving the route would unmount this test's only element. + renderWithProviders(, { preferences }, "/overview"); + act(() => startGuidedTour()); + await screen.findByText(i18n.t("tour.rail")); + + await user.click(screen.getByRole("button", { name: i18n.t("action.continue") })); + + expect(await screen.findByText(i18n.t("tour.incidents"), {}, { timeout: 2000 })).toBeInTheDocument(); + }); +}); diff --git a/src/ui/web/components/GuidedTour.tsx b/src/ui/web/components/GuidedTour.tsx new file mode 100644 index 0000000..672062b --- /dev/null +++ b/src/ui/web/components/GuidedTour.tsx @@ -0,0 +1,170 @@ +import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { useLocation, useNavigate } from "react-router"; +import { Button } from "@/components/ui/button.tsx"; +import { usePreferences, usePreferencesMutation } from "@/hooks/queries.ts"; +import { findAnchor, TOUR_STEPS } from "@/lib/tourSteps.ts"; + +const TOUR_EVENT = "isitdown:start-tour"; +const CARD_WIDTH = 320; +const GAP = 12; + +/** Starts the tour from anywhere: Settings' "Review the tour", the wizard's last step. */ +export function startGuidedTour(): void { + window.dispatchEvent(new Event(TOUR_EVENT)); +} + +interface Placement { + top: number; + left: number; + ring: { top: number; left: number; width: number; height: number }; +} + +function place(element: HTMLElement): Placement { + const rect = element.getBoundingClientRect(); + // Beside the anchor when there is room on the right (the rail), below it otherwise. + const room = window.innerWidth - rect.right; + const left = + room >= CARD_WIDTH + GAP * 2 + ? rect.right + GAP + : Math.min(Math.max(GAP, rect.left), window.innerWidth - CARD_WIDTH - GAP); + const top = room >= CARD_WIDTH + GAP * 2 ? rect.top : rect.bottom + GAP; + return { + top: Math.min(Math.max(GAP, top), Math.max(GAP, window.innerHeight - 220)), + left, + ring: { top: rect.top - 4, left: rect.left - 4, width: rect.width + 8, height: rect.height + 8 }, + }; +} + +/** + * Coach marks on the real interface — roadmap 4.6. Mounted in the shell beside + * the palette: it has to outlive the view it is pointing at. It starts by + * itself once, after the wizard has handed over (`wizardDone` and not yet + * `tourSeen`), so an installation that predates it is never interrupted; those + * operators reach it from Settings. + */ +export function GuidedTour() { + const { t } = useTranslation(); + const navigate = useNavigate(); + const { pathname } = useLocation(); + const { data: preferences } = usePreferences(); + // `mutate` is stable across renders; the mutation object is not, and an + // effect that depends on it would re-measure on every render. + const { mutate: patchPreferences } = usePreferencesMutation(); + const [index, setIndex] = useState(null); + const [placement, setPlacement] = useState(null); + const nextRef = useRef(null); + const autoStarted = useRef(false); + + const finish = useCallback((): void => { + setIndex(null); + setPlacement(null); + patchPreferences({ tourSeen: true }); + }, [patchPreferences]); + + useEffect(() => { + const onStart = (): void => setIndex(0); + window.addEventListener(TOUR_EVENT, onStart); + return () => window.removeEventListener(TOUR_EVENT, onStart); + }, []); + + useEffect(() => { + if (autoStarted.current) return; + if (preferences?.wizardDone === true && preferences.tourSeen === false) { + autoStarted.current = true; + setIndex(0); + } + }, [preferences]); + + const step = index === null ? undefined : TOUR_STEPS[index]; + + // A step on another view takes the operator there first. + useEffect(() => { + if (step?.route !== undefined && pathname !== step.route) void navigate(step.route); + }, [step, pathname, navigate]); + + // Anchor measured after the view had a frame to paint; a missing one is + // skipped, and running out of steps ends the tour. + useLayoutEffect(() => { + if (index === null || step === undefined) return; + const measure = (): void => { + const element = findAnchor(step.anchor); + if (element === null) return setPlacement(null); + setPlacement(place(element)); + }; + measure(); + const retry = window.setTimeout(() => { + if (findAnchor(step.anchor) === null) { + if (index + 1 >= TOUR_STEPS.length) finish(); + else setIndex(index + 1); + } else measure(); + }, 400); + window.addEventListener("resize", measure); + window.addEventListener("scroll", measure, true); + return () => { + window.clearTimeout(retry); + window.removeEventListener("resize", measure); + window.removeEventListener("scroll", measure, true); + }; + }, [index, step, finish]); + + useEffect(() => { + if (placement !== null) nextRef.current?.focus(); + }, [placement, index]); + + if (index === null || step === undefined || placement === null) return null; + const last = index === TOUR_STEPS.length - 1; + + const { ring } = placement; + const [w, h] = [window.innerWidth, window.innerHeight]; + // Everything but the anchor: the frame, then the anchor's rectangle wound the + // other way, so the even-odd rule leaves it unblurred. + const veil = `polygon(evenodd, 0px 0px, ${w}px 0px, ${w}px ${h}px, 0px ${h}px, 0px 0px, ${ring.left}px ${ring.top}px, ${ring.left}px ${ring.top + ring.height}px, ${ring.left + ring.width}px ${ring.top + ring.height}px, ${ring.left + ring.width}px ${ring.top}px, ${ring.left}px ${ring.top}px)`; + + return ( + <> +