hldays

Building hldays #6: Names are harder than dates

Computing the date of Easter is a solved problem. There's an algorithm, it's twenty lines, it has tests.

Working out which place someone means when they type "Braga" is not. After building the date engine, I found that most of the awkward decisions in hldays were about names, not dates.

This post is about how a string like porto, Évora or Sao Paulo becomes a jurisdiction id, and where that still goes wrong.

Ids first, names second

Every place in hldays has an id: PT for Portugal, PT-03 for the district of Braga, PT-M-0303 for the municipality of Braga (the number is the INE code), BR-M-3550308 for the city of São Paulo (its IBGE code). Ids are unambiguous and they're what I'd recommend storing.

But nobody types PT-M-0303. So every endpoint that takes a jurisdiction also accepts a name, and there's a search endpoint for when you're not sure. Both sit on the same index, built once when the Worker starts (src/core/calendar.ts):

constructor(readonly data: Dataset) {
  for (const j of data.jurisdictions) {
    this.byId.set(j.id.toUpperCase(), j);
    for (const name of [j.name, ...(j.aliases ?? [])]) {
      const key = slugify(name);
      const list = this.bySlug.get(key) ?? [];
      list.push(j);
      this.bySlug.set(key, list);
    }
  }
}

Note that bySlug maps a key to a list. That was the first lesson: a name is not a key.

Accent-insensitive, on purpose

The key is a slug:

export function slugify(s: string): string {
  return s
    .normalize("NFD")
    .replace(/\p{Diacritic}/gu, "")
    .toLowerCase()
    .replace(/[^a-z0-9]+/g, "-")
    .replace(/^-|-$/g, "");
}

normalize("NFD") splits "ã" into "a" plus a combining tilde, and the diacritic regex drops the tilde. So São Paulo, sao paulo, SAO-PAULO and São Paulo! all become sao-paulo. Most people typing Portuguese place names on an English keyboard leave out the accents, and they shouldn't get a 404 for it.

The same function makes the slug field for every jurisdiction when the data is built, so the search and the stored slugs can't drift apart.

It has a blind spot for German, though. NFD turns "ü" into "u", but Germans write "ue" when they can't type an umlaut. So Baden-Wuerttemberg would not match Baden-Württemberg. The fix is data, not code: the importer config adds aliases for those (scripts/import-country.py):

"subdiv_aliases": {
    "BW": ["Baden-Wuerttemberg"], "BY": ["Bavaria"], "HE": ["Hesse"], "MV": ["Mecklenburg-Western Pomerania"],
    "NI": ["Lower Saxony"], "NW": ["North Rhine-Westphalia"], "RP": ["Rhineland-Palatinate"],
    "SN": ["Saxony"], "ST": ["Saxony-Anhalt"], "TH": ["Thuringia"],
},

Same name, different places

Here's what the data actually contains. Counting places whose slugs collide, there are 19 names shared by two jurisdictions:

  • 18 in Portugal: every district is named after its capital, so Aveiro, Beja, Braga, Bragança, Castelo Branco, Coimbra, Évora, Faro, Guarda, Leiria, Lisboa, Portalegre, Porto, Santarém, Setúbal, Viana do Castelo, Vila Real and Viseu are each both a district and a municipality.
  • 1 in Brazil: São Paulo is a state (BR-SP) and a city (BR-M-3550308).

So yes, Braga is both. Search shows the two side by side:

curl "https://api.hldays.dev/v1/jurisdictions?q=braga"
{"data":[
  {"id":"PT-M-0303","name":"Braga","level":"municipality","country":"PT","parent":"PT-03"},
  {"id":"PT-03","name":"Braga","level":"subdivision","kind":"district","country":"PT","parent":"PT"},
  {"id":"PT-M-0402","name":"Bragança","level":"municipality","country":"PT","parent":"PT-04"},
  {"id":"PT-04","name":"Bragança","level":"subdivision","kind":"district","country":"PT","parent":"PT"}
]}

When a name is passed straight to /v1/holidays, there's no list to show, so resolve has to pick one:

const LEVEL_RANK: Record<Level, number> = { municipality: 0, subdivision: 1, country: 2 };

resolve(ref: string, country?: string): Jurisdiction | undefined {
  const byId = this.byId.get(ref.toUpperCase());
  if (byId) return byId;
  const candidates = (this.bySlug.get(slugify(ref)) ?? []).filter(
    (j) => !country || j.country === country.toUpperCase(),
  );
  return candidates.sort((a, b) => LEVEL_RANK[a.level] - LEVEL_RANK[b.level])[0];
}

The most specific level wins. "Porto" means the city, "São Paulo" means the city, "Lisboa" means the municipality. My reasoning: someone asking about holidays in "Porto" almost always means the place they live or work, and the municipality is where its own holiday (São João, 24 June) is defined. It also inherits everything from the district and the country, so it's the most complete answer.

In Portugal the choice is low-stakes anyway: districts carry no holidays of their own. In Brazil it matters a little more. The city of São Paulo inherits the state's 9 July holiday and adds its own on top, like its anniversary on 25 January, so asking for the state when you meant the city would lose days. The other way round, you have to use the id:

curl "https://api.hldays.dev/v1/holidays?jurisdiction=BR-SP&year=2026"

The tests pin the rule down, including the case-insensitive id:

expect(cal.resolve("Lisbon")!.id).toBe("PT-M-1106");
expect(cal.resolve("pt-11")!.level).toBe("subdivision");
expect(cal.resolve("azores")!.id).toBe("PT-20");

The two Lagoas and the two Calhetas

Portugal also has municipalities that share a name with each other: Lagoa in the Algarve (INE 0806) and Lagoa on São Miguel in the Azores (4201); Calheta on Madeira (3101) and Calheta on São Jorge, also in the Azores (4501).

For these I didn't rely on level ranking, since they're the same level. They get distinct names in the data: Lagoa and Lagoa (Açores), Calheta and Calheta de São Jorge. Plain "lagoa" resolves to the Algarve one, and search shows both (plus Alagoas in Brazil, a substring match):

curl "https://api.hldays.dev/v1/jurisdictions?q=lagoa"
{"data":[
  {"id":"PT-M-0806","name":"Lagoa","level":"municipality","country":"PT","parent":"PT-08"},
  {"id":"PT-M-4201","name":"Lagoa (Açores)","level":"municipality","country":"PT","parent":"PT-20"},
  {"id":"BR-AL","name":"Alagoas","level":"subdivision","kind":"state","country":"BR","parent":"BR"}
]}

The collision hurt more during research than in the API. One of the compiled lists I checked swaps the two Lagoas and the two Calhetas, and a research note in municipalities.yaml records one of those near-misses:

notes: "INE 0806 is Lagoa (Faro/Algarve); the IRN office is at 8400-343 Lagoa (Algarve postcode). The ASPL hint attached here is the Lagoa (Açores) row (11 April) - wrong municipality match."

The INE code and the postcode were what settled it. Names alone couldn't. (More on how sources get checked in "A source on every record".)

Partial search, ranked

/v1/jurisdictions?q= is what the hldays.dev search box and the MCP find_jurisdiction tool use. The ranking is deliberately simple:

const score = s === q ? 3 : s.startsWith(q) ? 2 : s.includes(q) ? 1 : -1;

Exact beats prefix beats substring. Within the same score, the more specific level comes first, then alphabetical order. The API returns up to 20 results. That's enough for "vila nova", which matches several municipalities, and the test only checks that there are more than three, so it doesn't break when a place is added.

Aliases

Aliases are extra names that point to the same place. There are 19 jurisdictions with one at the moment, and they're mostly English exonyms:

  - id: PT-M-1312
    country: PT
    level: municipality
    name: "Porto"
    aliases: [Oporto]

Others: Lisbon, Azores, Madeira, Bavaria, Lower Saxony, UK, Great Britain, USA, Brasil, Deutschland, Hellas. They go through the same slugify, so oporto, OPORTO and Oporto all work.

The country filter

With six countries, a name can easily exist in more than one. Every endpoint that takes a jurisdiction also takes an optional country, which filters candidates before the level ranking. On search it narrows the list:

curl "https://api.hldays.dev/v1/jurisdictions?q=SAO-PAULO&country=PT"
{"data":[]}

There's no cross-country collision in the data today, so country is mostly insurance. That will change when a country with a Braga-like name gets added, and I'd rather clients already send it.

Multilingual names: what exists and what doesn't

  • Place names are single strings. A jurisdiction has one name (in the local language: Bayern, Lisboa, Região Autónoma dos Açores) plus aliases. There's no translated name field.

  • Holiday names are a map of language codes. For example, Greece:

    - id: new-years-day
      name: {"el": "Πρωτοχρονιά", "en": "New Year's Day"}
    

    The importer asks python-holidays for each language in the country's langs config, so German holidays have de and en, Brazil's have pt and en, Greece's have el and en. The 308 Portuguese municipal holidays have only pt, because they come from council and registry pages written in Portuguese, and I'd rather not machine-translate a saint's name.

  • There's no lang parameter. The API returns the whole map and the client picks.

A bug I found while writing this

Look at slugify again, as it was until this week: anything that isn't a-z or 0-9 became a dash, and leading and trailing dashes were trimmed. For a Greek word with no Latin letters, the slug was the empty string.

Search guarded against that (if (!q) return [];), so searching for "Ελλάδα" returned nothing, even though it's an alias of Greece. Worse, resolve didn't guard, and the alias Ελλάδα was stored under the key "". So any name without Latin letters or digits resolved to Greece:

curl "https://api.hldays.dev/v1/holidays?jurisdiction=%E6%9D%B1%E4%BA%AC&year=2026"   # 東京 (Tokyo), before the fix
{"jurisdiction":{"id":"GR","name":"Greece","level":"country","country":"GR","parent":null},"year":2026, ...}

That's exactly the wrong failure: a confident answer for the wrong place, instead of a 404. The fix keeps letters from every script in the slug (\p{L} instead of a-z), and an empty slug never matches:

export function slugify(s: string): string {
  return s
    .normalize("NFD")
    .replace(/\p{Diacritic}/gu, "")
    .toLowerCase()
    // Any script's letters, so "Ελλάδα" keeps a slug instead of collapsing to "".
    .replace(/[^\p{L}\p{N}]+/gu, "-")
    .replace(/^-|-$/g, "");
}

Ελλάδα now becomes ελλαδα (the accent goes, the letters stay), and the tests pin both sides: the Greek name finds Greece, while 東京 and !!! find nothing. Only one existing name changed slug, which is how I knew the fix was safe.

Search and name resolution are the part of hldays I trust least, because names get added by people and nothing checks them for collisions. The build validates ids, rules and sources, but two places with the same name pass silently. A check that lists every colliding slug at build time would be cheap to add.

Have you had to resolve place names in your own projects? I'd like to know how you handled the "two places, one name" problem, in the comments.

Next in the series: religious and cultural observances, with Easter, Orthodox, Jewish, Chinese and Islamic calendars.

hldays.dev · Docs