From 44c9eab1d16fc632a9e1a2b97db232f1a064a97e Mon Sep 17 00:00:00 2001 From: Moskovkin Ilya Date: Tue, 7 Jul 2026 15:53:09 +0700 Subject: [PATCH] refactor: extract replay URL helpers --- src/components/dashboard.tsx | 39 ++++++------------------- src/lib/replay-url.ts | 56 ++++++++++++++++++++++++++++++++++++ 2 files changed, 65 insertions(+), 30 deletions(-) create mode 100644 src/lib/replay-url.ts diff --git a/src/components/dashboard.tsx b/src/components/dashboard.tsx index 124368b..ccfa857 100644 --- a/src/components/dashboard.tsx +++ b/src/components/dashboard.tsx @@ -62,6 +62,7 @@ import { type Provider, type ProviderId, } from "@/lib/mock-data"; +import { applyReplayUrlState, parseReplayUrlState } from "@/lib/replay-url"; import { cn, formatCurrency, formatMs, formatNumber } from "@/lib/utils"; const ranges = ["24h", "7d", "30d"] as const; @@ -176,8 +177,9 @@ function downloadCsv(filename: string, csv: string) { window.setTimeout(() => URL.revokeObjectURL(url), 0); } -const REPLAY_PARAM = "replay"; -const STEP_PARAM = "step"; +const replayStepCounts = new Map( + replayScenarios.map((scenario) => [scenario.id, scenario.steps.length]), +); /** Read an incoming shareable replay link from the URL (client only). */ function readReplayParams(): { scenarioId: string | null; step: number } { @@ -185,25 +187,7 @@ function readReplayParams(): { scenarioId: string | null; step: number } { return { scenarioId: null, step: 0 }; } - const params = new URLSearchParams(window.location.search); - const replay = params.get(REPLAY_PARAM); - - if (!replay) { - return { scenarioId: null, step: 0 }; - } - - const scenario = replayScenarios.find((item) => item.id === replay); - - if (!scenario) { - return { scenarioId: null, step: 0 }; - } - - const rawStep = Number(params.get(STEP_PARAM) ?? "0"); - const step = Number.isFinite(rawStep) - ? Math.max(0, Math.min(Math.floor(rawStep), scenario.steps.length - 1)) - : 0; - - return { scenarioId: replay, step }; + return parseReplayUrlState(window.location.search, replayStepCounts); } /** Keep the URL in sync with the active replay (no history entries, no scroll). */ @@ -212,15 +196,10 @@ function syncReplayUrl(scenarioId: string | null, step: number) { return; } - const url = new URL(window.location.href); - - if (scenarioId) { - url.searchParams.set(REPLAY_PARAM, scenarioId); - url.searchParams.set(STEP_PARAM, String(step)); - } else { - url.searchParams.delete(REPLAY_PARAM); - url.searchParams.delete(STEP_PARAM); - } + const url = applyReplayUrlState(new URL(window.location.href), { + scenarioId, + step, + }); window.history.replaceState(window.history.state, "", url); } diff --git a/src/lib/replay-url.ts b/src/lib/replay-url.ts new file mode 100644 index 0000000..1310f41 --- /dev/null +++ b/src/lib/replay-url.ts @@ -0,0 +1,56 @@ +export type ReplayUrlState = { + scenarioId: string | null; + step: number; +}; + +export const REPLAY_PARAM = "replay"; +export const STEP_PARAM = "step"; + +type ReplayStepCounts = ReadonlyMap | Record; + +function getStepCount(stepCounts: ReplayStepCounts, scenarioId: string): number | undefined { + if ("get" in stepCounts && typeof stepCounts.get === "function") { + return stepCounts.get(scenarioId); + } + + return (stepCounts as Record)[scenarioId]; +} + +export function parseReplayUrlState( + search: string | URLSearchParams, + stepCounts: ReplayStepCounts, +): ReplayUrlState { + const params = typeof search === "string" ? new URLSearchParams(search) : search; + const scenarioId = params.get(REPLAY_PARAM); + + if (!scenarioId) { + return { scenarioId: null, step: 0 }; + } + + const stepCount = getStepCount(stepCounts, scenarioId); + + if (!stepCount || stepCount < 1) { + return { scenarioId: null, step: 0 }; + } + + const rawStep = Number(params.get(STEP_PARAM) ?? "0"); + const step = Number.isFinite(rawStep) + ? Math.max(0, Math.min(Math.floor(rawStep), stepCount - 1)) + : 0; + + return { scenarioId, step }; +} + +export function applyReplayUrlState(url: URL, state: ReplayUrlState): URL { + const nextUrl = new URL(url.href); + + if (state.scenarioId) { + nextUrl.searchParams.set(REPLAY_PARAM, state.scenarioId); + nextUrl.searchParams.set(STEP_PARAM, String(state.step)); + } else { + nextUrl.searchParams.delete(REPLAY_PARAM); + nextUrl.searchParams.delete(STEP_PARAM); + } + + return nextUrl; +}