From d91d170ebfeb762ec615095f9ece952e585e4f68 Mon Sep 17 00:00:00 2001 From: Jason Lamb <92162022+jasrasr@users.noreply.github.com> Date: Fri, 18 Sep 2026 06:08:22 -0400 Subject: [PATCH] Fix attendee response recovery and return focus (v1.3.2) --- availability/CHANGELOG.md | 10 ++- availability/README.md | 12 +++- availability/app.js | 52 ++++++++++++--- availability/index.php | 16 +++-- availability/project.json | 67 ++++++++++++++++---- availability/styles.css | 10 ++- availability/tests/response-recovery.cjs | 81 ++++++++++++++++++++++++ 7 files changed, 212 insertions(+), 36 deletions(-) create mode 100644 availability/tests/response-recovery.cjs diff --git a/availability/CHANGELOG.md b/availability/CHANGELOG.md index ca1c6746..7bc80af7 100644 --- a/availability/CHANGELOG.md +++ b/availability/CHANGELOG.md @@ -1,6 +1,14 @@ - + # Changelog +## 1.3.2 — 2026-09-18 + +- Fix attendee recovery links to force response mode, override stale response credentials, and exclude remembered admin credentials, including after reload. +- Preserve legacy response links and retain token fragments when browser storage is unavailable. +- Move the private response link immediately below Save my availability, keeping it copyable for closed polls. +- Focus/scroll to the saved availability section once on recovery, without disrupting background refreshes. +- Add browser regressions for recovery, passwords, privacy, storage failure, link placement and focus. + ## 1.3.1 — 2026-09-17 - Remove accidentally committed stash-conflict markers from PHP, JavaScript, documentation, and project metadata, fixing the line-2 PHP parse error. diff --git a/availability/README.md b/availability/README.md index 85858dc5..e16c38e2 100644 --- a/availability/README.md +++ b/availability/README.md @@ -1,7 +1,7 @@ - + # Availability -**Project revision 1.3.1 · Updated 2026-09-17** +**Project revision 1.3.2 · Updated 2026-09-18** A shared date poll for family events, group outings, and team meetups. Built with PHP, vanilla JavaScript, CSS, and one protected JSON document per event. No database, build step, account, or external service is required. @@ -73,3 +73,11 @@ node availability/tests/suggestions.mjs http://127.0.0.1:8090 Browser checklist: create events with attendee-added options disabled and enabled; verify the attendee add-option area appears only when enabled; add date-only and date/time options; verify duplicate and stale additions are rejected; confirm existing answers remain intact and new options start unanswered; disable the feature again as admin; then repeat on an event protected with an event password. Also test no-password, admin-only, invite-only, and dual-password events; confirm invite mode never exposes admin editing; verify wrong/missing passwords cannot read protected data; verify the admin password plus private admin link reveals private attendee details; verify event-password attendees can vote and edit their own response; and verify a new browser/session asks for the password again. GitHub Actions validates PHP syntax, JavaScript syntax, project JSON, and committed conflict markers on Availability changes. A deployment must use source that passes these checks; never commit unresolved stash/merge markers. + +## Attendee response recovery + +After saving, the private response link appears directly below **Save my availability**. It restores that attendee’s saved name, contact details, headcounts, food contribution, and answers, even in a new browser. It selects explicit response mode so remembered organizer credentials cannot override it. Older response links are accepted too. An event password is still required when configured; a response link does not grant admin access. + +Opening the private response link or reloading a page with a remembered response focuses and scrolls to **Your availability** once after successful recovery. Background refreshes leave focus and scrolling alone. The link stays copyable on closed/expired polls. If browser storage is blocked, the token fragment stays in the URL so reload can still recover; keep that URL private. Other users’ details are never recovered through an attendee link. + +The browser regression suite requires Playwright and Chromium and a disposable local PHP server: `node availability/tests/response-recovery.cjs http://127.0.0.1:8090`. Set `PLAYWRIGHT_CHROMIUM_EXECUTABLE` if using a separately installed Chromium binary. It covers fresh browsers, protected events, remembered admin credentials, legacy links, reloads, blocked storage, editing the same response, placement, focus, and closed polls. diff --git a/availability/app.js b/availability/app.js index ba31bddf..cb64748b 100644 --- a/availability/app.js +++ b/availability/app.js @@ -1,10 +1,12 @@ -/* Revision 1.3.1 | 2026-09-17 | Admin-controlled attendee-added date/time options. - * History: 1.3.1 — Remove committed conflict markers while preserving all 1.3.0 features; 1.3.0 — Allow organizers to let attendees add options; 1.1.2 — Separate invite/public mode from remembered admin access; 1.1.1 — Single attendee name and Unicode-safe matching; 1.1.0 — Private contacts and event planning; 1.0.0 — Event creation, voting, live rankings and response matrix. */ +/* Revision 1.3.2 | 2026-09-18 | Admin-controlled attendee-added date/time options. + * History: 1.3.2 — Attendee response recovery, link placement and return focus; 1.3.1 — Remove committed conflict markers while preserving all 1.3.0 features; 1.3.0 — Allow organizers to let attendees add options; 1.1.2 — Separate invite/public mode from remembered admin access; 1.1.1 — Single attendee name and Unicode-safe matching; 1.1.0 — Private contacts and event planning; 1.0.0 — Event creation, voting, live rankings and response matrix. */ 'use strict'; const $ = id => document.getElementById(id); const params = new URLSearchParams(location.search); let eventId = params.get('event'), event = null, editing = false, dirty = false, sequence = 0; -let inviteMode = params.get('view') === 'invite'; +let responseMode = params.get('view') === 'response'; +let inviteMode = params.get('view') === 'invite' || responseMode; +let focusResponseOnLoad = false; let chosenDates = [], answers = {}, formRevision = 0, savingVote = false, addingDate = false; const storageKey = () => `availability:${eventId}`; let credentials = {}; @@ -15,7 +17,8 @@ function remember() { try { stored = JSON.parse(localStorage.getItem(storageKey()) || '{}') || {}; } catch { stored = {}; } const next = inviteMode && stored.adminToken ? {...stored, ...credentials, adminToken: stored.adminToken} : credentials; localStorage.setItem(storageKey(), JSON.stringify(next)); - } catch { message('Browser storage is unavailable. Save your private link to return later.'); } + return true; + } catch { message('Browser storage is unavailable. Save your private link to return later.'); return false; } } function message(text, error = false) { $('message').textContent = text; $('message').classList.toggle('error', error); } function node(tag, text, className) { const element = document.createElement(tag); if (text !== undefined) element.textContent = text; if (className) element.className = className; return element; } @@ -34,6 +37,8 @@ function link(kind) { const url = new URL(location.pathname, location.origin); url.searchParams.set('event', eventId); if (kind === 'invite') url.searchParams.set('view', 'invite'); + else if (kind === 'response' || (kind === undefined && responseMode)) url.searchParams.set('view', 'response'); + else if (kind === undefined && inviteMode) url.searchParams.set('view', 'invite'); const fragment = new URLSearchParams(); if (kind === 'admin') fragment.set('admin', credentials.adminToken); if (kind === 'response') { fragment.set('response', credentials.responseId); fragment.set('key', credentials.responseToken); } @@ -57,7 +62,7 @@ function renderLinks() { const hasAdmin = !inviteMode && !!credentials.adminToken; $('admin-link-row').hidden = !hasAdmin; $('response-link-row').hidden = !credentials.responseToken; - $('private-links').hidden = !hasAdmin && !credentials.responseToken; + $('private-links').hidden = !hasAdmin; if (hasAdmin) $('admin-link').value = link('admin'); if (credentials.responseToken) $('response-link').value = link('response'); $('edit-event').hidden = !hasAdmin; @@ -101,6 +106,7 @@ $('event-form').onsubmit = async e => { eventId = data.event.id; if (data.adminToken) credentials.adminToken = data.adminToken; inviteMode = false; + responseMode = false; remember(); history.replaceState(null, '', link()); editing = false; @@ -212,7 +218,13 @@ $('vote-form').onsubmit = async e => { const details = Object.fromEntries(Object.entries({...privateFields, ...partyFields}).map(([key, id]) => [key, ['adults', 'kids'].includes(key) ? ($(id).value === '' ? null : Number($(id).value)) : $(id).value])); const data = await api({action: 'vote', id: eventId, revision: event.revision, name: $('voter-name').value, ...details, answers, responseId: credentials.responseId || '', responseToken: credentials.responseToken || ''}); Object.assign(credentials, {responseId: data.responseId, responseToken: data.responseToken}); - remember(); + const remembered = remember(); + if (inviteMode || !credentials.adminToken) { + inviteMode = true; + responseMode = true; + // Preserve recovery across reloads even when browser storage is blocked. + history.replaceState(null, '', remembered ? link() : link('response')); + } dirty = false; applyEvent(data.event, data); renderLinks(); @@ -316,6 +328,13 @@ async function refresh() { $('poll').hidden = false; applyEvent(data.event, data); renderLinks(); + if (focusResponseOnLoad && data.myResponse) { + focusResponseOnLoad = false; + requestAnimationFrame(() => { + $('response-section').focus({preventScroll: true}); + $('response-section').scrollIntoView({block: 'start'}); + }); + } $('live-state').textContent = `Live · Updated ${new Date().toLocaleTimeString()} · Refreshes every 5 seconds`; } catch (err) { if (request !== sequence) return; @@ -327,10 +346,23 @@ if (eventId) { $('setup').hidden = true; try { credentials = JSON.parse(localStorage.getItem(storageKey()) || '{}') || {}; } catch { credentials = {}; } const fragment = new URLSearchParams(location.hash.slice(1)); - if (fragment.get('admin')) { credentials.adminToken = fragment.get('admin'); inviteMode = false; } - else if (inviteMode) delete credentials.adminToken; - if (fragment.get('response') && fragment.get('key')) { credentials.responseId = fragment.get('response'); credentials.responseToken = fragment.get('key'); } - if (location.hash) { remember(); history.replaceState(null, '', link()); } + if (fragment.get('response') && fragment.get('key')) { + // Response links (including older links without view=response) take precedence + // over saved admin credentials and any stale response from this browser. + responseMode = true; + inviteMode = true; + credentials.responseId = fragment.get('response'); + credentials.responseToken = fragment.get('key'); + } else if (fragment.get('admin')) { + credentials.adminToken = fragment.get('admin'); + inviteMode = false; + responseMode = false; + } + if (inviteMode) delete credentials.adminToken; + focusResponseOnLoad = !!credentials.responseId && !!credentials.responseToken; + if (focusResponseOnLoad) history.scrollRestoration = 'manual'; + // Without persistence, retain the original fragment so refresh can recover it. + if (location.hash && remember()) history.replaceState(null, '', link()); refresh(); } setInterval(() => { if (!editing && !savingVote && !addingDate) refresh(); }, 5000); diff --git a/availability/index.php b/availability/index.php index 437c4a0e..ef2f3fb5 100644 --- a/availability/index.php +++ b/availability/index.php @@ -1,6 +1,6 @@
Save these to return from another device. Only share the invite link with attendees. An admin password, if configured, is required in addition to the admin link.
Save these to return from another device. Only share the invite link with attendees. An admin password, if configured, is required in addition to the admin link.
YOUR TURN
Answer each date, or use a shortcut and change the exceptions. Unanswered dates never count as a yes.
+YOUR TURN
Answer each date, or use a shortcut and change the exceptions. Unanswered dates never count as a yes.
+Save this link to restore and edit your availability on another device. Keep it private. If this event has a password, you’ll need that too.
The organizer has allowed attendees to add additional date/time choices.
THE GROUP AT A GLANCE
Refreshing every 5 seconds
✓ Can attend ✕ Can’t attend — Unanswered
Visible only to this event’s admin. These details are not included in public results.