<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Alexander Denisov</title>
    <description>The latest articles on DEV Community by Alexander Denisov (@altofire).</description>
    <link>https://dev.to/altofire</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4202032%2F38776ca8-e734-42c0-bf77-b7b32490165b.png</url>
      <title>DEV Community: Alexander Denisov</title>
      <link>https://dev.to/altofire</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/altofire"/>
    <language>en</language>
    <item>
      <title>Building hldays #3: A source on every record</title>
      <dc:creator>Alexander Denisov</dc:creator>
      <pubDate>Thu, 29 Oct 2026 19:00:00 +0000</pubDate>
      <link>https://dev.to/altofire/building-hldays-3-a-source-on-every-record-579c</link>
      <guid>https://dev.to/altofire/building-hldays-3-a-source-on-every-record-579c</guid>
      <description>&lt;p&gt;In the first post I promised "a source on every record". This post is about what that means in practice: where the data comes from, what "verified" means, and how I keep it from going stale.&lt;/p&gt;

&lt;p&gt;The short version: every holiday in hldays carries a link, a verbatim quote and the date it was checked, and the build refuses to call a record verified without them.&lt;/p&gt;

&lt;h2&gt;
  
  
  What a record looks like
&lt;/h2&gt;

&lt;p&gt;Here is Alenquer's municipal holiday, straight from &lt;code&gt;data/PT/municipalities.yaml&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;feriado-municipal&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;pt&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Feriado&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Municipal"&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
  &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;local&lt;/span&gt;
  &lt;span class="na"&gt;rule&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;easter&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;39&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
  &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;verified&lt;/span&gt;
  &lt;span class="na"&gt;source&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;url&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://irn.justica.gov.pt/Contactos-do-Registo/Espaco-Registos-de-Alenquer"&lt;/span&gt;
    &lt;span class="na"&gt;verified_at&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;2026-10-16"&lt;/span&gt;
    &lt;span class="na"&gt;quote&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Encerrado&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;no&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;feriado&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;municipal&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;(quinta-feira&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;da&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Ascensão)"&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And a regional one, Madeira's Autonomy Day, which cites the regional decree itself:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;dia-da-autonomia&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;pt&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;Dia da Autonomia&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;en&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;Autonomy Day&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
  &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;public&lt;/span&gt;
  &lt;span class="na"&gt;rule&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;fixed&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;04-02"&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
  &lt;span class="na"&gt;valid_from&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;2025&lt;/span&gt;
  &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;verified&lt;/span&gt;
  &lt;span class="na"&gt;source&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;url&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://joram.madeira.gov.pt/joram/1serie/Ano%20de%202024/ISerie-206-2024-12-16sup.pdf"&lt;/span&gt;
    &lt;span class="na"&gt;ref&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Decreto&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Legislativo&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Regional&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;n.º&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;17/2024/M"&lt;/span&gt;
    &lt;span class="na"&gt;verified_at&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;2026-10-16"&lt;/span&gt;
    &lt;span class="na"&gt;quote&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;O&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;dia&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;2&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;de&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;abril&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;constitui&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;o&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;feriado&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;do&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Dia&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;da&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Autonomia&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;na&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Região&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Autónoma&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;da&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Madeira."&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A source has four optional parts:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;url&lt;/code&gt;: the page or document&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;ref&lt;/code&gt;: a human reference, like a law number&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;quote&lt;/code&gt;: the sentence on that page that states the holiday, copied verbatim&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;verified_at&lt;/code&gt;: when someone (or something) last confirmed it&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The quote matters most. A URL alone tells you where to look. The quote tells you what the page said, so you can check it yourself without trusting me.&lt;/p&gt;

&lt;p&gt;All of this goes out in the API response. Every holiday in &lt;code&gt;/v1/holidays&lt;/code&gt; has its &lt;code&gt;status&lt;/code&gt; and &lt;code&gt;source&lt;/code&gt; next to the date, unless you ask for &lt;code&gt;include_sources=false&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="err"&gt;curl&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://api.hldays.dev/v1/holidays?jurisdiction=PT-30&amp;amp;year=2026"&lt;/span&gt;&lt;span class="w"&gt;


&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"date"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2026-04-02"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"holiday_id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"dia-da-autonomia"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"public"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"status"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"verified"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"source"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"url"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://joram.madeira.gov.pt/joram/1serie/..."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"ref"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Decreto Legislativo Regional n.º 17/2024/M"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"verified_at"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2026-10-16"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"quote"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"O dia 2 de abril constitui o feriado do Dia da Autonomia na Região Autónoma da Madeira."&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"jurisdiction"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"PT-30"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Região Autónoma da Madeira"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"level"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"subdivision"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;

&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The build enforces it
&lt;/h2&gt;

&lt;p&gt;There are only two statuses: &lt;code&gt;verified&lt;/code&gt; and &lt;code&gt;unverified&lt;/code&gt;. The rule for &lt;code&gt;verified&lt;/code&gt; lives in &lt;code&gt;src/core/validate.ts&lt;/code&gt;, which runs on every build:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;h&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;verified&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;h&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;source&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;h&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;source&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;verified_at&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
  &lt;span class="nx"&gt;errors&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;hw&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;: verified holidays need source.url and source.verified_at`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If any record breaks it, &lt;code&gt;npm run build:data&lt;/code&gt; fails, so the tests fail, so nothing deploys. It's a small check, but it means "verified" can't drift into meaning "I'm fairly sure".&lt;/p&gt;

&lt;p&gt;What counts as an official source is a convention, written in the README: a law, the Diário da República, a municipality's own website, a government registry page. News articles, bank holiday lists and Wikipedia can be useful leads, but a record that only has those stays &lt;code&gt;unverified&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Collecting Portugal: there is no official list
&lt;/h2&gt;

&lt;p&gt;Portugal has 308 municipalities, and each one sets its own holiday. There is no single official list of them. So the data had to be collected one municipality at a time, in a few passes:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;A pilot of 22 municipalities&lt;/strong&gt; , researched carefully to learn which sources are reliable.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A scan of registry pages.&lt;/strong&gt; This turned out to be the best trick of the whole project.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Research agents&lt;/strong&gt; for the rest, with strict instructions.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A merge script&lt;/strong&gt; (&lt;code&gt;research/pt/scripts/merge.py&lt;/code&gt;) that combines all of it into the YAML.&lt;/li&gt;
&lt;/ol&gt;

&lt;h3&gt;
  
  
  The registry trick
&lt;/h3&gt;

&lt;p&gt;Portugal's civil registry offices (IRN) close on the local municipal holiday. Their contact pages say so, in a very regular format: &lt;em&gt;"Encerrado no feriado municipal (...)"&lt;/em&gt;, "closed on the municipal holiday", with the date or rule in brackets.&lt;/p&gt;

&lt;p&gt;Those pages live on an official government domain, and their URLs follow a handful of patterns. So &lt;code&gt;research/pt/scripts/irn_scan.py&lt;/code&gt; tries each municipality's name against those patterns and pulls out the bracket:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;PATTERNS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Registo-Civil-de-{}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Espaco-Registos-de-{}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Registo-Civil-Predial-e-Comercial-de-{}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Registo-Civil-e-Predial-de-{}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Registo-Predial-de-{}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Registo-Predial-e-Comercial-de-{}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="n"&gt;RE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;compile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;r&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Encerrad[oa] no feriado municipal\s*\(([^)]{3,80})\)&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;I&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That found 102 of the 308 municipalities with no search at all. The merge script then turns the bracket into a rule. Fixed dates ("20 de agosto") are a regex; movable ones are a lookup table of every phrase the scan found:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;quinta-feira da ascensão&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;({&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;easter&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;39&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;terceira segunda-feira de julho&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;({&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;nth_weekday&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;month&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;7&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;weekday&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;n&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;}},&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;segunda-feira após o primeiro domingo de outubro&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;({&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;nth_weekday&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;month&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;weekday&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;7&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;n&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;offset&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;}},&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Some phrases needed a judgement call, and those carry a note. Mafra's registry says "5ª quinta-feira da Ascensão", which I read as Ascension Thursday ("5ª" is short for &lt;em&gt;quinta-feira&lt;/em&gt;, Thursday). Idanha-a-Nova's says "the third Tuesday after Easter Sunday", which is Easter + 16, even though other lists give a Monday. I kept the official text.&lt;/p&gt;

&lt;h3&gt;
  
  
  Agents for the rest
&lt;/h3&gt;

&lt;p&gt;For the remaining municipalities I used research agents: LLM agents with web access, each given a batch of about ten municipalities and the same instruction file (&lt;code&gt;research/pt/agent-instructions.md&lt;/code&gt;). The rules that mattered most:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Never guess, never fill a field from memory.&lt;/li&gt;
&lt;li&gt;"Official" means an official domain that explicitly says it is the &lt;em&gt;feriado municipal&lt;/em&gt;.&lt;/li&gt;
&lt;li&gt;The quote must come from a page the agent actually fetched. If it only saw a search snippet, it has to say so, and the record becomes secondary.&lt;/li&gt;
&lt;li&gt;Report conflicts between sources instead of picking one silently.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Each batch got a hint from a compiled list, labelled as unreliable, so the agent knew what to look for but not what to conclude. The merge script then decides the status, not the agent:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;official&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;source_tier&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;official&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;quote_from_snippet&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="bp"&gt;...&lt;/span&gt;
&lt;span class="n"&gt;src&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;url&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;source_url&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;official&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;src&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;verified_at&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;V&lt;/span&gt;
&lt;span class="k"&gt;else&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;src&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ref&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;secondary&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So an agent can't promote its own finding: without an official page and a fetched quote, the record lands as &lt;code&gt;unverified&lt;/code&gt; with &lt;code&gt;ref: secondary&lt;/code&gt;, and the build check above makes sure it stays that way. The merge also runs in a fixed order (pilot, then IRN, then agents), and an IRN hit that disagrees with a verified pilot record is logged as a mismatch rather than overwriting it.&lt;/p&gt;

&lt;p&gt;The agents were useful. They found pages I wouldn't have had the patience to dig up one municipality at a time. Still, I trust the rules in the merge script more than the agents, and I spot-checked a sample of their findings by hand.&lt;/p&gt;

&lt;h2&gt;
  
  
  Sources that disagree
&lt;/h2&gt;

&lt;p&gt;The most useful thing the research produced is a list of sources not to trust on their own, now in &lt;code&gt;docs/data-review-pt.md&lt;/code&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;gov.pt citizen-service pages sometimes show the &lt;strong&gt;district capital's&lt;/strong&gt; holiday instead of the municipality's (Mortágua, Tabuaço, Pedrógão Grande, Portel), or a stale year.&lt;/li&gt;
&lt;li&gt;The widely copied compiled list is wrong for several places and &lt;strong&gt;swaps the two Lagoas and the two Calhetas&lt;/strong&gt; (each name exists twice, on different islands).&lt;/li&gt;
&lt;li&gt;Some directories still show Alfândega da Fé's pre-2016 date. The câmara moved it from 29 June to 8 May, and its own page says so.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Then there are real conflicts, where official sources disagree with each other or with tradition. Nordeste in the Azores is one: older lists say 18 July, but the câmara's own festival programmes put the holiday on a Monday in mid-July. The data uses "the Monday on or before 18 July" and the note explains why.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where it stands
&lt;/h2&gt;

&lt;p&gt;From &lt;code&gt;src/generated/data.json&lt;/code&gt; at the time of writing:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;291 of 308&lt;/strong&gt; Portuguese municipal holidays are verified: 134 from IRN registry pages, 100 from gov.pt pages, 57 from câmara websites, the Diário da República and other official documents.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;17&lt;/strong&gt; are still unverified, with secondary sources only. They're listed by name in the review doc. The fastest fix is usually an email to the câmara.&lt;/li&gt;
&lt;li&gt;All of Portugal's &lt;strong&gt;national and regional&lt;/strong&gt; holidays are verified, against the consolidated Labour Code and the regional decrees.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Keeping it up to date, without AI
&lt;/h2&gt;

&lt;p&gt;Holidays change. A council votes to move its date, a page gets redesigned, a site goes down. So there's a script that re-checks every source: &lt;code&gt;scripts/verify-sources.ts&lt;/code&gt;. It uses no AI. For each record it fetches &lt;code&gt;source.url&lt;/code&gt;, strips the HTML, and looks for the saved quote.&lt;/p&gt;

&lt;p&gt;Pages change formatting more often than content, so both sides are normalised first (accents, quotes, whitespace):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;normalize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;normalize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;NFD&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;\p&lt;/span&gt;&lt;span class="sr"&gt;{Diacritic}/gu&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;""&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toLowerCase&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;[&lt;/span&gt;&lt;span class="sr"&gt;“”«»"'&lt;/span&gt;&lt;span class="se"&gt;]&lt;/span&gt;&lt;span class="sr"&gt;/g&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;""&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;\s&lt;/span&gt;&lt;span class="sr"&gt;+/g&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt; &lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;trim&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A quote can be shortened with "...", in which case every fragment of 15 characters or more must be on the page. Each record ends up in one bucket:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;ok&lt;/code&gt;: the quote is still there&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;date_found&lt;/code&gt;: the quote is gone but the date ("13 de junho") is still on the page, usually a redesign&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;changed&lt;/code&gt;: neither found; for a fixed date, the holiday may have moved&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;broken&lt;/code&gt;: HTTP error, timeout or a bad certificate&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;unreadable&lt;/code&gt;: a PDF or a page rendered by JavaScript&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The baseline run on 16 October 2026 gave 289 ok, 3 date_found, 22 unreadable, 8 changed (6 of them movable holidays, where the page just shows another year's date), 3 broken and 1 without a URL. The plan is to run it yearly and research only the &lt;code&gt;changed&lt;/code&gt; and &lt;code&gt;broken&lt;/code&gt; entries. That keeps maintenance to a handful of records a year instead of 308.&lt;/p&gt;

&lt;h2&gt;
  
  
  The other countries
&lt;/h2&gt;

&lt;p&gt;Brazil, Germany, the UK, Greece and the US work differently. Their national and state layers are imported from &lt;a href="https://github.com/vacanza/holidays" rel="noopener noreferrer"&gt;python-holidays&lt;/a&gt;, so their source is the dataset, pinned to a version, and every record is &lt;code&gt;unverified&lt;/code&gt;. Instead of quotes they get automatic cross-checks against two other open datasets. That process gets its own post: "Adding a new country: what it takes to go from 6 to 7".&lt;/p&gt;

&lt;h2&gt;
  
  
  Limitations
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;verified_at&lt;/code&gt; is when the source was checked, not a guarantee that it is still true today. That's what the yearly re-check is for.&lt;/li&gt;
&lt;li&gt;PDFs and JavaScript-rendered pages are not checked automatically, so 22 records depend on a person looking.&lt;/li&gt;
&lt;li&gt;The date fallback only works for fixed dates, and only in Portuguese ("13 de junho").&lt;/li&gt;
&lt;li&gt;A registry page saying it closes on a day is strong evidence, but it's still not the municipal deliberation itself. Where the two might disagree, I'd rather show the quote and let you judge.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;How do you handle provenance for reference data in your own projects? Do you store the source next to each record, or keep it somewhere else? I'd like to hear about it in the comments.&lt;/p&gt;

&lt;p&gt;Next in the series: Business days that know local holidays.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://hldays.dev" rel="noopener noreferrer"&gt;hldays.dev&lt;/a&gt; · &lt;a href="https://hldays.dev/docs" rel="noopener noreferrer"&gt;Docs&lt;/a&gt;&lt;/p&gt;

</description>
      <category>opendata</category>
      <category>api</category>
      <category>buildinpublic</category>
      <category>typescript</category>
    </item>
    <item>
      <title>Building hldays #2: Modelling holidays from country down to municipality</title>
      <dc:creator>Alexander Denisov</dc:creator>
      <pubDate>Thu, 29 Oct 2026 09:00:00 +0000</pubDate>
      <link>https://dev.to/altofire/building-hldays-2-modelling-holidays-from-country-down-to-municipality-bhb</link>
      <guid>https://dev.to/altofire/building-hldays-2-modelling-holidays-from-country-down-to-municipality-bhb</guid>
      <description>&lt;p&gt;In &lt;a href="https://dev.to/altofire/building-hldays-1-i-kept-forgetting-my-municipal-holiday-so-i-built-an-api-for-it-4f3h-temp-slug-2109626"&gt;part 1&lt;/a&gt; I explained why I started hldays: I kept forgetting my municipal holiday, and found out that every Portuguese municipality has its own.&lt;/p&gt;

&lt;p&gt;This post is about the first real problem: &lt;strong&gt;how do you model holidays so the same API works for Lisbon, Bavaria and Texas?&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The naive version
&lt;/h2&gt;

&lt;p&gt;My first mental model was a table:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;date&lt;/th&gt;
&lt;th&gt;name&lt;/th&gt;
&lt;th&gt;country&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;2026-12-25&lt;/td&gt;
&lt;td&gt;Christmas&lt;/td&gt;
&lt;td&gt;PT&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;That breaks almost immediately:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Holidays aren't dates, they're rules.&lt;/strong&gt; Christmas is "25 December". Good Friday is "two days before Easter". Thanksgiving is "fourth Thursday of November". You can't store 2016 to 2099 by hand.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Holidays aren't national.&lt;/strong&gt; Bavaria has Epiphany, Berlin doesn't. Madeira has Madeira Day. Braga has São João on 24 June.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Places have different shapes.&lt;/strong&gt; Portugal has districts, two autonomous regions and 308 municipalities. Germany has states. The US has states and territories.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So the model became two things: a tree of jurisdictions, and a set of rules attached to them.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. A tree of jurisdictions
&lt;/h2&gt;

&lt;p&gt;Every place is a jurisdiction with an id, a level and a parent:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;PT country
├── PT-03 subdivision (district) Braga
│ └── PT-M-0303 municipality Braga
├── PT-11 subdivision (district) Lisboa
│ └── PT-M-1106 municipality Lisboa
├── PT-20 subdivision (autonomous_region) Azores
└── PT-30 subdivision (autonomous_region) Madeira

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There are only three &lt;strong&gt;levels&lt;/strong&gt; (&lt;code&gt;country&lt;/code&gt;, &lt;code&gt;subdivision&lt;/code&gt;, &lt;code&gt;municipality&lt;/code&gt;), but each subdivision also has a &lt;strong&gt;kind&lt;/strong&gt; (&lt;code&gt;district&lt;/code&gt;, &lt;code&gt;autonomous_region&lt;/code&gt;, &lt;code&gt;state&lt;/code&gt;...). The levels keep the API predictable, and the kind keeps the local meaning.&lt;/p&gt;

&lt;p&gt;Not every country uses every level. Right now Portugal, Germany and Brazil go down to municipalities. The UK and the US stop at subdivisions, and Greece is national only. Each country also says what its weekend is, because "business day" depends on it.&lt;/p&gt;

&lt;p&gt;The ids follow ISO 3166 where it exists (&lt;code&gt;PT&lt;/code&gt;, &lt;code&gt;PT-03&lt;/code&gt;, &lt;code&gt;DE-BY&lt;/code&gt;), with a municipality code for the extra level (&lt;code&gt;PT-M-0303&lt;/code&gt;, from Portugal's official municipality code).&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Holidays are inherited
&lt;/h2&gt;

&lt;p&gt;The main idea is simple: a jurisdiction gets its own holidays plus everything from its parents.&lt;/p&gt;

&lt;p&gt;Ask for Braga municipality and you get:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;the 13 national public holidays from &lt;code&gt;PT&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;São João on 24 June from &lt;code&gt;PT-M-0303&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Every holiday in the response says where it came from:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"date"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2026-06-24"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"holiday_id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"feriado-municipal"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"pt"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"São João Baptista"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"local"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"status"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"verified"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"source"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"url"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://irn.justica.gov.pt/Contactos-do-Registo/Registo-Civil-de-Braga"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"verified_at"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2026-10-16"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"quote"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Encerrado no feriado municipal (24 de junho)"&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"jurisdiction"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"PT-M-0303"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Braga"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"level"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"municipality"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;

&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That &lt;code&gt;jurisdiction&lt;/code&gt; field turned out to matter a lot. For Bavaria, Epiphany comes from &lt;code&gt;DE-BY&lt;/code&gt; and Christmas from &lt;code&gt;DE&lt;/code&gt;, so you can tell "this is a Bavarian holiday" apart from "this is a German holiday" without guessing.&lt;/p&gt;

&lt;p&gt;Inheritance also needs an escape hatch for the day a place doesn't observe something its parent does (a US state that skips a federal holiday, say). A jurisdiction can list &lt;code&gt;excludes&lt;/code&gt;: inherited holiday ids it skips. No place in the data needs it yet, but it's tested and ready.&lt;/p&gt;

&lt;p&gt;The whole mechanism is small. Walk from the place up to its country, collect each level's holidays, and drop anything a more specific level has excluded. This is the real code from &lt;code&gt;src/core/calendar.ts&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="cm"&gt;/** The jurisdiction followed by its ancestors up to the country. */&lt;/span&gt;
&lt;span class="nf"&gt;chain&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;j&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Jurisdiction&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nx"&gt;Jurisdiction&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;out&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Jurisdiction&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt;
  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;cur&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Jurisdiction&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;j&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;cur&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;cur&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;cur&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;parent&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;byId&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;cur&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;parent&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;out&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;cur&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;out&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="cm"&gt;/** Own + inherited occurrences, minus inherited holidays a descendant in the chain `excludes`. */&lt;/span&gt;
&lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="nf"&gt;resolved&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;j&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Jurisdiction&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;year&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;municipal&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;boolean&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;includeSelf&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nx"&gt;Occurrence&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;out&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Occurrence&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;excluded&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nb"&gt;Set&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;x&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;chain&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;j&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;includeSelf&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;x&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="nx"&gt;j&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;municipal&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;x&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;level&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;municipality&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
      &lt;span class="nx"&gt;out&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(...&lt;/span&gt;&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;own&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;x&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;year&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;o&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;excluded&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;has&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;holiday_id&lt;/span&gt;&lt;span class="p"&gt;)));&lt;/span&gt;
    &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;id&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;x&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;excludes&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="p"&gt;[])&lt;/span&gt; &lt;span class="nx"&gt;excluded&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;out&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;municipal&lt;/code&gt; flag is also where the free plan is enforced: with &lt;code&gt;municipal: false&lt;/code&gt; the walk simply skips the municipality level, so the same code serves both plans.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Rules, not dates
&lt;/h2&gt;

&lt;p&gt;hldays stores 716 holiday rules, not dates. When you ask for a year (anything from 2016 to 2099), the rules are expanded into concrete dates.&lt;/p&gt;

&lt;p&gt;The data lives in YAML files in the repo. This is Braga's entire entry:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;PT-M-0303&lt;/span&gt;
  &lt;span class="na"&gt;country&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;PT&lt;/span&gt;
  &lt;span class="na"&gt;level&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;municipality&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Braga"&lt;/span&gt;
  &lt;span class="na"&gt;parent&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;PT-03&lt;/span&gt;
  &lt;span class="na"&gt;codes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;ine&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;0303"&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
  &lt;span class="na"&gt;holidays&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;feriado-municipal&lt;/span&gt;
      &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;pt&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;São&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;João&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Baptista"&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
      &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;local&lt;/span&gt;
      &lt;span class="na"&gt;rule&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;fixed&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;06-24"&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
      &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;verified&lt;/span&gt;
      &lt;span class="na"&gt;source&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;url&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://irn.justica.gov.pt/Contactos-do-Registo/Registo-Civil-de-Braga"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;verified_at&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;2026-10-16"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;quote&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Encerrado&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;no&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;feriado&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;municipal&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;(24&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;de&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;junho)"&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A rule has exactly one shape. These are the real ones, from &lt;code&gt;src/core/types.ts&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;Rule&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
  &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;fixed&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="c1"&gt;// "MM-DD"&lt;/span&gt;
  &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;easter&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="c1"&gt;// offset in days from Easter Sunday (Gregorian)&lt;/span&gt;
  &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;nth_weekday&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;month&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;weekday&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;n&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;offset&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="c1"&gt;// n=-1 is last&lt;/span&gt;
  &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;on_or_before&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;date&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;weekday&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="c1"&gt;// that weekday on or before "MM-DD"&lt;/span&gt;
  &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;dates&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="c1"&gt;// explicit "YYYY-MM-DD" list, for holidays decreed year by year&lt;/span&gt;
  &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;orthodox_easter&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="c1"&gt;// offset in days from Orthodox (Julian) Easter Sunday&lt;/span&gt;
  &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;calendar_date&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;CalendarDate&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt; &lt;span class="c1"&gt;// a day in a non-Gregorian calendar&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In practice, three of them do most of the work: &lt;code&gt;fixed&lt;/code&gt; (437 rules), &lt;code&gt;nth_weekday&lt;/code&gt; (135) and &lt;code&gt;easter&lt;/code&gt; (105). Corpus Christi in Portugal is &lt;code&gt;{ easter: 60 }&lt;/code&gt;, Memorial Day in the US is the last Monday of May (&lt;code&gt;n: -1&lt;/code&gt;), and a municipality whose holiday is "the Monday after the first Sunday of October" is &lt;code&gt;nth_weekday&lt;/code&gt; with an &lt;code&gt;offset&lt;/code&gt; of 1. The odd ones exist because real holidays needed them: &lt;code&gt;dates&lt;/code&gt; covers holidays that a government decrees one year at a time, and &lt;code&gt;orthodox_easter&lt;/code&gt; is for Greece.&lt;/p&gt;

&lt;p&gt;Two more fields sit next to the rule:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;substitute&lt;/code&gt;&lt;/strong&gt; : what happens when the date falls on a weekend. US federal holidays move a Saturday to the Friday before and a Sunday to the Monday after. In 2026, 4 July is a Saturday, so the US response lists both 4 July and Friday 3 July, the second one with &lt;code&gt;"substitute_for": "2026-07-04"&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;valid_from&lt;/code&gt; / &lt;code&gt;valid_to&lt;/code&gt;&lt;/strong&gt; : holidays get added and removed by law. Brazil's National Day of Zumbi and Black Awareness (20 November) became a national holiday in 2024, so it has &lt;code&gt;valid_from: 2024&lt;/code&gt;, while the state-level versions that existed before end with &lt;code&gt;valid_to: 2023&lt;/code&gt;. 95 rules have a range like this.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Portugal has its own famous case: four national holidays were suspended between 2013 and 2015 and restored in 2016. The API's range starts in 2016, so that one never needs a range at all.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Types and status
&lt;/h2&gt;

&lt;p&gt;Every holiday also carries two fields that I now consider essential:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;type&lt;/code&gt;&lt;/strong&gt; : &lt;code&gt;public&lt;/code&gt; (a day off by law), &lt;code&gt;local&lt;/code&gt; (a municipal holiday, observed by public services and optional for some private employers) or &lt;code&gt;optional&lt;/code&gt; (a day off only by agreement or government decision, like Carnival in Portugal). By default the API returns &lt;code&gt;public&lt;/code&gt; and &lt;code&gt;local&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;status&lt;/code&gt;&lt;/strong&gt; : &lt;code&gt;verified&lt;/code&gt; or &lt;code&gt;unverified&lt;/code&gt;. A holiday is only &lt;code&gt;verified&lt;/code&gt; when it's backed by an official source, with a URL and the date it was checked. That's the topic of the next post.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Right now Portugal has 309 of 326 rules verified. The other countries are still &lt;code&gt;unverified&lt;/code&gt;, and the API says so instead of hiding it.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. Names are harder than dates
&lt;/h2&gt;

&lt;p&gt;"Braga" is both a district (&lt;code&gt;PT-03&lt;/code&gt;) and a municipality (&lt;code&gt;PT-M-0303&lt;/code&gt;). Searching for it returns both:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="err"&gt;curl&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://api.hldays.dev/v1/jurisdictions?q=braga"&lt;/span&gt;&lt;span class="w"&gt;


&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"data"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"PT-M-0303"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Braga"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"level"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"municipality"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"country"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"PT"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"parent"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"PT-03"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"PT-03"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Braga"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"level"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"subdivision"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"kind"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"district"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"country"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"PT"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"parent"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"PT"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"PT-M-0402"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Bragança"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"level"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"municipality"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"country"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"PT"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"parent"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"PT-04"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"PT-04"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Bragança"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"level"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"subdivision"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"kind"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"district"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"country"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"PT"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"parent"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"PT"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;]}&lt;/span&gt;&lt;span class="w"&gt;

&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So the holiday endpoints accept a name (&lt;code&gt;jurisdiction=lisboa&lt;/code&gt; or &lt;code&gt;jurisdiction=Lisbon&lt;/code&gt;) and &lt;strong&gt;prefer the most specific level&lt;/strong&gt;. Search ignores accents, aliases cover English names, and a &lt;code&gt;country&lt;/code&gt; parameter resolves names that exist in more than one country. There's a whole post on this later in the series.&lt;/p&gt;

&lt;p&gt;Holiday names are stored per language (&lt;code&gt;{ "pt": "Ano Novo", "en": "New Year's Day" }&lt;/code&gt;): the official name first, plus English where I have it. National holidays have both; the municipal ones only have their Portuguese name so far.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd tell myself at the start
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Model the places before the holidays.&lt;/strong&gt; Once the jurisdiction tree was right, inheritance was almost free.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Store rules, generate dates.&lt;/strong&gt; It's less data, it covers every year, and changes in the law become a date range instead of a migration.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Always say where a value came from.&lt;/strong&gt; The &lt;code&gt;jurisdiction&lt;/code&gt; on each holiday, and the &lt;code&gt;source&lt;/code&gt;, made the data easier to trust and easier to debug.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Next time: a source on every record, and how I collect, verify and keep 300+ municipal holidays up to date.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://hldays.dev" rel="noopener noreferrer"&gt;hldays.dev&lt;/a&gt; · &lt;a href="https://hldays.dev/docs" rel="noopener noreferrer"&gt;Docs&lt;/a&gt;&lt;/p&gt;

</description>
      <category>api</category>
      <category>typescript</category>
      <category>datamodeling</category>
      <category>buildinpublic</category>
    </item>
    <item>
      <title>Building hldays #1: I kept forgetting my municipal holiday, so I built an API for it</title>
      <dc:creator>Alexander Denisov</dc:creator>
      <pubDate>Fri, 23 Oct 2026 09:00:00 +0000</pubDate>
      <link>https://dev.to/altofire/building-hldays-1-i-kept-forgetting-my-municipal-holiday-so-i-built-an-api-for-it-5dac</link>
      <guid>https://dev.to/altofire/building-hldays-1-i-kept-forgetting-my-municipal-holiday-so-i-built-an-api-for-it-5dac</guid>
      <description>&lt;p&gt;At work I get one extra day off a year: my municipality's holiday.&lt;/p&gt;

&lt;p&gt;Every year I forgot about it.&lt;/p&gt;

&lt;p&gt;That was the whole problem at first. But when I looked into it, I found something more interesting: &lt;strong&gt;every municipality has its own holiday&lt;/strong&gt;. Lisbon's is on 13 June, Porto's on 24 June, Coimbra's on 4 July. Portugal has 308 municipalities, and each one sets its own local holiday on top of the national ones.&lt;/p&gt;

&lt;p&gt;I wanted to understand how that worked. Then I wanted to see if I could organise it properly. That turned into &lt;a href="https://hldays.dev" rel="noopener noreferrer"&gt;hldays&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  What hldays is
&lt;/h2&gt;

&lt;p&gt;hldays is a public holidays API that goes &lt;strong&gt;down to the municipality&lt;/strong&gt; :&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;National, regional and municipal holidays&lt;/strong&gt; in one API&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A source on every record&lt;/strong&gt; : each holiday links to the law, registry or council page it comes from, with a quote and the date it was checked&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Business-day maths that knows local holidays&lt;/strong&gt; : "Is this a working day in Porto?" or "What's 10 business days from today in Braga?" is one call&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;REST and MCP&lt;/strong&gt; : the same data is available to apps and to AI agents like Claude&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Getting national holidays for a country needs no key:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="s2"&gt;"https://api.hldays.dev/v1/holidays?jurisdiction=PT&amp;amp;year=2026"&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A single municipality (Braga here) uses the same call. Without a key you get the national and regional holidays that apply there, plus a notice; Braga's own holiday comes with a paid plan:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="s2"&gt;"https://api.hldays.dev/v1/holidays?jurisdiction=PT-M-0303&amp;amp;year=2026"&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Where it is today
&lt;/h2&gt;

&lt;p&gt;The site is live at &lt;a href="https://hldays.dev" rel="noopener noreferrer"&gt;hldays.dev&lt;/a&gt;, with &lt;a href="https://hldays.dev/docs" rel="noopener noreferrer"&gt;docs&lt;/a&gt; and the API at &lt;code&gt;api.hldays.dev&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;At the time of writing it covers 6 countries (Portugal, Brazil, Germany, the UK, Greece and the US): 440 places and 716 holiday rules. Portugal is the most complete: all 308 municipal holidays, nearly all of them verified against an official source. For the other countries, verification is still to come.&lt;/p&gt;

&lt;p&gt;National and regional data is free (rate limited). Municipal holidays and religious/cultural observances are on paid plans through RapidAPI.&lt;/p&gt;

&lt;p&gt;It still has limitations, and I'll be honest about them as the series goes on.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why I kept going
&lt;/h2&gt;

&lt;p&gt;The first goal was to stop forgetting one day off. What kept me going was the engineering:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Data modelling.&lt;/strong&gt; How do you represent "the Tuesday after Easter in this municipality" next to "25 April, nationwide", across countries with different hierarchies?&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Maintenance.&lt;/strong&gt; Holidays change. Laws are amended, councils move dates. What does it take to keep this data correct and prove where every record came from?&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Working with it.&lt;/strong&gt; Business-day calculations sound simple until local holidays get involved.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Shipping it.&lt;/strong&gt; Docs, pricing, rate limits, and getting a side project in front of people.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Agents.&lt;/strong&gt; How do you expose data so an AI agent can use it reliably? That's why hldays has an MCP server.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I learned more from this side project than I expected, so I'm going to write it down.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's next in this series
&lt;/h2&gt;

&lt;p&gt;Here's the rough plan:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;This post&lt;/strong&gt; : why hldays exists&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Modelling holidays&lt;/strong&gt; : jurisdictions, rules, and the layers from country to municipality&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A source on every record&lt;/strong&gt; : how the data is collected, verified and kept fresh&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Business days that know local holidays&lt;/strong&gt; : weekends, observed days, adding and subtracting business days&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Observed holidays and edge cases&lt;/strong&gt; : weekend shifts and changes in the law&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Names are harder than dates&lt;/strong&gt; : same-name places, aliases and accent-insensitive search&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Religious and cultural observances&lt;/strong&gt; : Easter, Orthodox, Jewish, Islamic and Chinese calendars&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Making an API agent-friendly with MCP&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The architecture&lt;/strong&gt; : TypeScript on Cloudflare Workers, with no database&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Docs that sell the API&lt;/strong&gt; : OpenAPI, Redoc and the homepage demo&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Shipping and monetising a side-project API&lt;/strong&gt; : free vs paid tiers, RapidAPI, rate limits&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Adding a new country&lt;/strong&gt; : what it takes to go from 6 to 7&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Lessons learned so far&lt;/strong&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If you've ever had to deal with holidays in code, or you have a municipal holiday you always forget, I'd love to hear about it in the comments. Feedback on the API is very welcome too.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://hldays.dev" rel="noopener noreferrer"&gt;hldays.dev&lt;/a&gt; · &lt;a href="https://hldays.dev/docs" rel="noopener noreferrer"&gt;Docs&lt;/a&gt;&lt;/p&gt;

</description>
      <category>api</category>
      <category>typescript</category>
      <category>buildinpublic</category>
      <category>sideprojects</category>
    </item>
  </channel>
</rss>
