"use strict"; const choices = { tue: "Tuesday, November 10", wed: "Wednesday, November 11", thu: "Thursday, November 12", }; const keys = Object.keys(choices), nights = [...document.querySelectorAll(".night")]; const $ = (id) => document.getElementById(id); let current = 0, selected = null, busy = false, recorded = false, pending = null, pendingKey = "", checked = false, terminal = false; // Only a choice key is shared, never the bearer. Web Locks make claiming it // atomic across same-origin tabs. Unresolved attempts must not be replaced. function showFailure(error) { if (!error.terminal) { showUnknown(); return; } terminal = true; if (error.expired) { try { localStorage.removeItem(pendingKey); } catch {} pending = null; } $("reply").hidden = false; $("reply-title").textContent = "This pick cannot be sent."; $("send-status").textContent = error.message; $("send").hidden = true; $("change").hidden = true; $("confirmation-note").textContent = "No new pick was confirmed by this response. If you sent one earlier, check with Niq in your usual conversation."; choiceLock(); } function storageFailure() { return Object.assign(new Error("This browser cannot safely remember an unfinished pick. Use a browser with site storage and Web Locks enabled, or tell Niq directly."), {terminal:true}); } function readPending() { let value; try { value = localStorage.getItem(pendingKey); } catch { throw storageFailure(); } if (value !== null && !Object.hasOwn(choices, value)) throw storageFailure(); return value; } function showUnknown() { if (pending) confirm(pending, false); $("reply").hidden = false; $("change").hidden = true; $("send").textContent = pending ? "Try this pick again" : "Check saved pick"; $("send-status").textContent = pending ? "We couldn’t confirm whether your pick was recorded. Try again with this pick, or check with Niq." : "We couldn’t check for a saved pick. Check again before choosing a night."; $("confirmation-note").textContent = "A previous request may still finish. A different pick cannot be sent until this is resolved."; } async function reconcile() { const result = await request(); checked = true; if (result.recorded) showRecorded(result); else if (pending) showUnknown(); else { $("reply").hidden = true; $("browse").hidden = false; $("change").hidden = false; $("send").textContent = "Send my pick"; $("confirmation-note").textContent = "This saves one pick. You can change it before sending."; } } function choiceLock() { document.querySelectorAll("[data-choice]").forEach(b => { b.disabled = terminal || busy || recorded || !!pending || (token && !checked); }); } let token = location.hash.slice(1); if (!/^[a-f0-9]{64}$/.test(token)) token = ""; try { if (token) { sessionStorage.setItem("invitation-token", token); history.replaceState(null, "", location.pathname); } else token = sessionStorage.getItem("invitation-token") || ""; } catch { /* The fragment remains available if storage is disabled. */ } function showNight(index, announce = true) { current = (index + keys.length) % keys.length; nights.forEach((n, i) => { n.classList.toggle("active", i === current); n.hidden = i !== current; }); document .querySelectorAll("[data-open]") .forEach((b, i) => b.setAttribute("aria-pressed", String(i === current))); $("counter").textContent = current + 1 + " / 3"; if (announce) $("browse-status").textContent = choices[keys[current]] + ". " + nights[current].querySelector("h2").textContent; } function confirm(key, focus = true) { selected = key; $("stamp").textContent = choices[key].split(",")[0]; $("choice-description").textContent = choices[key] + "."; $("browse").hidden = true; $("reply").hidden = false; if (focus) $("reply-title").focus(); } function showRecorded(result) { const attempted = selected; recorded = true; try { localStorage.removeItem(pendingKey); } catch {} pending = null; confirm(result.choice); $("browse").hidden = false; showNight(keys.indexOf(result.choice), false); choiceLock(); $("reply-title").textContent = "Your pick is recorded."; $("send-status").textContent = (attempted && attempted !== result.choice ? choices[attempted] + " was not saved. " + choices[result.choice] + " was already recorded. " : "") + "Niq’s automatic notification isn’t connected yet. Your choice is saved here."; $("send").hidden = true; $("change").hidden = true; $("confirmation-note").textContent = "If you need to change it, let Niq know in your usual conversation."; } function lock(value) { busy = value; $("send").disabled = value; $("change").disabled = value; choiceLock(); } async function request(method = "GET") { const controller = new AbortController(), timer = setTimeout(() => controller.abort(), 8000); try { const response = await fetch("/api/pick", { method, headers: { Authorization: "Bearer " + token, ...(method === "POST" ? { "Content-Type": "application/json" } : {}), }, ...(method === "POST" ? { body: JSON.stringify({ choice: selected }) } : {}), cache: "no-store", signal: controller.signal, }); let data; try { data = await response.json(); } catch { if (response.status >= 400 && response.status < 500) throw Object.assign(new Error("This invitation request was rejected. Check the link with Niq."), {terminal:true}); throw new Error("unconfirmed"); } const failures = { expired: "This invitation is closed. No new pick can be saved.", invalid_invitation: "This invitation link is invalid. Ask Niq for the full link.", not_configured: "This invitation is not configured to accept picks. Let Niq know.", }; if (failures[data.error] || (response.status >= 400 && response.status < 500 && response.status !== 409)) throw Object.assign(new Error(failures[data.error] || "This invitation request was rejected. Check the link with Niq."), {terminal:true, expired:data.error === "expired"}); if ( (response.status === 200 || response.status === 409) && data.recorded === true && Object.hasOwn(choices, data.choice) && data.notification === "not_connected" ) return data; if (response.status === 200 && data.recorded === false) return data; throw new Error("unconfirmed"); } finally { clearTimeout(timer); } } document .querySelectorAll("[data-open]") .forEach((b) => b.addEventListener("click", () => showNight(keys.indexOf(b.dataset.open))), ); $("prev").addEventListener("click", () => showNight(current - 1)); $("next").addEventListener("click", () => showNight(current + 1)); document.querySelectorAll("[data-choice]").forEach((b) => b.addEventListener("click", () => { if (terminal || busy || recorded || pending || (token && !checked)) return; confirm(b.dataset.choice); $("send-status").textContent = ""; if (!token) { $("send").disabled = true; $("send-status").textContent = "Open the full invitation link to send a pick. You can also tell Niq in your usual conversation."; } }), ); $("change").addEventListener("click", () => { if (terminal || busy || recorded || pending || (token && !checked)) return; $("reply").hidden = true; $("browse").hidden = false; showNight(keys.indexOf(selected), false); nights[current].querySelector(".choose").focus(); }); $("send").addEventListener("click", async () => { if (terminal || busy || recorded || !token) return; lock(true); try { if (!selected && !pending) { await reconcile(); return; } // Claim atomically across tabs before issuing any POST. if (!navigator.locks) throw storageFailure(); await navigator.locks.request(pendingKey, () => { const shared = readPending(); if (shared && shared !== selected) { pending = shared; showUnknown(); throw new Error("other_pending"); } try { localStorage.setItem(pendingKey, selected); } catch { throw storageFailure(); } pending = selected; }); $("change").hidden = true; $("confirmation-note").textContent = "Only this pick can be retried while its result is uncertain."; $("send-status").textContent = "Saving your pick…"; const result = await request("POST"); if (result.recorded) showRecorded(result); else throw new Error("unconfirmed"); } catch (error) { if (error.terminal) showFailure(error); else { try { await reconcile(); } catch (error) { showFailure(error); } } } finally { lock(false); } }); showNight(0, false); document.documentElement.classList.add("js"); if (token) { lock(true); $("browse-status").textContent = "Checking for a saved pick…"; (async () => { pendingKey = "invitation-pending-" + Array.from(new Uint8Array( await crypto.subtle.digest("SHA-256", new TextEncoder().encode(token)) ), b => b.toString(16).padStart(2, "0")).join(""); pending = readPending(); if (pending) showUnknown(); await reconcile(); })().catch(showFailure).finally(() => { lock(false); $("browse-status").textContent = ""; }); } window.addEventListener("storage", async (event) => { if (event.key !== pendingKey || busy || recorded || terminal) return; try { pending = readPending(); if (pending) showUnknown(); else await reconcile(); } catch (error) { showFailure(error); } choiceLock(); });