Home-screen web app; phone layout; reload on deploy

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
rope 2026-10-18 14:31:41 +01:00
parent 9058039eb7
commit 14b4c07208
8 changed files with 121 additions and 25 deletions

View file

@ -14,6 +14,8 @@ let selected = null; // bank item
let pickedClass = null; // registration
let refreshing = false;
let refreshedFor = null; // action start a completion refresh was sent for
let version = null; // server build this page was loaded from
let failures = 0; // polls in a row that didn't reach the server
const $ = (sel, root = document) => root.querySelector(sel);
const esc = (s) => String(s ?? "").replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c]);
@ -56,14 +58,22 @@ async function refresh() {
refreshing = true;
try {
accept(await api("/api/state"));
failures = 0;
} catch (err) {
toast(err.message, "bad");
// A deploy restarts the server for a second or two; only speak up if
// the city stays out of reach.
if (++failures === 3) toast("Lost contact with the city. Retrying…", "bad");
} finally {
refreshing = false;
}
}
function accept(state) {
version ??= state.version;
if (state.version !== version && !document.querySelector("[data-keep]:focus") && $("#modal").hidden) {
location.reload(); // a new build was deployed
return;
}
S = state;
skew = state.now - Date.now() / 1000;
if (state.summary) showSummary(state.summary);
@ -87,12 +97,18 @@ function clock(t) {
return new Date((t - skew) * 1000).toLocaleString("en-GB", { weekday: "short", hour: "2-digit", minute: "2-digit" });
}
// Coarse on purpose: the markup stays the same between polls (see setHtml).
function ago(t) {
const s = serverNow() - t;
if (s < 60) return "just now";
return dur(s) + " ago";
if (s < 3600) return `${Math.floor(s / 60)}m ago`;
if (s < 86400) return `${Math.floor(s / 3600)}h ago`;
return `${Math.floor(s / 86400)}d ago`;
}
// A live "3h 12m" until `t`, filled in and kept current by animate().
const countdown = (t) => `<span data-countdown="${t}"></span>`;
// --- toasts & summaries ---------------------------------------------------
function toast(text, kind = "") {
@ -101,7 +117,8 @@ function toast(text, kind = "") {
el.textContent = text;
$("#toasts").append(el);
setTimeout(() => el.remove(), kind === "level" ? 6000 : 3500);
while ($("#toasts").children.length > 5) $("#toasts").firstChild.remove();
const keep = matchMedia("(max-width: 900px)").matches ? 2 : 4;
while ($("#toasts").children.length > keep) $("#toasts").firstChild.remove();
}
function showSummary(sum) {
@ -147,9 +164,9 @@ function render() {
for (const el of document.querySelectorAll("[data-keep]")) kept[el.dataset.keep] = el.value;
const focus = document.activeElement?.dataset?.keep;
$("#side").innerHTML = S.me ? sidebar() : `<div class="brand"><img src="/static/icon.svg" alt="">Guildhall <small>alpha</small></div>${S.dev ? devbar() : ""}`;
$("#top").innerHTML = topbar();
$("#main").innerHTML = S.me ? pageHtml() : register();
setHtml($("#side"), S.me ? sidebar() : `<div class="brand"><img src="/static/icon.svg" alt="">Guildhall <small>alpha</small></div>${S.dev ? devbar() : ""}`);
setHtml($("#top"), topbar());
setHtml($("#main"), S.me ? pageHtml() : register());
for (const [key, value] of Object.entries(kept)) {
const el = document.querySelector(`[data-keep="${key}"]`);
@ -161,6 +178,21 @@ function render() {
animate();
}
// Only touch the DOM when the markup changed. Replacing an element mid-swipe
// cancels the swipe on iOS, and polls arrive every few seconds.
function setHtml(el, html) {
if (el.dataset.html === html) return;
const scroll = el.scrollTop;
el.innerHTML = html;
el.dataset.html = html;
el.scrollTop = scroll;
}
function setMenu(open) {
$("#app").classList.toggle("open", open);
document.documentElement.classList.toggle("menu-open", open);
}
function nav(id, icon, label, meta = "", extra = "") {
return `<button class="nav ${page === id ? "on" : ""}" data-nav="${id}"><span class="ic">${icon}</span><span class="label">${label}</span>${extra}<span class="meta">${meta}</span></button>`;
}
@ -229,9 +261,8 @@ function eventIcon(kind) {
}
function eventWhen(ev, short = false) {
const now = serverNow();
if (ev.exact) return short ? `in ${dur(ev.exact - now)}` : `${clock(ev.exact)} (in ${dur(ev.exact - now)})`;
if (short) return `in ${dur(ev.earliest - now)}–${dur(ev.latest - now)}`;
if (ev.exact) return short ? `in ${countdown(ev.exact)}` : `${clock(ev.exact)} (in ${countdown(ev.exact)})`;
if (short) return ev.earliest <= serverNow() + 60 ? `within ${countdown(ev.latest)}` : `in ${countdown(ev.earliest)}–${countdown(ev.latest)}`;
return `between ${clock(ev.earliest)} and ${clock(ev.latest)}`;
}
@ -397,11 +428,11 @@ function town() {
<div class="stat"><div class="k">Raids repelled</div><div class="v">${city.repelled}</div></div>
</div>`;
if (city.starving) html += `<div class="banner bad">🍞<div class="grow"><b>The granary is empty.</b> People are leaving and the city defends worse. Donate bread, cooked meat or stew.</div></div>`;
if (city.merchant_until > serverNow()) html += `<div class="banner">🐪<div class="grow"><b>A merchant caravan is in town.</b> Everything sells for 50% more for another ${dur(city.merchant_until - serverNow())}.</div></div>`;
if (city.merchant_until > serverNow()) html += `<div class="banner">🐪<div class="grow"><b>A merchant caravan is in town.</b> Everything sells for 50% more for another ${countdown(city.merchant_until)}.</div></div>`;
const work = city.construction;
if (work) {
const spec = C.buildings[work.building];
html += `<div class="banner">🏗️<div class="grow"><b>${work.repair ? "Repairing" : "Building"} ${esc(spec.name)}</b> · done in <span data-countdown="${work.done_at}">${dur(work.done_at - serverNow())}</span>
html += `<div class="banner">🏗️<div class="grow"><b>${work.repair ? "Repairing" : "Building"} ${esc(spec.name)}</b> · done in ${countdown(work.done_at)}
<div class="bar gold" style="margin-top:6px"><span data-span="${work.start},${work.done_at}"></span></div></div></div>`;
}
html += `<div class="section-title">Buildings</div><div class="grid wide">`;
@ -575,16 +606,22 @@ document.addEventListener("click", (e) => {
if (!t) return;
const d = t.dataset;
if (t.matches("[data-close]") || e.target === $("#modal")) { $("#modal").hidden = true; return; }
if ("menu" in d) { $("#app").classList.toggle("open"); return; }
if ("menu" in d) { setMenu(!$("#app").classList.contains("open")); return; }
if (d.nav) {
page = d.nav; remember("page", page); selected = null;
history.replaceState(null, "", "#" + page);
$("#app").classList.remove("open");
setMenu(false);
render(); window.scrollTo(0, 0); return;
}
if (d.start) return act("/api/action", { id: d.start });
if ("stop" in d) return act("/api/stop", {});
if ("select" in d) { selected = d.select || null; render(); return; }
if ("select" in d) {
selected = d.select || null;
render();
// On a phone the item panel sits under the bank grid: bring it into view.
if (selected && matchMedia("(max-width: 900px)").matches) $(".item-panel")?.scrollIntoView({ behavior: "smooth", block: "start" });
return;
}
const qty = d.qty === "all" ? "all" : Number(d.qty);
if (d.donate) return act("/api/donate", { item: d.donate, qty });
if (d.sell) return act("/api/sell", { item: d.sell, qty });