diff --git a/apps/site/migrations/0001_participants.sql b/apps/site/migrations/0001_participants.sql
index fb3befb..a4432a7 100644
--- a/apps/site/migrations/0001_participants.sql
+++ b/apps/site/migrations/0001_participants.sql
@@ -80,7 +80,8 @@ CREATE TABLE bingo (
);
-- Fixed-window request counters. key is the SHA-256 of the limit's name and
--- the IP address or contact; window_start counts hours since 1970.
+-- the IP address or contact; window_start is the minute (counted from 1970)
+-- the window began (worker/rate-limit.ts).
CREATE TABLE rate_limits (
key TEXT NOT NULL,
window_start INTEGER NOT NULL,
diff --git a/apps/site/migrations/0005_partner_credit.sql b/apps/site/migrations/0005_partner_credit.sql
new file mode 100644
index 0000000..79be083
--- /dev/null
+++ b/apps/site/migrations/0005_partner_credit.sql
@@ -0,0 +1,14 @@
+-- Where a sign-up came from, for volunteers' counts. Deleted with the rest
+-- of the participant on November 30, 2026 (worker/cleanup.ts).
+--
+-- * partner is the slug of the partner group whose ?ref= link brought the
+-- person (src/lib/partners.ts), or null. It is set when the sign-up is
+-- made and never changes.
+-- * shared_device is 1 when a volunteer signed the person up on a shared
+-- tablet or phone, after tapping "Sign up someone else" in that tab. LVBT's
+-- newsletter sign-up records it as the source of the request.
+--
+-- The Worker writes both after the sign-up itself, so a database without
+-- this migration still takes sign-ups, just without the credit.
+ALTER TABLE participants ADD COLUMN partner TEXT;
+ALTER TABLE participants ADD COLUMN shared_device INTEGER NOT NULL DEFAULT 0 CHECK (shared_device IN (0, 1));
diff --git a/apps/site/public/scripts/my-week.js b/apps/site/public/scripts/my-week.js
index 5415284..05c9ac1 100644
--- a/apps/site/public/scripts/my-week.js
+++ b/apps/site/public/scripts/my-week.js
@@ -18,6 +18,8 @@
const MAX_ENTRIES = 8;
const MAX_PHOTO_BYTES = 10 * 1024 * 1024;
const PREVIEW_KEY = 'lvwwd_preview_link';
+ // 12:00 am on October 9, 2026, in Las Vegas.
+ const SIGN_UP_ENDS = Date.parse('2026-10-09T07:00:00Z');
const params = new URLSearchParams(window.location.search);
let me = null;
@@ -275,6 +277,22 @@
});
}
+ // "Sign up someone else", for a volunteer signing people up on one
+ // tablet: signs this device out and opens the empty sign-up form. The
+ // sign-up that was open stays saved. Gone once sign-up closes.
+ function bindSomeoneElse() {
+ const block = document.querySelector('[data-someone-else]');
+ const button = document.querySelector('[data-someone-else-button]');
+ if (!block || !button || Date.now() >= SIGN_UP_ENDS) return;
+ block.hidden = false;
+ button.addEventListener('click', async () => {
+ setText('[data-someone-else-status]', '');
+ const { ok, message } = await api.signUpSomeoneElse();
+ if (ok) window.location.href = '/sign-up';
+ else setText('[data-someone-else-status]', message);
+ });
+ }
+
async function start() {
// Without the flag cookie this phone isn't signed in; don't ask.
if (!/(?:^|; )lvwwd_signed_in=1(?:;|$)/.test(document.cookie)) return showSignedOut();
@@ -295,6 +313,7 @@
bindTrip();
bindReminders();
bindSignOut();
+ bindSomeoneElse();
return undefined;
}
diff --git a/apps/site/public/scripts/needs-connection.js b/apps/site/public/scripts/needs-connection.js
new file mode 100644
index 0000000..fb33d3c
--- /dev/null
+++ b/apps/site/public/scripts/needs-connection.js
@@ -0,0 +1,149 @@
+/**
+ * Offline notices for the forms that need a connection: Sign up, today's
+ * trip, reminders and "Send my link again" on My week, and Get my link.
+ *
+ * Each form (or section) carries data-needs-connection with its notice
+ * key, and each button that sends something to lvwwd.org carries
+ * data-needs-connection-button. While the phone is offline, the notice
+ * sits directly above the form's first such button, where a screen reader
+ * reaches it first, and every such button is dimmed with aria-disabled: it
+ * can still be reached with Tab, but a tap sends nothing and the notice is
+ * read again. Whatever the visitor typed, picked or attached stays as it
+ * is, and nothing is kept to send later. When the connection returns, the
+ * notices go, the buttons work again, and screen readers hear "You're back
+ * online."
+ *
+ * navigator.onLine only says whether the phone has a network. A request
+ * that still can't reach lvwwd.org shows the form's own error message.
+ * Once a form has ended (sign-up and trips at 12:00 am on October 9,
+ * reminders at 8:00 am on October 8, Las Vegas time) it gets no notice.
+ * A personal "Open my week" link opened offline gets the offline page
+ * from the service worker, and offline-page.js gives it its own notice.
+ */
+(() => {
+ const NOTICES = {
+ 'sign-up': 'You’re offline. You can sign up as soon as you’re back online.',
+ trip: 'You’re offline. My week needs a connection to show your entries and send your post.',
+ 'edit-details': 'You’re offline. You can save your details as soon as you’re back online.',
+ 'send-link': 'You’re offline. You can send your link as soon as you’re back online.',
+ 'get-link': 'You’re offline. You can get your link as soon as you’re back online.',
+ reminders: 'You’re offline. You can sign up for reminders when you’re back online.',
+ };
+ const TRIPS_END = Date.parse('2026-10-09T07:00:00Z');
+ const ENDS = {
+ 'sign-up': TRIPS_END,
+ trip: TRIPS_END,
+ reminders: Date.parse('2026-10-08T15:00:00Z'),
+ };
+ const BACK_ONLINE = 'You’re back online.';
+ const CLEAR_AFTER_MS = 3000;
+ const BUTTON = '[data-needs-connection-button]';
+ const DIMMED = '[data-needs-connection-button][aria-disabled="true"]';
+
+ const places = () => [...document.querySelectorAll('[data-needs-connection]')];
+ if (places().length === 0) return;
+
+ // Read politely when the connection returns, then emptied so the same
+ // words are read again next time.
+ const announcer = document.createElement('p');
+ announcer.className = 'sr-only';
+ announcer.setAttribute('aria-live', 'polite');
+ document.body.append(announcer);
+ let clearing = 0;
+
+ function textFor(place) {
+ const key = place.getAttribute('data-needs-connection') ?? '';
+ if (Date.now() >= (ENDS[key] ?? Infinity)) return null;
+ return NOTICES[key] ?? null;
+ }
+
+ // Directly above the first button, as a child of the form itself, so a
+ // button inside a label or a row keeps its row together.
+ function spotFor(place) {
+ let spot = place.querySelector(BUTTON);
+ if (!spot) return null;
+ while (spot.parentElement && spot.parentElement !== place) spot = spot.parentElement;
+ return spot;
+ }
+
+ function noticeFor(place) {
+ const existing = place.querySelector('[data-offline-notice]');
+ if (existing) return existing;
+ const notice = document.createElement('p');
+ notice.className = 'offline-notice';
+ notice.setAttribute('data-offline-notice', '');
+ notice.setAttribute('aria-live', 'assertive');
+ const icon = document.createElement('span');
+ icon.className = 'offline-notice__icon';
+ icon.setAttribute('aria-hidden', 'true');
+ icon.textContent = '!';
+ notice.append(icon, document.createElement('span'));
+ const spot = spotFor(place);
+ if (spot) spot.before(notice);
+ else place.prepend(notice);
+ return notice;
+ }
+
+ // Emptying the words and writing them again a moment later makes screen
+ // readers read them again. A notice already there when the page opens is
+ // read in page order instead.
+ function say(notice, text, aloud) {
+ const words = notice.lastElementChild;
+ if (!words) return;
+ if (!aloud) {
+ words.textContent = text;
+ return;
+ }
+ words.textContent = '';
+ window.setTimeout(() => (words.textContent = text), 100);
+ }
+
+ function goOffline(aloud) {
+ places().forEach((place) => {
+ const text = textFor(place);
+ if (!text) return;
+ say(noticeFor(place), text, aloud);
+ place.querySelectorAll(BUTTON).forEach((button) => {
+ button.setAttribute('aria-disabled', 'true');
+ });
+ });
+ }
+
+ function goOnline() {
+ document.querySelectorAll('[data-offline-notice]').forEach((notice) => notice.remove());
+ document.querySelectorAll(DIMMED).forEach((button) => button.removeAttribute('aria-disabled'));
+ announcer.textContent = BACK_ONLINE;
+ window.clearTimeout(clearing);
+ clearing = window.setTimeout(() => (announcer.textContent = ''), CLEAR_AFTER_MS);
+ }
+
+ // A dimmed button sends nothing: its click (which a tap, Enter or Space
+ // on it makes) is stopped before the page's own handlers see it.
+ document.addEventListener(
+ 'click',
+ (event) => {
+ const button = event.target instanceof Element ? event.target.closest(DIMMED) : null;
+ if (!button) return;
+ event.preventDefault();
+ event.stopImmediatePropagation();
+ const place = button.closest('[data-needs-connection]');
+ const notice = place?.querySelector('[data-offline-notice]');
+ const text = place ? textFor(place) : null;
+ if (notice && text) say(notice, text, true);
+ },
+ true,
+ );
+ document.addEventListener(
+ 'submit',
+ (event) => {
+ if (!(event.target instanceof HTMLFormElement) || !event.target.querySelector(DIMMED)) return;
+ event.preventDefault();
+ event.stopImmediatePropagation();
+ },
+ true,
+ );
+
+ window.addEventListener('offline', () => goOffline(true));
+ window.addEventListener('online', goOnline);
+ if (!navigator.onLine) goOffline(false);
+})();
diff --git a/apps/site/public/scripts/offline-page.js b/apps/site/public/scripts/offline-page.js
index 0950920..9bea4cc 100644
--- a/apps/site/public/scripts/offline-page.js
+++ b/apps/site/public/scripts/offline-page.js
@@ -3,15 +3,30 @@
* when none are yet. The service worker shows this page at the address
* the visitor asked for, so My week and Sign up get their own line about
* needing a connection. Without JavaScript the whole list shows.
+ *
+ * A personal "Open my week" link (/my-week?t=...) opened offline says to
+ * open the link again, and tries it again by itself as soon as the phone
+ * is back online: the Worker then signs the phone in and opens My week.
*/
(() => {
const text = document.querySelector('[data-offline-text]');
const privateText = document.querySelector('[data-offline-private]');
+ const openLink = document.querySelector('[data-offline-open-link]');
const none = document.querySelector('[data-offline-none]');
const list = document.querySelector('[data-offline-list]');
- if (!text || !privateText || !none || !list) return;
+ if (!text || !privateText || !openLink || !none || !list) return;
- if (/^\/(?:my-week|sign-up)(?:\/|$)/.test(window.location.pathname)) {
+ const path = window.location.pathname;
+ const isLink =
+ /^\/my-week\/?$/.test(path) && new URLSearchParams(window.location.search).has('t');
+ if (isLink) {
+ text.hidden = true;
+ list.hidden = true;
+ openLink.hidden = false;
+ window.addEventListener('online', () => window.location.reload());
+ return;
+ }
+ if (/^\/(?:my-week|sign-up)(?:\/|$)/.test(path)) {
text.hidden = true;
privateText.hidden = false;
}
diff --git a/apps/site/public/scripts/old-links.js b/apps/site/public/scripts/old-links.js
new file mode 100644
index 0000000..76ac1cc
--- /dev/null
+++ b/apps/site/public/scripts/old-links.js
@@ -0,0 +1,18 @@
+/**
+ * Old section links of the one-page lvwwd.org, such as lvwwd.org/#giveaway,
+ * open the page that section became. Browsers never send the part after #
+ * to the server, so the Worker can't redirect these; this runs in the head
+ * of Home, before the page shows. It replaces the address rather than
+ * adding one, so Back returns to where the visitor came from. Any other
+ * section link, or none, stays on Home. Without JavaScript, Home opens.
+ */
+(() => {
+ const MOVED = {
+ '#how-to-participate': '/take-part',
+ '#giveaway': '/giveaway',
+ '#resources': '/resources',
+ '#partners': '/partners',
+ };
+ const to = MOVED[window.location.hash.toLowerCase()];
+ if (to) window.location.replace(to + window.location.search);
+})();
diff --git a/apps/site/public/scripts/participant-api.js b/apps/site/public/scripts/participant-api.js
index 928027b..230d54a 100644
--- a/apps/site/public/scripts/participant-api.js
+++ b/apps/site/public/scripts/participant-api.js
@@ -9,6 +9,12 @@
* Worker writes the site key into its data-sitekey attribute.
* - showPreviewLink(container, link) shows the "Open my week" link on the
* preview Worker, which returns it instead of only sending it.
+ * - whereFrom() is what the sign-up sends about where it came from: the
+ * partner link opened in this tab (site-nav.js keeps it) and whether a
+ * volunteer is signing people up on this device.
+ * - signUpSomeoneElse() is "Sign up someone else": it signs this device
+ * out, clears what it kept for the last person, and remembers for this
+ * tab that it is a shared device. It resolves to { ok, message }.
*/
(() => {
const OFFLINE = 'Couldn’t reach lvwwd.org. Check your connection and try again.';
@@ -145,5 +151,65 @@
container.hidden = false;
}
- window.lvwwdApi = { call, botCheck, showPreviewLink, OFFLINE };
+ const REF_KEY = 'lvwwd_ref';
+ const SHARED_KEY = 'lvwwd_shared_device';
+ const NEXT_KEY = 'lvwwd_next_person';
+ const UNREACHABLE = 'We couldn’t reach the server. Check your connection and try again.';
+
+ // Session storage lasts as long as the tab, and a private window keeps
+ // nothing after it closes. Any of it may be refused.
+ function stored(storage, key) {
+ try {
+ return window[storage].getItem(key);
+ } catch {
+ return null;
+ }
+ }
+ function store(storage, key, value) {
+ try {
+ if (value === null) window[storage].removeItem(key);
+ else window[storage].setItem(key, value);
+ } catch {
+ // Refused: the page works the same, only without the memory.
+ }
+ }
+
+ function whereFrom() {
+ const ref = stored('sessionStorage', REF_KEY);
+ return {
+ ...(ref ? { ref } : {}),
+ sharedDevice: stored('sessionStorage', SHARED_KEY) === '1',
+ };
+ }
+
+ async function signUpSomeoneElse() {
+ const { ok, status, data } = await call('POST', '/api/signout', {});
+ // 401: the session had already ended, which is the same result.
+ if (!ok && status !== 401) return { ok: false, message: status ? data.message : UNREACHABLE };
+ // The last person's bingo marks are saved with their sign-up; the next
+ // person starts a fresh card. The key is bingo.js's STORAGE_KEY.
+ store('localStorage', 'lvwwd_bingo_2026', null);
+ store('sessionStorage', 'lvwwd_preview_link', null);
+ store('sessionStorage', SHARED_KEY, '1');
+ store('sessionStorage', NEXT_KEY, '1');
+ document.dispatchEvent(new CustomEvent('lvwwd:signed-out'));
+ return { ok: true, message: '' };
+ }
+
+ /** True once, on the first sign-up form shown after "Sign up someone else". */
+ function readyForNextPerson() {
+ const ready = stored('sessionStorage', NEXT_KEY) === '1';
+ store('sessionStorage', NEXT_KEY, null);
+ return ready;
+ }
+
+ window.lvwwdApi = {
+ call,
+ botCheck,
+ showPreviewLink,
+ whereFrom,
+ signUpSomeoneElse,
+ readyForNextPerson,
+ OFFLINE,
+ };
})();
diff --git a/apps/site/public/scripts/sign-up.js b/apps/site/public/scripts/sign-up.js
index 12842ff..86ea645 100644
--- a/apps/site/public/scripts/sign-up.js
+++ b/apps/site/public/scripts/sign-up.js
@@ -7,6 +7,12 @@
* not signed in here; the Worker sends that person their link instead,
* and the page says so. Add ?edit=1 to change the details of the person
* signed in on this phone (PATCH /api/me); the contact can't be changed.
+ *
+ * A phone that is signed in sees "You're signed up" in place of the form.
+ * Until sign-up closes, it also offers "Sign up someone else", for a
+ * volunteer signing people up one after another on one tablet: it signs
+ * the device out and shows the empty form, ready for the next person.
+ * Each sign-up sends the partner link this tab was opened with, if any.
*/
(() => {
const api = window.lvwwdApi;
@@ -16,6 +22,9 @@
const FIELDS = ['firstName', 'contact', 'zip', 'instagram', 'age'];
const PREVIEW_KEY = 'lvwwd_preview_link';
+ // 12:00 am on October 9, 2026, in Las Vegas.
+ const SIGN_UP_ENDS = Date.parse('2026-10-09T07:00:00Z');
+ const nextPerson = form.querySelector('[data-next-person]');
const statusLine = form.querySelector('[data-signup-status]');
const submit = form.querySelector('[data-signup-submit]');
const submitLabel = submit?.textContent ?? 'Sign up';
@@ -139,6 +148,7 @@
}
const { ok, status, data } = await api.call('POST', '/api/signup', {
...details,
+ ...api.whereFrom(),
turnstileToken,
});
bot.reset();
@@ -178,6 +188,8 @@
function startEditing(me) {
editing = true;
+ // Offline, needs-connection.js says saving waits, not signing up.
+ form.setAttribute('data-needs-connection', 'edit-details');
field('firstName').value = me.firstName ?? '';
const contact = field('contact');
contact.value = me.contactMasked ?? '';
@@ -193,15 +205,52 @@
form.hidden = false;
}
+ // The empty form for the next person, with the line that says the last
+ // sign-up is saved. Focus goes to that line, so a screen reader reads it
+ // and the next Tab reaches "First name".
+ function showReadyForNext() {
+ form.reset();
+ showErrors({});
+ setStatus('');
+ startSignUp();
+ if (!(nextPerson instanceof HTMLElement)) return;
+ nextPerson.hidden = false;
+ nextPerson.focus();
+ }
+
+ function offerSomeoneElse() {
+ const block = already.querySelector('[data-someone-else]');
+ const button = already.querySelector('[data-someone-else-button]');
+ const status = already.querySelector('[data-someone-else-status]');
+ if (!block || !button || Date.now() >= SIGN_UP_ENDS) return;
+ block.hidden = false;
+ button.addEventListener('click', async () => {
+ if (status) status.textContent = '';
+ const { ok, message } = await api.signUpSomeoneElse();
+ if (!ok) {
+ if (status) status.textContent = message;
+ return;
+ }
+ api.readyForNextPerson();
+ already.hidden = true;
+ showReadyForNext();
+ });
+ }
+
function showAlready(me) {
- const nameLine = already.querySelector('[data-signup-already-name]');
- if (nameLine) nameLine.textContent = `You signed up as ${me.firstName}.`;
+ const title = already.querySelector('[data-signup-already-title]');
+ if (title) title.textContent = `You’re signed up, ${me.firstName}.`;
+ offerSomeoneElse();
already.hidden = false;
}
// Someone signed in on this phone sees who they are, or edits with ?edit=1.
async function start() {
- if (!/(?:^|; )lvwwd_signed_in=1(?:;|$)/.test(document.cookie)) return startSignUp();
+ if (!/(?:^|; )lvwwd_signed_in=1(?:;|$)/.test(document.cookie)) {
+ // Just after "Sign up someone else" on My week.
+ if (api.readyForNextPerson()) return showReadyForNext();
+ return startSignUp();
+ }
form.hidden = true;
const { ok, status, data } = await api.call('GET', '/api/me');
if (!ok) {
diff --git a/apps/site/public/scripts/site-nav.js b/apps/site/public/scripts/site-nav.js
index 1114d94..3cb9d3d 100644
--- a/apps/site/public/scripts/site-nav.js
+++ b/apps/site/public/scripts/site-nav.js
@@ -17,15 +17,34 @@
});
}
+ // A partner's link or QR code adds ?ref=