{
    "type": "article",
    "title": "A live calendar feed and badge from a Cloudflare Worker",
    "canonical": "https://www.dealmobile.co.uk/writing/calendar-feed-and-badge-from-a-worker",
    "path": "/writing/calendar-feed-and-badge-from-a-worker",
    "url": "https://www.dealmobile.co.uk/writing/calendar-feed-and-badge-from-a-worker",
    "markdown": "https://www.dealmobile.co.uk/writing/calendar-feed-and-badge-from-a-worker.md",
    "json": "https://www.dealmobile.co.uk/writing/calendar-feed-and-badge-from-a-worker.json",
    "summary": "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.",
    "published": "2026-10-20T00:00:00+01:00",
    "modified": "2026-10-25T18:32:07+00:00",
    "image": {
        "url": "https://www.dealmobile.co.uk/sites/default/files/content/images/posts/calendar-feed-and-badge-from-a-worker/hero.png",
        "alt": "The Solar Calendar’s live badge reading 19 Winterfylleþ 2026, above a real calendar feed event titled “☀ 19 Winterfylleþ” on a dark terminal card."
    },
    "fields": {
        "image_dark": null
    },
    "tags": [
        {
            "name": "TypeScript",
            "url": "https://www.dealmobile.co.uk/writing/tag/typescript",
            "json": "https://www.dealmobile.co.uk/writing/tag/typescript.json"
        },
        {
            "name": "Calendars",
            "url": "https://www.dealmobile.co.uk/writing/tag/calendars",
            "json": "https://www.dealmobile.co.uk/writing/tag/calendars.json"
        },
        {
            "name": "Testing",
            "url": "https://www.dealmobile.co.uk/writing/tag/testing",
            "json": "https://www.dealmobile.co.uk/writing/tag/testing.json"
        },
        {
            "name": "Solar Calendar",
            "url": "https://www.dealmobile.co.uk/writing/tag/solar-calendar",
            "json": "https://www.dealmobile.co.uk/writing/tag/solar-calendar.json"
        }
    ],
    "projects": [
        {
            "name": "Solar Calendar",
            "url": "https://www.dealmobile.co.uk/projects/solar-calendar",
            "json": "https://www.dealmobile.co.uk/projects/solar-calendar.json"
        }
    ],
    "body": {
        "html": "<p>The <a href=\"/projects/solar-calendar\">Solar Calendar</a> is a static site, apart from two addresses. <code>/feed.ics</code> is a calendar feed: subscribe to it in Google Calendar, Apple Calendar or Outlook, and every day shows its solar date. <code>/today.svg</code> 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.</p>\n<h2 id=\"why-not-a-static-file\">Why not a static file</h2>\n<p>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.</p>\n<p>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:</p>\n<pre><code class=\"language-jsonc\">\"main\": \"dist-worker/worker.js\",\n\"assets\": {\n  \"directory\": \"./dist\",\n  \"run_worker_first\": [\"/feed.ics\", \"/today.svg\"]\n}\n</code></pre>\n<p>Everything else in <code>dist/</code> is served as a plain file.</p>\n<h2 id=\"one-date-core-two-places\">One date core, two places</h2>\n<p>The calendar’s rules live in <code>src/core/</code>, a few modules with no DOM. Every date is an integer day number, and <code>solar.ts</code> 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:</p>\n<pre><code class=\"language-ts\">import { utcDayOfInstant } from './core/days';\nimport { MAX_YEAR, MIN_YEAR, solarYearOf, yearStart } from './core/solar';\nimport { badgeSvg, todayIn } from './feed/badge';\nimport { buildIcs } from './feed/ics';\n</code></pre>\n<p>The download button calls <code>buildIcs()</code> 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.</p>\n<p>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:</p>\n<pre><code class=\"language-ts\">// Bundled into the Worker, unlike the app, which loads packs on demand\nconst packs = new Map(\n  Object.entries(import.meta.glob&lt;unknown&gt;(['./names/packs/*.json', '!./names/packs/_*.json'], { eager: true, import: 'default' }))\n    .map(([path, data]) =&gt; {\n      const pack: NamePack = validatePack(data, path);\n      return [pack.id, pack];\n    }),\n);\n</code></pre>\n<p><code>import.meta.glob</code> 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 <code>dist-worker/</code> with every dependency bundled in, and <code>npm run build</code> makes both.</p>\n<h2 id=\"the-feed\">The feed</h2>\n<p><code>buildIcs()</code> 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:</p>\n<pre><code class=\"language-ts\">const year = Math.min(MAX_YEAR - 1, Math.max(MIN_YEAR + 1, solarYearOf(utcDayOfInstant(now))));\n// ...\nconst body = buildIcs({ pack, extra, from: yearStart(year - 1), to: yearStart(year + 2) - 1, now });\n</code></pre>\n<p>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:</p>\n<pre><code>UID:20261020-anglo-saxon@solar-calendar\nDTSTAMP:20261020T171853Z\nDTSTART;VALUE=DATE:20261020\nDTEND;VALUE=DATE:20261021\nSUMMARY:☀ 16 Winterfylleþ\nDESCRIPTION:Solar date: 16 Winterfylleþ 2026. Month 8 of 13\\, Winterfylle\n þ: winter full moon. Day 212 of 364.\nTRANSP:TRANSPARENT\n</code></pre>\n<p>The UID is made from the date and the set of names, so every fetch describes the same events under the same IDs.</p>\n<p>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.</p>\n<p>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:</p>\n<pre><code class=\"language-ts\">export function foldLine(line: string): string {\n  const parts: string[] = [];\n  let current = '';\n  let bytes = 0;\n  for (const character of line) {\n    const size = encoder.encode(character).length;\n    // Continuation lines start with a space, which counts towards their 75\n    if (bytes + size &gt; (parts.length === 0 ? 75 : 74)) {\n      parts.push(current);\n      current = '';\n      bytes = 0;\n    }\n    current += character;\n    bytes += size;\n  }\n  parts.push(current);\n  return parts.join('\\r\\n ');\n}\n</code></pre>\n<p>The calendar asks to be refreshed daily, with <code>REFRESH-INTERVAL</code> and <code>X-PUBLISHED-TTL</code> 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.</p>\n<p>Query parameters only ever choose from what’s bundled. <code>?names=</code> picks a set of month names, with the Gregorian names if there’s no such set. <code>?festivals=</code> and <code>?holidays=</code> add other sets’ festivals and public holidays, and unknown IDs are skipped. Nothing from the URL is copied into the response.</p>\n<h2 id=\"the-badge\">The badge</h2>\n<p><code>/today.svg</code> 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 <code>textLength</code> 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 <code>i</code> and <code>l</code>, and the rest.</p>\n<p>Any <code>&amp;</code>, <code>&lt;</code>, <code>&gt;</code> or quote mark in a month name is written as a numeric character reference, so no name can break the SVG.</p>\n<p>“Today” depends on where you are. By default the badge uses UTC, like the calendar itself. <code>?tz=</code> takes a time zone name such as <code>America/New_York</code>, and <code>todayIn()</code> asks <code>Intl.DateTimeFormat</code> 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:</p>\n<pre><code class=\"language-ts\">// Until the day ends, at most an hour\n'Cache-Control': `public, max-age=${Math.max(60, Math.min(3600, secondsLeft))}`,\n</code></pre>\n<p>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.</p>\n<h2 id=\"testing-the-real-bundle\">Testing the real bundle</h2>\n<p>The unit tests call the Worker’s <code>feedResponse()</code> and <code>badgeResponse()</code> 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.</p>\n<p>The browser tests go further. The Vite config has a small plugin that answers <code>/feed.ics</code> and <code>/today.svg</code> the way Cloudflare would: from the source in the dev server, and from the built <code>dist-worker/worker.js</code> in the preview server, which is what Playwright runs against:</p>\n<pre><code class=\"language-ts\">configurePreviewServer(server) {\n  server.middlewares.use(serveWorker(async () =&gt; (await import(pathToFileURL(resolve('dist-worker/worker.js')).href)).default.fetch));\n},\n</code></pre>\n<p>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.</p>\n<h2 id=\"using-it\">Using it</h2>\n<p>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 <code>webcal:</code> link, and opens Apple Calendar or Outlook directly. Or build the URL by hand:</p>\n<pre><code>https://solar.dealmobile.co.uk/feed.ics?names=old-norse&amp;festivals=persian&amp;holidays=ireland,us-federal\n</code></pre>\n<p>For the badge in a README:</p>\n<pre><code class=\"language-markdown\">![Today in the solar calendar](https://solar.dealmobile.co.uk/today.svg?names=anglo-saxon&amp;tz=Pacific/Kiritimati)\n</code></pre>\n<p>To build and run it yourself:</p>\n<pre><code class=\"language-bash\">npm install\nnpm run build    # the site in dist/, the Worker in dist-worker/\nnpm test         # unit tests, then the browser tests against the build\nnpm run deploy   # build, then wrangler deploy (after npx wrangler login)\n</code></pre>\n<p>The code is on <a href=\"https://github.com/tomgould/solar-calendar\">GitHub</a>.</p>"
    },
    "markdown_text": "---\ntitle: 'A live calendar feed and badge from a Cloudflare Worker'\ncanonical: 'https://www.dealmobile.co.uk/writing/calendar-feed-and-badge-from-a-worker'\nurl: 'https://www.dealmobile.co.uk/writing/calendar-feed-and-badge-from-a-worker'\ntype: Post\npublished: '2026-10-20'\nsummary: '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.'\n---\n\n# A live calendar feed and badge from a Cloudflare Worker\n\nPublished 2026-10-20\n\n![The Solar Calendar’s live badge reading 19 Winterfylleþ 2026, above a real calendar feed event titled “☀ 19 Winterfylleþ” on a dark terminal card.](https://www.dealmobile.co.uk/sites/default/files/styles/almanac_shot_md/public/content/images/posts/calendar-feed-and-badge-from-a-worker/hero.png.webp?itok=3O6sSDJ8)\n\nThe [Solar Calendar](https://www.dealmobile.co.uk/projects/solar-calendar.md) 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.\n\n## Why not a static file\n\nWhen 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.\n\nA 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:\n\n```jsonc\n\"main\": \"dist-worker/worker.js\",\n\"assets\": {\n  \"directory\": \"./dist\",\n  \"run_worker_first\": [\"/feed.ics\", \"/today.svg\"]\n}\n\n```\n\nEverything else in `dist/` is served as a plain file.\n\n## One date core, two places\n\nThe 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:\n\n```ts\nimport { utcDayOfInstant } from './core/days';\nimport { MAX_YEAR, MIN_YEAR, solarYearOf, yearStart } from './core/solar';\nimport { badgeSvg, todayIn } from './feed/badge';\nimport { buildIcs } from './feed/ics';\n\n```\n\nThe 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.\n\nThe 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:\n\n```ts\n// Bundled into the Worker, unlike the app, which loads packs on demand\nconst packs = new Map(\n  Object.entries(import.meta.glob<unknown>(['./names/packs/*.json', '!./names/packs/_*.json'], { eager: true, import: 'default' }))\n    .map(([path, data]) => {\n      const pack: NamePack = validatePack(data, path);\n      return [pack.id, pack];\n    }),\n);\n\n```\n\n`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.\n\n## The feed\n\n`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:\n\n```ts\nconst year = Math.min(MAX_YEAR - 1, Math.max(MIN_YEAR + 1, solarYearOf(utcDayOfInstant(now))));\n// ...\nconst body = buildIcs({ pack, extra, from: yearStart(year - 1), to: yearStart(year + 2) - 1, now });\n\n```\n\nThe 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:\n\n```\nUID:20261020-anglo-saxon@solar-calendar\nDTSTAMP:20261020T171853Z\nDTSTART;VALUE=DATE:20261020\nDTEND;VALUE=DATE:20261021\nSUMMARY:☀ 16 Winterfylleþ\nDESCRIPTION:Solar date: 16 Winterfylleþ 2026. Month 8 of 13\\, Winterfylle\n þ: winter full moon. Day 212 of 364.\nTRANSP:TRANSPARENT\n\n```\n\nThe UID is made from the date and the set of names, so every fetch describes the same events under the same IDs.\n\nThe ☀ 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.\n\nThe 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:\n\n```ts\nexport function foldLine(line: string): string {\n  const parts: string[] = [];\n  let current = '';\n  let bytes = 0;\n  for (const character of line) {\n    const size = encoder.encode(character).length;\n    // Continuation lines start with a space, which counts towards their 75\n    if (bytes + size > (parts.length === 0 ? 75 : 74)) {\n      parts.push(current);\n      current = '';\n      bytes = 0;\n    }\n    current += character;\n    bytes += size;\n  }\n  parts.push(current);\n  return parts.join('\\r\\n ');\n}\n\n```\n\nThe 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.\n\nQuery 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.\n\n## The badge\n\n`/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.\n\nAny `&`, `<`, `>` or quote mark in a month name is written as a numeric character reference, so no name can break the SVG.\n\n“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:\n\n```ts\n// Until the day ends, at most an hour\n'Cache-Control': `public, max-age=${Math.max(60, Math.min(3600, secondsLeft))}`,\n\n```\n\nSo, 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.\n\n## Testing the real bundle\n\nThe 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.\n\nThe 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:\n\n```ts\nconfigurePreviewServer(server) {\n  server.middlewares.use(serveWorker(async () => (await import(pathToFileURL(resolve('dist-worker/worker.js')).href)).default.fetch));\n},\n\n```\n\nSo 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.\n\n## Using it\n\nTo 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:\n\n```\nhttps://solar.dealmobile.co.uk/feed.ics?names=old-norse&festivals=persian&holidays=ireland,us-federal\n\n```\n\nFor the badge in a README:\n\n```markdown\n![Today in the solar calendar](https://solar.dealmobile.co.uk/today.svg?names=anglo-saxon&tz=Pacific/Kiritimati)\n\n```\n\nTo build and run it yourself:\n\n```bash\nnpm install\nnpm run build    # the site in dist/, the Worker in dist-worker/\nnpm test         # unit tests, then the browser tests against the build\nnpm run deploy   # build, then wrangler deploy (after npx wrangler login)\n\n```\n\nThe code is on [GitHub](https://github.com/tomgould/solar-calendar).\n\n- **Projects:** [Solar Calendar](https://www.dealmobile.co.uk/projects/solar-calendar.md)\n- **Tags:** [TypeScript](https://www.dealmobile.co.uk/writing/tag/typescript.md), [Calendars](https://www.dealmobile.co.uk/writing/tag/calendars.md), [Testing](https://www.dealmobile.co.uk/writing/tag/testing.md), [Solar Calendar](https://www.dealmobile.co.uk/writing/tag/solar-calendar.md)\n\n---\n\nIndex of this site: [llms.txt](https://www.dealmobile.co.uk/llms.txt)\n"
}