From 440113c8e86437600b85b5a04e7de5c883c86b20 Mon Sep 17 00:00:00 2001 From: Prabhudutt Dash Date: Mon, 24 Aug 2026 20:43:45 +0530 Subject: [PATCH] fix(dashboard): align sync interval slider ticks via magnetic checkpoints (#11394) Merged via consolidated batch validation. Model Database sync-interval slider used two incompatible coordinate systems (evenly spaced labels vs a linear 1-168h scale); moves the slider to checkpoint-space so the thumb and labels agree. Own test passes. --- .../fixes/11394-modelsdev-interval-slider.md | 1 + .../settings/components/ModelsDevSyncTab.tsx | 75 ++++++-- ...-sync-interval-slider-checkpoints.test.tsx | 176 ++++++++++++++++++ 3 files changed, 239 insertions(+), 13 deletions(-) create mode 100644 changelog.d/fixes/11394-modelsdev-interval-slider.md create mode 100644 tests/unit/ui/models-dev-sync-interval-slider-checkpoints.test.tsx diff --git a/changelog.d/fixes/11394-modelsdev-interval-slider.md b/changelog.d/fixes/11394-modelsdev-interval-slider.md new file mode 100644 index 0000000000..6f5e955c8e --- /dev/null +++ b/changelog.d/fixes/11394-modelsdev-interval-slider.md @@ -0,0 +1 @@ +- **fix(dashboard):** Model Database sync interval slider ticks now match the thumb position — checkpoint-space slider with magnetic snap on release ([#11394](https://github.com/diegosouzapw/OmniRoute/pull/11394)) — thanks @An0nym0us92 diff --git a/src/app/(dashboard)/dashboard/settings/components/ModelsDevSyncTab.tsx b/src/app/(dashboard)/dashboard/settings/components/ModelsDevSyncTab.tsx index c5a60fbad1..0a8a17cb92 100644 --- a/src/app/(dashboard)/dashboard/settings/components/ModelsDevSyncTab.tsx +++ b/src/app/(dashboard)/dashboard/settings/components/ModelsDevSyncTab.tsx @@ -24,6 +24,46 @@ interface SyncResult { error?: string; } +// Slider works in "checkpoint space": position p ∈ [0, 3] maps linearly onto +// these hour values, so the evenly spaced tick labels always match the thumb. +const INTERVAL_CHECKPOINTS = [1, 6, 24, 168]; +const SNAP_THRESHOLD = 0.15; + +function positionToHours(pos: number): number { + const p = Math.min(INTERVAL_CHECKPOINTS.length - 1, Math.max(0, pos)); + const lower = Math.floor(p); + const upper = Math.ceil(p); + if (lower === upper) return INTERVAL_CHECKPOINTS[lower]; + const t = p - lower; + return Math.round( + INTERVAL_CHECKPOINTS[lower] + (INTERVAL_CHECKPOINTS[upper] - INTERVAL_CHECKPOINTS[lower]) * t + ); +} + +function hoursToPosition(hours: number): number { + const cps = INTERVAL_CHECKPOINTS; + if (hours <= cps[0]) return 0; + for (let i = 0; i < cps.length - 1; i++) { + if (hours <= cps[i + 1]) { + return i + (hours - cps[i]) / (cps[i + 1] - cps[i]); + } + } + return cps.length - 1; +} + +// Magnetic checkpoints: snap to a reference point when released nearby, +// otherwise keep the freely chosen position. +function snapPosition(pos: number): number { + for (let i = 0; i < INTERVAL_CHECKPOINTS.length; i++) { + if (Math.abs(pos - i) <= SNAP_THRESHOLD) return i; + } + return pos; +} + +function formatInterval(hours: number): string { + return hours === 168 ? "7d" : `${hours}h`; +} + export default function ModelsDevSyncTab() { const t = useTranslations("settings"); const [status, setStatus] = useState(null); @@ -32,7 +72,7 @@ export default function ModelsDevSyncTab() { const [saving, setSaving] = useState(false); const [enabled, setEnabled] = useState(false); const [intervalHours, setIntervalHours] = useState(24); - const [draftIntervalHours, setDraftIntervalHours] = useState(24); + const [draftPos, setDraftPos] = useState(2); const [feedback, setFeedback] = useState<{ type: "success" | "error"; message: string } | null>( null ); @@ -58,7 +98,7 @@ export default function ModelsDevSyncTab() { const intervalMs = settingsData.modelsDevSyncInterval || 86400000; const hours = Math.round(intervalMs / 3600000); setIntervalHours(hours); - setDraftIntervalHours(hours); + setDraftPos(hoursToPosition(hours)); } }) .catch((err) => { @@ -126,7 +166,7 @@ export default function ModelsDevSyncTab() { const updateInterval = async (hours: number) => { const oldInterval = intervalHours; setIntervalHours(hours); - setDraftIntervalHours(hours); + setDraftPos(hoursToPosition(hours)); try { const res = await fetch("/api/settings", { method: "PATCH", @@ -135,20 +175,27 @@ export default function ModelsDevSyncTab() { }); if (!res.ok) { setIntervalHours(oldInterval); - setDraftIntervalHours(oldInterval); + setDraftPos(hoursToPosition(oldInterval)); setFeedback({ type: "error", message: t("enableSyncError") }); } else { setFeedback({ type: "success", message: "Interval updated" }); } } catch { setIntervalHours(oldInterval); - setDraftIntervalHours(oldInterval); + setDraftPos(hoursToPosition(oldInterval)); setFeedback({ type: "error", message: "Network error" }); } finally { setTimeout(() => setFeedback(null), 3000); } }; + // Commit on release: snap to a checkpoint when near one, else keep free value. + const commitDraftInterval = () => { + const snapped = snapPosition(draftPos); + if (snapped !== draftPos) setDraftPos(snapped); + updateInterval(positionToHours(snapped)); + }; + if (loading) { return ( @@ -238,18 +285,20 @@ export default function ModelsDevSyncTab() {

{t("modelsDevInterval")}

- {draftIntervalHours}h + {formatInterval(positionToHours(draftPos))}
setDraftIntervalHours(parseInt(e.target.value))} - onMouseUp={(e) => updateInterval(parseInt((e.target as HTMLInputElement).value))} - onBlur={(e) => updateInterval(parseInt(e.target.value))} + min="0" + max={INTERVAL_CHECKPOINTS.length - 1} + step="any" + value={draftPos} + onChange={(e) => setDraftPos(parseFloat(e.target.value))} + onMouseUp={commitDraftInterval} + onTouchEnd={commitDraftInterval} + onBlur={commitDraftInterval} + aria-label={t("modelsDevInterval")} className="w-full accent-blue-500" />
diff --git a/tests/unit/ui/models-dev-sync-interval-slider-checkpoints.test.tsx b/tests/unit/ui/models-dev-sync-interval-slider-checkpoints.test.tsx new file mode 100644 index 0000000000..5e0a4e2a34 --- /dev/null +++ b/tests/unit/ui/models-dev-sync-interval-slider-checkpoints.test.tsx @@ -0,0 +1,176 @@ +// @vitest-environment jsdom +import React, { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import ModelsDevSyncTab from "@/app/(dashboard)/dashboard/settings/components/ModelsDevSyncTab"; + +// Regression coverage for the Model Database sync interval slider +// (Settings > AI > Model Database): the reference ticks (1h/6h/24h/7d) used +// to be laid out evenly with flex justify-between while the underlying +// ran a linear 1-168 hour scale, so the thumb position +// never matched the labels (59h landed visually on top of "6h"). +// +// The slider now works in checkpoint space: position p in [0,3] maps linearly +// onto [1,6,24,168] hours. It slides freely (step=any) and on release snaps +// magnetically onto a checkpoint when dropped within threshold of one, +// otherwise keeps the freely chosen (interpolated) hour value. + +vi.mock("next-intl", () => ({ + useTranslations: () => (key: string) => key, +})); + +const roots: Array<{ root: Root; el: HTMLDivElement }> = []; + +async function render(): Promise { + const el = document.createElement("div"); + document.body.appendChild(el); + const root = createRoot(el); + await act(async () => { + root.render(); + }); + roots.push({ root, el }); + return el; +} + +function getSlider(container: HTMLDivElement): HTMLInputElement { + const input = container.querySelector('input[type="range"]'); + if (!input) throw new Error("sync interval slider not found"); + return input as HTMLInputElement; +} + +function getLabel(container: HTMLDivElement): string { + const span = container.querySelector("span.text-blue-400"); + if (!span?.textContent) throw new Error("interval label not found"); + return span.textContent; +} + +async function setSliderValue(container: HTMLDivElement, value: string) { + const input = getSlider(container); + // NOTE: synchronous act() on purpose — wrapping this in async act() lets the + // commit flush late, so the change handler would read the pre-dispatch value. + act(() => { + const setter = Object.getOwnPropertyDescriptor( + window.HTMLInputElement.prototype, + "value" + )?.set; + setter?.call(input, value); + input.dispatchEvent(new Event("input", { bubbles: true })); + }); +} + +function releaseSlider(container: HTMLDivElement) { + act(() => { + getSlider(container).dispatchEvent(new MouseEvent("mouseup", { bubbles: true })); + }); +} + +async function waitFor(predicate: () => boolean, label: string) { + const startedAt = Date.now(); + while (!predicate()) { + if (Date.now() - startedAt > 2000) { + throw new Error(`Timed out waiting for: ${label}`); + } + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 10)); + }); + } +} + +describe("ModelsDevSyncTab interval slider checkpoints", () => { + let fetchMock: ReturnType; + + beforeEach(() => { + (globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; + fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { + const url = String(input); + if (url.includes("/api/settings/models-dev")) { + return new Response( + JSON.stringify({ + enabled: true, + lastSync: null, + lastSyncModelCount: 0, + lastSyncCapabilityCount: 0, + nextSync: null, + intervalMs: 86400000, + providerCount: 1, + modelCount: 1, + capabilityCount: 1, + }), + { status: 200 } + ); + } + if (url.includes("/api/settings")) { + if (init?.method === "PATCH") { + return new Response(JSON.stringify({ ok: true }), { status: 200 }); + } + return new Response( + JSON.stringify({ modelsDevSyncEnabled: true, modelsDevSyncInterval: 86400000 }), + { status: 200 } + ); + } + return new Response(JSON.stringify({}), { status: 200 }); + }); + vi.stubGlobal("fetch", fetchMock); + }); + + afterEach(() => { + for (const { root, el } of roots.splice(0)) { + act(() => root.unmount()); + el.remove(); + } + vi.unstubAllGlobals(); + }); + + it("maps the saved 24h interval onto checkpoint position 2", async () => { + const container = await render(); + await waitFor(() => getSlider(container).value === "2", "saved interval to load"); + + expect(getLabel(container)).toBe("24h"); + }); + + it("shows interpolated hours while dragging between checkpoints", async () => { + const container = await render(); + await waitFor(() => getSlider(container).value === "2", "saved interval to load"); + + // midpoint of the 6h..24h segment -> 15h + await setSliderValue(container, "1.5"); + + expect(getLabel(container)).toBe("15h"); + const patch = fetchMock.mock.calls.find((call) => call[1]?.method === "PATCH"); + expect(patch).toBeUndefined(); // dragging alone must not save + }); + + it("snaps onto 6h when released near that checkpoint", async () => { + const container = await render(); + await waitFor(() => getSlider(container).value === "2", "saved interval to load"); + + await setSliderValue(container, "0.9"); // within snap threshold of checkpoint 1 + releaseSlider(container); + + await waitFor(() => { + return Boolean(fetchMock.mock.calls.find((call) => call[1]?.method === "PATCH")); + }, "PATCH request to be issued"); + + const patch = fetchMock.mock.calls.find((call) => call[1]?.method === "PATCH"); + expect(JSON.parse(String(patch?.[1]?.body))).toEqual({ modelsDevSyncInterval: 21600000 }); + expect(getSlider(container).value).toBe("1"); + expect(getLabel(container)).toBe("6h"); + }); + + it("keeps the free value when released away from any checkpoint", async () => { + const container = await render(); + await waitFor(() => getSlider(container).value === "2", "saved interval to load"); + + await setSliderValue(container, "1.5"); // mid-segment, no snap + releaseSlider(container); + + await waitFor(() => { + return Boolean(fetchMock.mock.calls.find((call) => call[1]?.method === "PATCH")); + }, "PATCH request to be issued"); + + const patch = fetchMock.mock.calls.find((call) => call[1]?.method === "PATCH"); + expect(JSON.parse(String(patch?.[1]?.body))).toEqual({ modelsDevSyncInterval: 54000000 }); + expect(getSlider(container).value).toBe("1.5"); + expect(getLabel(container)).toBe("15h"); + }); +});