Renaming and ETA added

can now rename solves for remembering what they are and they have an ETA
for starting and finishing
This commit is contained in:
Pagwin 2026-06-19 15:31:56 -04:00
parent 0c94d94936
commit b27a73bc1f
2 changed files with 265 additions and 33 deletions

View file

@ -365,7 +365,7 @@
<fieldset>
<legend>Objective &mdash; scoring terms</legend>
<p class="help">Each term scores a resource (or <code>renown</code>) at the end of a
turn (blank turn = final turn). For a <b>log</b> term, write a JS expression that
turn. For a <b>log</b> term, write a JS expression that
evals to a one-argument function, e.g. <code>(x) =&gt; Math.log2(x + 1)</code>.
It is called over amounts <code>0..max_resource</code> in the browser to build the
lookup table.</p>
@ -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=<id1>,<id2>,… so anyone who knows the ids can compose an
// arbitrary set; a single ?solve=<id> 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 @@
`<b>Final renown total:</b> ${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:
`<b>Began:</b> ${fmtClock(timing.started_at)} &nbsp; ` +
`<b>Finished:</b> ${fmtClock(timing.finished_at)} &nbsp; ` +
`<b>Took:</b> ${fmtDur(timing.started_at, timing.finished_at)}`
}));
}
const fr = el("p", {});
fr.append(el("b", {}, "Final resources: "));
fr.append(document.createTextNode(

122
main.py
View file

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