@@ -856,127 +867,208 @@
// them in request order.
let solutionCount = 0;
- // Whether *this* tab is the one currently solving. The /status poll only
- // disables the button for OTHER viewers, so the solver keeps its button
- // state under run()'s own control.
- let solvingHere = false;
+ // Live tracking of this tab's queued/running solves.
+ // token -> {token, card, n, confirmed}. Each gets a reserved "pending" card
+ // on the page right away; a shared /job_status event stream drives it through
+ // queued -> running -> done (rendered) / error / cancelled.
+ const pending = new Map();
- // Token identifying this tab's in-flight solve. When the tab closes mid
- // solve we beacon /cancel?token=... so the server stops it and frees the
- // lock immediately — reliable even behind a reverse proxy, which keeps
- // the upstream socket open and so hides the disconnect from the server.
- let activeToken = null;
+ function uuid() {
+ return crypto.randomUUID ? crypto.randomUUID()
+ : String(Date.now()) + Math.random();
+ }
- function cancelActiveSolve() {
- if (activeToken && navigator.sendBeacon) {
- navigator.sendBeacon("/cancel?token=" + encodeURIComponent(activeToken));
+ // A share URL for a set of solve tokens. The schema is
+ // ?solves=,,… so anyone who knows the ids can compose an
+ // arbitrary set; a single ?solve= link is also still understood.
+ function shareUrl(tokens) {
+ return location.origin + location.pathname +
+ "?solves=" + tokens.map(encodeURIComponent).join(",");
+ }
+
+ // A "copy link" button that briefly confirms after writing to the clipboard.
+ function copyLinkButton(label, getUrl) {
+ return el("button", {
+ class: "mini", type: "button",
+ onclick: (ev) => {
+ navigator.clipboard?.writeText(getUrl());
+ const orig = ev.target.textContent;
+ ev.target.textContent = "Copied!";
+ setTimeout(() => {ev.target.textContent = orig;}, 1500);
+ },
+ }, label);
+ }
+
+ // Copy a single link sharing every solution currently on the page (pending
+ // ones included — their links resolve once the solve finishes).
+ function shareVisible() {
+ const tokens = [...document.querySelectorAll("#output [data-token]")]
+ .map(e => e.dataset.token);
+ const btn = document.getElementById("shareAllBtn");
+ const reset = () => setTimeout(() => {btn.textContent = "Share visible solutions";}, 1500);
+ if (!tokens.length) {btn.textContent = "Nothing to share"; reset(); return;}
+ navigator.clipboard?.writeText(shareUrl(tokens));
+ btn.textContent = "Link copied!";
+ reset();
+ }
+
+ // Build the reserved card for a queued/running solve: a spinner + status,
+ // the (already valid) share link, and a Cancel button.
+ function makePendingCard(token, n) {
+ const card = el("div", {class: "solution-pending"});
+ card.dataset.token = token;
+ const status = el("span", {}, "Queued…");
+ card._status = status;
+ const actions = el("p", {}, [
+ copyLinkButton("Copy share link", () => shareUrl([token])), " ",
+ el("button", {
+ class: "mini", type: "button",
+ onclick: () => cancelPending(token),
+ }, "Cancel"),
+ ]);
+ card._actions = actions;
+ card.append(
+ el("div", {class: "pending-head"},
+ [el("span", {class: "spinner"}), `Solution ${n} — `, status]),
+ actions);
+ return card;
+ }
+
+ // Cancel a pending solve: drop it from the queue or stop the running search.
+ // The stream then reports "cancelled" (card removed) or, for a stopped
+ // running solve, "done" with the best plan found so far (card rendered).
+ function cancelPending(token) {
+ const entry = pending.get(token);
+ if (entry) entry.card._status.textContent = "Cancelling…";
+ fetch("/cancel?token=" + encodeURIComponent(token), {method: "POST"})
+ .catch(() => {/* the stream reflects the outcome regardless */});
+ }
+
+ // Stop tracking a pending solve and run a final action on its card. Once
+ // nothing is pending, close the event stream so it doesn't reconnect.
+ function finishPending(token, action) {
+ const entry = pending.get(token);
+ if (!entry) return;
+ pending.delete(token);
+ action(entry);
+ if (pending.size === 0 && stream) {stream.close(); stream = null;}
+ }
+
+ // Put a pending card into a terminal state: show the message, drop the
+ // spinner & cancel, and offer a Dismiss button. `isError` tints it red.
+ function finalizeCard(token, msg, isError = false) {
+ finishPending(token, (entry) => {
+ entry.card.classList.toggle("err", isError);
+ entry.card.querySelector(".spinner")?.remove(); // it's no longer loading
+ entry.card._status.textContent = msg;
+ entry.card._actions.replaceChildren(el("button", {
+ class: "mini", type: "button",
+ onclick: () => entry.card.remove(),
+ }, "Dismiss"));
+ });
+ }
+ function pendingError(token, msg) {finalizeCard(token, msg, true);}
+
+ // A single Server-Sent Events stream carries progress for every pending
+ // solve at once: /job_status?tokens=t1,t2,… It's rebuilt whenever the set
+ // of confirmed (enqueued) solves changes, and closed when none remain.
+ let stream = null;
+ function syncStream() {
+ if (stream) {stream.close(); stream = null;}
+ const tokens = [...pending.values()].filter(e => e.confirmed).map(e => e.token);
+ if (!tokens.length) return;
+ stream = new EventSource(
+ "/job_status?tokens=" + tokens.map(encodeURIComponent).join(","));
+ stream.onmessage = (ev) => handleJobEvent(JSON.parse(ev.data));
+ // On a transient network error EventSource auto-reconnects to the same
+ // URL; we explicitly close it (in finishPending) once all solves finish.
+ stream.onerror = () => {/* let the browser retry */};
+ }
+
+ // Advance one pending solve's card from a streamed state update.
+ function handleJobEvent(j) {
+ const entry = pending.get(j.token);
+ if (!entry) return; // already finished/dismissed
+ switch (j.status) {
+ case "queued":
+ entry.card._status.textContent =
+ j.position > 0 ? `Queued (${j.position} ahead)…` : "Queued (next up)…";
+ break;
+ case "running":
+ entry.card._status.textContent = "Solving…";
+ break;
+ case "done":
+ finishPending(j.token, (e) =>
+ renderSolution(j.solution, e.card, j.token, e.n));
+ break;
+ case "cancelled":
+ // Leave a visible "cancelled" card (whether this tab cancelled
+ // it or it was cancelled elsewhere) rather than silently vanishing.
+ finalizeCard(j.token, "This solve was cancelled.");
+ break;
+ case "error":
+ pendingError(j.token, "Error: " + (j.error || "solve failed"));
+ break;
+ default: // "unknown": no such job/solve (evicted, or server restarted)
+ pendingError(j.token,
+ `Solve ${j.token} not found (it may have been evicted or the server restarted).`);
}
}
- // pagehide fires on tab close / navigation away (not on mere tab switch,
- // so we don't cancel a solve the user backgrounds and comes back to).
- window.addEventListener("pagehide", cancelActiveSolve);
- // Cancel this tab's in-flight solve without closing the tab: POST /cancel
- // so the server stops the search. The running /solve then returns with the
- // best plan found so far (or an empty/infeasible one), which run() renders.
- function cancelSolve() {
- if (!activeToken) return;
- const btn = document.getElementById("cancelBtn");
- btn.disabled = true;
- btn.textContent = "Cancelling…";
- fetch("/cancel?token=" + encodeURIComponent(activeToken), {method: "POST"})
- .catch(() => {/* the solve still returns; nothing to do here */});
- }
- // Toggle the Cancel button's visibility, resetting its label/disabled state.
- function showCancel(visible) {
- const btn = document.getElementById("cancelBtn");
- btn.style.display = visible ? "" : "none";
- btn.disabled = false;
- btn.textContent = "Cancel";
- }
+ // Closing or leaving a tab no longer cancels its solves: with the queue,
+ // a left-behind solve just runs (or waits its turn) to completion and is
+ // stored, so its share link still resolves — and a tab that only *views* a
+ // shared solve can't cancel the author's work just by being closed. Cancel
+ // is now an explicit per-card button only (see cancelPending).
- async function run() {
+ // Queue a solve: reserve its card immediately, POST it, then subscribe to
+ // the progress stream once it's enqueued (confirmed). Multiple solves can
+ // be queued at once; the server runs them one at a time.
+ function run() {
const errBox = document.getElementById("error");
- const out = document.getElementById("output");
errBox.textContent = "";
- // A placeholder card for this request, prepended so the newest is on top.
- // It's replaced by the solution on success, or removed on error.
- const pending = el("p", {class: "pending"}, "Solving…");
- out.prepend(pending);
let problem, time;
try {
problem = buildProblem();
time = +document.getElementById("time").value;
- } catch (e) {errBox.textContent = e.message; pending.remove(); return;}
- solvingHere = true;
- setSolveDisabled(true, "Solving…");
- const token = (crypto.randomUUID ? crypto.randomUUID()
- : String(Date.now()) + Math.random());
- activeToken = token;
- showCancel(true);
- try {
- const resp = await fetch("/solve", {
- method: "POST",
- headers: {"Content-Type": "application/json"},
- body: JSON.stringify({problem, max_time_seconds: time, token}),
- });
- if (resp.status === 429) {
- pending.remove();
- errBox.textContent = "Another viewer is already solving — try again once they finish.";
+ } catch (e) {errBox.textContent = e.message; return;}
+ const token = uuid();
+ const n = ++solutionCount;
+ const card = makePendingCard(token, n);
+ document.getElementById("output").prepend(card);
+ pending.set(token, {token, card, n, confirmed: false});
+ fetch("/solve", {
+ method: "POST",
+ headers: {"Content-Type": "application/json"},
+ body: JSON.stringify({problem, max_time_seconds: time, token}),
+ }).then(async (resp) => {
+ if (!resp.ok) {
+ const data = await resp.json().catch(() => ({}));
+ pendingError(token, data.error || "Server error");
return;
}
- const data = await resp.json();
- if (!resp.ok) {pending.remove(); errBox.textContent = data.error || "Server error"; return;}
- renderSolution(data, pending, token);
- } catch (e) {pending.remove(); errBox.textContent = e.message;}
- finally {solvingHere = false; activeToken = null; showCancel(false); setSolveDisabled(false);}
+ // Now the server knows the token; (re)open the stream to include it.
+ const entry = pending.get(token);
+ if (entry) {entry.confirmed = true; syncStream();}
+ }).catch((e) => pendingError(token, e.message));
}
- // Enable/disable the Solve button with an optional note beside it. `kind`
- // ("self" or "other") picks the note's color so this tab's "Solving…" and
- // another viewer's "Another viewer is solving…" stand out distinctly.
- function setSolveDisabled(disabled, note = "", kind = "self") {
- document.getElementById("solveBtn").disabled = disabled;
- const busy = document.getElementById("busy");
- busy.textContent = note;
- busy.classList.toggle("busy-self", kind === "self");
- busy.classList.toggle("busy-other", kind === "other");
- }
-
- // Poll the server's solve state so that while one viewer is solving the
- // others see their Solve button disabled (and re-enabled when it frees up).
- async function pollStatus() {
- try {
- const resp = await fetch("/status", {cache: "no-store"});
- const {solving} = await resp.json();
- if (!solvingHere) setSolveDisabled(solving,
- solving ? "Another viewer is solving…" : "", "other");
- } catch (e) {/* leave button as-is on a transient error */}
- }
- setInterval(pollStatus, 1000);
- pollStatus();
-
- function renderSolution(s, placeholder, token) {
+ function renderSolution(s, placeholder, token, n) {
// Collapse any previously-shown solutions so the new one is the focus.
for (const d of document.querySelectorAll("#output details.solution")) d.open = false;
- const n = ++solutionCount;
+ n = n ?? ++solutionCount;
const details = el("details", {class: "solution", open: ""});
+ // Tag the card with its token so "Share visible solutions" can collect it.
+ if (token) details.dataset.token = token;
details.append(el("summary", {},
`Solution ${n} — ${s.status}, objective ${s.objective_value ?? "—"}`));
const out = details;
// A shareable permalink to this stored solve, looked up by its UUID.
if (token) {
- const url = location.origin + location.pathname + "?solve=" + encodeURIComponent(token);
- out.append(el("p", {}, el("button", {
- class: "mini", type: "button",
- onclick: (ev) => {
- navigator.clipboard?.writeText(url);
- ev.target.textContent = "Link copied!";
- setTimeout(() => {ev.target.textContent = "Copy share link";}, 1500);
- },
- }, "Copy share link")));
+ out.append(el("p", {}, copyLinkButton("Copy share link",
+ () => shareUrl([token]))));
}
out.append(el("p", {
html:
@@ -1197,26 +1289,28 @@
"electrum": 2
})
- // --- deep link: /?solve= loads a previously stored solve ---
- async function loadSharedSolve(token) {
- const out = document.getElementById("output");
- const errBox = document.getElementById("error");
- const pending = el("p", {class: "pending"}, "Loading shared solve…");
- out.prepend(pending);
- try {
- const resp = await fetch("/solve/" + encodeURIComponent(token), {cache: "no-store"});
- if (resp.status === 404) {
- pending.remove();
- errBox.textContent = "No stored solve found for that link (it may have been evicted).";
- return;
- }
- const rec = await resp.json();
- if (!resp.ok) {pending.remove(); errBox.textContent = rec.error || "Server error"; return;}
- renderSolution(rec.solution, pending, rec.token);
- } catch (e) {pending.remove(); errBox.textContent = e.message;}
+ // --- deep links ---
+ // /?solve= loads one solve; /?solves=,,… loads an arbitrary
+ // set (the schema "Share visible solutions" produces). Each id is attached
+ // to its live job state exactly like a locally-queued solve: a card is
+ // reserved synchronously (so the set keeps its given order) and the shared
+ // /job_status stream resolves it — rendering a finished solve, or tracking
+ // one that's still queued/running until it completes (jobs are server-wide).
+ // Only truly-unknown ids (evicted / never existed) fall to the "not found"
+ // message, instead of every in-flight solve 404ing as "not stored yet".
+ function loadSharedSolve(token) {
+ const n = ++solutionCount;
+ const card = makePendingCard(token, n);
+ card._status.textContent = "Loading…";
+ document.getElementById("output").prepend(card);
+ pending.set(token, {token, card, n, confirmed: true});
}
- const sharedToken = new URLSearchParams(location.search).get("solve");
- if (sharedToken) loadSharedSolve(sharedToken);
+ const params = new URLSearchParams(location.search);
+ const sharedTokens = (params.get("solves") || params.get("solve") || "")
+ .split(",").map(s => s.trim()).filter(Boolean);
+ // Load in reverse so the first id ends up on top (each load prepends).
+ for (const token of sharedTokens.slice().reverse()) loadSharedSolve(token);
+ if (sharedTokens.length) syncStream();