diff --git a/docs/GUI.md b/docs/GUI.md index b86f731..5a0050b 100644 --- a/docs/GUI.md +++ b/docs/GUI.md @@ -86,7 +86,8 @@ SCREENS Apply (2026-10-16, my request: one place per sheet tab; the Sheet screen merged in) three tabs, each with its count, the one you picked remembered (#/company, Today's buttons: NUworks co-ops; #/other: the internships): NUworks co-ops (the sheet's NUworks tab: the weekly limit), " internships" ("Other jobs" when - internships are off: the sheet's Other jobs tab, no limits), Assistants. The live run above them. + internships are off: the sheet's Other jobs tab, no limits), Assistants. The live run above them + (a finished run's box has × to close it: remembered for the session, per run; 2026-10-22, my request). A sheet tab: (NUworks) the week's count, At most and Start (Run on: this computer / the homelab, linked), How Start works folded; (the other tab, laid out the same, 2026-10-18, my request: "same buttons, same subtext") no limit here, how many are ready and applied, Run on, How Apply works folded; then one toolbar: Search this tab (words, all must appear, any case, in company, @@ -237,6 +238,13 @@ SPEED (2026-10-22, my request: "the apply page takes quite a while to load") (gui.review_lists, keyed by every data/ file's size and time: a few ms); searches and filters only narrow it. The taste model trains again only when your ratings or a job's text change (jobs.taste_for). The lists are made ahead at start and a few seconds after your last decision (warm_review), so the first open and the next search are instant. + (2026-10-22, my request: Review still took 0.5-3 s) Review asks with quick=1: the list made last comes at once (about + 10 ms) from the rows read last, never waiting on Google or a rebuild; when something changed since (stale) the page + asks again without quick and swaps the new list in without moving you (the job on screen stays, or the next one + still in the list; a search keeps how many it showed and the scroll; the same jobs draw nothing). A job decided here + after a list was made is left out of it (gui._decided). The monitor makes every kept list again when its rows, data/ + or settings changed (warm_changed, every 15 s and on a sheet read that finds a change), so stale is rare. Each + source (co-ops / internships) has its own kept list: the switch is instant both ways. - Buttons: one whose click starts something slower shows it at once (aria-busy) and takes no second click until it is done (busyWhile); sheet changes from a row's menu show "Updating the sheet…" until the answer comes. Switching screens shows Loading… at once; a screen you already left never takes over. diff --git a/src/nuauto/gui.py b/src/nuauto/gui.py index aaf320a..54cc245 100644 --- a/src/nuauto/gui.py +++ b/src/nuauto/gui.py @@ -351,12 +351,15 @@ def run_group(self, group): checks, rows = health.sheet_and_limits() with self.lock: if rows is not None: - if rows != self.rows: + changed = rows != self.rows + if changed: self.sheet_version += 1 self.rows, self.rows_at, self.rows_error = rows, now(), None self.rows_version = self.sheet_version else: - self.rows_error = checks[0].detail + changed, self.rows_error = False, checks[0].detail + if changed and SERVER is not None: + warm_changed() # Review's lists follow the sheet behind your back else: checks = health.run(self.GROUPS[group][0]) self._store(group, checks) @@ -449,7 +452,7 @@ def monitor(self): groups = [g for g in self.GROUPS if group_on(g)] for group in groups: self.next_run[group] = start + self.GROUPS[group][2] - warmed = False + warmed, warmed_at = False, now() while not self.stopping: if not warmed and self.rows is not None and now() - start > 5: # Review's lists, made ahead (1-5 s each) warmed = True @@ -470,6 +473,9 @@ def monitor(self): self.quit() if REMOTE and config.IS_SERVER: reload_config() # settings saved on the laptop (sync) or here: this process reads them again + if now() - warmed_at >= WARM_EVERY: + warmed_at = now() + warm_changed() time.sleep(2) def run_now(self, groups): @@ -770,8 +776,10 @@ def other_changed(): APP.other_cache = None -_review = {} # (mode, source) -> (what it was made from, todo, urgent, details) +_review = {} # (mode, source) -> (what it was made from, todo, urgent, details, when it was started) _review_locks = {} +_decided = {} # job id -> (when, mode): decided in Review since; a list made before leaves it out (gui.review) +ROWS_FOR = 60 # seconds the NUworks tab's rows count as fresh (APP.refresh_rows) def data_key(): @@ -798,20 +806,42 @@ def row_key(rows): return tuple((r.number, r.url, r.status) for r in rows or []) -def review_lists(mode, source="nuworks"): - """(todo, urgent, details) of the review queue, kept until what it is made from changes (training the taste model and - building the pool take 1-5 s; Review asks on every load and search).""" - rows = need_rows() +def review_key(source, rows, other): + return data_key(), date.today(), row_key(rows), row_key(other) if source == "intern" else None + + +def review_lists(mode, source="nuworks", quick=False): + """((todo, urgent, details, when it was started), stale) of the review queue, kept until what it is made from + changes (training the taste model and building the pool take 1-5 s; Review asks on every load and search). + quick: the list made last comes at once, made from the rows read last, even when something changed since; it is + made again behind it, and stale says so (Review then asks again without quick and swaps the new list in).""" if source == "intern": from nuauto import intern if not intern.ENABLED: raise Refused('Internships are off (add "internships": {} to local/local_config.json).') - other = other_rows(fresh=False) + kept = _review.get((mode, source)) + if quick and kept and APP.rows is not None and (source != "intern" or APP.other_cache): + old = now() - APP.rows_at >= ROWS_FOR + if old: + APP.refresh_soon(ROWS_FOR) + other = None + if source == "intern": + read_at, other, _ = APP.other_cache + if now() - read_at >= OTHER_ROWS_FOR: + old = True + APP.refresh_other_soon(OTHER_ROWS_FOR) + changed = kept[0] != review_key(source, APP.rows, other) + if changed and not _review_locks.setdefault((mode, source), threading.Lock()).locked(): + threading.Thread(target=warm_review, args=([(mode, source)],), daemon=True).start() + return kept[1:], old or changed + rows = need_rows() + other = other_rows(fresh=False) if source == "intern" else None with _review_locks.setdefault((mode, source), threading.Lock()): # (a warm-up under way: wait for it, don't redo it) - key = (data_key(), date.today(), row_key(rows), row_key(other) if source == "intern" else None) # (before reading) + key = review_key(source, rows, other) # (before reading) kept = _review.get((mode, source)) if kept and kept[0] == key: - return kept[1:] + return kept[1:], False + started = now() ratings = jobs.load("ratings.json", {}) if source == "intern": todo, urgent = intern.review_queue(mode, ratings, other, rows) @@ -819,12 +849,13 @@ def review_lists(mode, source="nuworks"): else: details = jobs.load_details() todo, urgent = jobs.review_queue(mode, details, ratings, rows) - _review[(mode, source)] = (key, todo, urgent, details) - return todo, urgent, details + _review[(mode, source)] = (key, todo, urgent, details, started) + return (todo, urgent, details, started), False def warm_review(pairs): - """Make the review lists ahead (in the background): at start, and a few seconds after your last decision.""" + """Make the review lists ahead (in the background): at start, a few seconds after your last decision, and when + what one is made from changes (warm_changed).""" for mode, source in pairs: try: review_lists(mode, source) @@ -832,6 +863,23 @@ def warm_review(pairs): pass +def warm_changed(): + """Every list already made whose sheet rows, data/ or settings changed is made again now (from the monitor, every + WARM_EVERY s, and when a sheet read finds a change), so opening Review almost never waits.""" + stale = [] + for (mode, source), kept in list(_review.items()): + if source == "intern" and not APP.other_cache: + continue + try: + if kept[0] != review_key(source, APP.rows, APP.other_cache[1] if source == "intern" else None): + stale.append((mode, source)) + except Exception: + pass + if stale: + threading.Thread(target=warm_review, args=(stale,), daemon=True).start() + + +WARM_EVERY = 15 _warm = {"timer": None, "last": ("approve", "nuworks")} # last: the list Review showed last @@ -850,12 +898,18 @@ def warm_review_soon(): WARM_AFTER = 3 -def review(mode, q="", category="", source="nuworks"): +def review(mode, q="", category="", source="nuworks", quick=False): """The review queue; q (words or "quoted phrases", all must appear) and category narrow what is shown. Decisions work on any job of the whole queue. source "intern": the internships (intern.py), whose Approve adds a - row to the Other jobs tab.""" - todo, urgent, details = review_lists(mode, source) + row to the Other jobs tab. quick: the list made last, at once (review_lists; stale: ask again without quick).""" + (todo, urgent, details, started), stale = review_lists(mode, source, quick) _warm["last"] = (mode, source) + + def gone(job_id): # decided here after the list was made (approve / no in Approve leaves both lists) + d = _decided.get(job_id) + return d is not None and d[0] >= started and (mode == "rate" or d[1] == "approve") + todo = [r for r in todo if not gone(r["id"])] + urgent = [r for r in urgent if not gone(r["id"])] ratings = jobs.load("ratings.json", {}) APP.queue = {r["id"]: r for r in todo} counts = {} @@ -878,7 +932,7 @@ def review(mode, q="", category="", source="nuworks"): "match": r["match"], "closes_text": jobs.closes_text(day), "soon": day is not None and (day - date.today()).days <= jobs.URGENT_DAYS, "rating": (ratings.get(r["id"]) or {}).get("label")}) - return {"jobs": out, "urgent": [r["id"] for r in urgent], "mode": mode, "source": source, "total": len(todo), + return {"jobs": out, "urgent": [r["id"] for r in urgent], "mode": mode, "source": source, "total": len(todo), "stale": stale, "categories": [{"key": k, "label": jobs.category_label(k), "count": counts[k]} for k in sorted(counts, key=lambda k: (-counts[k], k))]} @@ -964,6 +1018,7 @@ def decide(body): APP.refresh_rows(force=True) out["row"] = next((x.number for x in APP.rows or [] if x.url == jobs.job_url(job_id)), None) jobs.set_rating(job_id, {"label": int(decision != "no"), "date": date.today().isoformat()}) + _decided[job_id] = (now(), mode) warm_review_soon() return out @@ -991,6 +1046,7 @@ def undo(body): APP.refresh_rows(force=True) jobs.set_rating(job_id, {"label": previous["label"], "date": str(previous.get("date", ""))[:10]} if isinstance(previous, dict) and previous.get("label") in (0, 1) else None) + _decided.pop(job_id, None) warm_review_soon() return {} @@ -2369,7 +2425,7 @@ def serve_remote(): "/api/health": lambda q: {"checks": [c.to_dict() for c in APP.checks()], "running": sorted(APP.running_groups)}, "/api/review": lambda q: review(q.get("mode", "approve") if q.get("mode") in ("approve", "rate") else "approve", q.get("q", "")[:200], q.get("category", "")[:40], - "intern" if q.get("source") == "intern" else "nuworks"), + "intern" if q.get("source") == "intern" else "nuworks", q.get("quick") == "1"), "/api/apply": lambda q: apply_list(q.get("order", "default")), "/api/jobs": lambda q: jobs_tab("other" if q.get("tab") == "other" else "nuworks", q.get("order", "default"), q.get("recheck") == "1"), diff --git a/src/nuauto/gui_static/app.js b/src/nuauto/gui_static/app.js index 0cf21f9..753670a 100644 --- a/src/nuauto/gui_static/app.js +++ b/src/nuauto/gui_static/app.js @@ -737,25 +737,55 @@ screens.review = async (view) => { load(); } + // The server answers at once with the list it made last (quick); when something changed since, the new list follows + // (refresh) and takes its place without moving you. async function load() { const mine = ++loadSeq; // only the newest load may show its list (an older, slower answer is dropped) jobs = []; i = 0; card = null; shown++; // while it loads, no key decides on the old list (and no card of it comes back) - fill(body, el("p", { class: "empty", text: "Loading the review list…" })); + const slow = setTimeout(() => { if (mine === loadSeq && body.isConnected) fill(body, el("p", { class: "empty", text: "Loading the review list…" })); }, 150); await queue; // decisions still being written go first: the server decides on its last list, which this one replaces const params = new URLSearchParams({ mode, q, category, source }); - try { - const r = await api.get(`/api/review?${params}`); - if (mine !== loadSeq || !body.isConnected) return; - jobs = r.jobs; total = r.total; i = 0; listed = 0; cards.clear(); drawCategories(r.categories); - if (!q && resumeId) { i = Math.max(0, jobs.findIndex((x) => x.id === resumeId)); resumeId = null; } - } catch (e) { if (mine === loadSeq && body.isConnected) fill(body, el("div", { class: "note fail", text: e.message })); return; } + let r; + try { r = await api.get(`/api/review?${params}&quick=1`); } + catch (e) { clearTimeout(slow); if (mine === loadSeq && body.isConnected) fill(body, el("div", { class: "note fail", text: e.message })); return; } + clearTimeout(slow); + if (mine !== loadSeq || !body.isConnected) return; + jobs = r.jobs; total = r.total; i = 0; listed = 0; cards.clear(); drawCategories(r.categories); + if (!q && resumeId) { i = Math.max(0, jobs.findIndex((x) => x.id === resumeId)); resumeId = null; } show(); + if (r.stale) refresh(mine, params); + } + + // The list made from what changed since the quick one: the job on screen stays (one that left the list: the next one + // still in it), a search keeps how many it showed and where you scrolled, and the same jobs draw nothing again. + async function refresh(mine, params) { + let r; + try { await queue; r = await api.get(`/api/review?${params}`); } catch (e) { return; } + if (mine !== loadSeq || !body.isConnected) return; + const old = jobs, was = new Map(old.map((j) => [j.id, JSON.stringify({ ...j, rating: null })])); + const changed = (j) => was.get(j.id) !== JSON.stringify({ ...j, rating: null }); // (a rating made here is not a change) + total = r.total; drawCategories(r.categories); + if (r.jobs.length === old.length && r.jobs.every((j, k) => j.id === old[k].id && !changed(j))) return position(); + r.jobs.forEach((j) => { if (changed(j)) cards.delete(j.id); }); // (its card may say something new too) + jobs = r.jobs; + if (listMode()) { const had = listed, y = window.scrollY; showList(true, had); window.scrollTo(0, y); return; } + const at = new Map(jobs.map((j, k) => [j.id, k])); + const next = old.slice(i).find((j) => at.has(j.id)); + const same = next && old[i] && next.id === old[i].id && !changed(jobs[at.get(next.id)]); + i = next ? at.get(next.id) : jobs.length; + if (same) position(); else show(); // (the same job, unchanged: its card stays as it is) + } + + function position() { + const filtered = !!(q || category); + $("#review-pos").textContent = !jobs.length ? "" : listMode() ? `${jobs.length} ${jobs.length === 1 ? "match" : "matches"} (${total} without the search) · j/k scroll` : + `Job ${Math.min(i + 1, jobs.length)} of ${jobs.length}${filtered ? ` (${total} without the search)` : ""} · j/k scroll`; } async function show() { const filtered = !!(q || category); if (listMode()) return showList(filtered); - $("#review-pos").textContent = jobs.length ? `Job ${Math.min(i + 1, jobs.length)} of ${jobs.length}${filtered ? ` (${total} without the search)` : ""} · j/k scroll` : ""; + position(); if (!jobs.length && filtered) { fill(body, el("div", { class: "card", testid: "review-nomatch" }, el("h2", { text: "No jobs match" }), el("p", { class: "muted", text: `None of the ${total} jobs to review match this search.` }), @@ -784,9 +814,9 @@ screens.review = async (view) => { // Search results: every match as its own card with its own buttons (the job you were on is left as it was). const PAGE = 8; - function showList(filtered) { + function showList(filtered, upto = PAGE) { shown++; - $("#review-pos").textContent = jobs.length ? `${jobs.length} ${jobs.length === 1 ? "match" : "matches"} (${total} without the search) · j/k scroll` : ""; + position(); if (!jobs.length) { fill(body, el("div", { class: "card", testid: "review-nomatch" }, el("h2", { text: "No jobs match" }), el("p", { class: "muted", text: `None of the ${total} jobs to review match this search.` }), @@ -801,7 +831,8 @@ screens.review = async (view) => { listed = Math.min(jobs.length, listed + PAGE); fill(more, listed < jobs.length ? el("button", { class: "btn", testid: "review-more", text: `Show ${Math.min(PAGE, jobs.length - listed)} more (${jobs.length - listed} left)`, onclick: next }) : ""); }; - next(); + listed = 0; + do next(); while (listed < Math.min(upto, jobs.length)); } async function drawItem(j, slot) { @@ -1314,8 +1345,9 @@ screens.apply = async (view) => { function drawRun() { const t = S.task; - if (!t || t.kind !== "apply") { fill(runBox, ); return; } - const live = t.state === "running"; + const live = t && t.state === "running"; + // a finished run's box stays until you close it (×; remembered for the session, per run) + if (!t || t.kind !== "apply" || (!live && sessionStorage.getItem("runClosed") === String(t.id))) { fill(runBox, ); return; } const now = Date.now() / 1000; const waiting = live && t.wait_until && t.wait_until > now; fill(runBox, el("div", { class: "card", testid: "apply-run" }, @@ -1323,7 +1355,9 @@ screens.apply = async (view) => { el("h2", { text: live ? (waiting ? `Next job in ${Math.ceil(t.wait_until - now)} s` : S.taskWhere === "homelab" ? "Applying on the homelab…" : "Applying…") : `Last run${S.taskWhere === "homelab" ? " on the homelab" : ""}: ${t.state}` })), live ? el("div", { class: "row" }, el("button", { class: "btn danger", testid: "btn-stop", text: "Stop", onclick: () => stopTask(t) }), el("button", { class: "btn ghost small", text: "Force stop", title: "Only if Stop does nothing", onclick: () => - confirmBox("Force stop?", "Kills the run at once. If Submit was already clicked, that row stays Needs Human: check NUworks.", "Force stop", () => stopTask(t, true), { danger: true }) })) : null), + confirmBox("Force stop?", "Kills the run at once. If Submit was already clicked, that row stays Needs Human: check NUworks.", "Force stop", () => stopTask(t, true), { danger: true }) })) : + el("button", { class: "btn ghost small", testid: "btn-run-close", text: "×", title: "Close", "aria-label": "Close the last run's box", + onclick: () => { sessionStorage.setItem("runClosed", String(t.id)); fill(runBox, ); } })), t.browser_view ? el("p", { class: "note accent", testid: "apply-browser-view" }, el("b", { text: "Its browser: " }), linkOut(t.browser_view.url, "watch and use it here"), " · password ", el("code", { text: t.browser_view.password }), " (the homelab's screen; for this run only)") : null, diff --git a/tests/test_gui.py b/tests/test_gui.py index a8a6ea4..ac7ac93 100644 --- a/tests/test_gui.py +++ b/tests/test_gui.py @@ -150,8 +150,14 @@ def eventually(check, what, seconds=20): rows = json.load(open(os.path.join(STATE, "local", "demo_sheet_DEMO-SHEET.json")))["rows"] assert rows[r["row"] - 1][3] == "Approved" assert json.load(open(os.path.join(STATE, "data", "ratings.json")))[first]["label"] == 1 + # quick: the list made before the decision comes at once (stale: a new one is on its way), without the job just + # decided here; the next ask without quick waits for the new list + quick = get("/api/review?mode=approve&quick=1") + assert quick["stale"] and first not in [j["id"] for j in quick["jobs"]], quick # Review's list is kept between loads and made again when what it comes from changes (here: the sheet, a rating) - assert first not in [j["id"] for j in get("/api/review?mode=approve")["jobs"]] + fresh = get("/api/review?mode=approve") + assert not fresh["stale"] and first not in [j["id"] for j in fresh["jobs"]] + assert get("/api/review?mode=approve&quick=1")["stale"] is False # nothing changed since: the kept list, fresh post("/api/undo", {"id": first, "row": r["row"], "previous": None}) rows = json.load(open(os.path.join(STATE, "local", "demo_sheet_DEMO-SHEET.json")))["rows"] assert rows[r["row"] - 1][3] == "Proposed" and first not in json.load(open(os.path.join(STATE, "data", "ratings.json"))) @@ -819,6 +825,10 @@ def eventually(check, what, seconds=20): "document.querySelector('[data-testid=q-menu-u]')", timeout=120000) assert page.locator("[data-testid=q-menu-u]").count() == 0, "unexpected: NUworks' confirmation was not seen" assert page.locator("[data-testid=apply-shot]").count() == 1 + page.click("[data-testid=btn-run-close]") # a finished run's box closes (and stays closed when the screen redraws) + page.wait_for_selector("[data-testid=apply-run]", state="detached") + page.wait_for_timeout(2500) + assert page.locator("[data-testid=apply-run]").count() == 0 # the finished rows leave the Approved list by themselves (no reopening): Applied / Needs Human rows are not Approved page.wait_for_function("() => !document.querySelector('[data-testid=approved-list]')", timeout=20000) rows = {r[1]: r[3] for r in json.load(open(os.path.join(STATE, "local", "demo_sheet_DEMO-SHEET.json")))["rows"][1:]}