Building hldays #13: Lessons learned so far
This is the last post in the series, so it's a good moment to look back.
The git history of hldays starts on 16 October 2026 with "Scaffold holidays API: data model, rule engine, Worker, tests". At the time of writing there are under 40 commits, 6 countries, 440 places and 716 holiday rules, and Portugal has all 308 municipal holidays, 291 of them verified against an official source.
Here's what I learned along the way, grouped by where it hurt. Some of it is written down in the repo as "gotchas", which is usually a sign that something went wrong first.
It started as a way to stop forgetting one day off and grew into 6 countries, an MCP server and a paid API, far bigger than I planned. It also came together faster than I expected: most of the code was written in the first few days, and the data research took the longest.
Data: official sources are not always right
I expected the hard part to be finding the data. The harder part was that sources disagree, and some of the official-looking ones are wrong.
From docs/data-review-pt.md:
- gov.pt Espaço Cidadão pages sometimes show the district capital's holiday instead of the municipality's (Mortágua, Tabuaço, Pedrógão Grande, Portel), or a stale year's date (Ponte de Sor).
- A widely copied compiled list swaps the two Lagoas and the two Calhetas, and gets a few others wrong.
- Some official texts need interpretation. Idanha-a-Nova's registry page says "3ª terça-feira depois do domingo de Páscoa", which is a Tuesday, while other sources give a Monday.
What worked best was an unexpected source: the IRN registry offices. Their contact pages say when each office is "Encerrado no feriado municipal (...)", closed for the municipal holiday. 102 of the municipal holidays came from those pages.
Here's the Idanha-a-Nova record, trimmed from data/PT/municipalities.yaml:
- id: feriado-municipal
type: local
rule: { easter: 16 }
status: verified
source: { url: "https://irn.justica.gov.pt/-Registo-Civil-Predial-Comercial-e-Cartorio-Notarial-de-Idanha-a-Nova", verified_at: "2026-10-16", quote: "Encerrado no feriado municipal (3ª terça-feira depois do domingo de Páscoa)" }
notes: "CONFLICT - needs human review. IRN official page says 3rd TUESDAY after Easter Sunday (= Easter+16), while ASPL and Wikipedia say a MONDAY = Easter+15 ..."
The rule follows the official quote, and the disagreement stays next to it in notes, where the next person to touch it will see it. A conflict written down is much cheaper than a conflict rediscovered.
Lesson: never trust one source, keep the quote. Every verified record in hldays has a URL, a verbatim quote and the date it was checked, and the build refuses a verified record without them. From src/core/validate.ts:
if (h.status === "verified" && !(h.source?.url && h.source?.verified_at))
errors.push(`${hw}: verified holidays need source.url and source.verified_at`);
Two lines, but it means "verified" can't become a word I type without evidence.
Lesson: start small. The first data commit was a pilot of 22 municipalities, and it found the rule shapes I hadn't planned for (Easter+16, "Monday after the 1st Sunday", a holiday that moved in 2016) before I committed to 308.
Calendars: don't trust the platform blindly
Islamic and Hebrew dates in hldays come from JavaScript's Intl, which is backed by ICU. I checked that the output on Cloudflare Workers matches what I get locally, and it does.
The Chinese calendar was a different story. ICU misplaces 5 of the 1,039 lunar month starts between 2016 and 2099, and two of them are Chinese New Year: 2027 and 2030. Off by one day. So the engine uses tables from the Hong Kong Observatory instead, imported by a script into chinese-months.json:
if (calendar === "chinese") {
// ICU's Chinese calendar misplaces some month starts (Chinese New Year 2027, 2030), so use the HKO table.
I only found this because I compared against a second source. If I hadn't, the API would have confidently returned the wrong date for Chinese New Year next year. A test in test/core.test.ts pins exactly those years:
// 2027 and 2030 are days ICU's Chinese calendar gets wrong; the engine uses the HKO table
expect([2025, 2026, 2027, 2030].flatMap((y) => expandRule(lny, y))).toEqual(["2025-01-29", "2026-02-17", "2027-02-06", "2030-02-03"]);
If someone "simplifies" the engine back to Intl one day, this is the test that fails.
Lesson: for anything date-related, cross-check against an independent source for the whole year range you support, not a few spot checks.
Imports: generated files need a clear owner
For Brazil, Germany, the UK, Greece and the US, the national and state layers are generated from python-holidays by scripts/import-country.py. It turns their dates into rules, checks the engine reproduces them exactly for 2016–2099, and cross-checks against Nager.Date and OpenHolidays.
The self-check is the part I'd copy into any importer. After writing the YAML, the Python script rebuilds the data and asks the TypeScript engine to dump every date, then compares year by year (trimmed):
ours = dump("public,local,optional")
# Self-check: engine vs python-holidays with substitute days, every year.
targets = {cc: None, **{s: s.split("-", 1)[1] for s in sub_ids}, **muni_map}
...
report["self_check"] = compare(expected, ours, range(Y0, Y1 + 1))
It tests the round trip, not the converter: python-holidays' dates go in as rules, the engine expands them back into dates, and the two lists must match. An empty self-check is what lets me trust a country I can't verify by hand.
The catch: it overwrites data/<CC>/. Any hand edit to those YAML files disappears on the next import. The same is true for Portugal's municipalities: scripts/merge.py rebuilds that YAML from the research files and overwrites manual edits.
Both scripts now say so at the top, and so does the project's notes file:
# Overwrites data/<CC>/; change COUNTRIES below or the importer instead of editing the YAML by hand.
Lesson: a generated file should say it's generated, and there should be exactly one place to change it. If you need manual fixes, put them in the generator's input, not the output.
The cross-check paid off too. It found real disagreements, like Nager.Date missing Scotland's 4 January 2021 substitute day, which gov.uk did list. Those go into docs/data-review-import.md instead of being silently "fixed".
Tooling: testing without the Workers runtime
I don't run the Worker locally, with wrangler dev or anything else. I test it in plain Node instead. Hono apps expose app.request, which takes a URL, headers and an env object, and returns a Response. The whole API test suite runs that way:
const get = (path: string, env: Record<string, unknown> = {}, headers: Record<string, string> = {}) =>
app.request(path, { headers }, env);
Bindings like the rate limiter get small fakes. This one, from test/mcp.test.ts, just records which keys it was asked about:
const keys: string[] = [];
const env = { RAPIDAPI_PROXY_SECRET: "s3cret", ANON_LIMITER: { limit: async ({ key }: { key: string }) => (keys.push(key), { success: true }) } };
That's enough to check that an MCP tool call is rate limited by the caller's IP (expect(keys).toEqual(["203.0.113.7"])), which only works because the MCP server forwards CF-Connecting-IP to the REST routes it calls. Real bindings are checked on Cloudflare after deploy.
Lesson: keep platform-specific things behind small interfaces, and almost everything can be tested in plain Node.
Shipping: some lessons only show up in production
A few things I only learned after deploying:
- Bot Fight Mode blocks API clients. Cloudflare's Browser Integrity Check and Bot Fight Mode are great for websites and bad for an API: they block scripted clients (curl, server-side code) with error 1010. They have to stay off for hldays.dev.
- Edge rollout isn't instant. After a push, it takes about a minute, and some requests still hit the old version. I poll for a marker before testing anything live.
- Marketplaces don't let you fix typos. The first RapidAPI listing had "Bussiness" in its title, and RapidAPI wouldn't let me change it. I re-created the listing. Because the link lives in config (
UPGRADE_URLfor the API,PRICING_URLfor the site), the code side was two lines. - CI doesn't need to do everything. I added a GitHub Actions job to deploy and sync the RapidAPI listing, then removed both the same day. Cloudflare's Git integration deploys every push to
main, CI runs typecheck and tests, and I re-import/openapi.jsonon RapidAPI by hand when endpoints change. - Importers have opinions. The API serves OpenAPI 3.0.3, not 3.1, because that's what RapidAPI's importer handles.
- Domains move early. The API was briefly documented at
cal.hldays.devbefore it moved toapi.hldays.devthe same day, with the root domain kept for the site.
Design: one registry, enforced by a test
Every endpoint is described once, in src/docs.ts. The index at /, the OpenAPI spec and a test all derive from it, and the test calls every documented example and checks the served routes match:
it("documents exactly the endpoints it serves", async () => {
for (const e of ENDPOINTS.filter((e) => (e.method ?? "GET") === "GET")) expect((await get(e.example)).status, e.example).toBe(200);
That means docs can't drift from the code without a red build. The same idea applies to the MCP server: its tools call the REST routes internally and forward a fixed list of headers, so plans and auth apply the same way. From src/index.ts:
const FORWARD = ["X-RapidAPI-Proxy-Secret", "X-RapidAPI-Subscription", "CF-Connecting-IP", "User-Agent"];
The flip side is that a new header has to be added to that list, or MCP calls quietly lose it. That's written in the project notes, because it's exactly the kind of thing I'd forget.
Lesson: when two things must stay in sync, make one of them derived from the other, and let a test complain.
Working with AI
Most commits in this repo are co-authored with Claude, and the Portuguese research was fanned out to AI agents in batches, each with shared instructions and writing its results to a file. Verification against official sources, and the quote on every record, is what made that safe to use.
Claude was very fast, both at code and at the research legwork. I decided the design, the data rules and what counts as verified. I'd work the same way again.
What I'd do differently
- Put the free tier in from day one. At first, any request without the RapidAPI secret got a 401, so you couldn't try the API without signing up on a marketplace. The anonymous tier with a per-IP limit came later.
- Cross-check calendars before building on them. The ICU issue was found when I added Chinese dates. A check against independent tables for every calendar, from the start, is cheap.
- Decide what's generated before editing YAML by hand. Clear ownership of generated files would have avoided edits getting overwritten.
- Drive the research myself first. Agent research burns a lot of tokens. I'd find the great sources myself, understand the data, and only then point the agents at those sources with clear rules.
Where it goes from here
The open items are written down: 17 Portuguese municipal holidays still need an official source, Brazil's municipal layer has one city so far, and Bavaria's Assumption Day varies by municipality. On the product side, docs/plan-site.md has the plan for API keys on hldays.dev and, later, selling directly.
What I'm most looking forward to is country number 7. As I wrote in the last post, I haven't picked it yet.
Thanks for reading the series. More posts will follow as hldays grows. If you've built something similar, what's the one lesson you'd add? Tell me in the comments.