Everything needed to run, change and hand over kmceagles.org. Club admins who only edit content need the Pages CMS guide (published from docs/OWNERS-GUIDE.md); this page is the technical companion.
Build 3f33ae2 · Sun 18 Oct 2026 · Node 22+ · Eleventy 3
At a glance
Static site
Eleventy 3 turns YAML and Markdown into HTML at build time; no page is rendered on the server. Pages Functions relay the contact form and run website attendance, the only part with a database (D1) and logins (passkeys, codes by email, Google or GitHub; no passwords).
Zero recurring cost
Every service is on a free tier and none is priced per seat. The club pays for the domain and nothing else.
Edited by volunteers
Admins edit through Pages CMS in a browser. Every save is a Git commit; Cloudflare rebuilds and deploys in about two minutes.
Gated
Eight automated checks (npm run verify) run before a push. Each one was made to fail on purpose before it was trusted.
Repository
github.com/jonajinga/kmceagles, private. Branch main deploys to production.
Hosting
Cloudflare Pages project kmceagles. Custom domain kmceagles.org (the club's old site was kmceagles.com); previews at *.kmceagles.pages.dev.
Build
Command npm run build, output directory _site, Node 22 (pinned by .node-version; no variable needed).
Only in Cloudflare Pages (Settings → Variables and Secrets), in the GitHub Actions secrets CF_PAGES_DEPLOY_HOOK, CRON_SECRET, CLOUDFLARE_API_TOKEN, CLOUDFLARE_ACCOUNT_ID and BACKUP_PASSPHRASE, in the reminders timer (its CRON_SECRET, set by its deploy workflow), and in the gitignored .env and .dev.vars. The reminder keys are made by the attendance API and kept in the D1 table settings, unless VAPID_PUBLIC_KEY and VAPID_PRIVATE_KEY are set. Never in the repository, never in the CMS.
Docs in the repo
README.md, TASKS.md (open and finished work), IDEAS.md (undecided ideas), docs/OWNERS-GUIDE.md (Pages CMS, for admins, published at /admin-guide/; what the website does is in the user guide), docs/ATTENDANCE.md (website attendance: how it works, setup, the switch), docs/MANAGE.md (changing the club's content on the website), docs/DOMAIN.md (moving the site to kmceagles.org: the steps and the settings that follow the address).
How the site is built
A build is a pipeline with four stages. Knowing where a change belongs saves most debugging time.
Content is read.src/_data/club.js reads every YAML file under src/content/ (lib/content.mjs) and builds the club model with clubModel in lib/model.mjs, which resolves references (a roster row points at a person, an event at a rink) and derives what templates need: career histories, enriched events with local dates, per-rink event lists, the next event, the newest players, the visible board and the menu links to leave out while a page has nothing to show. People switched off in the CMS are dropped here, so no template has to check. Templates never join data themselves. The model is pure, so the server builds the same one from /data/content/site.json when a change made on the website waits for the build.
Templates render. One Nunjucks file per page type in src/pages/, extending src/_includes/layouts/base.njk. Pages that exist once per item (player, team, event, rink, gallery) use Eleventy pagination over the model. Calendars, the map data, rink cards and the service worker are .11ty.js templates that return text instead of HTML.
Assets are produced. CSS partials are concatenated in the order set in eleventy.config.js into one stylesheet, /assets/css/site.css, which every page links with a version on its address, so a browser keeps it for a year. The version is a fingerprint of the stylesheet's and scripts' sources (and of the locked tool versions), so it changes only when one of them does: the nightly rebuild leaves every address as it was, and an unchanged page is the same from one build to the next. The stylesheet, the scripts in /assets/js/ and instant.page are published minified by esbuild (a development dependency), about half the size; the sources in src/assets/ keep their comments. Photos become AVIF, WebP and JPEG at three widths, with a content hash in each file name. Share images are SVG rendered to PNG by sharp; player cards, recap images and the link previews of event pages by resvg (lib/png.mjs), with a 512 px copy of the emblem the build makes once (lib/emblem.mjs). Both use the bundled Barlow Condensed and are cached in .cache/.
After the build, Pagefind indexes _site/ for search. Cloudflare then serves _site/ with the headers and redirects from src/public/ and runs the Pages Functions in functions/: the contact-form relay, the attendance API (signing in included) and shared links. A build runs on every push to main (including every CMS save) and once a night.
Site settings, lists and computed globals: site.json, nav.json, memberNav.json (the members' links in the four areas and the tools of coaches and admins, which the menu and the mega menus draw), env.js, club.js, the YAML lists.
src/pages/
One template per page type. Front matter sets permalink, title, description, ogSubtitle, breadcrumbs, crumbTitle (a shorter name for the page's own step in the breadcrumbs, as on event pages, whose titles carry the date), noindex.
src/_includes/
layouts/base.njk (head, header, breadcrumbs, footer, scripts), partials/ (header, footer, macros, share menu, map, the Google and GitHub marks (brand-marks.njk), the attendance box and line-up on event pages (attendance.njk, lineup.njk), the gold attendance reminder (att-remind.njk), the print letterhead (print-head.njk), one season's numbers, map and rosters (season-body.njk), team tiles, news cards and the join band).
src/assets/css/
Partials in order: fonts, tokens, base, layout, components, pages. A partial not listed in CSS_PARTIAL_ORDER is not served.
src/assets/js/
main.js (menus, search dialog, forms, share, print, and kmceShowModal, through which every script opens its pop-up windows: it holds the page where it was and, on an iPhone whose screen shows only part of the page view, centers the window in what the screen shows; the More menus, the "i" guides and the share menus open the same way, their panel moved into a window that stays inside its <details>), pwa.js, calendar.js (day, week, month and year views on the schedule, and the months beyond those the page holds, fetched from /data/schedule/<YYYY-MM>.json, which schedule-months.11ty.js writes), map.js, gallery.js, search.js and search-kit.js (the search page, and the searches shared with the pop-up), notfound.js, tips.js (the site's own tooltips, on every page), keys.js (the keyboard shortcuts on computers, on every page), attendance.js (website attendance, loaded only while it is on: the core every page runs, with the parts of single pages in modules beside it that only those pages load, att-event.js, att-home.js, att-mine.js, att-inbox.js, att-settings.js, att-polls.js, att-dues.js, att-requests.js, att-people.js and att-notices.js; docs/ATTENDANCE.md, "The members' script"), lineup.js (the line-up), game-stats.js (game sheets and Game stats), share-card.js (pictures for the team chat, loaded on demand), you.js (You, Your stats and Your dues), feedback.js (Feedback and bug reports), testimonials.js (Your testimonial, and the admins' Testimonials), history.js (the attendance history), charts.js (its charts, and Your stats'), profile.js (Your profile), photo.js (placing a photo in its square), signup.js (the sign-up form for new players), chats.js (keeping the group chats' links), gear.js (the gear exchange) and the scripts of the forms for changing content (schedule-edit.js, roster-edit.js, setup.js, announcements.js). Every page can be read without them; the maps, search results, answering and the coach tools, the line-up builder and sharing to the team chat need them.
src/assets/icons/
Lucide's licence, a copy of the one in the lucide-static package, published with the site. The icons themselves are read from the package at build time (Icons, below).
src/public/
Copied as-is to the site root: _headers, _redirects, robots.txt, favicons, app icons, site.webmanifest.
lib/
Plain ES modules: content.mjs (model), time.mjs and zones.mjs (the club's time zone, Europe/Berlin by default, and an event's own), money.mjs (the club's currency), ics.mjs (RFC 5545), card.mjs (player cards), recap.mjs (score images), event-card.mjs (event link previews), png.mjs (SVG to PNG), maps.mjs (directions links), media.mjs (an event's live stream and video: platforms, players, when the live link shows), geocode.mjs (rink coordinates), member-pages.mjs (the members' pages, kept out of the service worker and search engines), attendance/api.mjs (the attendance API), attendance/push.mjs (Web Push encryption and signing), attendance/notify.mjs (every kind of notice and each member's choices), attendance/chats.mjs (the group chats' links), manage/ (changes made on the website, saved to GitHub; manage/profile.mjs, members' own profiles, photos and private details) and render/ (the page parts the server draws again while a change waits).
functions/
api/form.js, the contact-form relay; api/attendance/[[path]].js, the website-attendance API, signing in included (lib/attendance/auth.mjs); s/[[path]].js, links shared to the team chat and their preview.jpg (logic in lib/attendance/api.mjs, schema in migrations/, plan, setup and switch-back steps in docs/ATTENDANCE.md).
media/
Photos, headshots and brand files, uploaded through the CMS. Served under /media/.
scripts/
The gates (check-*.mjs), build helpers (build-*.mjs, fetch-fonts-css.mjs, geocode.mjs), a tiny static server (npm run serve), vapid-keys.mjs (reminder keys of your own) and two single-page diagnostics: axe-one.mjs and lh-detail.mjs.
.pages.yml
Pages CMS configuration. Must mirror the content model field for field.
.github/workflows/
daily-rebuild.yml, the nightly rebuild; reminders.yml, which asks the site every six hours to send the attendance reminders that are due, as a backstop; checks.yml, the checks on every push of code; backup.yml, the weekly database backup; start-fresh.yml, which clears a database once, by hand, after a backup (docs/ATTENDANCE.md, "Starting fresh"); and reminders-worker.yml, which deploys the reminders timer.
workers/reminders/
The reminders timer: a Cloudflare Worker with its own wrangler.toml that asks the site every five minutes to send the reminders that are due.
Local development
Install Node 22 or newer and clone the repository under ~/dev/ (not inside a cloud-synced folder; iCloud and OneDrive corrupt Git repositories).
Copy .env.example to .env. Leave the keys empty for a plain local build; the contact form needs the relay, so test it on a preview deployment.
Run npm install, then npm run dev. The site is at http://localhost:8473. The port is fixed so it never collides with other local projects.
Before pushing, run npm run verify. It takes about three minutes and fails loudly.
Command
What it does
npm install
Install dependencies. Node 22 or newer.
npm run dev
Serve at http://localhost:8473 and rebuild on change. Restart after editing anything under lib/.
npm run build
Clean build into _site/, including the search index, share images and calendars.
npm run clean
Delete _site/. The build does this first anyway.
npm run verify
The full gate: CMS config, style, attendance API, build, the members' journeys in a browser, calendars, SEO, accessibility, PWA. Run before every push.
npm run check:attendance
The attendance API alone, against an in-memory database. Needs no build; part of verify.
npm run check:journeys
The members' main journeys in a browser, on a phone and a computer, after a build; part of verify. --only phone or --only computer runs one.
npm run serve
Serve the last build at http://127.0.0.1:8474, as the gates see it.
npm run check:lighthouse
Lighthouse on Home, Schedule and a team page, mobile and desktop. Pass URLs to test other pages; LH_BASE=https://attendance-preview.kmceagles.pages.dev measures the preview instead of the local build.
npm run check:test-club
The test club, the seven made-up teams an admin can show beside our own (Pages CMS › Site settings › Test club): builds the site with it and without it, checks that its pages appear and are kept out of search engines, runs the schedule, SEO and calendar checks on the whole build and the accessibility and phone checks on a sample of its pages, and proves that the site with the test club turned off is the same as one without it. About seven minutes, and it needs the browsers; not part of verify. On a branch whose Site settings show the test club, run verify as TEST_CLUB=off npm run verify.
node scripts/test-club/generate.mjs
Writes the test club's content again (src/content/test-club/ and media/test-club/), the same every time. Run it after changing the generator, or when check:test-club says the committed files are out of date, and commit what it writes.
node scripts/build-pwa-icons.mjs
Regenerate the app icons from the emblem after a logo change.
node scripts/fetch-fonts-css.mjs
Re-download the Barlow Condensed files into src/assets/fonts and regenerate the inlined @font-face rules (only when changing weights).
node scripts/vapid-keys.mjs
Optional: prints a VAPID key pair for attendance reminders and a CRON_SECRET, to put in Pages as VAPID_PUBLIC_KEY, VAPID_PRIVATE_KEY and CRON_SECRET. Without them the API makes its own keys and keeps them in D1. Changing the keys later means every device must turn reminders on again.
npm run geocode
Look up every rink address not yet in src/_data/geocache.json (OpenStreetMap Nominatim, one request per second) and commit the result. The build does the same for new places, but cannot commit.
Files under lib/ are ES modules and are cached by the dev server. After editing them, stop and restart npm run dev; otherwise the old model keeps rendering.
Deploying on Cloudflare Pages
Every push to main builds and deploys production. Every other branch and every pull request gets a preview URL with the same headers and functions. There is no staging server to maintain.
Project settings (set once)
Build commandnpm run build, output directory_site, root/.
Environment variables below, in Production and, if previews should send email, in Preview. The rows marked GitHub Actions go in the repository instead (Settings → Secrets and variables → Actions).
Functions are picked up automatically from functions/. Website attendance needs one binding: the D1 database as DB (Settings → Bindings; kmceagles-attendance for Production, kmceagles-attendance-preview for Preview), plus SESSION_SECRET. The steps are in docs/ATTENDANCE.md.
Nightly rebuild: a deploy hook for main (Settings → Builds → Deploy hooks), saved in GitHub as the Actions secret CF_PAGES_DEPLOY_HOOK. .github/workflows/daily-rebuild.yml calls it just after midnight German time, so the next event, upcoming lists and past games roll over on days nobody edits. Run it by hand from GitHub → Actions → Daily rebuild.
The reminders timer: a Cloudflare Worker (workers/reminders/) posts to /api/attendance/push/run every five minutes, and again at once while the reply says more notifications are waiting. .github/workflows/reminders-worker.yml deploys it when it changes, or by hand. Setup: the secret CRON_SECRET in Pages and in GitHub, and the permission Account › Workers Scripts › Edit on CLOUDFLARE_API_TOKEN (docs/ATTENDANCE.md, "Reminders").
Attendance reminders: .github/workflows/reminders.yml does the same every six hours (at seven past midnight, six, noon and six, UTC), as a backstop. Its secret is CRON_SECRET (the same value as in Pages); after the domain moves, the Actions variable REMINDERS_SITE with https://kmceagles.org serves both. Run it by hand from GitHub → Actions → Attendance reminders.
Custom domainkmceagles.org with www redirecting to the apex. HSTS is sent by _headers, so keep HTTPS on. The steps for the move, and the settings that follow the domain (sign-in, email, Turnstile, reminders), are in docs/DOMAIN.md.
Variable
Used by
Purpose
WEB3FORMS_ACCESS_KEY
Build
Optional. The club's Web3Forms access key is the default in src/_data/env.js; this replaces it. Public by design: the visitor's browser sends the form to Web3Forms, whose free plan refuses messages sent from a server.
TURNSTILE_SITE_KEY
Build
Public Turnstile key; renders the spam-check widget on the contact form. Empty locally, no widget.
TURNSTILE_SECRET
Function
Private Turnstile key used by the relay to verify tokens. Omit locally to skip verification.
SESSION_SECRET
Function
Required for website attendance: a long random value used to hash session ids, login codes and the scrambled network addresses kept for the limits on codes. Changing it logs everyone out at once.
ADMIN_EMAIL
Function
Optional: the emergency admin. This email (or several, separated by commas) logs in as an admin called Emergency admin while no member has it, with a code, Google or GitHub: to add the first real admins, or to get back in. Once a member has the address, it logs in as that member.
RESEND_API_KEY, EMAIL_FROM
Function
Optional: login codes by email through Resend (an API key with sending access, and a sender such as KMC Eagles <[email protected]> on a domain confirmed at Resend). The site stops at 90 codes a day and 2,900 a month. Without them the Log in page offers no codes.
EMAIL_REPLY_TO
Function
Optional: where replies to the login emails go (the club's inbox, such as [email protected]), as the sender's address has no mailbox.
GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET
Function
Optional: Continue with Google. An OAuth client (Web application) in the Google Cloud console with the redirect URI https://<host>/api/attendance/auth/google/callback for each address the site runs on.
GITHUB_CLIENT_ID, GITHUB_CLIENT_SECRET
Function
Optional: Continue with GitHub. A GitHub OAuth app with the callback https://<host>/api/attendance/auth/github/callback; GitHub allows one callback per app, so Production and Preview each need their own app, set per environment.
DB
Function (binding)
The D1 database for website attendance, bound under Settings → Bindings: kmceagles-attendance for Production, kmceagles-attendance-preview for Preview. Required while attendance is on.
GITHUB_TOKEN
Function
Optional: a fine-grained GitHub token for this repository with Contents read and write. It saves the changes made on the website (events, rosters, profiles and photos, the club's setup) to GitHub; without it the site offers no changes to content files. With Actions: Read-only as well, the Setup checklist (/club/checklist/) shows the GitHub workflows' last runs and the health watch reads the backup's. The site keeps the date it expires from GitHub's answers and warns the webmasters 30, 7 and 1 days before. Steps in docs/MANAGE.md.
GOOGLE_SITE_VERIFICATION
Build
Optional Search Console token, rendered as a meta tag.
SITE_URL
Build
Canonical origin. Optional: production builds use https://kmceagles.org and branch previews their own pages.dev address.
SHARE_URL
Build
The address in links people share (Copy link, WhatsApp, email) and in og:url. Optional: production uses https://kmceagles.org, branch previews their own address.
MEDIA_URL
Build
Where share images and logos load from (WhatsApp and social previews). Optional: production uses https://kmceagles.org; previews use their own address.
VAPID_PUBLIC_KEY, VAPID_PRIVATE_KEY
Function
Optional: keys of your own for attendance reminders (node scripts/vapid-keys.mjs). Without them the site makes its own and keeps them in the database.
CRON_SECRET
Function
Starts the reminder run at once for the reminders timer (a Cloudflare Worker, every five minutes) and the GitHub workflow every six hours, and lets them call again while more notifications are waiting. The same value goes in GitHub as the Actions secret CRON_SECRET; the Worker's deploy workflow copies it to the Worker. Without it, reminders still go out from visits, at most every ten minutes.
CF_PAGES_DEPLOY_HOOK
Function
Optional secret: the address of a Pages deploy hook (Settings → Builds → Deploy hooks; branch main for Production, attendance-preview for Preview). With it, and Club setup › Features › Publish saved changes together on, the website's commits skip their own build ([CF-Pages-Skip]) and the reminder run calls the hook once the changes have rested ten minutes: one build for a run of changes. Without it, each change builds on its own. The Setup checklist shows whether it is set, never its address. Details in docs/MANAGE.md, Fewer builds.
WORKERS_PLAN
Function
Optional: paid once the club's Cloudflare account is on the Workers Paid plan, so the website sends 400 notifications in one request instead of 40 and compares 50 changes in an event's history instead of 15 (lib/plan.mjs). Without it the website keeps to the free plan's limits, which are safe on either plan. docs/ATTENDANCE.md, Moving to the Workers Paid plan.
PAGES_WRANGLER_MAJOR_VERSION
Build
Optional: 4 lets a paid plan take 100,000 files in one deployment instead of 20,000. The Setup checklist shows the count.
REMINDERS_SITE
GitHub Actions
Optional repository variable (not a secret): the address the Attendance reminders workflow and the reminders timer call. Without it, https://kmceagles.org.
CLOUDFLARE_API_TOKEN, CLOUDFLARE_ACCOUNT_ID
GitHub Actions
Repository secrets for the weekly database backup, for deploying the reminders timer and for the health watch's daily figures: a Cloudflare API token with Account › D1 › Edit, Account › Workers Scripts › Edit and Account › Account Analytics › Read, and the account's ID. The health watch warns before the token expires.
SKIP_PAGEFIND
Build
Set to 1 to skip the search index (faster local builds).
GEOCODE
Build
Set to 0 to skip looking up new rink addresses during the build (faster local builds).
Rolling back
Cloudflare keeps every deployment. Workers & Pages → the project → Deployments → the last good one → Rollback. The live site switches in seconds while you fix main. Content mistakes are better fixed forward in the CMS; the history is in Git anyway.
Old addresses
src/public/_redirects maps every URL of the previous PHP site to its replacement with a 301. Add a line there whenever a page moves. Query strings cannot be matched, so old event ids land on the schedule.
Content model and the CMS
One file per subject; references are slugs. Admins never see the files: Pages CMS presents them as forms driven by .pages.yml. The everyday tasks for admins are written up step by step in one place each: what the website does (add a game, cancel an event, add a player) in the user guide (src/content/pages/user-guide.yml, with the admins' part at /guide/admins/), and what Pages CMS keeps (publish a gallery, retire a number, post a volunteer role) in docs/OWNERS-GUIDE.md.
Section
Files
Notes
Schedule (events)
src/content/events/*.yml
Every game, practice, tournament, social and volunteer event. Feeds the schedule, event pages, calendars and rink cards.
News
src/content/news/*.md
Posts with Markdown body. Tag recap and fill the Game recap fields for a score share image.
People
src/content/people/*.yml
Players and coaches. Never email, phone, rank or unit (a phone number and birthday the club chooses to keep live only in D1, never in these files). show_publicly: false hides a person everywhere: profile, rosters, coaches and the board. status: retired turns the profile into a retired profile and drops them from this season's roster; first_year, last_year, past_teams and farewell cover seasons before the rosters on the site. Alumni (club.alumni, team.alumni) and players not on a team yet (club.unplaced) are derived.
Rosters
src/content/rosters/*.yml
One file per team and season; history stays intact. A head coach, assistant coaches and player rows with number, role and status. A roster with nobody visible on it is treated as unpublished.
Teams, Seasons, Rinks
src/content/{teams,seasons,rinks}/*.yml
Reference data. Rink coordinates are never typed: they come from the geocode cache, which the website also writes when a coach or an admin saves a rink there (docs/MANAGE.md, Rinks on the website).
Page text
src/content/pages/*.yml, src/_data/help.yml
Home, Club, Join, Getting started, Contact, Shop, Uniforms, Media pack, Memorial, History, Accessibility, Impressum, Manage users (the words of Everyone and the login-link message), the intros of the list pages (intros.yml), the User guide (user-guide.yml, one Markdown document; a heading ending in {#id} keeps that anchor) and the "i" buttons (help.yml), as fields and rich text; templates keep the design. [[min_age]], [[league]] and [[email]] in the text are filled in from Site settings.
Geocode cache
src/_data/geocache.json
Coordinates for every rink address, filled by the build or npm run geocode. Not shown in the CMS.
Photo galleries
src/content/galleries/*.yml
Photos under media/photos, with alt text and credits. A gallery is published once it has a real photo.
Site settings
src/_data/site.json
Club name, tagline, disclaimer (verbatim), social links, contact email, Impressum.
Lists
src/_data/*.yml, *.json
honors, retired-numbers, volunteer-needs, testimonials, videos, leadership, positions, faq, dues, announcement, sent (form confirmations), nav. CMS lists keep their rows under an items: key; the yml loader unwraps it.
Rules that keep the CMS and the site in step
Every field in a content file must be declared in .pages.yml with the same name. npm run check:cms enforces it, including select values.
Times are wall-clock strings such as 2026-10-03T19:30, in the event's own time zone: its tz, else its rink's, else the club's (Site settings, timeZone, Europe/Berlin by default). All-day events carry a date only. lib/time.mjs and lib/zones.mjs handle offsets and DST.
Calendar UIDs come from the event's file name (or a stored uid on older events), and SEQUENCE rises with every build, so subscribed calendars always take the newest version. Both fields are hidden in the CMS; admins never touch them.
Entries that other files point at (people, rinks, seasons, teams) have an editable slug that is also the file name, and cannot be deleted or renamed in the CMS.
Nothing is deleted to hide sample content: people use show_publicly, testimonials show. Sections with nothing visible hide themselves and come back when an admin publishes an entry.
People carry no contact details. Since 8 October 2026 attendance is taken on the website (see Website attendance); team news and chat stay in the teams' WhatsApp groups.
Hyphenated data files (retired-numbers.yml, volunteer-needs.yml) are exposed to templates under camelCase names in eleventy.config.js, because Nunjucks cannot read a hyphenated variable.
Giving an admin access
In Pages CMS, open the project settings and invite them by email as a collaborator. They sign in at app.pagescms.org with the link they receive; no GitHub account is needed. No seat, no invoice.
A second webmaster gets a GitHub collaborator invitation with write access instead.
Remove the collaborator when they leave. Their past commits stay attributed.
Common changes
Add a page
Create src/pages/<name>.njk with front matter: permalink, title, description (50–160 characters), ogSubtitle, and breadcrumbs if it sits under another page.
Extend layouts/base.njk and put the content in {% block content %} inside <div class="container"> with a .page-head.
Link it from src/_data/nav.json (a menu link or a legal link) and from the overview page of its section. The human sitemap is built from nav.json; the XML sitemap from every page.
npm run verify. The SEO gate checks the title length, description, image and breadcrumbs for you.
Add a menu entry
Edit src/_data/nav.json: each group has a label, a landing URL and items with label, URL and one-line description. The navbar highlights a group on its own URLs; other URLs that belong to a group are listed in partials/header.njk near isCurrent.
Add a contact-form reason
Add the reason to the reasons list in src/pages/contact.njk and a <fieldset data-for="…"> with its fields.
Allow the new field names in ALLOWED_FIELDS and add an email subject in SUBJECTS in functions/api/form.js.
Add a confirmation page entry in src/_data/sent.yml; it renders at /contact/sent/<reason>/.
Link with /contact/?reason=<reason>. Fields marked data-prefill take their value from a matching query parameter.
Add a list managed in the CMS
Create src/_data/<name>.yml with the rows under an items: key.
Declare it in .pages.yml as a file section with one items field of type object, list with a collapsible summary, and one field per key with a plain label and a description.
If the file name has a hyphen, alias it with addGlobalData in eleventy.config.js.
Describe the task for admins in docs/OWNERS-GUIDE.md (published at /admin-guide/), the Pages CMS guide.
Allow a new external host
The Content Security Policy in src/public/_headers blocks everything not listed. Add the host to the matching directive (script-src, connect-src, img-src, frame-src) and to the service worker's allowed origins if it must work offline. Test in a preview deployment; the browser console names any blocked request.
Change the announcement banner
Admins do this in the CMS (announcement.yml) or on the website under Announcements (/club/announcements/), which keeps every bar announcement in the file's list and shows the change at once: the bar is a marked part (lib/render/club.mjs) that the server draws again. Dismissal is remembered in the visitor's browser per message text, so a new message shows again to everyone. Hide after is checked when the page is drawn and again in the browser.
Change the logo
Replace the files in media/brand/ and media/, then run node scripts/build-pwa-icons.mjs for the app icons and rebuild. Share images, player cards and the recap image read the emblem at build time. Update favicon.ico and favicon-32.png by hand.
Icons
Icons come from Lucide (the lucide-static package, at one exact version in package.json) and are drawn by meaning, never by picture. The glossary, src/_data/icons.json, gives each meaning its one icon and says whether it may stand alone (alone), whether a script or the server draws it, so that every page carries it (script), and whether a later step of the icon plan will be its first use (planned). Words come first: an icon never marks content (titles, In and Out, statuses, event types, figures, labels, empty states, the footer).
Beside a word: {{ iconText("print", "Print the roster") }} from partials/macros.njk, or {% icon "print" %} next to the word. The icon is silent and the word is the name.
Alone: {{ iconButton("change", "Change Ryan Hembree") }}, a 44px button whose label is both its name and its tip; the build stops on a meaning not marked alone. By hand, an icon-only link or button needs aria-label (verb and object) and data-tip in the same words.
In a script: kmceIcon("call") writes the same markup (an inline script at the top of every page, whose hash is in the CSP); in lib/render, iconSvg("next") from lib/icons.mjs. A meaning a script or the server draws is marked script.
In the menu: each link of Your pages and Tools names its icon in src/_data/memberNav.json ("i", a meaning); a new link needs one, as every item of a list has an icon or none does. The cards on Your account (/you/) are drawn from the same list, so each takes its link's icon.
A word that may go where there is no room: a control shows its icon and its word, and below a breakpoint its icon alone, still named (aria-label) and tipped. Add an event on the Schedule (below 40rem) marks this data-tip-alone, so its tip shows only while the word is out of sight. The header's buttons (Install, Log in, search, the menu) are icons alone at every width, each named and tipped; the menu has Log in with its word. Every More (pageMenu in partials/macros.njk, an event's head, a gear listing) is the ellipsis alone on every screen, a square a tap wide at the end of its row, named for what it is more of (aria-label, "More for the Inbox"); tips.js names it first in its tip, then what the menu holds. Header and title rows must never wrap: npm run check:mobile fails on a wrapped label.
The "i" windows (infoPop): a text whose last paragraph is a lone link, such as [Read more in the user guide](/guide/#rosters), ends with that link as a secondary button with the guide's icon, whatever its words. The texts in Page text › Help buttons stay plain Markdown.
A new meaning: add it to the glossary with a Lucide name no other meaning has (lucide.dev lists them). The build stops on a meaning or a Lucide name it does not know.
Each page carries only the icons it uses, as a hidden sprite: at build time lib/icon-sprite.mjs reads each icon's shapes from the package and puts them in place of <!--icon-sprite--> at the top of the page, with the script meanings always. Three sizes, --icon-sm, --icon-md and --icon-lg (16, 20 and 24px), one 1.75px stroke at every size, in the colour of the words (tokens.css). npm run check:style checks that every icon named is a meaning, and that every link of the menu's lists names one; that no two meanings share an icon; that every meaning is used or planned; that a link or button showing only an icon has its name, its tip (close buttons are let off the tip, which tips.js never shows on phones) and a meaning that may stand alone; that no icon sits in a title, badge, status, answer button, the footer or the content; and that no other <svg> is written by hand, apart from the Google and GitHub marks (partials/brand-marks.njk) and the share images. In Windows contrast themes (forced colours), the open-and-close arrows, the selects' arrows, the Inbox's unread dot and pressed buttons take the theme's own colours.
Meaning
Icon
Lucide
May stand alone
On every page
Used for
close
x
Yes
Yes
Every window, bar and tip
menu
menu
Yes
No
The visitor's menu button; Your menu (Settings) and Arrange this menu (the foot of the menu)
search
search
Yes
Yes
The header's search button, the magnifier in every search field
previous
chevron-left
Yes
Yes
Calendars, the photo viewer, the testimonials
next
chevron-right
Yes
Yes
Calendars, the photo viewer, the testimonials, the menu's toggles
back-to-top
chevron-up
Yes
No
The floating Back to top button
info
info
Yes
No
The "i" buttons; Help on the pages (Settings)
guide
book-open
No
No
The user guide: the button at the foot of every "i" window (Read more in the user guide), User guide at the foot of the menu
keys
keyboard
No
No
Keyboard shortcuts (the foot of the menu, on computers)
home
house
Yes
No
The tab bar
events
calendar
Yes
No
The tab bar (Your events), the schedule; Your events in the menu
teams
users
Yes
Yes
The tab bar, rosters, members: Rosters in the menu; the Inbox's Members
inbox
inbox
Yes
No
The tab bar, the Inbox in the menu
language
languages
No
No
Sprache / Language, the language row at the foot of the menu
notes
sticky-note
Yes
Yes
Event-card marker; the Notes heading on the event's page
comments
message-square
Yes
Yes
Event-card marker; the Comments heading; the Inbox's comments and replies to your comments
rides
car
Yes
Yes
Event-card marker; the Rides heading; the Inbox's rides
duties
clipboard-list
Yes
Yes
Event-card marker; the Duties heading; the Inbox's duties
lineup
layout-grid
Yes
Yes
Event-card marker; the Line-up panel; the Inbox's line-ups (weak: elsewhere always with its word)
player-details
id-card
Yes
Yes
About a player: their profile at a glance, from the line-up (coach tools), on the corner of their photo or initials
sharpening
sparkles
Yes
Yes
Event-card marker; the Skate sharpening heading; Skate sharpener (Settings); the Inbox's sharpening (weak: elsewhere always with its word)
arrive-by
clock
Yes
Yes
Event-card marker: arrive by
room
door-open
Yes
Yes
Event-card marker: the dressing room
waiting-list
hourglass
Yes
Yes
Event-card marker; the Waiting list heading; the Inbox's waiting list
live
radio
Yes
Yes
Event-card marker: a live stream; the Watch heading
video
circle-play
Yes
Yes
Event-card marker: a video; the Video heading
call
phone
Yes
Yes
Call an emergency contact
text
message-circle
Yes
Yes
Text an emergency contact; WhatsApp in share menus
share
share-2
Yes
Yes
Share buttons and menus
add
plus
No
Yes
A control that opens a form to make something new: New announcement, List an item, Add a team, Add the next season, Ask a question, Add a charge
new-event
calendar-plus
Yes
Yes
Add an event (the Schedule's title, alone on a phone; a team's More; the menu's Tools); the Inbox's New event
many-events
calendars
No
No
Add many events (a team's More; the menu's Schedule and Tools)
league-table
table-2
No
No
Update the league table (a team's More)
add-to-calendar
calendar-arrow-down
No
Yes
An event's calendar file (the event's More)
subscribe
calendar-sync
No
Yes
Subscribe to a calendar: the event's and the Schedule's More, a team's, the subscribe box, Your season, Your calendar (Settings)
schedule-change
calendar-clock
No
Yes
The Inbox's schedule changes
seasons
calendar-range
No
Yes
Seasons (the menu's Tools); the Inbox's Next season
change
pencil
Yes
Yes
Change or cancel an event (the event's More and coach tools), change a roster; the poll's and the charge's form while it changes one
delete
trash-2
No
No
Delete your login and data (Settings)
print
printer
No
No
Print in the menus: the Schedule, a team, Teams, Rosters, Attendance, a player
download
download
No
Yes
Spreadsheets, your data, the player card, Download .ics, Save for offline
copy-link
link
No
Yes
Every Copy link
copy
copy
No
Yes
Copy the payment reference (Your dues); Copy the emails
email
mail
No
Yes
Email in share menus
directions
navigation
No
Yes
Get directions
rinks
map-pinned
No
No
Rinks for coaches and admins (the menu's Schedule and Tools): add and change rinks and venues
log-in
log-in
Yes
No
Log in: the header's (alone, as the header's buttons all are), the menu's for visitors, and the Log in page's after the code
log-out
log-out
No
No
Log out (the menu)
install
monitor-down
Yes
No
Install the app: the header's Install (alone, as the header's buttons all are)
passkey
key-round
No
No
Continue with a passkey (Log in); Sign-in (Settings, the menu's Tools)
notifications
bell
No
Yes
Notifications (Settings); the Inbox's other notices
device
smartphone
No
No
Notifications on this device (Settings)
away
plane
No
No
Away dates (Settings; weak)
goalie
shield
Yes
Yes
Goalie needed: the event-card marker while an event is short of goalies, the Inbox's goalie asks
goalie-found
shield-check
Yes
Yes
Goalie found: the event-card marker once enough goalies are In after the website asked
subs
arrow-left-right
No
Yes
Subs: Sub opportunities (the menu), Be a sub (Settings), the coach tools' Subs, the Inbox's sub notices (weak)
leaving
user-minus
No
Yes
Leaving the club (Settings); the Inbox's Leaving the club
your-data
database
No
No
Your data (Settings; weak)
photo-consent
camera
No
No
Photos of you (Settings): whether club photos that show the member may be published
club-rules
scroll-text
No
No
The club's rules (Settings), which members agree to
documents
file-text
No
Yes
Club documents: the page in the menu and under Club, each document on its shelf, and the Inbox's new documents
profile
circle-user-round
No
Yes
Your profile (the menu); the Inbox's Your profile
stats
chart-column
No
No
Your stats and Game stats (the menu); Season stats and map (a team's More)
dues
wallet
No
Yes
Your dues and Club dues (the menu); the Inbox's dues
kitty
piggy-bank
No
Yes
The team kitty: its page in the menu and the Teams views; the Inbox's kitty notices
new-player
sprout
No
Yes
A first practice for new players: the Inbox's new players for a coach's team
mentor
handshake
No
Yes
Mentors for new players: the Inbox's mentor notices
equipment
package
No
Yes
Club equipment: its page in the menu; the Inbox's equipment notices
kit-orders
shirt
No
Yes
Jersey and kit orders: Kit orders in the menu and on Shop › Uniforms; the Inbox's kit order notices
trip
luggage
No
Yes
A tournament's trip: the Trip heading on its page; the Inbox's trip notices
bank-statement
file-spreadsheet
No
No
Match a bank statement (Club dues)
costs
receipt-text
No
Yes
Costs for the board: Costs in the menu (the attendance history's Costs view); Coach tools › Costs on an event's page
settings
settings
No
No
Settings (the menu)
feedback
lightbulb
No
Yes
Feedback (the menu); the Inbox's feedback (weak)
testimonials
quote
No
Yes
Your testimonial and Testimonials (the menu); the Inbox's testimonials
attendance
user-check
No
Yes
Attendance (the menu), Who came (coach tools)
profile-requests
user-pen
No
Yes
Requests (the menu); the Inbox's profile requests (weak)
add-people
user-plus
No
Yes
Add players and coaches (the menu), Call up a player (coach tools), a team's join link (Rosters)
group-chats
messages-square
No
Yes
Group chats and Chat links (the menu); the Inbox's group chats
polls
vote
No
Yes
Polls (the menu); the Inbox's polls
gear
tag
No
Yes
The gear exchange (the menu); the Inbox's gear exchange (weak)
announcements
megaphone
No
Yes
Announcements (the menu); the Inbox's announcements
club-setup
sliders-horizontal
No
No
Club setup (the menu; weak)
features
toggle-right
No
No
Features (the menu's Tools)
menu-links
external-link
No
No
Menu links (the menu's Tools)
checklist
list-todo
No
No
The setup checklist (the menu's Tools)
activity-log
logs
No
No
The club's activity, the record of admin actions (Club setup › Activity; the menu's Tools)
set-answers
list-checks
No
Yes
Set answers for players (coach tools)
maybe
circle-question-mark
No
Yes
Maybe for this event (coach tools)
answer-by
alarm-clock
No
Yes
Answers for this event (coach tools): when answers close, and the requests to join after
wait-order
list-ordered
No
Yes
The waiting list's order (coach tools)
outside-guest
user-round-plus
No
Yes
Guests from outside the club (coach tools)
player-of-the-game
medal
No
Yes
Player of the game: the vote on a game's page, its Coach tools panel, Your stats, Game stats and the Inbox's notices
game-sheet
clipboard-pen-line
No
Yes
The game sheet (coach tools; an optional part of an event page, so on every page)
results
trophy
No
Yes
Final scores: the Inbox's final scores; a tournament's games and their scores on its page
news
newspaper
No
Yes
Game reports: Game reports to publish (the menu), Write the game report (the game sheet); the Inbox's game reports
club-year
calendar-check
No
Yes
The club's year: its section in Club setup and This month on the admin desk; the Inbox's yearly jobs
emergency
siren
No
Yes
Emergency contacts (coach tools; weak)
history
history
No
Yes
Changes to this event (coach tools)
columns
columns-3
No
No
Choose the columns (Rosters)
saved-view
bookmark
No
No
Save this view (Rosters)
select
square-check-big
No
No
Select players (Rosters)
birthday
cake
No
Yes
The Inbox's birthdays
few-in
triangle-alert
No
Yes
The Inbox's few players In
website-health
activity
No
Yes
The website's health: the Inbox's notices to the webmasters from the health watch
whats-new
rocket
No
Yes
What's new on the website: the Inbox's notes after a release
late-out
user-x
No
Yes
The Inbox's late Out (weak)
time-zone
globe
No
No
Your time zone (Settings)
zoom-in
zoom-in
Yes
No
The photo viewer
zoom-out
zoom-out
Yes
No
The photo viewer
fullscreen
maximize-2
Yes
No
The photo viewer
options
ellipsis
Yes
Yes
More choices, always alone: every More, beside a page's title, in an event's head and on a gear listing (its filters, views and actions), and a roster row's Options
Design system
Every color, size, space and radius is a custom property in src/assets/css/tokens.css. Components use tokens only; a hex code outside that file is a bug. The living reference with swatches and every component is the style guide.
Colors: navy, red, ice and gold brand tokens; semantic tokens for background, surface, text, muted, border, link. Dark theme redefines the semantic tokens under prefers-color-scheme: dark; it follows the device setting and has no toggle.
Type: Barlow Condensed for headings and labels, the system stack for body text. A modular scale from --fs--2 (Label, short words in capitals) to --fs-5; the style check refuses any other text size below 2rem.
Spacing: a 4 px scale, --s-1 to --s-16.
Square corners by club decision: every radius token is 0.
Breakpoints: only 40 rem and 64 rem, and only for columns and spacing. Mobile first.
Tap targets: 44 px minimum, 48 px for primary buttons; the accessibility gate measures them.
Print: an @media print block at the end of pages.css hides the chrome and shows the schedule as a plain table.
CSS is inlined into every page, so its size matters for performance. Keep page-specific rules short and reuse .card, .notice, .btn, .grid-sidebar, .table-wrap and the other components before adding new ones.
Contents. A page with several sections gets its "On this page" list from the contents macro in partials/macros.njk, called twice with the same items: once with "bar", first in the block that holds the sections (a line that stays under the breadcrumbs on phones and tablets, naming the section being read and opening the list), and once as the rail (a side column that stays in view on a large screen; in a .grid-sidebar aside, it leads the column). .toc-layout gives a page the side column (the members' home page, the legal pages); Settings, Club setup and Your profile use .settings-layout, the guides .guide-layout. A line wrapped in .toc-wide stays on a large screen too, for pages of wide card rows; an event page puts its line in the event bar (.ev-bar, event.njk), which also carries the event's date and title once the card's heading has scrolled away and, on a computer, shows whole only then, with a second list (.ev-toc-side) that names for members only the parts of the right-hand column and Also coming up; each list marks its own section being read. An event page lists every part, down to each Coach tools panel (att-event.js gives each panel its own address, PANEL_ID), and pressing a panel's entry opens the panel as well. A player's profile has the line only, on phones and tablets. Entries for parts that only some pages or members have (an event's rides, a team's results) are listed always and shown only where their section is. main.js ("Contents") marks the section being read, keeps a chosen entry marked until the reader scrolls by hand, and leaves out an entry whose section a script has hidden or not drawn yet (it looks again whenever the page's parts change). Scrolling to a section leaves room for the line through --toc-h in the page's scroll-padding-top.
Images, share images and cards
Photos: the {% image %} shortcode (eleventy-img) writes AVIF, WebP and JPEG at the widths you ask for, with fixed dimensions, lazy loading and a content hash in the file name (so /img/ can be cached for a year). Small avatars use the thumb filter. Inside a loop use asyncEach; the shortcode is asynchronous. Raw <img> tags for CMS paths go through the mediaUrl filter.
Share images: every page gets a 1200×630 PNG with its title, rendered from SVG by sharp through fontconfig using scripts/og-fonts/BarlowCondensed-Bold.ttf (the fontconfig file is generated into .cache/). Player pages use the landscape trading card; news posts tagged recap with scores get the score card from lib/recap.mjs. Output is content-addressed under /og/ and cached in .cache/og/, so unchanged images are not re-rendered.
Player cards: portrait 900×1260 PNG for download and print, built in lib/card.mjs with the headshot embedded. The Print card button prints the PNG at standard trading-card size from a hidden frame.
App icons: generated from the transparent emblem by scripts/build-pwa-icons.mjs; the maskable variants keep the emblem inside the central 80 % safe zone.
Calendars and time
/calendar.ics (all events), /calendar/<team>.ics and /events/<slug>.ics are written by lib/ics.mjs: a VTIMEZONE for the club's zone (Europe/Berlin's fixed rules by default) and one for each other zone its events abroad use, stable UIDs, DTSTAMP, SEQUENCE, VALUE=DATE for all-day events, STATUS:CANCELLED, LOCATION and GEO from the rink, URL back to the event page, and 75-octet line folding. Apple and Google refresh subscriptions on their own schedule.
Never delete a canceled event; mark it canceled so subscribers see the strike-through. The ICS gate parses every feed with an independent library and was proven to fail on a broken month name.
Contact form
One form at /contact/ covers every reason (general, join, roster, schedule, media, testimonial, volunteer, website). The reason selects which fieldsets show and sets the email subject.
The browser posts to /api/form. The Pages Function drops the honeypot, verifies Turnstile with the secret when it is set, keeps an inquiry to join or a sign-up for the club admins, and returns only the allowlisted fields. The browser then sends them to Web3Forms with the club's access key, which emails the club inbox; Web3Forms' free plan refuses messages sent from a server.
On success the visitor is redirected to /contact/sent/<reason>/, a confirmation page with next steps from src/_data/sent.yml (Thank-you pages in the CMS). Without JavaScript the function issues the same redirect.
Testimonials arrive by email and are added in the CMS by an admin once approved, with Show on the website checked; nothing is published automatically. While none is shown, every testimonial section and link stays hidden.
Website attendance
Players say In or Out for practices and games on the site (and Maybe, where the club admins turn it on); anyone who has not answered shows as Awaiting response, and a short note can go with an answer. The feature is switched in Site settings › Attendance on the website; while it is off, every page shows the WhatsApp wording. Setup, the switch and the file list are in docs/ATTENDANCE.md.
Signing in. An admin enters each member's email on Rosters › Everyone (/teams/rosters/?team=everyone, the last choice in the Rosters bar, listed by players, coaches and admins, with everyone's profile) and invites them on WhatsApp with a message that holds no key (Page text › Manage users). Members log in on /login/ with a passkey (checked in lib/attendance/auth.mjs with WebCrypto: ES256 and RS256, no attestation), a six-digit code by email (Resend; ten minutes, five tries, a minute between codes, five an hour per address, ten an hour per network address, 90 a day and 2,900 a month), or Google or GitHub (OAuth with PKCE; only a verified email, or an account the member linked). Each login is a session of 90 days on that device; only hashes of sessions and codes are stored. The members' pages send a visitor to /login/?next=.
Settings › Sign-in. Each member sees their email, adds, renames and removes passkeys, links and unlinks Google and GitHub, and sees every device they are logged in on, with Log out for one and Log out on all other devices. An admin can log a member out everywhere and remove their passkeys (a lost phone) from the member's window on Everyone. Club setup › Sign-in shows which ways are ready, the codes sent against the limits, Resend's last refusal, and sends a test email.
The installed app. A home-screen app keeps its own log-in; the menu offers Log in while the switch is on, and a passkey makes it one tap.
The four areas. The members' pages sit in the menus' areas at clean addresses: You (/you/: Inbox, Your profile, Your stats, Your dues, Settings), Schedule (Your events, Sub opportunities, Add an event), Teams (Rosters, with a team for each the member may change, All teams and, for admins, Everyone; Attendance; Game stats; for admins Requests; and Add players and coaches, a page of its own) and Club (Group chats, Polls, Gear exchange, Announcements, Club dues, Club setup). src/_data/memberNav.json draws the menu's Your pages and Tools and the mega menus; lib/member-pages.mjs lists the pages for the service worker, the SEO check and the layout. The Schedule's and the Teams area's views share tabs under the title; the Teams pages share one layout (title and More, tabs, a bar of fields, one count line) and one person window (partials/person-window.njk).
The tab bar. On a phone, while a member is logged in, four places, each an icon with a word under it, sit at the foot of the screen (base.njk, "Tab bar" in layout.css): Home, Your events, Teams and Inbox. It shows by CSS alone (html.is-member, set by the head script before the first paint), so nothing moves; the page keeps room for it through --tabbar-h, which also lifts what sits at the foot of the screen (back to top, the coach's bulk bar, the profile's save bar). On an iPhone it keeps its icons above the home bar: the viewport tag says viewport-fit=cover (base.njk), so the phone reports the room its home bar needs (env(safe-area-inset-bottom)), and the bar adds that room below its icons; turned sideways, .container keeps text clear of the camera cut-out the same way. check-a11y fails if either goes. attendance.js (tabBar) puts the counts still to answer and unread on the icons and in their names, and points Teams at the member's own team (summary.teams) or, for those who keep the rosters, at Rosters. It slides away while a field has the focus (html.is-typing). The four tabs are the same for everyone: coaches' and admins' tools are in the menu (the Tools tab was removed on 14 October 2026, since it held exactly what the menu's Tools holds). Everything else is in the menu; the More tab and its page were removed on 13 October 2026.
The menu and Tools. The header has one button, details[data-menu] in partials/header.njk (data-popover data-pop-sheet data-pop-title="Menu"): the ☰ for visitors; for a logged-in member, their photo or initials (from the first paint: html.is-member, and has-photo with --att-photo), with a red number on its corner for the things waiting for them (waiting() in attendance.js: events to answer, unread notices, polls to answer, sub offers to approve, and the requests and testimonials waiting for admins). nav() sets that number (data-att-waiting) and the button's aria-label, "Menu and your account: <name>, N things waiting for you". The menu is details[data-pop-sheet]: main.js opens it as a full page, on a phone the whole screen above the tab bar (a tap on a tab closes the window and goes where the tab leads; the Menu tab closes it), on wider screens a panel at the right, which on a tablet stops above the tab bar (components.css, "Full-page windows"). The close button, Escape or a press outside closes them; following a link in the menu closes it too (closeMenu). The menu holds, in order: Search the website (any [data-search-open] opens the search window, which lists a logged-in member's own pages first: own in search-kit.js; / still opens it from anywhere on computers); for a member, who they are (acctWho in partials/macros.njk), Waiting for you (menuWaiting: a tile for each thing there is, each leading to its page, or "Nothing is waiting for you."), Your pages (acctLinks: the links for every member and for players, each with a line on what it is for, d in memberNav.json) and, for coaches and admins, Tools, as compact rows with each tool's line in its tip; Around the website (the club's sections), with the club's menu links for members (acctClub); for visitors, Join the Eagles and Log in; and the foot: rows like the menu's links, one line each with its icon (Sprache / Language, where Google Translate loads only when it is pressed, then its list of languages shows in the row's place, [data-translate-box]; User guide; Keyboard shortcuts on computers; Arrange this menu for members), then Log out across the menu with "Logged in as … Not you?" under it (members), All pages and the legal pages. The member's next event is not in the menu; the home page shows it. Tools (toolsParts in partials/macros.njk) shows, on an event page with Coach tools, that event's panels (a press opens and scrolls to one); the next event the coach runs, with its counts and a button to its Coach tools (toolsNext); and their team and club tools (memberNav.json's tools). attendance.js ("Menu" and "Tools") fills both from each summary.
Coaches' answers, who came and the history. The event's coaches (from the rosters) and every admin can set answers for anyone on an event's list, and mark who came after the start. /teams/attendance/ (history.js with charts.js) shows it to coaches and admins by season, team, kind and period, in four views (Players, Events, Trends with charts, Grid), each downloadable as CSV. Past events come from /data/attendance/history.json, built from the schedule and each season's rosters. The extra tables (answer_by, came, totals) are created by the API itself; migrations/0002_attendance_history.sql holds the same definitions.
Guests. Coaches of an event and admins can add a player from another team (or not on a team yet) to that one event; the guest is then on its list everywhere (answers, counts, names, who came, history under the event's team). Stored in the guests table, which the API creates itself (migrations/0003_attendance_guests.sql holds the same definition).
Subs. Members offer to play for another team's event (one event, or a standing offer for a team's games, practices or tournaments); the event's coaches and admins see everyone who offered (also once added as guests, with their answer) and add them as guests, and can ask for subs with a note. Sub opportunities (/schedule/subs/, a tab of the Schedule) lists the events a member can offer for, from /api/attendance/mine, and the members' home page shows the first four. Tables sub_offers, sub_prefs and subs_wanted (with the coaches' note), created by the API.
Line-ups. Coaches build a line-up per team event (four lines, three pairs, two goalies) in lineup.js; once published, the event's list sees it. Pictures for the team chat come from share-card.js, drawn on the device. Table lineups.
Shared links.Share reminder and the line-up's Post to the team chat send the picture to POST /api/attendance/event/<slug>/share, which keeps it with a snapshot of the names from the server's own data under a random token (table shares). /s/<token>/ opens the built page /schedule/share/ with the snapshot filled in, and /s/<token>/preview.jpg is the chat's link preview. A link works until three days after the event; at most ten per event are kept.
Your events, Your stats and the banner./schedule/mine/ lists a member's events with filters, answers for several at once and a calendar; Your stats (/you/stats/, from GET /api/attendance/me/stats) shows their season event by event, by type and by month beside everyone's In rate on the same events, with their line from the game sheets. On every other page a gold banner (partials/att-remind.njk, filled by attendance.js) names their unanswered events of the next two weeks, or Subs wanted when nothing of their own needs an answer.
Reminders. Web Push from lib/attendance/push.mjs, started every five minutes by the reminders timer (workers/reminders/), every six hours by .github/workflows/reminders.yml, and by visits at most every ten minutes. No setup: the API makes its own keys (table settings); VAPID_PUBLIC_KEY and VAPID_PRIVATE_KEY override them. Tables push_subs, push_sent and event_seen. What reaches a device is the member's own choice per kind (Notifications, below): "Coming up", the chosen time before each event the player is In for (with the arrive time, dressing room, duty and ride), a reminder of their events still to answer, and the notices they chose as notifications, such as an event moved, canceled or back on (compared with the last run's snapshot in event_seen); never in the member's quiet hours (22:00 to 07:00 unless they choose others), at most 40 notifications per request, and a reply that says when more are waiting, so the clock calls again at once.
Notifications.lib/attendance/notify.mjs lists every kind of notice, whom it is for (members, coaches, admins) and how it may reach them. In Settings (/you/settings/#notifications) each member chooses for each kind the Inbox, a notification, both or neither (Inbox and Notification are buttons that each turn on and off; Off is lit while neither is), the reminder times and their quiet hours (GET and PUT /api/attendance/notify, table notify_prefs). Notices are sent as things happen (rides, duties, line-ups, guests, subs, late Outs, polls, dues, profile requests, group chats) or by the reminder run (reminders, new events, changes, few players In, polls closing, dues, birthdays). The Inbox (/you/inbox/, table inbox) keeps what each member chose to keep. See docs/ATTENDANCE.md.
Away dates. Table away (per member, dates in the club's time zone). Saving a period turns the member's events on those days Out (responses.away = 1); each run does the same for events added since; the member's own answer clears the mark; deleting the period deletes the away Outs. responses.late marks a switch from In to Out in the last 24 hours, for the history's Late Out column. Send a test says on the page what happened on that device: the push service's answer when it refused (status and reason), "expired" when it no longer knows the device, or that the service worker received it.
Skate sharpening and the gear exchange. Each game, practice and tournament has a sharpening board like Rides (lib/attendance/sharpen.mjs; tables sharpeners, sharpen_rules, sharpen), with a standing rule in Settings (/you/settings/). /club/gear/ is the members' gear exchange (lib/attendance/gear.mjs; tables gear, gear_interest): listings with a photo kept in the database, a price or free, 30 days each, and interest told by notices. Neither takes contact details or money. See docs/ATTENDANCE.md.
An event's changes and open pages. Coach tools › Changes to this event, on each event's page, lists who added and changed the event and when, read from the commits on GitHub (lib/manage/events-api.mjs, lib/manage/history.mjs): the REST commits list for one event's full history, read once per visit. Each change also says what it changed ("Start: 21:15 to 20:45"): the file at that commit compared with the file at the one before (eventChanges), kept in settings under hist:<commit>:<file>, since a change never changes. The Schedule's Manage view was removed on 12 October 2026; its address goes to the Schedule. Open pages marked data-live (the Schedule, Your events, event pages) ask GET /api/attendance/pulse every 20 seconds while in front and redraw what changed: the attendance from the API, the page's own parts by their fingerprints. Tabs of one browser tell each other at once. See docs/MANAGE.md.
Changing the schedule, rosters, profiles and the club's setup on the website. Coaches and admins add, change, cancel and score events at /schedule/add/; coaches change their own team's roster at /teams/rosters/, and admins every roster, its coaches (/teams/add/) and the profiles (in each person's window, from a roster or from Rosters › Everyone), and accept or decline players' requested changes field by field on Requests (/teams/requests/); members change their own profile and photo at /you/profile/ as far as Club setup › Profiles allows; admins change the teams and the next season under Club setup, /club/setup/, and the announcement bar under Announcements, /club/announcements/. lib/manage/schedule-api.mjs, people-api.mjs, setup-api.mjs and announcements-api.mjs commit each change to its content file on GitHub with the GITHUB_TOKEN secret (lib/manage/save.mjs), and store the new text in the table content_pending until the build that has it is live (/data/build.json). Meanwhile functions/_middleware.js builds the club model from /data/content/site.json with the change in place (lib/model.mjs) and draws every page's marked parts, the page of a changed event, and players' pages, with the same code the build uses (lib/render/); npm run check:schedule proves the two agree. A new photo, its profile and the removal of the old photo go in one commit through Git's data API (commitFiles in lib/manage/github.mjs), and the API serves the photo (/api/attendance/media/headshots/<file>, table media_pending) until the build has it. Details in docs/MANAGE.md.
Duties. A team's Usual duties (Pages CMS, Teams) reach each event as duties in events.json. Table duties: who does each, who assigned it, and duties a coach added for one event. PUT /api/attendance/event/<slug>/duty takes, hands back, assigns, adds and removes; the event GET suggests the fair turn (fewest times in 120 days among players who are In). The reminder names the player's duty.
Goalie check and goalie asks. The goalies an event needs and when we ask come from Club setup › Features › Subs, Team settings and the event's Coach tools (lib/attendance/goalie-asks.mjs); Site settings goalies_needed and goalie_hours (rules) are only the club's starting values. Each reminder run adds an ask for a goalie sub (subs_wanted, by "the site") to a team event short of goalies within that time, and deletes it once enough are In; then it asks the club's free goalies (notice kind goalieask, with I can play and Not this time), asks once more at the team's second point and tells the coaches (goaliecoach), and, where a team chose Add the first one, adds the first goalie to offer (lib/attendance/api-goalies.mjs; tables goalie_asks and goalie_asked). A coach who stops it sets goalie_skip:<slug> in settings. The same run asks for skaters where a team or the event set how many it needs (lib/attendance/skater-check.mjs), in the same ask.
Dues. Tracking only; no money and no payment provider. Tables charges (title, amount in cents, due date, who it was charged to, fixed when added or changed) and payments (paid, part paid with the amount, or waived, per person). The payment instructions are the settings key payment_instructions, sent only to logged-in members. /api/attendance/dues: every member reads their own; only admins read all and change them. Pages: Your dues (/you/dues/) and /club/dues/.
Player limits. Event and team field limit (skaters) reaches events.json. Columns responses.in_at (when a player said In, the line's order) and responses.waited (seen on the waiting list, so moving up sends "A spot opened", push kind spot). Counts carry waiting and limit.
Rides. Table rides, one row per person and event (a driver with seats, place and time, or someone who needs a ride, with their driver). PUT /api/attendance/event/<slug>/ride; the board is worked out from the rows as they stand, so an Out answer takes someone out of a car. Deleted a day after the event.
Menu links. The club's own sections of links in the menu, after the club's sections, for logged-in members (Club setup › Menu links, #more; lib/more.mjs; the setting more, GET and PUT /api/attendance/more). The default is one section, More from the club, with News, Rinks and Head-to-head. The menu never shows a page twice: moreForAccount leaves out what it holds already (the skip list in src/_data/moreMenu.js, whose inMenu the editor uses to grey those pages out). The build draws the default; the admins' own sections come in the summary and are drawn by attendance.js ("Menu links"). Details in docs/MANAGE.md, "Menu links".
Health watch. Once an hour, at the end of the reminder run (the settings row health_run), the site checks its own working against the free limits and what it already knows: the reminders timer (cron:worker), notifications to the webmasters' devices, the build (content_pending), the weekly backup (the workflow's last run), Resend and the login codes, the live site after the last release (health:release: the reminders timer opens the home page, the Schedule, a coming event and /api/attendance/status after each deployment), the GitHub and Cloudflare keys before they expire (key:github from GitHub's answers, key:cloudflare), and the files (20,000), the database's size (500 MB) and, from Cloudflare's own figures, the day before's D1 rows read (5 million) and written (100,000) and requests (100,000). The two webmasters (WEBMASTERS) hear of each problem as the notice kind website, at most once a day, and by email for the clock silent, notifications refused or a failure lasting more than a day, within the 10 emails a day that Resend's free plan leaves beside the login codes; Club setup › Features › Email the webmasters when the site fails turns the emails off. Problems are kept in health_log for 30 days after they end, and shown under Health on the Setup checklist. When a free limit passes 80% on two days in a month, the club moves to the Workers Paid plan, and the notice says so. It never counts visits or people. Details in docs/ATTENDANCE.md, "Health watch" (lib/attendance/health.mjs).
Feature switches. Club setup › Features turns each feature on or off for the club and for each team, either way (lib/features.mjs; the setting features, GET and PUT /api/attendance/features). Events get the features off for them as ev.off, and their routes, reminders and notices refuse or leave them out. Links and parts carry data-feature: functions/_middleware.js takes out those off everywhere and answers the feature's own pages with /feature-off.html (404); attendance.js hides those off for the member's teams. Details in docs/MANAGE.md, "Features".
Comments. Table comments, one row per comment (the event, the writer's login and profile, their name at the time, the words, when written and changed). POST /api/attendance/event/<slug>/comments, then PUT and DELETE on …/comments/<id>. Who may write is the comments switch (Club setup › Features, for the club and each team: the event's players and coaches, only its coaches and the admins, or nobody). The rules and limits are in lib/attendance/comments.mjs. Deleted two months after the event.
Game sheets and stats. Table game_items: goals, penalties, shots per period, the goalie in net per period and "final", one row each. /api/attendance/event/<slug>/sheet (coaches, admins and the scorekeeper duty write; members read) and /api/attendance/stats; drawn by game-stats.js on game pages and /teams/stats/. Kept two seasons.
Polls. Tables polls and poll_votes; /api/attendance/polls. Coaches ask teams whose roster they are on, admins any team or the club. Names only when the poll was asked with them. Closed polls go a month later.
Your calendar./api/attendance/cal/<member>.<signature>.ics, no login: the signature is an HMAC with SESSION_SECRET and a version in settings (cal_v:<member>), so nothing is stored and New link stops the old one. Changing SESSION_SECRET stops every calendar link as well as every login.
Profiles, private details and check-in. The rules for the profiles are the settings key profile_rules, set in Club setup › Profiles. Tables profile_requests (handled ones go after 90 days), profile_photos (a photo waiting for an admin), person_private (a phone number and a birthday, where the club keeps them: seen only by the player, the club's coaches and the admins, and never in the content files) and checkins (check-in, an hour before to half an hour after the start).
Group chats. Links to the club's and the teams' group chats in any app (lib/attendance/chats.mjs, table chat_links, /api/attendance/chats), kept by coaches and admins on Club › Group chats (/club/chats/, every member's chats to join, then the ones the coach or admin keeps) and by admins in Club setup, and shown only to logged-in members (Group chats, You, the members' home page, the team page). Adding one tells its members. The site only links to the chats.
Tooltips and help.tips.js turns data-tip, and the browser's own title text, into one tooltip that shows on hover, focus and tap; a button that shows only an icon has its label as its tip, every More button says what it holds, and the tabs, column heads and count lines explain themselves. Menus and pop-up windows open with a short motion, like a blind coming down (components.css, "Opening motion"), and none for a reader who asks for less motion. Settings › Help on the pages hides the "i" buttons and the explanations (html.no-help, set before the page is drawn by the head script, whose hash is in _headers).
Repeating events and league tables. Built, not stored: expandRepeats in lib/schedule.mjs turns an event with repeat and repeat_until into one event per date (slug-YYYY-MM-DD after the first; skip_dates, cancelled_dates). src/content/standings/ holds one league table per team and season, shown on the team page; admins and the team's coaches paste it there from the league's website (Update the table, lib/manage/league-table.mjs), and coaches paste a season's games at /schedule/add-many/ (lib/manage/schedule-many.mjs), each saved in one commit.
Keeping and deleting. Detail is kept for the current season and the one before. On the next write after that, older seasons become totals per season and person and the detail is deleted, and settled charges due before then go with their payments; the privacy notice says so. Nothing is deleted while history.json cannot be read. Queries go by date range, because D1 accepts at most 100 values in one query.
Who sees what. Everyone sees the counts. Names are shown only to the event's own players and coaches (everyone on a roster for whole-club events) and to every coach and admin. Phone numbers and birthdays, where kept, only to the player, the club's coaches and the admins; group chats only to logged-in members.
Members' care. The public profile choice (lib/public-profile.mjs, public_profile in the person's file, recorded in public_choices; public pages use the listed people only, isListed in lib/model.mjs); emergency contacts (person_private, shown to the event's coaches through GET event/<slug>/emergency); Your data (GET and DELETE me/data); leaving the club (me/leaving, table leaving, carried out by the reminder run); "Playing next season?" (season/ask, me/season, table season_plans, read by Club setup when the next season is added); join inquiries (lib/attendance/joins.mjs, kept by functions/api/form.js, table join_requests); the members' "Get set up" list (me/setup). /data/attendance/*.json answers only logged-in members (functions/_middleware.js).
Team managers and the treasurer. Helpers who are not admins (lib/attendance/roles.mjs): the columns helper and helper_teams of members, beside the role the table always had. A team manager counts among the coaches of their teams' events and teams for a request whose power Club setup › Team managers ticks (the setting manager_powers, GET and PUT /api/attendance/roles); the treasurer may use every dues route. Never in the build.
Photos and the club's rules.lib/attendance/consent.mjs, tables photo_consent (every choice, the newest in force), club_rules (every version of the text) and rules_agreed (the version each member agreed to, and when); me/consent, club-rules, consent/<slug> and, for Sign up to play, GET rules. The hourly housekeeping deletes the superseded rows once older than the seasons kept in detail.
Club documents.lib/attendance/documents.mjs, tables documents and document_parts (each file as base64 in parts of about 256 KB), the setting documents (the groups and who adds them); /api/attendance/documents. A file is at most 1 MB and the club keeps 25 MB in all, served only through the API to the members it is meant for, never in the build.
Team kitty.lib/attendance/kitty.mjs, tables kitty_items (each team's list), kitty_keepers and kitty_entries (owed, paid, spent, and what the housekeeping brought forward); /api/attendance/kitty; the page /teams/kitty/ (kitty.js). Tracking only. The switch kitty is off until the admins turn it on, team by team as Maybe. A deleted member's entries stay without the name.
A first practice for new players.lib/attendance/first-practice.mjs with the routes in api-first-practice.mjs, table first_practice (per sign-up: its team, the invitation to a practice and its guest entry in outside_guests, a coach's ask to make them a member, the mentor and the check-in a month later), kept as long as the sign-up. The switches firstPractice and mentors are on for everyone, and may be left to each team as Maybe. The WhatsApp invitation is a wa.me link opened on the coach's own phone: the site sends nothing.
Club equipment.lib/attendance/equipment.mjs and api-equipment.mjs, tables equipment_items, equipment_loans (who holds each item, since when, until when, and the deposit noted) and equipment_asks (requests to borrow); /api/attendance/equipment; the page /club/equipment/ (equipment.js) and its part on You. Kept by the equipment manager (Club setup › Club roles) and the admins. Tracking only. The switch equipment is on, for the club only. The reminder run tells a holder once of an item past its day; the housekeeping deletes returned loans a year after.
Jersey and kit orders.lib/attendance/kit-orders.mjs and api-kit.mjs, table kit_orders, the setting kit_window (the order window); /api/attendance/kit; Shop › Uniforms and Kit orders (/club/kit-orders/), drawn by src/assets/js/kit-orders.js, with the name and number checks in src/assets/js/kit-check.js, which the API runs too. The build writes /data/content/kit.json (the uniforms list in cents and the number rules) for the server. Each order adds a dues charge through chargePerson in api-dues.mjs. Switch: kitOrders.
Bank statements. Club dues › Match a bank statement reads the file in the treasurer's browser (src/assets/js/bank-match.js, statement.js); only the ticked marks reach PUT /api/attendance/dues/statement (lib/attendance/statement.mjs). Nothing of the statement is stored. The payment references on Your dues come from the same module.
Costs for the board (Round 4; it was Ice time). The history's fifth view, drawn by costs.js with the sums of cost-sums.js from GET /api/attendance/costs (lib/attendance/api-costs.mjs, costs.mjs): the events' figures without names, the hourly cost per rink (the settings key ice_cost), each team's game costs and whether coaches see them (the key costs), the tournaments' entry fees and extra sessions' prices (event_costs) and the season's other costs (other_costs). Charge those who came adds dues charges through chargePeople in api-dues.mjs. Switch: costs.
Backups..github/workflows/backup.yml exports the database every Monday, encrypts it with BACKUP_PASSPHRASE and keeps it 90 days as a workflow artifact; it needs the secrets CLOUDFLARE_API_TOKEN and CLOUDFLARE_ACCOUNT_ID too, and does nothing until they are set. Restoring: docs/ATTENDANCE.md, "Backups".
If no admin can log in. Set ADMIN_EMAIL to your own address and log in at /login/; the emergency admin corrects the admins' emails under Everyone. No members or answers are lost.
Services and accounts
All accounts should be registered to [email protected], not to a person, so a handover is a password change and nothing else.
Service
Role
Cost
Where
GitHub
Source of truth. Pages CMS commits here; Cloudflare builds from main.
Free
github.com/jonajinga/kmceagles (private)
Cloudflare Pages
Hosting, builds, headers, redirects, DNS, and the Pages Functions: the form relay and website attendance (the API, signing in, shared links, the reminder run).
Free
Cloudflare dashboard → Workers & Pages
Cloudflare D1
The attendance database: members with their login emails, passkeys (public keys only), linked Google or GitHub accounts, sessions, login codes (hashed) and the log of codes sent, answers and notes, guests, offers to sub, line-ups, who came, shared snapshots, reminder devices, each member's notification choices, the private details the club keeps, photos waiting for approval or for the build, the group chats' links, and settings.
Free
Cloudflare dashboard → D1: kmceagles-attendance (and kmceagles-attendance-preview), bound to the project as DB
Pages CMS
Browser editor for admins. Reads .pages.yml.
Free
app.pagescms.org; admins are invited by email
GitHub Actions
Nightly rebuild: calls the Cloudflare deploy hook at 23:10 UTC (ten minutes past midnight in Germany in winter, past one in summer), and asks Cloudflare for the day before's figures for the health watch. Attendance reminders: asks the site every six hours to send the reminders that are due, as a backstop to the reminders timer. Checks: check:cms, check:style, check:attendance and check:manage on every push of code to main and attendance-preview. Database backup, weekly, each copy opened again to prove it works; the first of each month kept 400 days, the others 90. Deploy the reminders worker, when it changes.
Free (private repo minutes; each run counts as at least a minute, so the scheduled workflows use about 200 of the 2,000 free minutes a month, and the checks about four a push of code; artifacts within the 500 MB of free storage)
.github/workflows/daily-rebuild.yml (secret CF_PAGES_DEPLOY_HOOK), reminders.yml (secret CRON_SECRET, variable REMINDERS_SITE), checks.yml (no secret), backup.yml and reminders-worker.yml (secrets CLOUDFLARE_API_TOKEN and CLOUDFLARE_ACCOUNT_ID)
Cloudflare Workers
The reminders timer (workers/reminders/): asks the site every five minutes to send the reminders that are due.
Free (one Cron Trigger of the five allowed; about 300 requests a day of 100,000)
Workers & Pages › kmceagles-reminders
Web3Forms
Delivers contact-form and sign-up emails to the club inbox, sent from the visitor's browser.
Free (250/month)
web3forms.com; the access key in src/_data/env.js (or WEB3FORMS_ACCESS_KEY)
Cloudflare Turnstile
Cookie-free spam check on the form.
Free
Cloudflare dashboard → Turnstile
GTranslate
Machine translation, loaded only on request.
Free widget
No account; settings in main.js
Resend
Sends the login codes for website attendance by email. Needs the sending domain confirmed with DNS records.
Free (100/day, 3,000/month; the site stops at 90 and 2,900)
resend.com; key in RESEND_API_KEY, sender in EMAIL_FROM
Google and GitHub sign-in
Continue with Google and Continue with GitHub on the Log in page (OAuth with PKCE).
Free
Google Cloud console → Credentials; GitHub → Developer settings → OAuth Apps; keys in GOOGLE_* and GITHUB_*
Browser push services
Carry attendance reminders to devices: Apple, Google, Mozilla or Microsoft, depending on the browser. Each message is encrypted for its device.
Free
No account; signed with the club's VAPID key (D1 settings, or VAPID_PUBLIC_KEY and VAPID_PRIVATE_KEY)
YouTube
Club channel; videos embed after a click via youtube-nocookie.com.
Free
youtube.com/@kmceagles
Search, maps, translation, prefetch
Search: Pagefind builds a static index after every build. The header's magnifier, and Search the website at the top of the menu, open a dialog with instant results (/ or Ctrl/⌘+K opens it from anywhere on a computer); /search/ is the full page. Only <main data-pagefind-body> is indexed, so private pages stay out.
Maps: Leaflet, self-hosted in src/assets/vendor/, with OpenStreetMap tiles that load as the map comes into view (IntersectionObserver in map.js). In dark mode the same tiles are drawn dark (a CSS filter on the tile layer in pages.css: inverted, with the hues turned back, so water stays blue and parks green), and the controls, pop-ups and credit line take the site's dark colors; no other tile server is used. /map/ reads /data/map.json; each rink page has its own map. Pins that would overlap at the current zoom join into one white group pin numbered with its venues (gather in map.js, worked out again after every zoom); selecting it, by tap or Enter, zooms to its venues, and venues on the very same spot open their pop-ups together. Every pin then stays whole and at least 28px square, as tap targets should, and a map fits its pins with room kept clear of the zoom buttons and the credit line. Coordinates come from src/_data/geocache.json, filled from the rink address by lib/geocode.mjs (an eleventy.before hook looks up new places; GEOCODE=0 skips it).
Rosters: /players/ is the league roster (club.leagueRoster), a table every current Eagle is on; team pages group the roster by position. One engine in main.js ([data-roster]) adds search, filter chips and sorting to both; without JavaScript everything shows.
Season stats: teamStats and clubStats on each season page, from final scores (W–L–T, goals, home and away, last five, streak) and ice time (each event's start and end; the usual lengths in site.json facts as a fallback). Shown on the season pages, each team page and the Club page.
Season maps: club.seasonPages groups the schedule by season dates. season.njk renders /seasons/<slug>/, seasons.njk the current season plus the archive at /seasons/, and season-data.11ty.js writes /data/seasons/<slug>.json, which map.js draws (data-map="season") with team and kind filters and trip lines. Distances are straight lines from the home rink.
Translation: GTranslate loads only when Sprache / Language, at the foot of the menu, is pressed; its list of languages then shows in the row's place. Its settings are in main.js; the hosts are allowed in the CSP.
Prefetch: instant.page (assets/vendor/instantpage.js) preloads same-site pages on hover and touch, so most pages open at once. It leaves out links with a query string, and links that start a sign-in or give a file (Continue with Google or GitHub, Download your data) carry data-no-instant, so a passing pointer never starts them.
Fonts: Barlow Condensed is self-hosted under /assets/fonts; the @font-face rules are inlined from fonts.css so no stylesheet blocks rendering and no third-party font server is contacted.
App, service worker and offline
The site installs as an app from site.webmanifest (name KMC Eagles, short name Eagles, navy theme, standalone). pwa.js registers the service worker, keeps the browser's own install prompt for the header's Install, and handles the Save for offline buttons.
Service worker
Generated per build by src/pages/sw.11ty.js; the assets' fingerprint is baked into the cache names, so a deploy that changes a script or the stylesheet retires the old caches on activation, and one that does not leaves them be.
Navigations, /data/, calendar files (.ics) and /feed.xml: network first; after 4 seconds without an answer a saved copy is shown, then /offline/. The Schedule's month files (/data/schedule/) are kept as they are fetched, so offline the Schedule has its own months and those already looked at.
/assets/ (scripts and fonts), images and share images: cache first. The precache bypasses the HTTP cache, so a new build never copies a stale script.
Never cached: /api/, the Log in page (/login/), shared links (/s/), the members' pages (lib/member-pages.mjs: Your events and the other Schedule, Teams and Club members' pages, and everything under /you/), /data/attendance/, form confirmations, the search index.
Reminders: the push handler shows the notification and tells every open window of the site that it came and whether it could be shown, so Send a test can say so with the app open. notificationclick opens the event, in a window of the site that is already open if there is one.
A new worker activates itself on install, so the next navigation after a deploy is current.
Rink cards
Each rink has a small JSON at /data/rinks/<slug>.json with name, address, directions link and the next two events. Pressing Save for offline on a rink or event page stores that JSON and the pages in a rink-cards cache that survives upgrades. /offline/ lists the saved cards straight from the cache, so the address and directions are readable in a rink parking lot with no signal.
Testing
npm run check:pwa validates the manifest and icons, waits for the worker, saves a rink, cuts the network and checks that the rink page, the offline page and the home shell still load. To reset a browser during testing: DevTools → Application → Storage → Clear site data.
Security and privacy
Headers in src/public/_headers: a strict Content Security Policy (the inline script in the head, which keeps a dismissed banner closed and holds the space of parts drawn later, is allowed by hash; npm run check:seo names a new hash when it changes), HSTS with preload, frame denial, a referrer policy, a permissions policy and per-path cache rules. Cloudflare joins header values when several rules match, so Cache-Control rules must not overlap; site scripts revalidate, fonts and hashed images are immutable.
Data minimization: one strictly necessary session cookie for website attendance (docs/ATTENDANCE.md), and a ten-minute one during a Google or GitHub sign-in, and the googtrans cookie once a visitor picks a language, no consent banner. Members, sessions, answers with their notes, offers to sub, line-ups, push endpoints for reminders, notification choices, the name snapshots behind shared links and, where the club collects them, players' phone numbers and birthdays live only in D1, with members' login emails, passkeys' public keys and no passwords. Player records hold name, positions and hockey history only. Maps and videos load as they come into view (privacy notice section 4); translation loads only when a visitor chooses a language.
Secrets: Cloudflare environment variables, GitHub Actions secrets for the two workflows, and the gitignored .env / .dev.vars. No key ever reaches a browser.
Legal pages: Impressum and privacy notice read their details from site.json. Update them when a service changes.
Dependencies: run npm outdated and npm audit at season rollover; the build pins nothing exotic.
Quality gates
npm run verify runs every gate here but Lighthouse, in order, and stops at the first failure. Each gate was made to fail on purpose before it was trusted; when you add a check, break it first and confirm it goes red.
Gate
What it proves
npm run check:cms
.pages.yml parses; every content field, select value, required field, pattern, default and reference matches it. A CMS config Pages CMS would reject, or content an admin could not save, fails here first.
npm run check:style
Colors, type sizes, spacing and radii come from the tokens; breakpoints are 40 rem and 64 rem only; no stray inline styles.
npm run check:attendance
The attendance API (lib/attendance/api.mjs) against an in-memory SQLite database: signing in (codes by email with their limits, against a stand-in for Resend; passkeys, with a simulated authenticator for ES256 and RS256 keys; Google and GitHub against stand-ins, with the state, nonce and PKCE checks; linking accounts, devices, log out everywhere, the emergency admin), sessions, who may answer, notes, answers set by coaches, the lock at the start, names only for the event's players, every coach and the admins, line-ups, shared links, offers to sub and asks for subs, Web Push reminders (the encryption checked against the RFC 8291 example), each kind of notice as members choose it, who came, the history, admin rights, the season clean-up and the off switch.
npm run build
Every page, feed, calendar, share image and the search index.
npm run check:journeys
The journeys members depend on, in a real browser on a phone and a computer, on the built site in the local Cloudflare runtime with stand-ins for GitHub, Resend and the sign-in providers (scripts/harness/): a visitor's home page and Schedule; a player who logs in with a code, answers In with a note and checks in; a coach who publishes a line-up and records a goal; an admin who adds a charge and marks it paid; and a goal recorded with no signal that arrives once. Each step checks what the member sees; an answer of 500 or a script error fails it. About a minute (docs/ATTENDANCE.md, Journeys in a browser).
npm run check:ics
Every calendar feed parsed by ical.js and checked line by line against RFC 5545, including VTIMEZONE and canceled events.
npm run check:seo
One h1, unique title (15–70 chars) and description (50–160), canonical, Open Graph and Twitter tags whose image exists, alt text on every image, JSON-LD parses, noindex only on private pages and the test club's (and always on those), BreadcrumbList everywhere, and every inline script allowed by a hash in the Content Security Policy.
npm run check:a11y
axe-core (WCAG 2.2 AA + best practice) on every page in light and dark themes at 390 px, plus 44 px tap targets and no horizontal overflow.
npm run check:mobile
Every page on a phone (393 by 852), as a visitor and, for the members' pages, as a member: no button, chip or tab label on two lines, nothing fixed covering more than a fifth of the screen while scrolling, a members' page beginning within the top third, and no page beginning lower than the figures kept in scripts/mobile-baseline.json. Record new figures with npm run check:mobile -- --update.
npm run check:pwa
Manifest fields and icon sizes, service worker activates, offline shell loads, a saved rink is served without a network.
npm run check:lighthouse
Performance, accessibility, best practices and SEO at 95 or better, mobile and desktop. Not part of verify because it needs Chrome and takes minutes; run it before a release. Locally it serves _site as production does for a visitor (the stylesheet and scripts compressed in advance, the visitor's attendance requests answered), so the scores match the live site's within a point or two; an event page loses a few best-practices points there that it keeps in production, because only the attendance server can answer for its list.
For a single page, node scripts/axe-one.mjs /page/ lists accessibility problems and node scripts/lh-detail.mjs /page/ explains a low Lighthouse performance score (layout shifts, LCP element, large files). Both serve _site/ themselves; build first.
Season rollover
Admins handle seasons, rosters and fixtures on the website (the user guide's Club setup), and the fees and page text in Pages CMS (docs/OWNERS-GUIDE.md). The webmaster's part, every August:
Run npm run geocode after a rink is added in Pages CMS and commit src/_data/geocache.json, so production builds never wait on lookups. A rink saved on the website brings its position with it.
Check that GitHub → Actions → Daily rebuild ran every night and Attendance reminders every six hours, and that Club › Setup checklist shows the reminders timer calling in the last few minutes. GitHub can switch off scheduled workflows after 60 days without activity in the repository; re-enable them there.
Nothing to do in D1: the attendance API keeps the current season and the one before in detail and turns older seasons into totals by itself. /api/attendance/status should still report the database, its tables and SESSION_SECRET as present.
Confirm the disclaimer text in Site settings is unchanged. It is a legal requirement and must stay verbatim.
Run npm outdated, update dependencies in a branch, run npm run verify and npm run check:lighthouse, merge.
Check that Web3Forms is still on its free tier and that its account uses the club inbox.
Remind admins to update the board in Club info › Club leadership and the media pack text.
Troubleshooting
Symptom
Cause and fix
Where to look
Cloudflare build fails after a CMS save
Open the deployment log. A YAML typo (usually a date or an unquoted colon) names the file. Fix it in the CMS or GitHub; the live site kept the last good build.
npm run check:cms
A page has no share image or the SEO gate says og:image missing
The SVG for that page failed to render. Run the build locally and read the console; a missing font file or an unescaped character in the title is the usual cause.
npm run build
An event has the wrong length on phones or in the stats
Every event needs an End. Practices are 90 minutes, games are usually 2 hours; correct the event's End in the CMS.
Schedule in the CMS
Calendar subscribers do not see a changed time
Every build raises SEQUENCE automatically, so this is the subscriber's refresh interval: Apple and Google refresh on their own schedule, up to a day.
npm run check:ics
A section or menu link has disappeared
Nothing in it is visible: no testimonial switched on, no visible person on a roster or the board, no new players. It comes back by itself once an admin publishes an entry (see hiddenLinks and board in clubModel, lib/model.mjs).
lib/model.mjs
"Next event" is a day behind
The nightly rebuild did not run. Check GitHub → Actions → Daily rebuild and that the deploy hook still exists in Cloudflare.
GitHub Actions
Contact form says it could not send
Check the Web3Forms access key (src/_data/env.js, or WEB3FORMS_ACCESS_KEY) and that the Web3Forms account is active, and TURNSTILE_SECRET if the spam check is set up; then the browser console and the Functions log.
Pages → Functions → Logs
A visitor sees an old version of a page
The service worker serves pages network-first, so this only happens offline or when a new build has not activated. A reload fetches the new worker; two reloads guarantee it. Cache names include the build time.
/sw.js in the browser
Headings render in the fallback font
The font files under /assets/fonts are missing from the build or the inlined @font-face rules are stale.
node scripts/fetch-fonts-css.mjs
A map pin is missing
The rink address was not found by the geocoder, or the lookup has not run yet. Run npm run geocode locally (it reports what was not found), fix the spelling or add the country, commit src/_data/geocache.json.
npm run geocode
Accessibility gate fails on a new page
The message names the page, theme and rule. Contrast failures are almost always a color used outside the tokens; tap-target failures are links or buttons under 44 px.
node scripts/axe-one.mjs /page/
A player is logged in in the browser but not in the home-screen app
The installed app keeps its own log-in. In the app: Menu › Log in, then log in once there too; a passkey makes it one tap.
/login/
A member cannot log in
Their email under Everyone is missing or misspelt, the member was archived, or the switch is off. If no code arrives, Club setup › Sign-in shows the day's count and Resend's last refusal; while codes fail or the limit is reached, the Log in page offers passkeys, Google and GitHub instead. A Google or GitHub account works only with a verified email the club has, or once linked in Settings.
/club/setup/#signin
Reminders do not arrive
Check that GitHub → Actions → Attendance reminders runs without errors (visits to the site also start the run). On the device: Settings › This device › Send a test; the page then says whether the test reached the device or what the push service answered. Under Settings › Notifications, check that the kind is not set to Inbox only or Off, and the member's quiet hours. iPhones need the app on the home screen, notifications allowed for it (Settings › Notifications) and Focus or Do Not Disturb off. GitHub pauses scheduled workflows after 60 days without activity in the repository; re-enable it under Actions.
/api/attendance/status
No admin can log in
Nothing is lost. Set ADMIN_EMAIL in Pages to your own address, redeploy, log in at kmceagles.org/login/ with a code (or Google or GitHub with that address), and correct the admins' emails under Everyone.
Pages → Settings → Variables and Secrets
Pages CMS cannot see a new field
Every field must be declared in .pages.yml with the same name as in the content file. The CMS gate tells you which one is missing.
npm run check:cms
Handover
A new webmaster needs four things. Everything else is in this guide and the repository.
The club inbox[email protected]. Every service account recovers through it.
Owner rights on the GitHub repository and the Cloudflare account. Add the new person first, then remove the old one. This carries over the D1 database (kmceagles-attendance, bound as DB), the deploy hook and the Actions secrets and variable (CF_PAGES_DEPLOY_HOOK, CRON_SECRET, REMINDERS_SITE).
The environment variables as they stand in Cloudflare. Copy them into a fresh .env; do not email them.
A local build: clone, npm install, npm run verify. If it is green, and GitHub → Actions shows Daily rebuild and Attendance reminders enabled and passing, the handover is complete.
Keep the repository private (it holds photos of members) and never commit .env or .dev.vars. The .gitignore already refuses them.