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.
This commit is contained in:
Prabhudutt Dash
2026-08-24 20:43:45 +05:30
committed by GitHub
parent 3c2906a80e
commit 440113c8e8
3 changed files with 239 additions and 13 deletions

View File

@@ -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

View File

@@ -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<ModelsDevStatus | null>(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 (
<Card>
@@ -238,18 +285,20 @@ export default function ModelsDevSyncTab() {
<div className="flex items-center justify-between mb-3">
<p className="text-sm font-medium">{t("modelsDevInterval")}</p>
<span className="text-sm font-mono tabular-nums text-blue-400">
{draftIntervalHours}h
{formatInterval(positionToHours(draftPos))}
</span>
</div>
<input
type="range"
min="1"
max="168"
step="1"
value={draftIntervalHours}
onChange={(e) => 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"
/>
<div className="flex justify-between text-xs text-text-muted mt-1">

View File

@@ -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
// <input type=range> 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<HTMLDivElement> {
const el = document.createElement("div");
document.body.appendChild(el);
const root = createRoot(el);
await act(async () => {
root.render(<ModelsDevSyncTab />);
});
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<typeof vi.fn>;
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");
});
});