From 64d4735e1cc39b6c439fc42bae1d0759759c3365 Mon Sep 17 00:00:00 2001
From: Willie Chalmers III
Date: Wed, 23 Sep 2026 17:23:04 -0700
Subject: [PATCH 1/3] feat(site): Send old lvwwd.org links to their new pages
The campaign was shared as lvwwd.org/wwd, www.lvwwd.org and section
links of the old one-page site, such as lvwwd.org/#giveaway. Those
would now show the page-not-found page or the wrong page.
The Worker answers /wwd and /wwd/ with one permanent redirect to Home,
and any page address that ends in a slash with one to the address
without it, keeping the query, so /giveaway/?ref=x becomes
/giveaway?ref=x. www.lvwwd.org already went to the apex; a request now
gets at most one redirect, so www.lvwwd.org/wwd/ lands on Home in one
step. The API and the admin views keep their own addresses, and a form
being sent is never redirected.
Browsers never send the part after #, so a small script in the head of
Home sends the four old section links to Take part, Win prizes,
Resources and Partners. It replaces the address, so Back returns to
where the visitor came from. Other section links, or none, stay on
Home, and without JavaScript Home opens as before.
The API preview Worker now sees every request first, as lvwwd.org does,
so the redirects can be tried there.
Co-Authored-By: Claude Opus 5.5
---
apps/site/public/scripts/old-links.js | 18 ++++++++
apps/site/src/pages/index.astro | 2 +
apps/site/tests/e2e/old-links.spec.ts | 42 +++++++++++++++++++
apps/site/tests/worker.test.ts | 59 +++++++++++++++++++++++++--
apps/site/worker/index.ts | 7 ++--
apps/site/worker/redirect.ts | 34 +++++++++++++--
apps/site/wrangler.api-preview.jsonc | 4 +-
7 files changed, 156 insertions(+), 10 deletions(-)
create mode 100644 apps/site/public/scripts/old-links.js
create mode 100644 apps/site/tests/e2e/old-links.spec.ts
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/src/pages/index.astro b/apps/site/src/pages/index.astro
index 92ef52b..1bc0610 100644
--- a/apps/site/src/pages/index.astro
+++ b/apps/site/src/pages/index.astro
@@ -97,6 +97,8 @@ const leaderMailto = `mailto:wwd@lasvegasfortransit.org?subject=${encodeURICompo
+ {/* Old one-page links like /#giveaway: runs before the page shows, so it can't wait. */}
+
{/* HERO: what it is, when, and one button. */}
diff --git a/apps/site/tests/e2e/old-links.spec.ts b/apps/site/tests/e2e/old-links.spec.ts
new file mode 100644
index 0000000..91494ac
--- /dev/null
+++ b/apps/site/tests/e2e/old-links.spec.ts
@@ -0,0 +1,42 @@
+import { expect, test } from '@playwright/test';
+
+// Section links of the old one-page lvwwd.org open the page each section
+// became, without leaving Home in the history.
+const MOVED = [
+ ['#how-to-participate', '/take-part'],
+ ['#giveaway', '/giveaway'],
+ ['#resources', '/resources'],
+ ['#partners', '/partners'],
+] as const;
+
+for (const [hash, page] of MOVED) {
+ test(`/${hash} opens ${page}`, async ({ page: tab }) => {
+ await tab.goto(`/${hash}`);
+ await expect(tab).toHaveURL(page);
+ await expect(tab.getByRole('heading', { level: 1 })).toHaveCount(1);
+ });
+}
+
+test('Back from the new page returns to the page before, not to Home', async ({ page }) => {
+ await page.goto('/partners');
+ await page.goto('/#giveaway');
+ await expect(page).toHaveURL('/giveaway');
+ await page.goBack();
+ await expect(page).toHaveURL('/partners');
+});
+
+test('any other section link, or none, stays on Home', async ({ page }) => {
+ for (const address of ['/#first-trip', '/']) {
+ await page.goto(address);
+ await expect(page).toHaveURL(address);
+ }
+});
+
+test.describe('without JavaScript', () => {
+ test.use({ javaScriptEnabled: false });
+
+ test('an old section link shows Home', async ({ page }) => {
+ await page.goto('/#giveaway');
+ await expect(page).toHaveURL('/#giveaway');
+ });
+});
diff --git a/apps/site/tests/worker.test.ts b/apps/site/tests/worker.test.ts
index 4a1f68a..01d114f 100644
--- a/apps/site/tests/worker.test.ts
+++ b/apps/site/tests/worker.test.ts
@@ -2,14 +2,67 @@ import { describe, expect, it } from 'vitest';
import { redirectFor } from '../worker/redirect';
+const destination = (url: string, method = 'GET') => {
+ const response = redirectFor(new Request(url, { method }));
+ return response ? { status: response.status, location: response.headers.get('location') } : null;
+};
+
describe('redirectFor', () => {
it('sends www to the apex, keeping the path and query', () => {
- const response = redirectFor(new Request('https://www.lvwwd.org/?utm_source=x'));
- expect(response?.status).toBe(301);
- expect(response?.headers.get('location')).toBe('https://lvwwd.org/?utm_source=x');
+ expect(destination('https://www.lvwwd.org/?utm_source=x')).toEqual({
+ status: 301,
+ location: 'https://lvwwd.org/?utm_source=x',
+ });
+ expect(destination('https://www.lvwwd.org/bingo')?.location).toBe('https://lvwwd.org/bingo');
});
it('leaves the apex alone', () => {
expect(redirectFor(new Request('https://lvwwd.org/'))).toBeUndefined();
+ expect(redirectFor(new Request('https://lvwwd.org/?ref=a-group'))).toBeUndefined();
+ });
+
+ it('sends the old /wwd address to Home in one step', () => {
+ for (const url of [
+ 'https://lvwwd.org/wwd',
+ 'https://lvwwd.org/wwd/',
+ 'https://www.lvwwd.org/wwd/',
+ ]) {
+ expect(destination(url)).toEqual({ status: 301, location: 'https://lvwwd.org/' });
+ }
+ });
+
+ it('drops the slash at the end of a page address, keeping the query', () => {
+ expect(destination('https://lvwwd.org/bingo/')).toEqual({
+ status: 301,
+ location: 'https://lvwwd.org/bingo',
+ });
+ expect(destination('https://lvwwd.org/giveaway/?ref=a-group')?.location).toBe(
+ 'https://lvwwd.org/giveaway?ref=a-group',
+ );
+ expect(destination('https://lvwwd.org/guides/heat/', 'HEAD')?.location).toBe(
+ 'https://lvwwd.org/guides/heat',
+ );
+ expect(destination('https://www.lvwwd.org/partners/')?.location).toBe(
+ 'https://lvwwd.org/partners',
+ );
+ });
+
+ it('keeps redirects on the host that was asked, apart from www', () => {
+ expect(destination('https://lvwwd-api-preview.example.workers.dev/wwd')?.location).toBe(
+ 'https://lvwwd-api-preview.example.workers.dev/',
+ );
+ });
+
+ it('never redirects the API, the admin views or a form being sent', () => {
+ expect(redirectFor(new Request('https://lvwwd.org/api/me/'))).toBeUndefined();
+ expect(redirectFor(new Request('https://lvwwd.org/admin/'))).toBeUndefined();
+ expect(
+ redirectFor(new Request('https://lvwwd.org/sign-up/', { method: 'POST' })),
+ ).toBeUndefined();
+ });
+
+ it('leaves page addresses that match nothing to the page-not-found page', () => {
+ expect(redirectFor(new Request('https://lvwwd.org/bingp'))).toBeUndefined();
+ expect(redirectFor(new Request('https://lvwwd.org/wwdx'))).toBeUndefined();
});
});
diff --git a/apps/site/worker/index.ts b/apps/site/worker/index.ts
index e7dd7c3..c7e6664 100644
--- a/apps/site/worker/index.ts
+++ b/apps/site/worker/index.ts
@@ -10,9 +10,10 @@ import { redirectFor } from './redirect';
* lvwwd.org's Worker. The site is static pages; the Worker adds the
* participant API under /api/, the volunteer admin views under /admin and
* /api/admin/ (behind Cloudflare Access), opens "Open my week" links, writes the
- * Turnstile site key into the two pages with a bot check, and sends www to
- * the apex. wrangler.jsonc's run_worker_first lists the page paths it has
- * to see before the assets do.
+ * Turnstile site key into the two pages with a bot check, and answers old
+ * addresses (www, /wwd, a slash at the end) with one redirect each
+ * (redirect.ts). wrangler.jsonc's run_worker_first sends it every request
+ * before the assets see it.
*/
// Only the default export: the runtime reads every named export of the entry
diff --git a/apps/site/worker/redirect.ts b/apps/site/worker/redirect.ts
index 7b2d132..4d0fee3 100644
--- a/apps/site/worker/redirect.ts
+++ b/apps/site/worker/redirect.ts
@@ -1,8 +1,36 @@
export const APEX = 'lvwwd.org';
-// Workers serve a custom domain as-is, so the www redirect has to live here.
+/**
+ * Old addresses of the campaign, answered with one permanent redirect
+ * before any page or file is looked up:
+ *
+ * - www.lvwwd.org, on any path, goes to the same path and query on the apex.
+ * Workers serve a custom domain as-is, so this has to live here.
+ * - /wwd and /wwd/, the campaign's old address, go to Home.
+ * - A page address that ends in a slash goes to the same address without
+ * it, which is the address in the page's canonical link. The API and the
+ * volunteer admin views answer their own addresses as they are.
+ *
+ * Each request gets at most one redirect, straight to where it ends up:
+ * www.lvwwd.org/wwd/ goes to lvwwd.org/ in one step. Old section links of
+ * the one-page site, such as /#giveaway, never reach the Worker (browsers
+ * don't send the part after #); public/scripts/old-links.js on Home
+ * handles those.
+ */
export function redirectFor(request: Request): Response | undefined {
const url = new URL(request.url);
- if (url.hostname.toLowerCase() !== `www.${APEX}`) return undefined;
- return Response.redirect(`https://${APEX}${url.pathname}${url.search}`, 301);
+ const www = url.hostname.toLowerCase() === `www.${APEX}`;
+ const path = legacyPath(request.method, url.pathname);
+ if (!www && path === url.pathname) return undefined;
+ const origin = www ? `https://${APEX}` : url.origin;
+ return Response.redirect(`${origin}${path}${url.search}`, 301);
+}
+
+/** Where an old path now lives, or the path itself when nothing moved. */
+function legacyPath(method: string, path: string): string {
+ if (/^\/wwd\/?$/i.test(path)) return '/';
+ const readable = method === 'GET' || method === 'HEAD';
+ const own = path.startsWith('/api/') || path.startsWith('/admin/');
+ if (!readable || own || path === '/' || !path.endsWith('/')) return path;
+ return path.replace(/\/+$/, '') || '/';
}
diff --git a/apps/site/wrangler.api-preview.jsonc b/apps/site/wrangler.api-preview.jsonc
index a60fad6..8017d3d 100644
--- a/apps/site/wrangler.api-preview.jsonc
+++ b/apps/site/wrangler.api-preview.jsonc
@@ -22,7 +22,9 @@
"binding": "ASSETS",
"not_found_handling": "404-page",
"html_handling": "drop-trailing-slash",
- "run_worker_first": ["/api/*", "/admin*", "/my-week*", "/sign-up*"],
+ // As on lvwwd.org, the Worker sees every request first, so the old-address
+ // redirects (worker/redirect.ts) can be tried here too.
+ "run_worker_first": true,
},
"workers_dev": true,
"preview_urls": false,
From 1eaf81311d9e8586f65a0e1f0d2ad1fc3493311b Mon Sep 17 00:00:00 2001
From: Willie Chalmers III
Date: Wed, 23 Sep 2026 17:23:14 -0700
Subject: [PATCH 2/3] feat(site): Say plainly when a form needs a connection
On a bus the signal comes and goes. Before this, tapping "Sign up" or
sending a trip with no connection did nothing visible, and a screen
reader said nothing, so a person could think they were signed up or
counted when they were not.
A small shared script now watches the phone's online status on Sign up,
My week and Get my link. While the phone is offline, each form that
needs a connection shows a short notice directly above its buttons, and
those buttons are dimmed with aria-disabled: they can still be reached
with Tab, but a tap sends nothing and the notice is read again. What the
visitor typed, picked or attached stays as it is. When the connection
returns, the notices go and screen readers hear "You're back online."
The notices cover the sign-up form, editing details, today's trip, the
reminder choices, "Send my link again" and Get my link. They stop once
sign-up and trips end on October 9, and reminders on October 8 at 8 am.
A personal "Open my week" link opened offline gets the saved offline
page, which now says to open the link again and tries it by itself once
the phone is back online.
The script is about 1 KB gzipped and makes no requests, so the Content
Security Policy is unchanged.
Co-Authored-By: Claude Opus 5.5
---
apps/site/public/scripts/needs-connection.js | 149 ++++++++++++++
apps/site/public/scripts/offline-page.js | 19 +-
apps/site/public/scripts/sign-up.js | 2 +
apps/site/src/pages/my-week.astro | 47 +++--
apps/site/src/pages/my-week/link.astro | 10 +-
apps/site/src/pages/offline.astro | 7 +-
apps/site/src/pages/sign-up.astro | 22 +-
apps/site/src/styles/global.css | 34 +++-
apps/site/tests/e2e/offline-notices.spec.ts | 199 +++++++++++++++++++
9 files changed, 460 insertions(+), 29 deletions(-)
create mode 100644 apps/site/public/scripts/needs-connection.js
create mode 100644 apps/site/tests/e2e/offline-notices.spec.ts
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/sign-up.js b/apps/site/public/scripts/sign-up.js
index 12842ff..3797b12 100644
--- a/apps/site/public/scripts/sign-up.js
+++ b/apps/site/public/scripts/sign-up.js
@@ -178,6 +178,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 ?? '';
diff --git a/apps/site/src/pages/my-week.astro b/apps/site/src/pages/my-week.astro
index 2ae8741..4128b8b 100644
--- a/apps/site/src/pages/my-week.astro
+++ b/apps/site/src/pages/my-week.astro
@@ -6,12 +6,13 @@ import { lvbt } from '../lib/site';
import { wwd } from '../lib/wwd';
// My week: the page a signed-up person comes back to. It shows their
-// entries as the eight days of the week, the one button that checks them
-// in for today, an optional photo, reminders, and their details. The page
-// is the same for everyone; /scripts/my-week.js fills it in from the
-// Worker's API for the person signed in on this phone, or shows the
-// signed-out state. Opening an "Open my week" link (/my-week?t=...) is
-// handled by the Worker before this page loads.
+// entries as the eight days of the week, the form that sends today's
+// shared trip, reminders and their details. The page is the same for
+// everyone; /scripts/my-week.js fills it in from the Worker's API for the
+// person signed in on this phone, or shows the signed-out state.
+// /scripts/needs-connection.js says so, and grays out the buttons that
+// send something, while the phone is offline. Opening an "Open my week"
+// link (/my-week?t=...) is handled by the Worker before this page loads.
const title = 'My week · Week Without Driving Las Vegas';
// How someone got around without driving. Any one of them makes the day a
@@ -88,7 +89,7 @@ const shortcuts = [
{/* Today's trip, in three steps: how, the post, and sending it in. */}
-