diff --git a/index.html b/index.html index bcf6499..963ed5b 100644 --- a/index.html +++ b/index.html @@ -365,7 +365,7 @@
Objective — scoring terms

Each term scores a resource (or renown) at the end of a - turn (blank turn = final turn). For a log term, write a JS expression that + turn. For a log term, write a JS expression that evals to a one-argument function, e.g. (x) => Math.log2(x + 1). It is called over amounts 0..max_resource in the browser to build the lookup table.

@@ -556,9 +556,10 @@ return sel; } - // Multi-turn picker: a row of checkboxes ("final" + turn 0..count-1). - // _selected() returns the checked values as strings ("" = final). Registers - // for refresh so its options track the Turns count, preserving selections. + // Multi-turn picker: a row of checkboxes for turn 0..count-1. + // _selected() returns the checked turn values as strings; none checked is + // interpreted by the caller as "every turn". Registers for refresh so its + // options track the Turns count, preserving selections. const multiTurnGroups = new Set(); function fillMultiTurnOptions(group) { const prev = new Set((group._boxes || []).filter(b => b.checked).map(b => b.value)); @@ -572,7 +573,6 @@ return el("label", {style: "display:inline-block;margin-right:.6rem;font-size:.85rem"}, [cb, " " + label]); }; - group.append(mk("", "final")); for (let t = 0; t < turnsCount(); t++) group.append(mk(String(t), "turn " + t)); } function multiTurnSelect(values) { @@ -854,7 +854,7 @@ field("Resource", res), field("Op", op), field("Value", value), - field("Turn (blank=final)", turn), + field("Turn", turn), el("div", {class: "card-actions"}, removeBtn(card))); document.getElementById("constraints").append(card); } @@ -884,7 +884,7 @@ field("From amount", fromAmt), field("To", to), field("To amount", toAmt), - field("Turn (blank=final)", turn), + field("Turn", turn), el("div", {class: "card-actions"}, removeBtn(card))); document.getElementById("conversions").append(card); } @@ -910,8 +910,11 @@ from_amount: +fromAmt.value, to_amount: +toAmt.value, max_count: +maxCount.value, }; + // No turn checked => make the conversion available on every turn. const sel = turns._selected(); - return (sel.length ? sel : [""]).map(tv => { + const chosen = sel.length ? sel + : [...Array(turnsCount()).keys()].map(String); + return chosen.map(tv => { const o = {...base}; if (tv !== "") o.turn = +tv; return o; @@ -923,7 +926,7 @@ field("To", to), field("To amount", toAmt), field("Max count", maxCount), - field("Turns (none=final)", turns), + field("Turns (none = every turn)", turns), el("div", {class: "card-actions"}, removeBtn(card))); document.getElementById("optional_conversions").append(card); } @@ -987,6 +990,59 @@ : String(Date.now()) + Math.random(); } + // Custom display names for solves, keyed by token. Persisted in + // localStorage so a rename survives reloads and shared-link loads in the + // same browser, and mirrored to the server (POST /rename) so it also + // shows for viewers who open the share link elsewhere. When unset a card + // falls back to its default "Solution n" label. + const SOLVE_NAMES_KEY = "dws_solve_names"; + function loadSolveNames() { + try { + return new Map(Object.entries( + JSON.parse(localStorage.getItem(SOLVE_NAMES_KEY) || "{}"))); + } catch (e) {return new Map();} + } + const solveNames = loadSolveNames(); + function saveSolveNames() { + try { + localStorage.setItem(SOLVE_NAMES_KEY, + JSON.stringify(Object.fromEntries(solveNames))); + } catch (e) {/* storage may be unavailable; names just won't persist */} + } + // The label to show for a solve: its custom name, or "Solution n". + function solveLabel(token, n) { + return (token && solveNames.get(token)) || `Solution ${n}`; + } + // Adopt a server-provided name (from a status event) when this browser + // hasn't got one locally — so a shared solve renamed elsewhere shows it. + function adoptName(token, name) { + if (token && name && !solveNames.has(token)) { + solveNames.set(token, name); + saveSolveNames(); + } + } + // A "Rename" button: prompts for a new name, stores it locally + on the + // server, then calls apply(label) so the card updates its displayed name. + function renameButton(token, n, apply) { + return el("button", { + class: "mini", type: "button", + onclick: () => { + const name = prompt("Rename solution:", solveLabel(token, n)); + if (name == null) return; // cancelled + const trimmed = name.trim(); + if (trimmed) solveNames.set(token, trimmed); + else solveNames.delete(token); // empty reverts to default + saveSolveNames(); + fetch("/rename", { + method: "POST", + headers: {"Content-Type": "application/json"}, + body: JSON.stringify({token, name: trimmed}), + }).catch(() => {/* local name still applies */}); + apply(solveLabel(token, n)); + }, + }, "Rename"); + } + // 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. @@ -1021,6 +1077,54 @@ reset(); } + // --- time formatting (for queue ETAs and actual begin/finish times) --- + // Server timestamps are epoch *seconds*. fmtClock shows a wall-clock time; + // fmtRel shows a coarse "in ~Xm Ys" / "Xm Ys ago" relative to now; fmtDur + // shows an elapsed span between two epochs. + function fmtClock(sec) { + return sec ? new Date(sec * 1000).toLocaleTimeString() : "—"; + } + function fmtSpan(s) { + s = Math.max(0, Math.round(s)); + const m = Math.floor(s / 60), sec = s % 60; + return m ? `${m}m ${sec}s` : `${sec}s`; + } + function fmtRel(sec) { + if (!sec) return "—"; + const d = sec * 1000 - Date.now(); + return d >= 0 ? `in ~${fmtSpan(d / 1000)}` : `${fmtSpan(-d / 1000)} ago`; + } + function fmtDur(fromSec, toSec) { + if (!fromSec || !toSec) return "—"; + return fmtSpan(toSec - fromSec); + } + + // Render a pending card's status line from its latest timing snapshot + // (card._timing, set by handleJobEvent). The relative ETAs are recomputed + // here so the ticker can refresh them in place without a new server event. + function renderPendingStatus(card) { + const j = card._timing; + if (!j || !card._status) return; + if (j.status === "queued") { + const ahead = j.position > 0 ? `${j.position} ahead` : "next up"; + let msg = `Queued (${ahead})`; + if (j.eta_start) msg += ` — begins ${fmtRel(j.eta_start)}`; + if (j.eta_finish) msg += `, finishes by ${fmtRel(j.eta_finish)}`; + card._status.textContent = msg + "…"; + } else if (j.status === "running") { + let msg = "Solving"; + if (j.started_at) msg += ` — began ${fmtClock(j.started_at)}`; + if (j.eta_finish) msg += `, finishes by ${fmtRel(j.eta_finish)}`; + card._status.textContent = msg + "…"; + } + } + + // Keep the relative ETAs on every still-pending card ticking without + // waiting for the next server event. + setInterval(() => { + for (const e of pending.values()) renderPendingStatus(e.card); + }, 1000); + // 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) { @@ -1028,8 +1132,12 @@ card.dataset.token = token; const status = el("span", {}, "Queued…"); card._status = status; + // The name part of the head, updated in place when renamed. + const nameSpan = el("span", {}, solveLabel(token, n)); + card._nameSpan = nameSpan; const actions = el("p", {}, [ copyLinkButton("Copy share link", () => shareUrl([token])), " ", + renameButton(token, n, (label) => {nameSpan.textContent = label;}), " ", el("button", { class: "mini", type: "button", onclick: () => cancelPending(token), @@ -1038,7 +1146,8 @@ card._actions = actions; card.append( el("div", {class: "pending-head"}, - [el("span", {class: "spinner"}), `Solution ${n} — `, status]), + [el("span", {class: "spinner"}), nameSpan, + document.createTextNode(" — "), status]), actions); return card; } @@ -1098,17 +1207,22 @@ function handleJobEvent(j) { const entry = pending.get(j.token); if (!entry) return; // already finished/dismissed + // A name set on another tab/browser arrives with the status event; + // adopt it (if we have none locally) and reflect it on the card. + adoptName(j.token, j.name); + if (entry.card._nameSpan) + entry.card._nameSpan.textContent = solveLabel(j.token, entry.n); 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…"; + // Stash the latest timing snapshot and (re)render the status + // line; a ticker keeps its relative ETAs fresh between events. + entry.card._timing = j; + renderPendingStatus(entry.card); break; case "done": finishPending(j.token, (e) => - renderSolution(j.solution, e.card, j.token, e.n)); + renderSolution(j.solution, e.card, j.token, e.n, j)); break; case "cancelled": // Leave a visible "cancelled" card (whether this tab cancelled @@ -1162,7 +1276,7 @@ }).catch((e) => pendingError(token, e.message)); } - function renderSolution(s, placeholder, token, n) { + function renderSolution(s, placeholder, token, n, timing) { // Collapse any previously-shown solutions so the new one is the focus. for (const d of document.querySelectorAll("#output details.solution")) d.open = false; @@ -1170,14 +1284,22 @@ 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 ?? "—"}`)); + // The name part of the summary is its own span so a rename can update + // it in place without rebuilding the rest of the summary text. + const nameSpan = el("span", {}, solveLabel(token, n)); + const summary = el("summary", {}, [nameSpan, + document.createTextNode( + ` — ${s.status}, objective ${s.objective_value ?? "—"}`)]); + details.append(summary); const out = details; - // A shareable permalink to this stored solve, looked up by its UUID. + // A shareable permalink to this stored solve, looked up by its UUID, + // plus a Rename button (only meaningful for a stored/shareable solve). if (token) { - out.append(el("p", {}, copyLinkButton("Copy share link", - () => shareUrl([token])))); + out.append(el("p", {}, [ + copyLinkButton("Copy share link", () => shareUrl([token])), " ", + renameButton(token, n, (label) => {nameSpan.textContent = label;}), + ])); } out.append(el("p", { html: @@ -1185,6 +1307,18 @@ `Final renown total: ${s.final_renown_total}` })); + // When this tab watched the solve run, show when it actually began, + // finished and how long it took. (Solves loaded fresh from a share + // link carry no timing, so this is omitted for them.) + if (timing && timing.started_at) { + out.append(el("p", { + html: + `Began: ${fmtClock(timing.started_at)}   ` + + `Finished: ${fmtClock(timing.finished_at)}   ` + + `Took: ${fmtDur(timing.started_at, timing.finished_at)}` + })); + } + const fr = el("p", {}); fr.append(el("b", {}, "Final resources: ")); fr.append(document.createTextNode( diff --git a/main.py b/main.py index 3c60672..f24a5f6 100644 --- a/main.py +++ b/main.py @@ -52,25 +52,39 @@ def init_db(): conn.execute( "CREATE TABLE IF NOT EXISTS solves (" "token TEXT PRIMARY KEY, ts REAL NOT NULL, status TEXT, " - "problem TEXT NOT NULL, solution TEXT NOT NULL)") + "name TEXT, problem TEXT NOT NULL, solution TEXT NOT NULL)") conn.execute("CREATE INDEX IF NOT EXISTS idx_solves_ts ON solves(ts)") + # Add the custom-name column to pre-existing DBs that lack it. + cols = [r[1] for r in conn.execute("PRAGMA table_info(solves)")] + if "name" not in cols: + conn.execute("ALTER TABLE solves ADD COLUMN name TEXT") finally: conn.close() +# Custom display names for solves, keyed by token. A solve can be renamed while +# it is still queued/running (before its row exists), so names are tracked in +# memory and written into the row when the solve is stored; renaming an +# already-stored solve also updates the row directly (see _handle_rename). +_names = {} +_names_lock = threading.Lock() + + def store_solve(token, problem, solution): # token is client-generated; skip blanks. INSERT OR REPLACE means a repeated # token overwrites (a client could clobber its own/another's row — acceptable # for this app; switch to a server-issued id if that ever matters). if not token: return + with _names_lock: + name = _names.get(token) conn = _db() try: with conn: conn.execute( - "INSERT OR REPLACE INTO solves (token, ts, status, problem, solution) " - "VALUES (?, ?, ?, ?, ?)", - (token, time.time(), solution.get("status"), + "INSERT OR REPLACE INTO solves (token, ts, status, name, problem, solution) " + "VALUES (?, ?, ?, ?, ?, ?)", + (token, time.time(), solution.get("status"), name, json.dumps(problem), json.dumps(solution))) conn.execute( "DELETE FROM solves WHERE token NOT IN " @@ -84,14 +98,14 @@ def fetch_solve(token): conn = _db() try: row = conn.execute( - "SELECT token, ts, status, problem, solution FROM solves WHERE token = ?", + "SELECT token, ts, status, name, problem, solution FROM solves WHERE token = ?", (token,)).fetchone() finally: conn.close() if row is None: return None - return {"token": row[0], "ts": row[1], "status": row[2], - "problem": json.loads(row[3]), "solution": json.loads(row[4])} + return {"token": row[0], "ts": row[1], "status": row[2], "name": row[3], + "problem": json.loads(row[4]), "solution": json.loads(row[5])} # Solves are queued and run one at a time by a single background worker, so any # number of viewers can pile requests on without being rejected — each request @@ -138,6 +152,7 @@ def _solve_worker(): if job is None or job["status"] != "queued": continue # cancelled (or vanished) before it ran job["status"] = "running" + job["started_at"] = time.time() problem = job["problem"] raw_problem = job["raw_problem"] max_time = job["max_time"] @@ -159,10 +174,12 @@ def _solve_worker(): store_solve(token, raw_problem, sol_dict) with _cond: job["status"] = "done" + job["finished_at"] = time.time() _cond.notify_all() except Exception as exc: with _cond: job["status"] = "error" + job["finished_at"] = time.time() job["error"] = f"{type(exc).__name__}: {exc}" _cond.notify_all() finally: @@ -171,6 +188,28 @@ def _solve_worker(): _active["solver"] = None +def _estimate_start(token): + # Caller holds _cond. Estimate the wall-clock time (epoch seconds) at which a + # still-queued token will *begin* running: now, plus the running solve's + # remaining time budget, plus the full time budget of every queued solve + # ahead of it. Each solve's max_time is an upper bound (a search can stop + # early), so this is a worst-case "no later than" estimate. + now = time.time() + wait = 0.0 + active_token = _active["token"] + if active_token: + aj = _jobs.get(active_token) + if aj is not None: + started = aj.get("started_at") or now + wait += max(0.0, aj["max_time"] - (now - started)) + if token in _queue: + for ahead in _queue[:_queue.index(token)]: + j = _jobs.get(ahead) + if j is not None: + wait += j.get("max_time", 0.0) + return now + wait + + def _stop_search(solver): # StopSearch() exists in OR-Tools 9.x+; degrade gracefully on older builds # (the solve then just runs to max_time_seconds). @@ -215,23 +254,44 @@ class Handler(BaseHTTPRequestHandler): # Report a queued/running solve's live state, falling back to SQLite for # a finished (or evicted-from-memory) one. The client polls this to drive # each pending card: queued -> running -> done (render) / error / cancelled. + # Timing carried through to terminal states so the UI can show when a + # solve actually began/finished (and how long it took). + timing = {} + # Read the custom name without holding _cond (kept separate to avoid any + # lock-ordering coupling between _names_lock and _cond). + with _names_lock: + name = _names.get(token) with _cond: job = _jobs.get(token) if job is not None: status = job["status"] + for k in ("started_at", "finished_at"): + if job.get(k) is not None: + timing[k] = job[k] + max_time = job.get("max_time") if status == "queued": pos = _queue.index(token) if token in _queue else 0 - return {"status": "queued", "position": pos} + eta_start = _estimate_start(token) + return {"status": "queued", "position": pos, + "eta_start": eta_start, + "eta_finish": eta_start + (max_time or 0.0), + "max_time": max_time, "name": name} if status == "running": - return {"status": "running"} + started = job.get("started_at") + return {"status": "running", "started_at": started, + "eta_finish": (started + max_time) + if (started and max_time) else None, + "max_time": max_time, "name": name} if status == "error": - return {"status": "error", "error": job.get("error")} + return {"status": "error", "error": job.get("error"), + "name": name, **timing} if status == "cancelled": - return {"status": "cancelled"} + return {"status": "cancelled", "name": name, **timing} # status == "done": fall through to load the stored solution. rec = fetch_solve(token) if rec is not None: - return {"status": "done", "solution": rec["solution"]} + return {"status": "done", "solution": rec["solution"], + "name": name or rec.get("name"), **timing} return {"status": "unknown"} # Statuses a job can't move on from — once a tracked token reaches one we @@ -289,6 +349,9 @@ class Handler(BaseHTTPRequestHandler): if path.path == "/cancel": self._handle_cancel(parse_qs(path.query)) return + if path.path == "/rename": + self._handle_rename() + return if path.path != "/solve": self._send(404, json.dumps({"error": "not found"})) return @@ -319,6 +382,40 @@ class Handler(BaseHTTPRequestHandler): except Exception as exc: # surface errors to the browser self._send(400, json.dumps({"error": f"{type(exc).__name__}: {exc}"})) + def _handle_rename(self): + # Set (or clear) a solve's custom display name. Works whether the solve + # is still queued/running (name kept in memory, applied when stored) or + # already stored (the row is updated too). An empty name clears it, + # reverting the card to its default "Solution n" label. + try: + length = int(self.headers.get("Content-Length", 0)) + payload = json.loads(self.rfile.read(length) or b"{}") + except Exception as exc: + self._send(400, json.dumps({"error": f"{type(exc).__name__}: {exc}"})) + return + token = str(payload.get("token") or "") + raw = payload.get("name") + name = (str(raw).strip() or None) if raw is not None else None + if not token: + self._send(400, json.dumps({"error": "missing token"})) + return + with _names_lock: + if name is None: + _names.pop(token, None) + else: + _names[token] = name + # Persist onto the stored row if the solve has already been saved. + conn = _db() + try: + with conn: + conn.execute("UPDATE solves SET name = ? WHERE token = ?", (name, token)) + finally: + conn.close() + # Push the change to anyone watching this token's status stream. + with _cond: + _cond.notify_all() + self._send(200, json.dumps({"ok": True, "name": name}), no_cache=True) + def _handle_cancel(self, query): # Cancel iff the request's token matches a queued/running solve, so a # Cancel click (or closing tab) can't cancel a *different* viewer's @@ -333,6 +430,7 @@ class Handler(BaseHTTPRequestHandler): job = _jobs.get(token) if job is not None: job["status"] = "cancelled" + job["finished_at"] = time.time() result = "dequeued" _cond.notify_all() elif token and token == _active["token"] and _active["solver"] is not None: