Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion availability/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,14 @@
<!-- File revision 1.3.1 | Modified 2026-09-17 | Optional attendee-added date/time options. -->
<!-- File revision 1.3.2 | Modified 2026-09-18 | Optional attendee-added date/time options. -->
# 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.
Expand Down
12 changes: 10 additions & 2 deletions availability/README.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
<!-- Revision 1.3.1 | 2026-09-17 | Attendee-added options, optional password protection, privacy, party planning, proposed times and deadlines. -->
<!-- Revision 1.3.2 | 2026-09-18 | Attendee-added options, optional password protection, privacy, party planning, proposed times and deadlines. -->
# 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.

Expand Down Expand Up @@ -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.
52 changes: 42 additions & 10 deletions availability/app.js
Original file line number Diff line number Diff line change
@@ -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 = {};
Expand All @@ -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; }
Expand All @@ -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); }
Expand All @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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;
Expand All @@ -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);
Expand Down
16 changes: 9 additions & 7 deletions availability/index.php
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
<?php
/** Revision 1.3.1 | 2026-09-17 | Optional attendee-added date/time options.
* History: 1.3.1 — Remove committed conflict markers while preserving all 1.3.0 features; 1.3.0 — Organizer-controlled attendee-added options; 1.2.0 — Independent optional admin and invite/event passwords; 1.1.2 — Invite links force attendee mode even in a browser that remembers admin access; 1.1.1 — Single attendee name and Unicode-safe matching; 1.1.0 — Private contacts and event planning; 1.0.0 — Initial responsive availability poll interface. */
/** Revision 1.3.2 | 2026-09-18 | Optional 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 — Organizer-controlled attendee-added options; 1.2.0 — Independent optional admin and invite/event passwords; 1.1.2 — Invite links force attendee mode even in a browser that remembers admin access; 1.1.1 — Single attendee name and Unicode-safe matching; 1.1.0 — Private contacts and event planning; 1.0.0 — Initial responsive availability poll interface. */
declare(strict_types=1);
header('Referrer-Policy: no-referrer');
header('X-Content-Type-Options: nosniff');
Expand All @@ -11,7 +11,7 @@
<head>
<meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
<title>Availability · Find a day that works</title>
<link rel="stylesheet" href="styles.css?v=1.1.1"><script src="password-ui.js?v=1.2.0"></script><script src="app.js?v=1.3.1" defer></script>
<link rel="stylesheet" href="styles.css?v=1.3.2"><script src="password-ui.js?v=1.2.0"></script><script src="app.js?v=1.3.2" defer></script>
</head>
<body>
<header><a class="brand" href="./"><span class="brand-icon">✓</span> availability</a><a href="./" class="new-link">+ New event</a></header>
Expand All @@ -38,10 +38,10 @@
</section>
<div id="poll" hidden>
<div class="toolbar"><span id="event-meta" class="muted"></span><div><button id="copy-public" class="secondary">Copy invite link</button> <button id="edit-event" class="secondary" hidden>Edit event</button></div></div>
<section id="private-links" class="panel" hidden><h2>Keep your private links</h2><p class="muted">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.</p><div id="admin-link-row" hidden><label>Private admin link<input id="admin-link" readonly></label><button id="copy-admin" class="secondary">Copy admin link</button></div><div id="response-link-row" hidden><label>Your private response link<input id="response-link" readonly></label><button id="copy-response" class="secondary">Copy response link</button></div></section>
<section id="private-links" class="panel" hidden><h2>Keep your private admin link</h2><p class="muted">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.</p><div id="admin-link-row" hidden><label>Private admin link<input id="admin-link" readonly></label><button id="copy-admin" class="secondary">Copy admin link</button></div></section>
<div id="schedule-note" class="best-summary"></div>
<div class="poll-layout">
<section class="panel vote-panel"><p class="eyebrow">YOUR TURN</p><h2>Your availability</h2><p class="muted">Answer each date, or use a shortcut and change the exceptions. Unanswered dates never count as a yes.</p>
<section id="response-section" class="panel vote-panel" tabindex="-1" aria-label="Your availability"><p class="eyebrow">YOUR TURN</p><h2>Your availability</h2><p class="muted">Answer each date, or use a shortcut and change the exceptions. Unanswered dates never count as a yes.</p>
<form id="vote-form">
<label>Your name <span class="muted">(required; visible to everyone)</span><input id="voter-name" required maxlength="100" autocomplete="name" placeholder="Name others will see"></label>
<fieldset class="private-fields"><legend>Private contact details (optional)</legend><p id="private-notice" class="muted">Only you and the admin of this event can see these details. Other attendees cannot see them.</p>
Expand All @@ -50,8 +50,10 @@
</fieldset>
<fieldset><legend>Your group (optional; visible to everyone)</legend><p class="muted">Include yourself. These counts apply to each option you mark available. Leave blank if not known yet.</p><div class="two-columns"><label>Total adults<input id="adults" type="number" min="0" max="1000" step="1" placeholder="Not specified"></label><label>Total kids<input id="kids" type="number" min="0" max="1000" step="1" placeholder="Not specified"></label></div></fieldset>
<fieldset><legend>Food to share (optional; visible to everyone)</legend><label>Food type<select id="food-type"><option value="">Not decided</option><option>Main dish</option><option>Side dish</option><option>Dessert</option><option>Snack</option><option>Drinks</option><option>Other</option><option>Not bringing food</option></select></label><label>What are you bringing?<input id="food-note" maxlength="300" placeholder="For example: pasta salad for 8"></label></fieldset>
<div class="shortcuts"><button type="button" data-fill="yes" class="secondary">Can attend all</button><button type="button" data-fill="no" class="secondary">Can’t attend any</button><button type="button" data-fill="" class="text-button">Clear</button></div><div id="vote-dates"></div><p id="answer-summary" class="muted"></p><button id="save-vote" type="submit">Save my availability</button><p id="vote-state" class="muted" aria-live="polite"></p>
<div class="shortcuts"><button type="button" data-fill="yes" class="secondary">Can attend all</button><button type="button" data-fill="no" class="secondary">Can’t attend any</button><button type="button" data-fill="" class="text-button">Clear</button></div><div id="vote-dates"></div><p id="answer-summary" class="muted"></p><button id="save-vote" type="submit">Save my availability</button>
</form>
<div id="response-link-row" hidden><label>Your private response link<input id="response-link" readonly></label><p class="muted">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.</p><button id="copy-response" type="button" class="secondary">Copy response link</button></div>
<p id="vote-state" class="muted" aria-live="polite"></p>
<section id="attendee-date-panel" hidden><hr><h3>Add another option</h3><p class="muted">The organizer has allowed attendees to add additional date/time choices.</p><div class="date-add"><input id="attendee-date" type="date" aria-label="Additional event date"><input id="attendee-time" type="time" aria-label="Additional proposed time (optional)"><button id="attendee-add-date" type="button" class="secondary">+ Add option</button></div><p id="attendee-date-state" class="muted" aria-live="polite"></p></section>
</section>
<section class="panel results-panel"><div class="section-heading"><div><p class="eyebrow">THE GROUP AT A GLANCE</p><h2>What’s looking good?</h2></div><span class="pill" id="response-count">0 responses</span></div><p id="live-state" class="muted" aria-live="polite">Refreshing every 5 seconds</p><div id="best-summary" class="best-summary"></div><div id="results"></div><p class="legend"><span class="yes-text">✓ Can attend</span> <span class="no-text">✕ Can’t attend</span> <span>— Unanswered</span></p></section>
Expand All @@ -60,5 +62,5 @@
<section id="admin-contacts" class="panel" hidden><h2>Private attendee details</h2><p class="muted">Visible only to this event’s admin. These details are not included in public results.</p><div id="admin-contact-table" class="table-scroll" tabindex="0" aria-label="Private attendee details"></div></section>
</div>
</main>
<footer>availability <span>v1.3.1 · Updated September 17, 2026</span></footer>
<footer>availability <span>v1.3.2 · Updated September 18, 2026</span></footer>
</body></html>
Loading
Loading