Tom Gould

Writing

A live calendar feed and badge from a Cloudflare Worker

How the Solar Calendar serves a feed people can subscribe to in their calendar app, and a badge with today’s date, from a small Cloudflare Worker that runs the same date code as the app.

The Solar Calendar is a static site, apart from two addresses. /feed.ics is a calendar feed: subscribe to it in Google Calendar, Apple Calendar or Outlook, and every day shows its solar date. /today.svg is a small badge with today’s solar date, for a README or any other page. Both are built on request by a Cloudflare Worker, from the same TypeScript as the app.

Why not a static file

When I planned the rebuild, the feed was the one part that didn’t fit static hosting. A calendar app subscribes once and then fetches the same URL for years. On GitHub Pages the feed would have to be built in advance for a fixed range of dates, with a scheduled job to rebuild it before it ran out. Shared hosting could build it on request in PHP, but that meant a second copy of the date rules, and the first version of the calendar had exactly that: a PHP port that gave different answers from the JavaScript.

A Worker can build the feed on request from the app’s own code. So the site runs on Cloudflare Workers with static assets, and only two paths run any code:

"main": "dist-worker/worker.js",
"assets": {
  "directory": "./dist",
  "run_worker_first": ["/feed.ics", "/today.svg"]
}

Everything else in dist/ is served as a plain file.

One date core, two places

The calendar’s rules live in src/core/, a few modules with no DOM. Every date is an integer day number, and solar.ts turns a day number into a solar date. The Worker imports those modules directly, along with the feed builder that the app’s download button uses:

import { utcDayOfInstant } from './core/days';
import { MAX_YEAR, MIN_YEAR, solarYearOf, yearStart } from './core/solar';
import { badgeSvg, todayIn } from './feed/badge';
import { buildIcs } from './feed/ics';

The download button calls buildIcs() in the browser for one solar year. The feed calls it for three. A downloaded year and a subscription can’t disagree, because one function writes both.

The month names are the one difference. The app loads each set of names only when it’s chosen, while the Worker bundles all twelve, checked against the same schema:

// Bundled into the Worker, unlike the app, which loads packs on demand
const packs = new Map(
  Object.entries(import.meta.glob<unknown>(['./names/packs/*.json', '!./names/packs/_*.json'], { eager: true, import: 'default' }))
    .map(([path, data]) => {
      const pack: NamePack = validatePack(data, path);
      return [pack.id, pack];
    }),
);

import.meta.glob is a Vite feature that wrangler’s own bundler doesn’t understand. So Vite builds the Worker too, as a second, server-side build into dist-worker/ with every dependency bundled in, and npm run build makes both.

The feed

buildIcs() writes one all-day event for each day, titled with its solar date. Then come the festivals and public holidays that were asked for, and the equinoxes and solstices at their exact times in UTC. The feed covers last solar year to next, worked out from the time of the request:

const year = Math.min(MAX_YEAR - 1, Math.max(MIN_YEAR + 1, solarYearOf(utcDayOfInstant(now))));
// ...
const body = buildIcs({ pack, extra, from: yearStart(year - 1), to: yearStart(year + 2) - 1, now });

The clamp keeps the range inside the years the core supports, 1600 to 2400. Today the feed runs from 24 March 2025 to 19 March 2028, and the window moves on by itself each spring. Here’s today’s event from the live feed:

UID:20261020-anglo-saxon@solar-calendar
DTSTAMP:20261020T171853Z
DTSTART;VALUE=DATE:20261020
DTEND;VALUE=DATE:20261021
SUMMARY:☀ 16 Winterfylleþ
DESCRIPTION:Solar date: 16 Winterfylleþ 2026. Month 8 of 13\, Winterfylle
 þ: winter full moon. Day 212 of 364.
TRANSP:TRANSPARENT

The UID is made from the date and the set of names, so every fetch describes the same events under the same IDs.

The ☀ wasn’t there at first. With the Gregorian month names, solar January starts on 28 December, and an event called “1 January 2026” on 28 December looked like a mistake. Now the title is “☀ 1 January”, and the solar year is left to the description.

The rest comes from RFC 5545, the iCalendar standard. Commas in text are escaped with a backslash, and any line longer than 75 bytes is folded, carrying on in the next line after a space. The limit is in bytes, not characters, and þ takes two bytes in UTF-8. The first line of that description is 74 bytes before the þ, so the fold comes just before it rather than through the middle of it:

export function foldLine(line: string): string {
  const parts: string[] = [];
  let current = '';
  let bytes = 0;
  for (const character of line) {
    const size = encoder.encode(character).length;
    // Continuation lines start with a space, which counts towards their 75
    if (bytes + size > (parts.length === 0 ? 75 : 74)) {
      parts.push(current);
      current = '';
      bytes = 0;
    }
    current += character;
    bytes += size;
  }
  parts.push(current);
  return parts.join('\r\n ');
}

The calendar asks to be refreshed daily, with REFRESH-INTERVAL and X-PUBLISHED-TTL both set to a day, and the response can be cached for an hour. How often an app really checks is up to the app.

Query parameters only ever choose from what’s bundled. ?names= picks a set of month names, with the Gregorian names if there’s no such set. ?festivals= and ?holidays= add other sets’ festivals and public holidays, and unknown IDs are skipped. Nothing from the URL is copied into the response.

The badge

/today.svg has the shape of the badges at the top of many READMEs: a label on the left and a value on the right, here on the colour of the current month. An image can’t use the page’s fonts, so the badge asks for Verdana or the reader’s own sans-serif, estimates how wide its text will be, and lets SVG’s textLength stretch or squeeze the text to exactly that width. The estimate only knows a few kinds of character: digits, capitals, spaces, narrow ones such as i and l, and the rest.

Any &, <, > or quote mark in a month name is written as a numeric character reference, so no name can break the SVG.

“Today” depends on where you are. By default the badge uses UTC, like the calendar itself. ?tz= takes a time zone name such as America/New_York, and todayIn() asks Intl.DateTimeFormat for the date there, falling back to UTC if the name is missing or unknown. It also works out how many seconds are left in that day, and the cache time follows from them:

// Until the day ends, at most an hour
'Cache-Control': `public, max-age=${Math.max(60, Math.min(3600, secondsLeft))}`,

So, as far as caches follow the header, the badge shows yesterday’s date for at most a minute into the new day. The tests check this with Kiritimati, 14 hours ahead of UTC: at 23:30 UTC on 17 October it’s already the 18th there, while the UTC badge is cached for the 30 minutes left of the 17th.

Testing the real bundle

The unit tests call the Worker’s feedResponse() and badgeResponse() directly with a fixed time, and parse the feed with ical.js, an independent iCalendar parser. Solar 2026 has to come out as 364 day events, 17 October 2026 as “☀ 13 Winterfylleþ”, and the equinoxes and solstices in the right order. Every line has to fit in 75 bytes, and a holiday that falls on a weekend has to bring its substitute day with it.

The browser tests go further. The Vite config has a small plugin that answers /feed.ics and /today.svg the way Cloudflare would: from the source in the dev server, and from the built dist-worker/worker.js in the preview server, which is what Playwright runs against:

configurePreviewServer(server) {
  server.middlewares.use(serveWorker(async () => (await import(pathToFileURL(resolve('dist-worker/worker.js')).href)).default.fetch));
},

So the end-to-end test opens the “Add to calendar” dialog, reads the feed URL it shows, and fetches that URL from the built Worker, as Cloudflare would run it. The suite runs in browsers set to time zones far apart, from New York to Kiritimati, because the original app showed the wrong day west of UTC.

Using it

To subscribe, open the app, choose the month names, festivals and holidays you want, and press “Add to calendar”. The dialog builds the feed URL, gives it to Google Calendar’s own “add calendar” page as a webcal: link, and opens Apple Calendar or Outlook directly. Or build the URL by hand:

https://solar.dealmobile.co.uk/feed.ics?names=old-norse&festivals=persian&holidays=ireland,us-federal

For the badge in a README:

![Today in the solar calendar](https://solar.dealmobile.co.uk/today.svg?names=anglo-saxon&tz=Pacific/Kiritimati)

To build and run it yourself:

npm install
npm run build    # the site in dist/, the Worker in dist-worker/
npm test         # unit tests, then the browser tests against the build
npm run deploy   # build, then wrangler deploy (after npx wrangler login)

The code is on GitHub.