mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-26 00:52:18 +03:00
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:
1
changelog.d/fixes/11394-modelsdev-interval-slider.md
Normal file
1
changelog.d/fixes/11394-modelsdev-interval-slider.md
Normal 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
|
||||
@@ -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">
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user