Enrico Fantini

Enrico Fantini

Senior Software Engineer, focused on Android, based in Italy.

← All posts

The League Site Should Be Easy to Leave Alone

Posted on July 07, 2026

The scarce resource for Lega Pauper Forlì-Cesena is attention. Players, whether veterans or casuals, just wanna play with their favorite cards and (mostly) that’s it. They usually don’t care about a big, pompous website showing off the “community”; most would be fine with a raw list of results. That made building a portal for this group a strange brief: interesting enough that people open it out of curiosity, but simple, self-sustaining, and easy to update, so it doesn’t burden me, the technical administrator, with ordinary maintenance and data entry.

LPFC is a Magic: The Gathering Pauper group around Forlì-Cesena, and lpfc.it is not just a brochure for it. The site is now where players read articles, check league material, and see the organizer’s official records. Rankings and registrations don’t mirror some hidden source of truth; once published, they are the source of truth most people will use.

That makes correctness visible. A wrong ranking changes what the league appears to have decided; a stale registration can make a player’s season look different from the organizer’s intent. For a hobby project that sounds heavy. For a community, it is exactly why the boring parts matter.

Two projects, one job

The first architecture grew from a reasonable split: an Astro site on Cloudflare Pages and a Worker API behind api.lpfc.it. That split was not accidental. At the start, I did not know whether LPFC would stay a mostly static public site, grow into something with reusable endpoints, or need a cleaner boundary between the thing players read and the data machinery behind it. Separating concerns kept that decision open.

At the time, the API hostname made sense: rankings, events, deck stats, player profiles, and player-name lookups were endpoints, and the site consumed them as a separate surface. An API_SECRET kept the internal API from being casually open. The URL looked like real infrastructure because it was real infrastructure, and keeping it separate made the early project easier to reason about.

It also carried the usual cost of real infrastructure. Two projects meant two deployment paths, duplicated secrets, cross-project refresh mechanics, and more places where a future maintainer had to remember why something existed. The first consolidation plan still kept the API alive inside a unified Worker: requests to /api/* or the legacy api.lpfc.it hostname would dispatch to handlers, while everything else would serve static assets. That was already an improvement because the code lived together, but it still preserved an open decision that had effectively been answered.

Once the Astro pages could call the shared Google Sheets layer directly at build time, the website stopped needing runtime HTTP calls to its own API. The only consumer had moved inside the build. Later, Cloudflare Workers Builds gave Workers the Pages-style rebuild workflow the project needed: push to main, run tests and build, deploy; trigger a rebuild from a hook when collaborators update Sheets. With Cloudflare’s direction making Workers the place to consolidate that workflow, keeping a separate Pages project plus API Worker no longer bought enough value. The earlier separation had done its job; after that, it became one more thing to maintain.

One line of runtime

The current shape is intentionally small. One Cloudflare Worker serves prerendered assets for lpfc.it and www.lpfc.it; api.lpfc.it was removed. The request handler is only this:

async fetch(request: Request, env: Env): Promise<Response> {
  return env.ASSETS.fetch(request)
}

The handler is one line because the engineering happens before deployment. A visitor request should not depend on Google responding quickly, a runtime secret being present, or an internal router choosing the right branch. If a problem exists, I want it to appear before publication, while the last good version is still online.

The data still changes. Rankings, registrations, external results, and league details live in Google Sheets, because Sheets is the editing surface organizers and the volunteers who enter results already understand. Astro fetches and parses those rows during the build, then prerenders the result into static output. After deployment, readers receive generated pages rather than live spreadsheet calls.

This is a compromise. A row edit does not appear online immediately, which would be wrong for some systems. For this one, delayed publication is safer: a bad credential, a changed column shape, or a failed test blocks the new release while the previous version stays online.

The spreadsheet remains useful because the code narrows its role: build variables identify the source documents, parsing code defines the expected records, and tests defend those assumptions. Organizers edit a familiar document, readers receive generated HTML, and the release step decides whether publication is safe.

Ranking rules are social rules

The ranking logic is where this stops being a generic static-site story. League standings have rules, and those rules are social before they are technical. Players should be rewarded for showing up, but the season should not punish someone harshly for missing a week because life happened. Late registration should not retroactively count results from before payment. Ties should break in a way the organizer can defend.

The code handles those rules explicitly. By default, standings count a player’s best weekly entries — roughly 90% of the weeks in the season, so longer seasons allow more drops. Lower weeks stay visible, but they fall out of the point total once a player has enough better results.

Late registration has its own boundary. The registration sheet carries a first valid entry value. Weeks before that threshold stay visible for display, but they do not count toward the total. That distinction matters: the site can show what happened without pretending those points were eligible for the paid league race.

The tiebreaker is also more deliberate than a simple sort. Entries compare first by total points. If totals match, the algorithm repeatedly removes each player’s minimum and maximum weekly scores and compares the remaining sums. If that still ties, it checks highest weekly score, then most recent results from the end of the season backward, then alphabetical order. We also play in two venues each week, Wednesday and Thursday; if you play both, only your best score of the week counts. The tests cover the uncomfortable cases: recursive tiebreaks, fallback to max score, custom valid-entry counts, multiple locations, the first-place bonus, and a first valid entry that changes the winner.

This is why I do not want a fragile runtime path between the organizer’s records and the public page. The complexity belongs in tested ranking code and reviewed source data, not in a request-time service that has to be healthy every time someone refreshes a browser.

Publishing without operators

Publishing runs through Cloudflare Workers Builds hooks. One hook serves the scheduled refresh, one serves collaborators after they edit Sheets, and one is reserved for maintainer operations. The scheduled Worker handler runs on Saturday at midnight UTC and posts to DEPLOY_HOOK_URL; Cloudflare then rebuilds and redeploys the static output.

cron → deploy hook → Workers Builds rebuild → new deploy

That gives collaborators a narrow capability instead of a broad control panel. They can update official data and trigger a rebuild without becoming operators. If the build fails, the last good deployment remains live. The secrets follow the same logic: Google credentials are build-time variables because prerendering is what uses them, and DEPLOY_HOOK_URL is a runtime secret because the scheduled handler is the code that reads it.

Deleting is maintenance too

The events page showed the other side of the same principle. The code could fetch event rows from Google Sheets and render a table, but the source sheet had stopped receiving current entries. Most rows described past events, and upcoming events were missing. A filtering bug made the result worse: one layer removed older rows before the UI filters ran, so the route could report an event total while the table looked empty.

The fix was to remove the route and navigation item. The data-layer code stayed documented and tested; if someone starts maintaining event data again, the page can return without rebuilding the pipeline. Until then, the public surface no longer promises a feature the group has not committed to operating. A route that nobody feeds becomes a liability, even when every function compiles.

The maintainer belongs in the architecture

Volunteer software gets touched after paid work, before an event, or in the small window when someone notices stale information and has enough energy to fix it. A clever setup works while the clever person remembers every edge; after that, it turns into oral history. So LPFC favors boring handoffs: the editing tool is familiar, the public output is static, refreshing the site is one limited action, and removed features carry notes explaining what happened and how to revive them.

My rule for this kind of project is to choose the smallest system that preserves trust, and to let each upgrade wait until something concrete justifies it: live reads over static output, a CMS over Sheets, direct mutation over rebuilds. Public surfaces disappear when the habit underneath them disappears.

On a good week, an organizer corrects a row, triggers the collaborator hook, watches the release pass, and closes the laptop. The next conversation can be about pairings and sideboards.