openvibers/openvibe.today Commits
Today: the public holidays of a country show on your week (plan L48 R3) (#47)
Changes
@@@ -173,7 +173,7 @@ HTML and stable `td-` ids/classes:| `/@:username` | a channel's schedule, with Subscribe (`webcal://`) and the `.ics` || `/e/:id` | one event, with `application/ld+json` `Event` (name, startDate, endDate, eventStatus, eventAttendanceMode, VirtualLocation, organizer); Remind me (`POST /e/:id/remind`, `/remind/remove`), Add to calendar (`/e/:id.ics`), Watch on Live, and Cancel for its owner (`POST /e/:id/cancel`) || `/me` | agenda, reminders, the calendars you imported (a form and a list, with a Remove button), the feed URL and its rotate form |--| `/me/week` | your week: seven days from Monday in your own zone — your reminders at each time they fire, your events, the streams of the channels you follow, and the events of the calendars you imported |++| `/me/week` | your week: seven days from Monday in your own zone — your reminders at each time they fire, your events, the streams of the channels you follow, the events of the calendars you imported, and the public holidays of the country you choose (a small select sets it) || `/brief` | today's brief || `/schedule` | a plain form that schedules a stream or an event: the start is wall time in the chosen zone (the device's, with JavaScript), plus a length (`POST /schedule` → 303 `/e/:id`) || `/developers` | the API |@@@ -200,7 +200,10 @@ signature), the outbox (fixed id, backoff, restart), reminders (one notification(sections, Rundown failure, one per day), pages, sign-in, account export/deletion and discovery. The imported calendarsare tested on their own: reading a calendar (a zone, an all-day event, a weekly rule, an excluded day, a cancelledevent, a clock change), refusing a private address and a body that is too big, the week that shows them, the five--addresses a person may keep, and a form post from another site refused.++addresses a person may keep, and a form post from another site refused. The public holidays are tested on their++own too: the rules for several years (Thanksgiving, Memorial Day, a July 4 on a weekend, Easter Monday, the August++bank holiday, Pfingstmontag, a fixed Japanese holiday, Carnaval), the country a zone says, the `?country=` and the++cookie, an unknown code ignored, and the week with the holiday at the top of its day.## Imports@@@ -229,6 +232,27 @@ in it (`BYMONTH`, `BYSETPOS`, an ordinal `BYDAY` such as `2FR`), a zone this serwith no `DTSTART` are left out and counted, never guessed at. The address belongs to the person: it is neverexported and it goes with the account.++## Public holidays++++Your week shows the public holidays of a country: an all-day line at the top of its day, with the country code in++small letters. `server/holidays.js` works them out by rule, with no network call and no dependency: a fixed date++(1 January), the nth weekday of a month (the fourth Thursday of November is Thanksgiving Day), the last weekday of a++month (the last Monday of May is Memorial Day) and Easter with the days counted from it (Good Friday is two days++before Easter, Whit Monday fifty after). A holiday that lands on a weekend is moved the way that country moves it:++the United States takes the Friday before a Saturday holiday and the Monday after a Sunday one; Britain, Canada,++Ireland and Australia take the Monday; New Zealand the Monday or the Tuesday.++++The country is the one the person asks for: the `?country=` of the week, else the `td_country` cookie, else the one++their time zone says (`Europe/Berlin` → Germany). The little select on the week page sets the cookie with a plain++form (`POST /me/week/country`, same-origin, no JavaScript); a code this service does not know is ignored, and a++zone that says nothing (`UTC`) leaves the person without holidays until they choose.++++The lists are the countries' own: the United States' federal holidays, Canada's statutory ones, the England and++Wales bank holidays, Ireland's public holidays, Australia's national eight, New Zealand's Mondayised ones,++Germany's, France's, Spain's, Italy's, the Netherlands', Brazil's, Mexico's días de descanso obligatorio, India's++three gazetted ones and Japan's. Each country keeps the comment that says where its list comes from, and a country++whose list cannot be stated is left out rather than guessed at.++## Deploy`deploy/systemd/openvibe-today.service` (127.0.0.1:5100, `/etc/openvibe/today.env`) and@@@ -253,6 +253,11 @@ html[data-ov-motion=reduced] .td-demo-toast, html[data-ov-motion=reduced] .td-ri.td-wi.is-event { --k: #a78bfa; }.td-wi.is-stream { --k: #f43f5e; }.td-wi.is-import { --k: #38bdf8; }++.td-wi.is-holiday { --k: #fbbf24; }++.td-holsel { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; margin: 12px 0 0; }++.td-holsel label { font-size: 12.5px; color: var(--text-secondary, #96a7c2); }++.td-holsel select { min-height: 34px; padding: 4px 10px; border-radius: 10px; border: 1px solid var(--border, #1f2d47); background: var(--bg-secondary, #0e1626); color: var(--text-primary, #e8eef8); font: inherit; font-size: 13px; }++.td-holsel select:focus-visible { outline: 2px solid var(--room-accent); outline-offset: 1px; }/* An imported event has no page of its own here: it is shown, not linked. */.td-wi a:not([href]) { cursor: default; }.td-wi a:not([href]):hover { background: color-mix(in srgb, var(--text-primary, #e8eef8) 4%, transparent); }nnew file mode 100644@@@ -0,0 +1,508 @@++'use strict';++/**++ * The public holidays of a country for a year, by rule — no network, no dependency (plan L48 R3).++ *++ * holidays('US', 2026) → [{ date: '2026-11-26', name: 'Thanksgiving Day', country: 'US' }, …]++ * countryOfZone('Europe/Berlin') → 'DE'++ * resolveCountry({ query, cookie, tz }) → 'FR' (the ?country=, else the cookie, else the zone)++ *++ * A holiday is a day, not an instant: the week puts it at the top of its day, like an imported all-day++ * event. The rules are the ones each country's own list states:++ * - a fixed date (1 January), sometimes moved when it lands on a weekend: the United States takes a++ * Saturday holiday on the Friday before and a Sunday one on the Monday after (5 U.S.C. § 6103);++ * Britain, Canada, Ireland and Australia take the Monday; New Zealand takes the Monday or the Tuesday;++ * - the nth weekday of a month (the fourth Thursday of November is Thanksgiving Day);++ * - the last weekday of a month (the last Monday of May is Memorial Day in the United States);++ * - Easter and the days counted from it (Good Friday is two days before Easter, Whit Monday fifty after).++ *++ * Only what an official list states is here. A country whose list cannot be stated is left out rather than++ * guessed, and a country keeps the holidays its own law names nationally (India's gazetted three, Britain's++ * England and Wales list, Australia's national eight). The sources:++ *++ * US federal: https://www.opm.gov/policy-data-oversight/pay-leave/federal-holidays/ (5 U.S.C. § 6103)++ * CA federal: https://www.canada.ca/en/revenue-agency/services/tax/public-holidays.html++ * GB England/Wales: https://www.gov.uk/bank-holidays++ * IE: https://www.citizensinformation.ie/en/employment/employment-rights/leave-and-holidays/public-holidays-in-ireland/++ * AU national: https://www.fairwork.gov.au/employment-conditions/public-holidays++ * NZ: https://www.employment.govt.nz/leave-and-holidays/public-holidays/public-holidays-and-anniversary-dates/++ * DE national: https://www.gesetze-im-internet.de/arbzg/__9.html (Arbeitszeitgesetz § 9)++ * FR national: https://www.service-public.fr/particuliers/vosdroits/F2405++ * ES national: https://www.boe.es/buscar/act.php?id=BOE-A-1983-21506 (Real Decreto 2001/1983, art. 45)++ * IT national: https://www.normattiva.it/uri-res/N2Ls?urn:nir:stato:legge:1949-12-27;260 (legge 260/1949)++ * NL national: https://wetten.overheid.nl/BWBR0007670/ (Arbeidstijdenwet, art. 2)++ * BR national: https://www.planalto.gov.br/ccivil_03/leis/l216/l216.htm (Lei 662/1949, Lei 10.607/2002)++ * MX national: https://www.diputados.gob.mx/LeyesBiblio/pdf/LFT.pdf (Ley Federal del Trabajo, art. 74)++ * IN gazetted: https://www.indiacode.nic.in/ (the three holidays the gazette names)++ * JP national: https://www8.cao.go.jp/chosatu/ (Cabinet Office, national holidays)++ *++ * This is the rule book for the week (server/week.js). The other holiday reader in this service,++ * server/imports/holidays.js, is a different thing: it imports the network's holiday events from an open++ * dataset for the public pages.++ */++++const pad = (n) => String(n).padStart(2, '0');++const key = (d) => `${d.y}-${pad(d.m)}-${pad(d.d)}`;++const dow = (y, m, d) => new Date(Date.UTC(y, m - 1, d)).getUTCDay(); // 0 Sunday … 6 Saturday++const addDays = (y, m, d, n) => {++ const t = new Date(Date.UTC(y, m - 1, d + n));++ return { y: t.getUTCFullYear(), m: t.getUTCMonth() + 1, d: t.getUTCDate() };++};++++/** The nth weekday of a month (weekday 1 Monday … 7 Sunday), the first when n is 1. */++function nthWeekday(y, m, weekday, n) {++ const first = dow(y, m, 1);++ const offset = (8 - first) % 7; // days from the 1st to the first Monday++ return { y, m, d: 1 + offset + (weekday - 1) + (n - 1) * 7 };++}++++/** The last weekday of a month (weekday 1 Monday … 7 Sunday). */++function lastWeekday(y, m, weekday) {++ const last = new Date(Date.UTC(y, m, 0));++ const lastDow = last.getUTCDay() === 0 ? 7 : last.getUTCDay();++ return { y, m, d: last.getUTCDate() - (lastDow - weekday) };++}++++/** The Monday of the week before a date: the Monday strictly before it. */++function mondayBefore(y, m, d) {++ const day = dow(y, m, d);++ const back = day === 1 ? 7 : day === 0 ? 6 : day - 1;++ return addDays(y, m, d, -back);++}++++/** Easter Sunday, Western (Gregorian) computus. */++function easter(y) {++ const a = y % 19;++ const b = Math.floor(y / 100);++ const c = y % 100;++ const d = Math.floor(b / 4);++ const e = b % 4;++ const f = Math.floor((b + 8) / 25);++ const g = Math.floor((b - f + 1) / 3);++ const h = (19 * a + b - d - g + 15) % 30;++ const i = Math.floor(c / 4);++ const k = c % 4;++ const l = (32 + 2 * e + 2 * i - h - k) % 7;++ const m = Math.floor((a + 11 * h + 22 * l) / 451);++ const month = Math.floor((h + l - 7 * m + 114) / 31);++ const day = ((h + l - 7 * m + 114) % 31) + 1;++ return { y, m: month, d: day };++}++++/** Easter and the days counted from it: Good Friday is −2, Easter Monday +1, Ascension +39, Whit Monday +50. */++function fromEaster(y, offset) {++ const e = easter(y);++ return addDays(y, e.m, e.d, offset);++}++++/** A Saturday holiday is taken the Friday before, a Sunday one the Monday after (the United States). */++function usObserved(y, m, d) {++ const day = dow(y, m, d);++ if (day === 6) return addDays(y, m, d, -1);++ if (day === 0) return addDays(y, m, d, 1);++ return { y, m, d };++}++++/** A holiday on a weekend is taken the Monday after (Britain, Canada, Ireland, Australia, most states). */++function mondayIfWeekend(y, m, d) {++ const day = dow(y, m, d);++ if (day === 6) return addDays(y, m, d, 2);++ if (day === 0) return addDays(y, m, d, 1);++ return { y, m, d };++}++++/**++ * Christmas Day and Boxing Day together, as Britain, Canada, Ireland and Australia take them: a++ * Saturday Christmas is taken Monday and Tuesday, a Sunday Christmas Tuesday and Monday, and a++ * Saturday Boxing Day the Monday after.++ */++function christmasPair(y) {++ const c = dow(y, 12, 25);++ if (c === 6) return { christmas: { y, m: 12, d: 27 }, boxing: { y, m: 12, d: 28 } };++ if (c === 0) return { christmas: { y, m: 12, d: 27 }, boxing: { y, m: 12, d: 26 } };++ if (c === 5) return { christmas: { y, m: 12, d: 25 }, boxing: { y, m: 12, d: 28 } };++ return { christmas: { y, m: 12, d: 25 }, boxing: { y, m: 12, d: 26 } };++}++++/** The United States (federal): the eleven days OPM lists. */++function us(y) {++ const out = [];++ const add = (dt, name) => out.push({ ...dt, name });++ add(usObserved(y, 1, 1), "New Year's Day");++ add(nthWeekday(y, 1, 1, 3), 'Birthday of Martin Luther King, Jr.');++ add(nthWeekday(y, 2, 1, 3), "Washington's Birthday");++ add(lastWeekday(y, 5, 1), 'Memorial Day');++ if (y >= 2021) add(usObserved(y, 6, 19), 'Juneteenth National Independence Day');++ add(usObserved(y, 7, 4), 'Independence Day');++ add(nthWeekday(y, 9, 1, 1), 'Labor Day');++ add(nthWeekday(y, 10, 1, 2), 'Columbus Day');++ add(usObserved(y, 11, 11), 'Veterans Day');++ add(nthWeekday(y, 11, 4, 4), 'Thanksgiving Day');++ add(usObserved(y, 12, 25), 'Christmas Day');++ return out;++}++++/** Canada (the federal statutory holidays the CRA lists). */++function ca(y) {++ const out = [];++ const add = (dt, name) => out.push({ ...dt, name });++ // New Year's Day: a Saturday is taken the Friday before, the last day of the year before.++ if (dow(y, 1, 1) === 6) add(addDays(y, 1, 1, -1), "New Year's Day");++ else add(mondayIfWeekend(y, 1, 1), "New Year's Day");++ add(fromEaster(y, -2), 'Good Friday');++ add(fromEaster(y, 1), 'Easter Monday');++ add(mondayBefore(y, 5, 25), 'Victoria Day');++ add(mondayIfWeekend(y, 7, 1), 'Canada Day');++ add(nthWeekday(y, 9, 1, 1), 'Labour Day');++ if (y >= 2021) add({ y, m: 9, d: 30 }, 'National Day for Truth and Reconciliation');++ add(nthWeekday(y, 10, 1, 2), 'Thanksgiving Day');++ add({ y, m: 11, d: 11 }, 'Remembrance Day');++ const pair = christmasPair(y);++ add(pair.christmas, 'Christmas Day');++ add(pair.boxing, 'Boxing Day');++ return out;++}++++/** Great Britain (England and Wales): the bank holidays gov.uk lists. */++function gb(y) {++ const out = [];++ const add = (dt, name) => out.push({ ...dt, name });++ add(mondayIfWeekend(y, 1, 1), "New Year's Day");++ add(fromEaster(y, -2), 'Good Friday');++ add(fromEaster(y, 1), 'Easter Monday');++ add(nthWeekday(y, 5, 1, 1), 'Early May bank holiday');++ add(lastWeekday(y, 5, 1), 'Spring bank holiday');++ add(lastWeekday(y, 8, 1), 'Summer bank holiday');++ const pair = christmasPair(y);++ add(pair.christmas, 'Christmas Day');++ add(pair.boxing, 'Boxing Day');++ return out;++}++++/** Ireland: the public holidays citizensinformation.ie lists (St Brigid's Day since 2023). */++function ie(y) {++ const out = [];++ const add = (dt, name) => out.push({ ...dt, name });++ add(mondayIfWeekend(y, 1, 1), "New Year's Day");++ if (y >= 2023) add(nthWeekday(y, 2, 1, 1), "St Brigid's Day");++ add({ y, m: 3, d: 17 }, "St Patrick's Day");++ add(fromEaster(y, 1), 'Easter Monday');++ add(nthWeekday(y, 5, 1, 1), 'May Bank Holiday');++ add(nthWeekday(y, 6, 1, 1), 'June Bank Holiday');++ add(nthWeekday(y, 8, 1, 1), 'August Bank Holiday');++ add(lastWeekday(y, 10, 1), 'October Bank Holiday');++ const pair = christmasPair(y);++ add(pair.christmas, 'Christmas Day');++ add(pair.boxing, "St Stephen's Day");++ return out;++}++++/** Australia (the national public holidays Fair Work lists; the states add a few of their own). */++function au(y) {++ const out = [];++ const add = (dt, name) => out.push({ ...dt, name });++ add(mondayIfWeekend(y, 1, 1), "New Year's Day");++ add(mondayIfWeekend(y, 1, 26), 'Australia Day');++ add(fromEaster(y, -2), 'Good Friday');++ add(fromEaster(y, 1), 'Easter Monday');++ add({ y, m: 4, d: 25 }, 'Anzac Day');++ add(nthWeekday(y, 6, 1, 2), y >= 2023 ? "King's Birthday" : "Queen's Birthday");++ const pair = christmasPair(y);++ add(pair.christmas, 'Christmas Day');++ add(pair.boxing, 'Boxing Day');++ return out;++}++++/** New Zealand: the holidays that are Mondayised when they land on a weekend. */++function nz(y) {++ const out = [];++ const add = (dt, name) => out.push({ ...dt, name });++ // New Year's Day and the day after: a Saturday New Year is taken Monday and Tuesday.++ const ny = dow(y, 1, 1);++ if (ny === 6) { add({ y, m: 1, d: 3 }, "New Year's Day"); add({ y, m: 1, d: 4 }, "Day after New Year's Day"); }++ else if (ny === 0) { add({ y, m: 1, d: 2 }, "New Year's Day"); add({ y, m: 1, d: 3 }, "Day after New Year's Day"); }++ else if (ny === 1) { add({ y, m: 1, d: 1 }, "New Year's Day"); add({ y, m: 1, d: 2 }, "Day after New Year's Day"); }++ else { add({ y, m: 1, d: 1 }, "New Year's Day"); add(mondayIfWeekend(y, 1, 2), "Day after New Year's Day"); }++ add(mondayIfWeekend(y, 2, 6), 'Waitangi Day');++ add(fromEaster(y, -2), 'Good Friday');++ add(fromEaster(y, 1), 'Easter Monday');++ add(mondayIfWeekend(y, 4, 25), 'Anzac Day');++ add(nthWeekday(y, 6, 1, 1), y >= 2023 ? "King's Birthday" : "Queen's Birthday");++ add(nthWeekday(y, 10, 1, 4), 'Labour Day');++ // Christmas and Boxing Day: a Sunday Christmas is taken Monday and Tuesday.++ const c = dow(y, 12, 25);++ if (c === 6) { add({ y, m: 12, d: 27 }, 'Christmas Day'); add({ y, m: 12, d: 28 }, 'Boxing Day'); }++ else if (c === 0) { add({ y, m: 12, d: 26 }, 'Christmas Day'); add({ y, m: 12, d: 27 }, 'Boxing Day'); }++ else { add({ y, m: 12, d: 25 }, 'Christmas Day'); add(mondayIfWeekend(y, 12, 26), 'Boxing Day'); }++ return out;++}++++/** Germany: the nine days the Arbeitszeitgesetz § 9 names. */++function de(y) {++ const out = [];++ const add = (dt, name) => out.push({ ...dt, name });++ add({ y, m: 1, d: 1 }, 'Neujahr');++ add(fromEaster(y, -2), 'Karfreitag');++ add(fromEaster(y, 1), 'Ostermontag');++ add({ y, m: 5, d: 1 }, 'Tag der Arbeit');++ add(fromEaster(y, 39), 'Christi Himmelfahrt');++ add(fromEaster(y, 50), 'Pfingstmontag');++ add({ y, m: 10, d: 3 }, 'Tag der Deutschen Einheit');++ add({ y, m: 12, d: 25 }, 'Erster Weihnachtstag');++ add({ y, m: 12, d: 26 }, 'Zweiter Weihnachtstag');++ return out;++}++++/** France: the eleven jours fériés the service-public lists. */++function fr(y) {++ const out = [];++ const add = (dt, name) => out.push({ ...dt, name });++ add({ y, m: 1, d: 1 }, "Jour de l'an");++ add(fromEaster(y, 1), 'Lundi de Pâques');++ add({ y, m: 5, d: 1 }, 'Fête du Travail');++ add({ y, m: 5, d: 8 }, 'Victoire 1945');++ add(fromEaster(y, 39), 'Ascension');++ add(fromEaster(y, 50), 'Lundi de Pentecôte');++ add({ y, m: 7, d: 14 }, 'Fête Nationale');++ add({ y, m: 8, d: 15 }, 'Assomption');++ add({ y, m: 11, d: 1 }, 'Toussaint');++ add({ y, m: 11, d: 11 }, 'Armistice');++ add({ y, m: 12, d: 25 }, 'Noël');++ return out;++}++++/** Spain: the national holidays (Real Decreto 2001/1983, art. 45); a holiday on a Sunday is taken Monday. */++function es(y) {++ const out = [];++ const add = (dt, name) => out.push({ ...dt, name });++ const monday = (dt) => (dow(dt.y, dt.m, dt.d) === 0 ? addDays(dt.y, dt.m, dt.d, 1) : dt);++ add(monday({ y, m: 1, d: 1 }), 'Año Nuevo');++ add(monday({ y, m: 1, d: 6 }), 'Epifanía del Señor');++ add(monday({ y, m: 3, d: 25 }), 'San José');++ if (y >= 2024) add(monday(fromEaster(y, -2)), 'Viernes Santo');++ add(monday({ y, m: 5, d: 1 }), 'Fiesta del Trabajo');++ add(monday({ y, m: 8, d: 15 }), 'Asunción de la Virgen');++ add(monday({ y, m: 10, d: 12 }), 'Fiesta Nacional de España');++ add(monday({ y, m: 11, d: 1 }), 'Todos los Santos');++ add(monday({ y, m: 12, d: 6 }), 'Día de la Constitución Española');++ add(monday({ y, m: 12, d: 8 }), 'Immaculada Concepción');++ add(monday({ y, m: 12, d: 25 }), 'Natividad del Señor');++ return out;++}++++/** Italy: the national holidays (legge 260/1949, as the Ministry of Labour lists them). */++function it(y) {++ const out = [];++ const add = (dt, name) => out.push({ ...dt, name });++ add(fromEaster(y, 0), 'Pasqua');++ add(fromEaster(y, 1), "Lunedì dell'Angelo");++ add({ y, m: 5, d: 1 }, 'Festa del Lavoro');++ add({ y, m: 8, d: 15 }, 'Ferragosto');++ add({ y, m: 11, d: 1 }, 'Ognissanti');++ add({ y, m: 12, d: 8 }, 'Immacolata Concezione');++ add({ y, m: 12, d: 25 }, 'Natale');++ add({ y, m: 12, d: 26 }, 'Santo Stefano');++ return out;++}++++/** The Netherlands: the national holidays the Arbeidstijdenwet names (art. 2). */++function nl(y) {++ const out = [];++ const add = (dt, name) => out.push({ ...dt, name });++ add({ y, m: 1, d: 1 }, 'Nieuwjaarsdag');++ add(fromEaster(y, -2), 'Goede Vrijdag');++ add(fromEaster(y, 0), 'Eerste Paasdag');++ add(fromEaster(y, 1), 'Tweede Paasdag');++ // King's Day is 27 April when 30 April is a Sunday.++ if (dow(y, 4, 30) === 0) add({ y, m: 4, d: 27 }, 'Koningsdag');++ else add({ y, m: 4, d: 30 }, 'Koningsdag');++ add({ y, m: 5, d: 5 }, 'Bevrijdingsdag');++ add({ y, m: 12, d: 25 }, 'Eerste Kerstdag');++ add({ y, m: 12, d: 26 }, 'Tweede Kerstdag');++ return out;++}++++/** Brazil: the national holidays (Lei 662/1949 and Lei 10.607/2002). */++function br(y) {++ const out = [];++ const add = (dt, name) => out.push({ ...dt, name });++ add({ y, m: 1, d: 1 }, 'Confraternização Universal');++ add(fromEaster(y, -47), 'Carnaval');++ add(fromEaster(y, -48), 'Carnaval');++ add(fromEaster(y, -2), 'Sexta-feira Santa');++ add({ y, m: 4, d: 21 }, 'Tiradentes');++ add({ y, m: 5, d: 1 }, 'Dia do Trabalho');++ add(fromEaster(y, 60), 'Corpus Christi');++ add({ y, m: 9, d: 7 }, 'Independência do Brasil');++ add({ y, m: 10, d: 12 }, 'Nossa Senhora Aparecida');++ add({ y, m: 11, d: 2 }, 'Finados');++ add({ y, m: 11, d: 15 }, 'Proclamação da República');++ if (y >= 2024) add({ y, m: 11, d: 20 }, 'Dia da Consciência Negra');++ add({ y, m: 12, d: 25 }, 'Natal');++ return out;++}++++/** Mexico: the días de descanso obligatorio of the Ley Federal del Trabajo, art. 74. */++function mx(y) {++ const out = [];++ const add = (dt, name) => out.push({ ...dt, name });++ add({ y, m: 1, d: 1 }, 'Año Nuevo');++ add(nthWeekday(y, 2, 1, 1), 'Día de la Constitución');++ add(nthWeekday(y, 3, 1, 3), 'Aniversario de Benito Juárez');++ add({ y, m: 5, d: 1 }, 'Día del Trabajo');++ add({ y, m: 9, d: 16 }, 'Día de la Independencia');++ if (y >= 2024 && (y - 2024) % 6 === 0) add({ y, m: 10, d: 1 }, 'Transmisión del Poder Ejecutivo Federal');++ add({ y, m: 11, d: 2 }, 'Día de Muertos');++ add(nthWeekday(y, 11, 1, 3), 'Día de la Revolución');++ add({ y, m: 12, d: 25 }, 'Navidad');++ return out;++}++++/** India: the three holidays the gazette names nationally. */++function in_(y) {++ return [++ { y, m: 1, d: 26, name: 'Republic Day' },++ { y, m: 8, d: 15, name: 'Independence Day' },++ { y, m: 10, d: 2, name: 'Gandhi Jayanti' },++ ];++}++++/**++ * The equinox day, as the National Astronomical Observatory of Japan works it out: the day of the++ * vernal (or autumnal) equinox, 1980–2099.++ */++function equinox(y, which) {++ const base = which === 'spring' ? 20.8431 : 23.2488;++ const n = y - 1980;++ const day = Math.floor(base + 0.242194 * n - Math.floor(n / 4));++ if (y < 1980) return day + 1;++ if (y > 2099) return day - 1;++ return day;++}++++/**++ * Japan: the national holidays (Cabinet Office). A holiday on a Sunday is taken the next free day++ * (振替休日). The one-off moves of 2019 (the Silver Week), 2020 and 2021 (the Olympic games) are not++ * modelled: the standing rules are.++ */++function jp(y) {++ const out = [];++ const add = (dt, name) => out.push({ ...dt, name });++ add({ y, m: 1, d: 1 }, '元日');++ add(nthWeekday(y, 1, 1, 3), '成人の日');++ add({ y, m: 2, d: 11 }, '建国記念の日');++ if (y >= 2020) add({ y, m: 2, d: 23 }, '天皇誕生日');++ else add({ y, m: 12, d: 23 }, '天皇誕生日');++ add({ y, m: 3, d: equinox(y, 'spring') }, '春分の日');++ add({ y, m: 4, d: 29 }, '昭和の日');++ add({ y, m: 5, d: 3 }, '憲法記念日');++ add({ y, m: 5, d: 4 }, 'みどりの日');++ add({ y, m: 5, d: 5 }, 'こどもの日');++ add(nthWeekday(y, 7, 1, 3), '海の日');++ add({ y, m: 8, d: 11 }, '山の日');++ add(nthWeekday(y, 9, 1, 3), '敬老の日');++ add({ y, m: 9, d: equinox(y, 'autumn') }, '秋分の日');++ add(nthWeekday(y, 10, 1, 2), 'スポーツの日');++ add({ y, m: 11, d: 3 }, '文化の日');++ add({ y, m: 11, d: 23 }, '勤労感謝の日');++ // 振替休日: a national holiday on a Sunday is taken the next day that is not a holiday itself.++ const taken = new Set(out.map((h) => key(h)));++ for (const h of out.slice()) {++ if (dow(h.y, h.m, h.d) !== 0) continue;++ let d = addDays(h.y, h.m, h.d, 1);++ while (taken.has(key(d))) d = addDays(d.y, d.m, d.d, 1);++ taken.add(key(d));++ out.push({ ...d, name: '振替休日' });++ }++ return out;++}++++/** The countries this service knows, with the names the week page shows. */++const COUNTRIES = Object.freeze([++ { code: 'US', name: 'United States' },++ { code: 'CA', name: 'Canada' },++ { code: 'GB', name: 'United Kingdom' },++ { code: 'IE', name: 'Ireland' },++ { code: 'AU', name: 'Australia' },++ { code: 'NZ', name: 'New Zealand' },++ { code: 'DE', name: 'Germany' },++ { code: 'FR', name: 'France' },++ { code: 'ES', name: 'Spain' },++ { code: 'IT', name: 'Italy' },++ { code: 'NL', name: 'Netherlands' },++ { code: 'BR', name: 'Brazil' },++ { code: 'MX', name: 'Mexico' },++ { code: 'IN', name: 'India' },++ { code: 'JP', name: 'Japan' },++]);++++const RULES = { US: us, CA: ca, GB: gb, IE: ie, AU: au, NZ: nz, DE: de, FR: fr, ES: es, IT: it, NL: nl, BR: br, MX: mx, IN: in_, JP: jp };++++/** The two-letter code of a country, when this service knows it. */++function countryOf(code) {++ const cc = String(code || '').trim().toUpperCase();++ return RULES[cc] ? cc : null;++}++++/** The name of a country in English, when this service knows it. */++function countryName(code) {++ const cc = countryOf(code);++ return cc ? COUNTRIES.find((c) => c.code === cc).name : null;++}++++/** The holidays of a country in a year, in date order: [{ date, name, country }]. */++function holidays(country, year) {++ const cc = countryOf(country);++ const y = Number(year);++ if (!cc || !Number.isInteger(y) || y < 1583 || y > 2999) return [];++ return RULES[cc](y)++ .map((h) => ({ date: key(h), name: h.name, country: cc }))++ .sort((a, b) => (a.date < b.date ? -1 : a.date > b.date ? 1 : 0));++}++++/**++ * The zones that say which country a person is in. A zone the map does not know (UTC, or a zone in a++ * country this service has no list for) says nothing: the person then chooses a country, or has none.++ */++const ZONE_COUNTRIES = Object.freeze({++ 'America/New_York': 'US', 'America/Chicago': 'US', 'America/Denver': 'US', 'America/Phoenix': 'US',++ 'America/Los_Angeles': 'US', 'America/Anchorage': 'US', 'America/Detroit': 'US', 'Pacific/Honolulu': 'US',++ 'America/Toronto': 'CA', 'America/Vancouver': 'CA', 'America/Edmonton': 'CA', 'America/Winnipeg': 'CA',++ 'America/Halifax': 'CA', 'America/St_Johns': 'CA', 'America/Regina': 'CA',++ 'America/Mexico_City': 'MX', 'America/Monterrey': 'MX',++ 'America/Sao_Paulo': 'BR', 'America/Bahia': 'BR', 'America/Fortaleza': 'BR', 'America/Manaus': 'BR',++ 'America/Argentina/Buenos_Aires': null, 'America/Bogota': null, 'America/Lima': null, 'America/Santiago': null,++ 'Europe/London': 'GB', 'Europe/Dublin': 'IE', 'Europe/Berlin': 'DE', 'Europe/Paris': 'FR',++ 'Europe/Madrid': 'ES', 'Europe/Rome': 'IT', 'Europe/Amsterdam': 'NL', 'Europe/Brussels': null,++ 'Europe/Lisbon': null, 'Europe/Vienna': null, 'Europe/Zurich': null, 'Europe/Stockholm': null,++ 'Europe/Oslo': null, 'Europe/Copenhagen': null, 'Europe/Helsinki': null, 'Europe/Warsaw': null,++ 'Europe/Athens': null, 'Europe/Istanbul': null, 'Europe/Moscow': null,++ 'Australia/Sydney': 'AU', 'Australia/Melbourne': 'AU', 'Australia/Brisbane': 'AU', 'Australia/Adelaide': 'AU',++ 'Australia/Perth': 'AU', 'Australia/Hobart': 'AU', 'Australia/Darwin': 'AU', 'Australia/Lord_Howe': 'AU',++ 'Pacific/Auckland': 'NZ', 'Pacific/Fiji': null,++ 'Asia/Tokyo': 'JP', 'Asia/Kolkata': 'IN', 'Asia/Calcutta': 'IN', 'Asia/Shanghai': null,++ 'Asia/Singapore': null, 'Asia/Seoul': null, 'Asia/Bangkok': null, 'Asia/Dubai': null, 'Asia/Jerusalem': null,++ 'Africa/Lagos': null, 'Africa/Johannesburg': null, 'Africa/Cairo': null, 'Africa/Nairobi': null,++ 'UTC': null, 'Etc/UTC': null, 'GMT': null,++});++++/** The country a time zone says, when it says one this service knows. */++function countryOfZone(tz) {++ const zone = String(tz || '').trim();++ if (!zone) return null;++ if (Object.prototype.hasOwnProperty.call(ZONE_COUNTRIES, zone)) return ZONE_COUNTRIES[zone];++ return null;++}++++/**++ * The person's country: the ?country= asked for, else the td_country cookie, else the country their++ * zone says. A code this service does not know is ignored (the query, the cookie and the zone alike).++ */++function resolveCountry({ query = null, cookie = null, tz = null } = {}) {++ const asked = countryOf(query);++ if (asked) return asked;++ const remembered = countryOf(cookie);++ if (remembered) return remembered;++ return countryOfZone(tz);++}++++module.exports = { COUNTRIES, ZONE_COUNTRIES, holidays, countryOf, countryName, countryOfZone, resolveCountry };@@@ -231,14 +231,43 @@ function createPageRoutes(ctx) {const imported = calendarImports? await calendarImports.events(p.subject, { from: Date.parse(first.from), to: Date.parse(first.to), tz }): { events: [], problems: [] };-- const w = require('../week').build({ tz, start, now: s.now(), reminders: own, events, imports: imported.events });++ // The country whose public holidays the week shows: the ?country= asked for, else the cookie, else the zone.++ const holidaysLib = require('../holidays');++ const country = holidaysLib.resolveCountry({ query: req.query.country, cookie: (req.cookies && req.cookies.td_country) || null, tz });++ const fromYear = Number(String(first.from).slice(0, 4));++ const toYear = Number(new Date(Date.parse(first.to) - 86400000).toISOString().slice(0, 4));++ const holidays = fromYear === toYear++ ? holidaysLib.holidays(country, fromYear)++ : [...holidaysLib.holidays(country, fromYear), ...holidaysLib.holidays(country, toYear)];++ const w = require('../week').build({ tz, start, now: s.now(), reminders: own, events, imports: imported.events, holidays });return page(req, res, {title: 'Your week',description: 'Your reminders, your events and the streams of the channels you follow, day by day.',-- body: raw(views.week({ name: req.viewer.displayName || req.viewer.username, week: w, problems: imported.problems })),++ body: raw(views.week({++ name: req.viewer.displayName || req.viewer.username, week: w, problems: imported.problems,++ country, countries: holidaysLib.COUNTRIES,++ })),});});++ // The country whose public holidays the week shows. A plain form: it posts here, the country is kept in a++ // cookie, and the week is drawn again. Nothing here needs JavaScript.++ r.post('/me/week/country', express.urlencoded({ extended: false, limit: '2kb' }), async (req, res) => {++ if (!signedIn(req)) return res.redirect(303, `/auth/login?next=${encodeURIComponent('/me/week')}`);++ if (!sameOrigin(req, config.baseUrl)) return page(req, res, { title: 'Not from this site', body: raw(views.notFound()) }, 403);++ const b = req.body || {};++ const asked = String(b.country || '').trim().toUpperCase();++ const code = require('../holidays').countryOf(asked);++ if (code) res.cookie('td_country', code, { sameSite: 'lax', secure: config.cookies.secure !== false, httpOnly: true, path: '/', maxAge: 365 * 24 * 60 * 60 * 1000 });++ else if (!asked) res.clearCookie('td_country', { path: '/' });++ const q = new URLSearchParams();++ const start = typeof b.start === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(b.start) ? b.start : null;++ if (start) q.set('start', start);++ const tz = require('../when').validTz(b.tz) ? b.tz : null;++ if (tz) q.set('tz', tz);++ res.redirect(303, `/me/week${q.toString() ? '?' + q.toString() : ''}`);++ });++// ── "Remind me" from anywhere (plan L48 R3): /remind?title=&at=&url=&from= from any OpenVibe site ──const remindArgs = (q) => ({title: String(q.title || '').replace(/\s+/g, ' ').trim().slice(0, 200),@@@ -8,7 +8,7 @@* channel({ username, rows, now }) a channel's schedule and its calendar* event({ e, end, reminder, own, signedIn, path }) one event: when, Remind, Add to calendar, Watch, Cancel (owner)* me({ name, agenda, reminders, feedUrl, now }) the person's agenda, reminders, calendars and private feed-- * week({ name, week, problems }) the person's week, day by day (server/week.js, plan L48 R3)++ * week({ name, week, problems, country, countries }) the person's week, day by day (server/week.js, plan L48 R3)* brief({ out, name, tz }) the day's brief* schedule({ values, problem, zones }) the schedule form* signIn({ title, text, next }) a page that needs an account@@@ -302,11 +302,11 @@ ${reminders.length ? `<ul class="td-rems">${reminders.map((x) => `<li><a href="/* mark for its kind: a reminder (once or repeating), a repeating reminder's span for the day, their own event, a* stream of a channel they follow, or an event of a calendar they imported.*/--function week({ name, week: w, problems = [] }) {-- const KIND = { reminder: ['bell', 'Reminder'], band: ['retry', 'Repeats'], event: ['calendar', 'Your event'], stream: ['live', 'Stream'], import: ['calendar', 'Calendar'] };++function week({ name, week: w, problems = [], country = null, countries = [] }) {++ const KIND = { reminder: ['bell', 'Reminder'], band: ['retry', 'Repeats'], event: ['calendar', 'Your event'], stream: ['live', 'Stream'], import: ['calendar', 'Calendar'], holiday: ['gift', 'Holiday'] };const item = (it) => {const [icon, word] = KIND[it.kind] || KIND.reminder;-- const meta = it.kind === 'band' ? it.summary : it.kind === 'stream' ? (it.channel ? `@${it.channel}` : word) : it.kind === 'import' ? (it.calendar || word) : it.kind === 'reminder' && it.repeats ? 'Repeats' : word;++ const meta = it.kind === 'band' ? it.summary : it.kind === 'stream' ? (it.channel ? `@${it.channel}` : word) : it.kind === 'import' ? (it.calendar || word) : it.kind === 'holiday' ? (it.country || word) : it.kind === 'reminder' && it.repeats ? 'Repeats' : word;const body = `<span class="td-wi-k" aria-hidden="true">${kit.icon(icon)}</span><time datetime="${esc(it.at)}">${esc(it.time)}</time><span class="td-wi-t">${esc(it.title)}</span><span class="td-wi-m">${it.live ? '<span class="td-pill is-live">Live now</span>' : ''}<span class="ovk-sr">${esc(word)}: </span>${esc(meta)}</span>`;// An imported event has no page of its own here: it is shown, not linked.return `<li class="td-wi is-${esc(it.kind)}${it.live ? ' is-live' : ''}">${it.href ? `<a href="${esc(it.href)}">${body}</a>` : `<a>${body}</a>`}</li>`;@@@ -325,14 +325,19 @@ ${d.items.length ? `<ul class="td-wis">${d.items.map(item).join('')}</ul>` : '<p? `<li class="td-pd is-empty">Nothing on ${esc(list(r.days.map((d) => d.label)))}</li>`: r.days.map((d) => `<li class="td-pd${d.today ? ' is-today' : ''}"><h2 class="td-pd-h">${esc(d.long)}${d.today ? ' <i>Today</i>' : ''}</h2>${d.items.length ? `<ul class="td-wis">${d.items.map(item).join('')}</ul>` : '<p class="td-wd-none">Nothing yet</p>'}</li>`).join(''))).join('');const count = w.days.reduce((a, d) => a + d.items.length, 0);++ const countryLabel = country ? ((countries.find((c) => c.code === country) || {}).name || null) : null;return `<div class="td-wrap td-page-pad" id="td-week" data-tz="${esc(w.tz)}"><header class="td-page-h"><p class="td-kicker">Your week</p><h1>${esc(w.title)}</h1>--<p class="td-lede">${count ? `${count} thing${count === 1 ? '' : 's'} this week` : 'A clear week so far'}: your reminders, your events and the streams of the channels you follow${name ? `, ${esc(name)}` : ''}. Times in ${esc(w.tz.replace(/_/g, ' '))}.</p>++<p class="td-lede">${count ? `${count} thing${count === 1 ? '' : 's'} this week` : 'A clear week so far'}: your reminders, your events and the streams of the channels you follow${countryLabel ? ` and the public holidays of ${esc(countryLabel)}` : ''}${name ? `, ${esc(name)}` : ''}. Times in ${esc(w.tz.replace(/_/g, ' '))}.</p><nav class="td-weeknav" aria-label="Weeks"><a class="ovk-btn ovk-btn--quiet ovk-btn--small" href="/me/week?start=${esc(w.prev)}" rel="prev">${kit.icon('back')}<span>Last week</span></a>${w.thisWeek ? '' : '<a class="ovk-btn ovk-btn--quiet ovk-btn--small" href="/me/week">This week</a>'}<a class="ovk-btn ovk-btn--quiet ovk-btn--small" href="/me/week?start=${esc(w.next)}" rel="next"><span>Next week</span>${kit.icon('chevron')}</a><a class="ovk-btn ovk-btn--quiet ovk-btn--small" href="/me">As a list</a></nav><form class="td-quick" method="post" action="/me/reminders"><input type="hidden" name="back" value="week"><input type="hidden" name="tz" value="${esc(w.tz)}" data-tz><label class="ovk-sr" for="td-quick-text">Add a reminder in plain words</label><input id="td-quick-text" name="text" maxlength="500" required placeholder="Remind me… every weekday at 9, tomorrow at 3pm, every 2 hours from 9 to 5" autocomplete="off"><button class="ovk-btn ovk-btn--primary ovk-btn--small" type="submit">${kit.icon('plus')}Add</button></form>--<p class="td-fine">The calendars you import show here too. <a href="/me#calendars">Add or remove one</a>.</p>++<form class="td-holsel" method="post" action="/me/week/country"><input type="hidden" name="start" value="${esc(w.start)}"><input type="hidden" name="tz" value="${esc(w.tz)}">++<label for="td-hol-country">Holidays</label>++<select id="td-hol-country" name="country"><option value=""${country ? '' : ' selected'}>None</option>${countries.map((c) => `<option value="${esc(c.code)}"${c.code === country ? ' selected' : ''}>${esc(c.name)}</option>`).join('')}</select>++<button class="ovk-btn ovk-btn--quiet ovk-btn--small" type="submit">Save</button></form>++<p class="td-fine">The public holidays of the country you choose, from that country's own list. The calendars you import show here too. <a href="/me#calendars">Add or remove one</a>.</p></header>${problems.length ? `<p class="td-note is-bad" role="status">${problems.map((p) => esc(p.name) + ': ' + esc(p.message)).join(' — ')}</p>` : ''}<ol class="td-week" aria-label="The week">${cols}</ol>@@@ -5,10 +5,11 @@* - their own reminders (server/my-reminders.js): each time a rule fires that week, except a rule that fires every* few minutes or hours (interval), which is one line a day with its span ("Every 2 hours, 9:00–17:00");* - their events, the events they set a reminder on, and the events of the channels they follow (server/agenda.js);-- * - the events of the calendars they imported (server/calendar-imports.js), each with the calendar's short name.++ * - the events of the calendars they imported (server/calendar-imports.js), each with the calendar's short name;++ * - the public holidays of their country (server/holidays.js), each at the top of its day.*-- * build({ tz, start, now, reminders, events, imports }) → { tz, start, prev, next, thisWeek, days: [{ key, label,-- * date, today, items: [{ kind, at, time, title, href, … }] }] }++ * build({ tz, start, now, reminders, events, imports, holidays }) → { tz, start, prev, next, thisWeek, days: [{ key,++ * label, date, today, items: [{ kind, at, time, title, href, … }] }] }* Pure: the callers fetch, this arranges. tz is an IANA zone (UTC when unknown); start is a YYYY-MM-DD (any day of the* week it is in) or null for this week.*/@@@ -26,7 +27,7 @@ function addDays(y, m, d, n) {}const clock = (p) => `${p.h}:${pad(p.min)}`;--function build({ tz = 'UTC', start = null, now = Date.now(), reminders = [], events = [], imports = [] } = {}) {++function build({ tz = 'UTC', start = null, now = Date.now(), reminders = [], events = [], imports = [], holidays = [] } = {}) {const zone = when.validTz(tz) ? tz : 'UTC';const today = when.partsIn(new Date(now), zone);// The Monday of the week asked for (or this one).@@@ -79,6 +80,19 @@ function build({ tz = 'UTC', start = null, now = Date.now(), reminders = [], eveif (!day) continue;day.items.push({ kind: e.own ? 'event' : 'stream', at: new Date(at).toISOString(), time: clock(when.partsIn(new Date(at), zone)), title: e.title, channel: e.channel || null, live: e.status === 'live', reminded: !!e.reminded, href: `/e/${e.id}`, id: e.id });}++ // The public holidays of their country: an all-day item at the top of its day, like an imported all-day event.++ for (const h of holidays || []) {++ if (!h || typeof h.date !== 'string') continue;++ const d = /^(\d{4})-(\d{2})-(\d{2})$/.exec(h.date);++ if (!d) continue;++ const day = days.find((x) => x.key === h.date);++ if (!day) continue;++ day.items.push({++ kind: 'holiday', at: when.atLocal(+d[1], +d[2], +d[3], 0, 0, zone).toISOString(),++ time: 'All day', allDay: true, title: h.name || 'A public holiday',++ country: h.country || null, href: null,++ });++ }// The calendars they imported: their own kind, in the person's zone. An all-day event sits at the top of its day.for (const it of imports || []) {if (!it) continue;@@@ -102,7 +116,9 @@ function build({ tz = 'UTC', start = null, now = Date.now(), reminders = [], eve}day.items.push({ kind: 'import', at, time, allDay: !!it.allDay, title: it.title || 'An event', calendar: it.calendar || null, href: null });}-- days.forEach((d) => d.items.sort((a, b) => (!!a.allDay !== !!b.allDay ? (a.allDay ? -1 : 1) : (a.at < b.at ? -1 : a.at > b.at ? 1 : 0))));++ // The order of a day: the holidays first, then the all-day events, then the hours.++ const rank = (it) => (it.allDay ? (it.kind === 'holiday' ? 0 : 1) : 2);++ days.forEach((d) => d.items.sort((a, b) => (rank(a) - rank(b) || (a.at < b.at ? -1 : a.at > b.at ? 1 : 0))));const prev = addDays(mon.y, mon.m, mon.d, -7), next = addDays(mon.y, mon.m, mon.d, 7);const thisMon = addDays(today.y, today.m, today.d, -((today.dow || 1) - 1));return {nnew file mode 100644@@@ -0,0 +1,163 @@++'use strict';++/**++ * The public holidays of a country (server/holidays.js) and the week that shows them: the rules for several++ * years, the country a zone says, the ?country= and the td_country cookie, an unknown code ignored, and the++ * week with a holiday at the top of its day.++ */++const assert = require('assert');++const { boot, check, done } = require('./helpers/boot');++const holidays = require('../server/holidays');++const week = require('../server/week');++++/** The dates of the holidays of a country in a year that carry a name. */++const dates = (country, year, name) => holidays.holidays(country, year)++ .filter((h) => !name || h.name.includes(name))++ .map((h) => h.date);++++/** The dates of the holidays of a country in a year whose name is exactly this one. */++const named = (country, year, name) => holidays.holidays(country, year)++ .filter((h) => h.name === name)++ .map((h) => h.date);++++(async () => {++ await check('the rules: Thanksgiving and Memorial Day in the United States, several years', () => {++ assert.deepStrictEqual(dates('US', 2026, 'Thanksgiving'), ['2026-11-26'], 'the fourth Thursday of November');++ assert.deepStrictEqual(dates('US', 2025, 'Thanksgiving'), ['2025-11-27']);++ assert.deepStrictEqual(dates('US', 2024, 'Thanksgiving'), ['2024-11-28']);++ assert.deepStrictEqual(dates('US', 2026, 'Memorial'), ['2026-05-25'], 'the last Monday of May');++ assert.deepStrictEqual(dates('US', 2025, 'Memorial'), ['2025-05-26']);++ assert.deepStrictEqual(dates('US', 2027, 'Memorial'), ['2027-05-31']);++ assert.deepStrictEqual(dates('US', 2026, "Washington"), ['2026-02-16'], 'the third Monday of February');++ assert.deepStrictEqual(dates('US', 2026, 'Labor'), ['2026-09-07'], 'the first Monday of September');++ });++++ await check('a holiday on a weekend is taken the Friday before or the Monday after, in the United States', () => {++ assert.deepStrictEqual(named('US', 2026, 'Independence Day'), ['2026-07-03'], 'July 4 on a Saturday is taken Friday');++ assert.deepStrictEqual(named('US', 2027, 'Independence Day'), ['2027-07-05'], 'July 4 on a Sunday is taken Monday');++ assert.deepStrictEqual(named('US', 2028, 'Independence Day'), ['2028-07-04'], 'a weekday is the day itself');++ assert.deepStrictEqual(dates('US', 2022, "New Year"), ['2021-12-31'], 'New Year\'s Day on a Saturday is the Friday before');++ assert.deepStrictEqual(dates('US', 2022, 'Christmas'), ['2022-12-26'], 'Christmas on a Sunday is taken Monday');++ });++++ await check('Great Britain: Easter Monday and the August bank holiday', () => {++ assert.deepStrictEqual(dates('GB', 2026, 'Easter Monday'), ['2026-04-06']);++ assert.deepStrictEqual(dates('GB', 2024, 'Easter Monday'), ['2024-04-01']);++ assert.deepStrictEqual(dates('GB', 2025, 'Easter Monday'), ['2025-04-21']);++ assert.deepStrictEqual(dates('GB', 2026, 'Summer'), ['2026-08-31'], 'the last Monday of August');++ assert.deepStrictEqual(dates('GB', 2024, 'Summer'), ['2024-08-26']);++ assert.deepStrictEqual(dates('GB', 2026, 'Early May'), ['2026-05-04'], 'the first Monday of May');++ });++++ await check('Germany: Pfingstmontag, fifty days after Easter', () => {++ assert.deepStrictEqual(dates('DE', 2026, 'Pfingstmontag'), ['2026-05-25']);++ assert.deepStrictEqual(dates('DE', 2025, 'Pfingstmontag'), ['2025-06-09']);++ assert.deepStrictEqual(dates('DE', 2024, 'Pfingstmontag'), ['2024-05-20']);++ assert.deepStrictEqual(dates('DE', 2026, 'Ostermontag'), ['2026-04-06']);++ });++++ await check('Japan: a fixed holiday, and the Monday ones', () => {++ assert.deepStrictEqual(dates('JP', 2026, '山の日'), ['2026-08-11'], 'Mountain Day is August 11');++ assert.deepStrictEqual(dates('JP', 2026, '海の日'), ['2026-07-20'], 'the third Monday of July');++ assert.deepStrictEqual(dates('JP', 2026, '成人の日'), ['2026-01-19'], 'the third Monday of January');++ });++++ await check('Brazil: Carnaval, the days before Easter', () => {++ assert.deepStrictEqual(dates('BR', 2026, 'Carnaval'), ['2026-02-16', '2026-02-17']);++ assert.deepStrictEqual(dates('BR', 2025, 'Carnaval'), ['2025-03-03', '2025-03-04']);++ });++++ await check('the zone says the country, and a zone that says nothing says none', () => {++ assert.strictEqual(holidays.countryOfZone('America/New_York'), 'US');++ assert.strictEqual(holidays.countryOfZone('Europe/London'), 'GB');++ assert.strictEqual(holidays.countryOfZone('Europe/Berlin'), 'DE');++ assert.strictEqual(holidays.countryOfZone('Asia/Tokyo'), 'JP');++ assert.strictEqual(holidays.countryOfZone('Asia/Kolkata'), 'IN');++ assert.strictEqual(holidays.countryOfZone('Australia/Sydney'), 'AU');++ assert.strictEqual(holidays.countryOfZone('Pacific/Auckland'), 'NZ');++ assert.strictEqual(holidays.countryOfZone('Europe/Paris'), 'FR');++ assert.strictEqual(holidays.countryOfZone('UTC'), null, 'UTC says nothing');++ assert.strictEqual(holidays.countryOfZone('Europe/Lisbon'), null, 'a country without a list says nothing');++ assert.strictEqual(holidays.countryOfZone(''), null);++ });++++ await check('the country: the query, else the cookie, else the zone; an unknown code is ignored', () => {++ assert.strictEqual(holidays.resolveCountry({ query: 'GB', cookie: 'JP', tz: 'Europe/Berlin' }), 'GB');++ assert.strictEqual(holidays.resolveCountry({ query: null, cookie: 'JP', tz: 'Europe/Berlin' }), 'JP');++ assert.strictEqual(holidays.resolveCountry({ query: null, cookie: null, tz: 'Europe/Berlin' }), 'DE');++ assert.strictEqual(holidays.resolveCountry({ query: 'ZZ', cookie: null, tz: 'Europe/Berlin' }), 'DE', 'an unknown code is ignored');++ assert.strictEqual(holidays.resolveCountry({ query: null, cookie: 'ZZ', tz: 'UTC' }), null);++ assert.strictEqual(holidays.resolveCountry({}), null);++ assert.strictEqual(holidays.countryOf('us'), 'US', 'the code is read whatever its case');++ assert.strictEqual(holidays.countryOf('ZZ'), null);++ assert.strictEqual(holidays.countryName('DE'), 'Germany');++ });++++ await check('the week: a holiday is an all-day item at the top of its day, with its own kind', () => {++ const w = week.build({++ tz: 'America/New_York', now: Date.parse('2026-11-25T12:00:00Z'),++ holidays: [{ date: '2026-11-26', name: 'Thanksgiving Day', country: 'US' }],++ imports: [{ date: '2026-11-26', allDay: true, title: 'Team offsite', calendar: 'Work' }],++ });++ const day = w.days.find((d) => d.key === '2026-11-26');++ assert.deepStrictEqual(day.items.map((i) => i.kind), ['holiday', 'import'], 'the holiday comes first');++ assert.strictEqual(day.items[0].title, 'Thanksgiving Day');++ assert.strictEqual(day.items[0].time, 'All day');++ assert.strictEqual(day.items[0].allDay, true);++ assert.strictEqual(day.items[0].country, 'US');++ assert.strictEqual(day.items[0].href, null, 'a holiday has no page of its own');++ assert.ok(!w.days.find((d) => d.key === '2026-11-24').items.length, 'a holiday outside the week is not shown');++ });++++ const t = await boot();++ const ana = t.network.addUser('ana');++ try {++ await check('the page: the country asked for, the cookie, and the holiday at the top of its day', async () => {++ const us = await t.get('/me/week?start=2026-11-23&country=US', { as: ana });++ assert.strictEqual(us.status, 200);++ assert.ok(us.text.includes('Thanksgiving Day'), 'the holiday of the country asked for');++ assert.ok(us.text.includes('is-holiday'), 'its own kind');++ assert.match(us.text, /<li class="td-wi is-holiday"/, 'a holiday is a line of its own');++ assert.ok(us.text.includes('<option value="US" selected>United States</option>'), 'the select shows the country');++ assert.ok(us.text.includes('<option value="">None</option>'), 'and the choice of none');++++ const jp = await t.get('/me/week?start=2026-11-23', { as: ana, headers: { cookie: 'td_country=JP' } });++ assert.ok(jp.text.includes('勤労感謝の日'), 'the cookie says the country');++ assert.ok(!jp.text.includes('Thanksgiving Day'), 'and only that country\'s holidays');++++ const none = await t.get('/me/week?start=2026-11-23', { as: ana });++ assert.ok(!none.text.includes('Thanksgiving Day'), 'no country, no holidays');++ assert.ok(!none.text.includes('is-holiday'));++++ const unknown = await t.get('/me/week?start=2026-11-23&country=ZZ', { as: ana });++ assert.ok(!unknown.text.includes('Thanksgiving Day'), 'an unknown code is ignored');++ assert.ok(unknown.text.includes('<option value="" selected>None</option>'), 'and no country is chosen');++ });++++ await check('the page: the form that sets the country, and the week it comes back to', async () => {++ const set = await t.get('/me/week/country', { as: ana, form: { country: 'GB', start: '2026-08-31', tz: 'Europe/London' } });++ assert.strictEqual(set.status, 303);++ assert.strictEqual(set.headers.get('location'), '/me/week?start=2026-08-31&tz=Europe%2FLondon');++ const cookie = set.headers.get('set-cookie') || '';++ assert.match(cookie, /td_country=GB/, 'the country is kept in a cookie');++ assert.match(cookie, /HttpOnly/, 'and the page does not read it');++ assert.match(cookie, /SameSite=Lax/);++++ const week2 = await t.get('/me/week?start=2026-08-31', { as: ana, headers: { cookie: 'td_country=GB' } });++ assert.ok(week2.text.includes('Summer bank holiday'), 'the country\'s holiday shows');++ assert.ok(week2.text.includes('<option value="GB" selected>United Kingdom</option>'));++++ const none = await t.get('/me/week/country', { as: ana, form: { country: '' } });++ assert.strictEqual(none.status, 303);++ assert.match(none.headers.get('set-cookie') || '', /td_country=;/, 'none clears the cookie');++++ const unknown = await t.get('/me/week/country', { as: ana, form: { country: 'ZZ' } });++ assert.strictEqual(unknown.status, 303);++ assert.strictEqual(unknown.headers.get('set-cookie'), null, 'an unknown code changes nothing');++++ const away = await t.get('/me/week/country', { as: ana, form: { country: 'GB' }, headers: { origin: 'https://elsewhere.example' } });++ assert.strictEqual(away.status, 403, 'a write from another site is refused');++ });++ } finally {++ await done(t);++ }++})();
@@ -173,7 +173,7 @@ HTML and stable `td-` ids/classes:
@@ -173,7 +173,7 @@ HTML and stable `td-` ids/classes:
| `/@:username` | a channel's schedule, with Subscribe (`webcal://`) and the `.ics` |
| `/@:username` | a channel's schedule, with Subscribe (`webcal://`) and the `.ics` |
| `/e/:id` | one event, with `application/ld+json` `Event` (name, startDate, endDate, eventStatus, eventAttendanceMode, VirtualLocation, organizer); Remind me (`POST /e/:id/remind`, `/remind/remove`), Add to calendar (`/e/:id.ics`), Watch on Live, and Cancel for its owner (`POST /e/:id/cancel`) |
| `/e/:id` | one event, with `application/ld+json` `Event` (name, startDate, endDate, eventStatus, eventAttendanceMode, VirtualLocation, organizer); Remind me (`POST /e/:id/remind`, `/remind/remove`), Add to calendar (`/e/:id.ics`), Watch on Live, and Cancel for its owner (`POST /e/:id/cancel`) |
| `/me` | agenda, reminders, the calendars you imported (a form and a list, with a Remove button), the feed URL and its rotate form |
| `/me` | agenda, reminders, the calendars you imported (a form and a list, with a Remove button), the feed URL and its rotate form |
-| `/me/week` | your week: seven days from Monday in your own zone — your reminders at each time they fire, your events, the streams of the channels you follow, and the events of the calendars you imported |
+| `/me/week` | your week: seven days from Monday in your own zone — your reminders at each time they fire, your events, the streams of the channels you follow, the events of the calendars you imported, and the public holidays of the country you choose (a small select sets it) |
| `/brief` | today's brief |
| `/brief` | today's brief |
| `/schedule` | a plain form that schedules a stream or an event: the start is wall time in the chosen zone (the device's, with JavaScript), plus a length (`POST /schedule` → 303 `/e/:id`) |
| `/schedule` | a plain form that schedules a stream or an event: the start is wall time in the chosen zone (the device's, with JavaScript), plus a length (`POST /schedule` → 303 `/e/:id`) |
| `/developers` | the API |
| `/developers` | the API |
@@ -200,7 +200,10 @@ signature), the outbox (fixed id, backoff, restart), reminders (one notification
@@ -200,7 +200,10 @@ signature), the outbox (fixed id, backoff, restart), reminders (one notification
(sections, Rundown failure, one per day), pages, sign-in, account export/deletion and discovery. The imported calendars
(sections, Rundown failure, one per day), pages, sign-in, account export/deletion and discovery. The imported calendars
are tested on their own: reading a calendar (a zone, an all-day event, a weekly rule, an excluded day, a cancelled
are tested on their own: reading a calendar (a zone, an all-day event, a weekly rule, an excluded day, a cancelled
event, a clock change), refusing a private address and a body that is too big, the week that shows them, the five
event, a clock change), refusing a private address and a body that is too big, the week that shows them, the five
-addresses a person may keep, and a form post from another site refused.
+addresses a person may keep, and a form post from another site refused. The public holidays are tested on their
+own too: the rules for several years (Thanksgiving, Memorial Day, a July 4 on a weekend, Easter Monday, the August
+bank holiday, Pfingstmontag, a fixed Japanese holiday, Carnaval), the country a zone says, the `?country=` and the
+cookie, an unknown code ignored, and the week with the holiday at the top of its day.
## Imports
## Imports
@@ -229,6 +232,27 @@ in it (`BYMONTH`, `BYSETPOS`, an ordinal `BYDAY` such as `2FR`), a zone this ser
@@ -229,6 +232,27 @@ in it (`BYMONTH`, `BYSETPOS`, an ordinal `BYDAY` such as `2FR`), a zone this ser
with no `DTSTART` are left out and counted, never guessed at. The address belongs to the person: it is never
with no `DTSTART` are left out and counted, never guessed at. The address belongs to the person: it is never
exported and it goes with the account.
exported and it goes with the account.
+## Public holidays
+
+Your week shows the public holidays of a country: an all-day line at the top of its day, with the country code in
+small letters. `server/holidays.js` works them out by rule, with no network call and no dependency: a fixed date
+(1 January), the nth weekday of a month (the fourth Thursday of November is Thanksgiving Day), the last weekday of a
+month (the last Monday of May is Memorial Day) and Easter with the days counted from it (Good Friday is two days
+before Easter, Whit Monday fifty after). A holiday that lands on a weekend is moved the way that country moves it:
+the United States takes the Friday before a Saturday holiday and the Monday after a Sunday one; Britain, Canada,
+Ireland and Australia take the Monday; New Zealand the Monday or the Tuesday.
+
+The country is the one the person asks for: the `?country=` of the week, else the `td_country` cookie, else the one
+their time zone says (`Europe/Berlin` → Germany). The little select on the week page sets the cookie with a plain
+form (`POST /me/week/country`, same-origin, no JavaScript); a code this service does not know is ignored, and a
+zone that says nothing (`UTC`) leaves the person without holidays until they choose.
+
+The lists are the countries' own: the United States' federal holidays, Canada's statutory ones, the England and
+Wales bank holidays, Ireland's public holidays, Australia's national eight, New Zealand's Mondayised ones,
+Germany's, France's, Spain's, Italy's, the Netherlands', Brazil's, Mexico's días de descanso obligatorio, India's
+three gazetted ones and Japan's. Each country keeps the comment that says where its list comes from, and a country
+whose list cannot be stated is left out rather than guessed at.
+
## Deploy
## Deploy
`deploy/systemd/openvibe-today.service` (127.0.0.1:5100, `/etc/openvibe/today.env`) and
`deploy/systemd/openvibe-today.service` (127.0.0.1:5100, `/etc/openvibe/today.env`) and
@@ -253,6 +253,11 @@ html[data-ov-motion=reduced] .td-demo-toast, html[data-ov-motion=reduced] .td-ri
@@ -253,6 +253,11 @@ html[data-ov-motion=reduced] .td-demo-toast, html[data-ov-motion=reduced] .td-ri
.td-wi.is-event { --k: #a78bfa; }
.td-wi.is-event { --k: #a78bfa; }
.td-wi.is-stream { --k: #f43f5e; }
.td-wi.is-stream { --k: #f43f5e; }
.td-wi.is-import { --k: #38bdf8; }
.td-wi.is-import { --k: #38bdf8; }
+.td-wi.is-holiday { --k: #fbbf24; }
+.td-holsel { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; margin: 12px 0 0; }
+.td-holsel label { font-size: 12.5px; color: var(--text-secondary, #96a7c2); }
+.td-holsel select { min-height: 34px; padding: 4px 10px; border-radius: 10px; border: 1px solid var(--border, #1f2d47); background: var(--bg-secondary, #0e1626); color: var(--text-primary, #e8eef8); font: inherit; font-size: 13px; }
+.td-holsel select:focus-visible { outline: 2px solid var(--room-accent); outline-offset: 1px; }
/* An imported event has no page of its own here: it is shown, not linked. */
/* An imported event has no page of its own here: it is shown, not linked. */
.td-wi a:not([href]) { cursor: default; }
.td-wi a:not([href]) { cursor: default; }
.td-wi a:not([href]):hover { background: color-mix(in srgb, var(--text-primary, #e8eef8) 4%, transparent); }
.td-wi a:not([href]):hover { background: color-mix(in srgb, var(--text-primary, #e8eef8) 4%, transparent); }
new file mode 100644
new file mode 100644
@@ -0,0 +1,508 @@
@@ -0,0 +1,508 @@
+'use strict';
+/**
+ * The public holidays of a country for a year, by rule — no network, no dependency (plan L48 R3).
+ *
+ * holidays('US', 2026) → [{ date: '2026-11-26', name: 'Thanksgiving Day', country: 'US' }, …]
+ * countryOfZone('Europe/Berlin') → 'DE'
+ * resolveCountry({ query, cookie, tz }) → 'FR' (the ?country=, else the cookie, else the zone)
+ *
+ * A holiday is a day, not an instant: the week puts it at the top of its day, like an imported all-day
+ * event. The rules are the ones each country's own list states:
+ * - a fixed date (1 January), sometimes moved when it lands on a weekend: the United States takes a
+ * Saturday holiday on the Friday before and a Sunday one on the Monday after (5 U.S.C. § 6103);
+ * Britain, Canada, Ireland and Australia take the Monday; New Zealand takes the Monday or the Tuesday;
+ * - the nth weekday of a month (the fourth Thursday of November is Thanksgiving Day);
+ * - the last weekday of a month (the last Monday of May is Memorial Day in the United States);
+ * - Easter and the days counted from it (Good Friday is two days before Easter, Whit Monday fifty after).
+ *
+ * Only what an official list states is here. A country whose list cannot be stated is left out rather than
+ * guessed, and a country keeps the holidays its own law names nationally (India's gazetted three, Britain's
+ * England and Wales list, Australia's national eight). The sources:
+ *
+ * US federal: https://www.opm.gov/policy-data-oversight/pay-leave/federal-holidays/ (5 U.S.C. § 6103)
+ * CA federal: https://www.canada.ca/en/revenue-agency/services/tax/public-holidays.html
+ * GB England/Wales: https://www.gov.uk/bank-holidays
+ * IE: https://www.citizensinformation.ie/en/employment/employment-rights/leave-and-holidays/public-holidays-in-ireland/
+ * AU national: https://www.fairwork.gov.au/employment-conditions/public-holidays
+ * NZ: https://www.employment.govt.nz/leave-and-holidays/public-holidays/public-holidays-and-anniversary-dates/
+ * DE national: https://www.gesetze-im-internet.de/arbzg/__9.html (Arbeitszeitgesetz § 9)
+ * FR national: https://www.service-public.fr/particuliers/vosdroits/F2405
+ * ES national: https://www.boe.es/buscar/act.php?id=BOE-A-1983-21506 (Real Decreto 2001/1983, art. 45)
+ * IT national: https://www.normattiva.it/uri-res/N2Ls?urn:nir:stato:legge:1949-12-27;260 (legge 260/1949)
+ * NL national: https://wetten.overheid.nl/BWBR0007670/ (Arbeidstijdenwet, art. 2)
+ * BR national: https://www.planalto.gov.br/ccivil_03/leis/l216/l216.htm (Lei 662/1949, Lei 10.607/2002)
+ * MX national: https://www.diputados.gob.mx/LeyesBiblio/pdf/LFT.pdf (Ley Federal del Trabajo, art. 74)
+ * IN gazetted: https://www.indiacode.nic.in/ (the three holidays the gazette names)
+ * JP national: https://www8.cao.go.jp/chosatu/ (Cabinet Office, national holidays)
+ *
+ * This is the rule book for the week (server/week.js). The other holiday reader in this service,
+ * server/imports/holidays.js, is a different thing: it imports the network's holiday events from an open
+ * dataset for the public pages.
+ */
+
+const pad = (n) => String(n).padStart(2, '0');
+const key = (d) => `${d.y}-${pad(d.m)}-${pad(d.d)}`;
+const dow = (y, m, d) => new Date(Date.UTC(y, m - 1, d)).getUTCDay(); // 0 Sunday … 6 Saturday
+const addDays = (y, m, d, n) => {
+ const t = new Date(Date.UTC(y, m - 1, d + n));
+ return { y: t.getUTCFullYear(), m: t.getUTCMonth() + 1, d: t.getUTCDate() };
+};
+
+/** The nth weekday of a month (weekday 1 Monday … 7 Sunday), the first when n is 1. */
+function nthWeekday(y, m, weekday, n) {
+ const first = dow(y, m, 1);
+ const offset = (8 - first) % 7; // days from the 1st to the first Monday
+ return { y, m, d: 1 + offset + (weekday - 1) + (n - 1) * 7 };
+}
+
+/** The last weekday of a month (weekday 1 Monday … 7 Sunday). */
+function lastWeekday(y, m, weekday) {
+ const last = new Date(Date.UTC(y, m, 0));
+ const lastDow = last.getUTCDay() === 0 ? 7 : last.getUTCDay();
+ return { y, m, d: last.getUTCDate() - (lastDow - weekday) };
+}
+
+/** The Monday of the week before a date: the Monday strictly before it. */
+function mondayBefore(y, m, d) {
+ const day = dow(y, m, d);
+ const back = day === 1 ? 7 : day === 0 ? 6 : day - 1;
+ return addDays(y, m, d, -back);
+}
+
+/** Easter Sunday, Western (Gregorian) computus. */
+function easter(y) {
+ const a = y % 19;
+ const b = Math.floor(y / 100);
+ const c = y % 100;
+ const d = Math.floor(b / 4);
+ const e = b % 4;
+ const f = Math.floor((b + 8) / 25);
+ const g = Math.floor((b - f + 1) / 3);
+ const h = (19 * a + b - d - g + 15) % 30;
+ const i = Math.floor(c / 4);
+ const k = c % 4;
+ const l = (32 + 2 * e + 2 * i - h - k) % 7;
+ const m = Math.floor((a + 11 * h + 22 * l) / 451);
+ const month = Math.floor((h + l - 7 * m + 114) / 31);
+ const day = ((h + l - 7 * m + 114) % 31) + 1;
+ return { y, m: month, d: day };
+}
+
+/** Easter and the days counted from it: Good Friday is −2, Easter Monday +1, Ascension +39, Whit Monday +50. */
+function fromEaster(y, offset) {
+ const e = easter(y);
+ return addDays(y, e.m, e.d, offset);
+}
+
+/** A Saturday holiday is taken the Friday before, a Sunday one the Monday after (the United States). */
+function usObserved(y, m, d) {
+ const day = dow(y, m, d);
+ if (day === 6) return addDays(y, m, d, -1);
+ if (day === 0) return addDays(y, m, d, 1);
+ return { y, m, d };
+}
+
+/** A holiday on a weekend is taken the Monday after (Britain, Canada, Ireland, Australia, most states). */
+function mondayIfWeekend(y, m, d) {
+ const day = dow(y, m, d);
+ if (day === 6) return addDays(y, m, d, 2);
+ if (day === 0) return addDays(y, m, d, 1);
+ return { y, m, d };
+}
+
+/**
+ * Christmas Day and Boxing Day together, as Britain, Canada, Ireland and Australia take them: a
+ * Saturday Christmas is taken Monday and Tuesday, a Sunday Christmas Tuesday and Monday, and a
+ * Saturday Boxing Day the Monday after.
+ */
+function christmasPair(y) {
+ const c = dow(y, 12, 25);
+ if (c === 6) return { christmas: { y, m: 12, d: 27 }, boxing: { y, m: 12, d: 28 } };
+ if (c === 0) return { christmas: { y, m: 12, d: 27 }, boxing: { y, m: 12, d: 26 } };
+ if (c === 5) return { christmas: { y, m: 12, d: 25 }, boxing: { y, m: 12, d: 28 } };
+ return { christmas: { y, m: 12, d: 25 }, boxing: { y, m: 12, d: 26 } };
+}
+
+/** The United States (federal): the eleven days OPM lists. */
+function us(y) {
+ const out = [];
+ const add = (dt, name) => out.push({ ...dt, name });
+ add(usObserved(y, 1, 1), "New Year's Day");
+ add(nthWeekday(y, 1, 1, 3), 'Birthday of Martin Luther King, Jr.');
+ add(nthWeekday(y, 2, 1, 3), "Washington's Birthday");
+ add(lastWeekday(y, 5, 1), 'Memorial Day');
+ if (y >= 2021) add(usObserved(y, 6, 19), 'Juneteenth National Independence Day');
+ add(usObserved(y, 7, 4), 'Independence Day');
+ add(nthWeekday(y, 9, 1, 1), 'Labor Day');
+ add(nthWeekday(y, 10, 1, 2), 'Columbus Day');
+ add(usObserved(y, 11, 11), 'Veterans Day');
+ add(nthWeekday(y, 11, 4, 4), 'Thanksgiving Day');
+ add(usObserved(y, 12, 25), 'Christmas Day');
+ return out;
+}
+
+/** Canada (the federal statutory holidays the CRA lists). */
+function ca(y) {
+ const out = [];
+ const add = (dt, name) => out.push({ ...dt, name });
+ // New Year's Day: a Saturday is taken the Friday before, the last day of the year before.
+ if (dow(y, 1, 1) === 6) add(addDays(y, 1, 1, -1), "New Year's Day");
+ else add(mondayIfWeekend(y, 1, 1), "New Year's Day");
+ add(fromEaster(y, -2), 'Good Friday');
+ add(fromEaster(y, 1), 'Easter Monday');
+ add(mondayBefore(y, 5, 25), 'Victoria Day');
+ add(mondayIfWeekend(y, 7, 1), 'Canada Day');
+ add(nthWeekday(y, 9, 1, 1), 'Labour Day');
+ if (y >= 2021) add({ y, m: 9, d: 30 }, 'National Day for Truth and Reconciliation');
+ add(nthWeekday(y, 10, 1, 2), 'Thanksgiving Day');
+ add({ y, m: 11, d: 11 }, 'Remembrance Day');
+ const pair = christmasPair(y);
+ add(pair.christmas, 'Christmas Day');
+ add(pair.boxing, 'Boxing Day');
+ return out;
+}
+
+/** Great Britain (England and Wales): the bank holidays gov.uk lists. */
+function gb(y) {
+ const out = [];
+ const add = (dt, name) => out.push({ ...dt, name });
+ add(mondayIfWeekend(y, 1, 1), "New Year's Day");
+ add(fromEaster(y, -2), 'Good Friday');
+ add(fromEaster(y, 1), 'Easter Monday');
+ add(nthWeekday(y, 5, 1, 1), 'Early May bank holiday');
+ add(lastWeekday(y, 5, 1), 'Spring bank holiday');
+ add(lastWeekday(y, 8, 1), 'Summer bank holiday');
+ const pair = christmasPair(y);
+ add(pair.christmas, 'Christmas Day');
+ add(pair.boxing, 'Boxing Day');
+ return out;
+}
+
+/** Ireland: the public holidays citizensinformation.ie lists (St Brigid's Day since 2023). */
+function ie(y) {
+ const out = [];
+ const add = (dt, name) => out.push({ ...dt, name });
+ add(mondayIfWeekend(y, 1, 1), "New Year's Day");
+ if (y >= 2023) add(nthWeekday(y, 2, 1, 1), "St Brigid's Day");
+ add({ y, m: 3, d: 17 }, "St Patrick's Day");
+ add(fromEaster(y, 1), 'Easter Monday');
+ add(nthWeekday(y, 5, 1, 1), 'May Bank Holiday');
+ add(nthWeekday(y, 6, 1, 1), 'June Bank Holiday');
+ add(nthWeekday(y, 8, 1, 1), 'August Bank Holiday');
+ add(lastWeekday(y, 10, 1), 'October Bank Holiday');
+ const pair = christmasPair(y);
+ add(pair.christmas, 'Christmas Day');
+ add(pair.boxing, "St Stephen's Day");
+ return out;
+}
+
+/** Australia (the national public holidays Fair Work lists; the states add a few of their own). */
+function au(y) {
+ const out = [];
+ const add = (dt, name) => out.push({ ...dt, name });
+ add(mondayIfWeekend(y, 1, 1), "New Year's Day");
+ add(mondayIfWeekend(y, 1, 26), 'Australia Day');
+ add(fromEaster(y, -2), 'Good Friday');
+ add(fromEaster(y, 1), 'Easter Monday');
+ add({ y, m: 4, d: 25 }, 'Anzac Day');
+ add(nthWeekday(y, 6, 1, 2), y >= 2023 ? "King's Birthday" : "Queen's Birthday");
+ const pair = christmasPair(y);
+ add(pair.christmas, 'Christmas Day');
+ add(pair.boxing, 'Boxing Day');
+ return out;
+}
+
+/** New Zealand: the holidays that are Mondayised when they land on a weekend. */
+function nz(y) {
+ const out = [];
+ const add = (dt, name) => out.push({ ...dt, name });
+ // New Year's Day and the day after: a Saturday New Year is taken Monday and Tuesday.
+ const ny = dow(y, 1, 1);
+ if (ny === 6) { add({ y, m: 1, d: 3 }, "New Year's Day"); add({ y, m: 1, d: 4 }, "Day after New Year's Day"); }
+ else if (ny === 0) { add({ y, m: 1, d: 2 }, "New Year's Day"); add({ y, m: 1, d: 3 }, "Day after New Year's Day"); }
+ else if (ny === 1) { add({ y, m: 1, d: 1 }, "New Year's Day"); add({ y, m: 1, d: 2 }, "Day after New Year's Day"); }
+ else { add({ y, m: 1, d: 1 }, "New Year's Day"); add(mondayIfWeekend(y, 1, 2), "Day after New Year's Day"); }
+ add(mondayIfWeekend(y, 2, 6), 'Waitangi Day');
+ add(fromEaster(y, -2), 'Good Friday');
+ add(fromEaster(y, 1), 'Easter Monday');
+ add(mondayIfWeekend(y, 4, 25), 'Anzac Day');
+ add(nthWeekday(y, 6, 1, 1), y >= 2023 ? "King's Birthday" : "Queen's Birthday");
+ add(nthWeekday(y, 10, 1, 4), 'Labour Day');
+ // Christmas and Boxing Day: a Sunday Christmas is taken Monday and Tuesday.
+ const c = dow(y, 12, 25);
+ if (c === 6) { add({ y, m: 12, d: 27 }, 'Christmas Day'); add({ y, m: 12, d: 28 }, 'Boxing Day'); }
+ else if (c === 0) { add({ y, m: 12, d: 26 }, 'Christmas Day'); add({ y, m: 12, d: 27 }, 'Boxing Day'); }
+ else { add({ y, m: 12, d: 25 }, 'Christmas Day'); add(mondayIfWeekend(y, 12, 26), 'Boxing Day'); }
+ return out;
+}
+
+/** Germany: the nine days the Arbeitszeitgesetz § 9 names. */
+function de(y) {
+ const out = [];
+ const add = (dt, name) => out.push({ ...dt, name });
+ add({ y, m: 1, d: 1 }, 'Neujahr');
+ add(fromEaster(y, -2), 'Karfreitag');
+ add(fromEaster(y, 1), 'Ostermontag');
+ add({ y, m: 5, d: 1 }, 'Tag der Arbeit');
+ add(fromEaster(y, 39), 'Christi Himmelfahrt');
+ add(fromEaster(y, 50), 'Pfingstmontag');
+ add({ y, m: 10, d: 3 }, 'Tag der Deutschen Einheit');
+ add({ y, m: 12, d: 25 }, 'Erster Weihnachtstag');
+ add({ y, m: 12, d: 26 }, 'Zweiter Weihnachtstag');
+ return out;
+}
+
+/** France: the eleven jours fériés the service-public lists. */
+function fr(y) {
+ const out = [];
+ const add = (dt, name) => out.push({ ...dt, name });
+ add({ y, m: 1, d: 1 }, "Jour de l'an");
+ add(fromEaster(y, 1), 'Lundi de Pâques');
+ add({ y, m: 5, d: 1 }, 'Fête du Travail');
+ add({ y, m: 5, d: 8 }, 'Victoire 1945');
+ add(fromEaster(y, 39), 'Ascension');
+ add(fromEaster(y, 50), 'Lundi de Pentecôte');
+ add({ y, m: 7, d: 14 }, 'Fête Nationale');
+ add({ y, m: 8, d: 15 }, 'Assomption');
+ add({ y, m: 11, d: 1 }, 'Toussaint');
+ add({ y, m: 11, d: 11 }, 'Armistice');
+ add({ y, m: 12, d: 25 }, 'Noël');
+ return out;
+}
+
+/** Spain: the national holidays (Real Decreto 2001/1983, art. 45); a holiday on a Sunday is taken Monday. */
+function es(y) {
+ const out = [];
+ const add = (dt, name) => out.push({ ...dt, name });
+ const monday = (dt) => (dow(dt.y, dt.m, dt.d) === 0 ? addDays(dt.y, dt.m, dt.d, 1) : dt);
+ add(monday({ y, m: 1, d: 1 }), 'Año Nuevo');
+ add(monday({ y, m: 1, d: 6 }), 'Epifanía del Señor');
+ add(monday({ y, m: 3, d: 25 }), 'San José');
+ if (y >= 2024) add(monday(fromEaster(y, -2)), 'Viernes Santo');
+ add(monday({ y, m: 5, d: 1 }), 'Fiesta del Trabajo');
+ add(monday({ y, m: 8, d: 15 }), 'Asunción de la Virgen');
+ add(monday({ y, m: 10, d: 12 }), 'Fiesta Nacional de España');
+ add(monday({ y, m: 11, d: 1 }), 'Todos los Santos');
+ add(monday({ y, m: 12, d: 6 }), 'Día de la Constitución Española');
+ add(monday({ y, m: 12, d: 8 }), 'Immaculada Concepción');
+ add(monday({ y, m: 12, d: 25 }), 'Natividad del Señor');
+ return out;
+}
+
+/** Italy: the national holidays (legge 260/1949, as the Ministry of Labour lists them). */
+function it(y) {
+ const out = [];
+ const add = (dt, name) => out.push({ ...dt, name });
+ add(fromEaster(y, 0), 'Pasqua');
+ add(fromEaster(y, 1), "Lunedì dell'Angelo");
+ add({ y, m: 5, d: 1 }, 'Festa del Lavoro');
+ add({ y, m: 8, d: 15 }, 'Ferragosto');
+ add({ y, m: 11, d: 1 }, 'Ognissanti');
+ add({ y, m: 12, d: 8 }, 'Immacolata Concezione');
+ add({ y, m: 12, d: 25 }, 'Natale');
+ add({ y, m: 12, d: 26 }, 'Santo Stefano');
+ return out;
+}
+
+/** The Netherlands: the national holidays the Arbeidstijdenwet names (art. 2). */
+function nl(y) {
+ const out = [];
+ const add = (dt, name) => out.push({ ...dt, name });
+ add({ y, m: 1, d: 1 }, 'Nieuwjaarsdag');
+ add(fromEaster(y, -2), 'Goede Vrijdag');
+ add(fromEaster(y, 0), 'Eerste Paasdag');
+ add(fromEaster(y, 1), 'Tweede Paasdag');
+ // King's Day is 27 April when 30 April is a Sunday.
+ if (dow(y, 4, 30) === 0) add({ y, m: 4, d: 27 }, 'Koningsdag');
+ else add({ y, m: 4, d: 30 }, 'Koningsdag');
+ add({ y, m: 5, d: 5 }, 'Bevrijdingsdag');
+ add({ y, m: 12, d: 25 }, 'Eerste Kerstdag');
+ add({ y, m: 12, d: 26 }, 'Tweede Kerstdag');
+ return out;
+}
+
+/** Brazil: the national holidays (Lei 662/1949 and Lei 10.607/2002). */
+function br(y) {
+ const out = [];
+ const add = (dt, name) => out.push({ ...dt, name });
+ add({ y, m: 1, d: 1 }, 'Confraternização Universal');
+ add(fromEaster(y, -47), 'Carnaval');
+ add(fromEaster(y, -48), 'Carnaval');
+ add(fromEaster(y, -2), 'Sexta-feira Santa');
+ add({ y, m: 4, d: 21 }, 'Tiradentes');
+ add({ y, m: 5, d: 1 }, 'Dia do Trabalho');
+ add(fromEaster(y, 60), 'Corpus Christi');
+ add({ y, m: 9, d: 7 }, 'Independência do Brasil');
+ add({ y, m: 10, d: 12 }, 'Nossa Senhora Aparecida');
+ add({ y, m: 11, d: 2 }, 'Finados');
+ add({ y, m: 11, d: 15 }, 'Proclamação da República');
+ if (y >= 2024) add({ y, m: 11, d: 20 }, 'Dia da Consciência Negra');
+ add({ y, m: 12, d: 25 }, 'Natal');
+ return out;
+}
+
+/** Mexico: the días de descanso obligatorio of the Ley Federal del Trabajo, art. 74. */
+function mx(y) {
+ const out = [];
+ const add = (dt, name) => out.push({ ...dt, name });
+ add({ y, m: 1, d: 1 }, 'Año Nuevo');
+ add(nthWeekday(y, 2, 1, 1), 'Día de la Constitución');
+ add(nthWeekday(y, 3, 1, 3), 'Aniversario de Benito Juárez');
+ add({ y, m: 5, d: 1 }, 'Día del Trabajo');
+ add({ y, m: 9, d: 16 }, 'Día de la Independencia');
+ if (y >= 2024 && (y - 2024) % 6 === 0) add({ y, m: 10, d: 1 }, 'Transmisión del Poder Ejecutivo Federal');
+ add({ y, m: 11, d: 2 }, 'Día de Muertos');
+ add(nthWeekday(y, 11, 1, 3), 'Día de la Revolución');
+ add({ y, m: 12, d: 25 }, 'Navidad');
+ return out;
+}
+
+/** India: the three holidays the gazette names nationally. */
+function in_(y) {
+ return [
+ { y, m: 1, d: 26, name: 'Republic Day' },
+ { y, m: 8, d: 15, name: 'Independence Day' },
+ { y, m: 10, d: 2, name: 'Gandhi Jayanti' },
+ ];
+}
+
+/**
+ * The equinox day, as the National Astronomical Observatory of Japan works it out: the day of the
+ * vernal (or autumnal) equinox, 1980–2099.
+ */
+function equinox(y, which) {
+ const base = which === 'spring' ? 20.8431 : 23.2488;
+ const n = y - 1980;
+ const day = Math.floor(base + 0.242194 * n - Math.floor(n / 4));
+ if (y < 1980) return day + 1;
+ if (y > 2099) return day - 1;
+ return day;
+}
+
+/**
+ * Japan: the national holidays (Cabinet Office). A holiday on a Sunday is taken the next free day
+ * (振替休日). The one-off moves of 2019 (the Silver Week), 2020 and 2021 (the Olympic games) are not
+ * modelled: the standing rules are.
+ */
+function jp(y) {
+ const out = [];
+ const add = (dt, name) => out.push({ ...dt, name });
+ add({ y, m: 1, d: 1 }, '元日');
+ add(nthWeekday(y, 1, 1, 3), '成人の日');
+ add({ y, m: 2, d: 11 }, '建国記念の日');
+ if (y >= 2020) add({ y, m: 2, d: 23 }, '天皇誕生日');
+ else add({ y, m: 12, d: 23 }, '天皇誕生日');
+ add({ y, m: 3, d: equinox(y, 'spring') }, '春分の日');
+ add({ y, m: 4, d: 29 }, '昭和の日');
+ add({ y, m: 5, d: 3 }, '憲法記念日');
+ add({ y, m: 5, d: 4 }, 'みどりの日');
+ add({ y, m: 5, d: 5 }, 'こどもの日');
+ add(nthWeekday(y, 7, 1, 3), '海の日');
+ add({ y, m: 8, d: 11 }, '山の日');
+ add(nthWeekday(y, 9, 1, 3), '敬老の日');
+ add({ y, m: 9, d: equinox(y, 'autumn') }, '秋分の日');
+ add(nthWeekday(y, 10, 1, 2), 'スポーツの日');
+ add({ y, m: 11, d: 3 }, '文化の日');
+ add({ y, m: 11, d: 23 }, '勤労感謝の日');
+ // 振替休日: a national holiday on a Sunday is taken the next day that is not a holiday itself.
+ const taken = new Set(out.map((h) => key(h)));
+ for (const h of out.slice()) {
+ if (dow(h.y, h.m, h.d) !== 0) continue;
+ let d = addDays(h.y, h.m, h.d, 1);
+ while (taken.has(key(d))) d = addDays(d.y, d.m, d.d, 1);
+ taken.add(key(d));
+ out.push({ ...d, name: '振替休日' });
+ }
+ return out;
+}
+
+/** The countries this service knows, with the names the week page shows. */
+const COUNTRIES = Object.freeze([
+ { code: 'US', name: 'United States' },
+ { code: 'CA', name: 'Canada' },
+ { code: 'GB', name: 'United Kingdom' },
+ { code: 'IE', name: 'Ireland' },
+ { code: 'AU', name: 'Australia' },
+ { code: 'NZ', name: 'New Zealand' },
+ { code: 'DE', name: 'Germany' },
+ { code: 'FR', name: 'France' },
+ { code: 'ES', name: 'Spain' },
+ { code: 'IT', name: 'Italy' },
+ { code: 'NL', name: 'Netherlands' },
+ { code: 'BR', name: 'Brazil' },
+ { code: 'MX', name: 'Mexico' },
+ { code: 'IN', name: 'India' },
+ { code: 'JP', name: 'Japan' },
+]);
+
+const RULES = { US: us, CA: ca, GB: gb, IE: ie, AU: au, NZ: nz, DE: de, FR: fr, ES: es, IT: it, NL: nl, BR: br, MX: mx, IN: in_, JP: jp };
+
+/** The two-letter code of a country, when this service knows it. */
+function countryOf(code) {
+ const cc = String(code || '').trim().toUpperCase();
+ return RULES[cc] ? cc : null;
+}
+
+/** The name of a country in English, when this service knows it. */
+function countryName(code) {
+ const cc = countryOf(code);
+ return cc ? COUNTRIES.find((c) => c.code === cc).name : null;
+}
+
+/** The holidays of a country in a year, in date order: [{ date, name, country }]. */
+function holidays(country, year) {
+ const cc = countryOf(country);
+ const y = Number(year);
+ if (!cc || !Number.isInteger(y) || y < 1583 || y > 2999) return [];
+ return RULES[cc](y)
+ .map((h) => ({ date: key(h), name: h.name, country: cc }))
+ .sort((a, b) => (a.date < b.date ? -1 : a.date > b.date ? 1 : 0));
+}
+
+/**
+ * The zones that say which country a person is in. A zone the map does not know (UTC, or a zone in a
+ * country this service has no list for) says nothing: the person then chooses a country, or has none.
+ */
+const ZONE_COUNTRIES = Object.freeze({
+ 'America/New_York': 'US', 'America/Chicago': 'US', 'America/Denver': 'US', 'America/Phoenix': 'US',
+ 'America/Los_Angeles': 'US', 'America/Anchorage': 'US', 'America/Detroit': 'US', 'Pacific/Honolulu': 'US',
+ 'America/Toronto': 'CA', 'America/Vancouver': 'CA', 'America/Edmonton': 'CA', 'America/Winnipeg': 'CA',
+ 'America/Halifax': 'CA', 'America/St_Johns': 'CA', 'America/Regina': 'CA',
+ 'America/Mexico_City': 'MX', 'America/Monterrey': 'MX',
+ 'America/Sao_Paulo': 'BR', 'America/Bahia': 'BR', 'America/Fortaleza': 'BR', 'America/Manaus': 'BR',
+ 'America/Argentina/Buenos_Aires': null, 'America/Bogota': null, 'America/Lima': null, 'America/Santiago': null,
+ 'Europe/London': 'GB', 'Europe/Dublin': 'IE', 'Europe/Berlin': 'DE', 'Europe/Paris': 'FR',
+ 'Europe/Madrid': 'ES', 'Europe/Rome': 'IT', 'Europe/Amsterdam': 'NL', 'Europe/Brussels': null,
+ 'Europe/Lisbon': null, 'Europe/Vienna': null, 'Europe/Zurich': null, 'Europe/Stockholm': null,
+ 'Europe/Oslo': null, 'Europe/Copenhagen': null, 'Europe/Helsinki': null, 'Europe/Warsaw': null,
+ 'Europe/Athens': null, 'Europe/Istanbul': null, 'Europe/Moscow': null,
+ 'Australia/Sydney': 'AU', 'Australia/Melbourne': 'AU', 'Australia/Brisbane': 'AU', 'Australia/Adelaide': 'AU',
+ 'Australia/Perth': 'AU', 'Australia/Hobart': 'AU', 'Australia/Darwin': 'AU', 'Australia/Lord_Howe': 'AU',
+ 'Pacific/Auckland': 'NZ', 'Pacific/Fiji': null,
+ 'Asia/Tokyo': 'JP', 'Asia/Kolkata': 'IN', 'Asia/Calcutta': 'IN', 'Asia/Shanghai': null,
+ 'Asia/Singapore': null, 'Asia/Seoul': null, 'Asia/Bangkok': null, 'Asia/Dubai': null, 'Asia/Jerusalem': null,
+ 'Africa/Lagos': null, 'Africa/Johannesburg': null, 'Africa/Cairo': null, 'Africa/Nairobi': null,
+ 'UTC': null, 'Etc/UTC': null, 'GMT': null,
+});
+
+/** The country a time zone says, when it says one this service knows. */
+function countryOfZone(tz) {
+ const zone = String(tz || '').trim();
+ if (!zone) return null;
+ if (Object.prototype.hasOwnProperty.call(ZONE_COUNTRIES, zone)) return ZONE_COUNTRIES[zone];
+ return null;
+}
+
+/**
+ * The person's country: the ?country= asked for, else the td_country cookie, else the country their
+ * zone says. A code this service does not know is ignored (the query, the cookie and the zone alike).
+ */
+function resolveCountry({ query = null, cookie = null, tz = null } = {}) {
+ const asked = countryOf(query);
+ if (asked) return asked;
+ const remembered = countryOf(cookie);
+ if (remembered) return remembered;
+ return countryOfZone(tz);
+}
+
+module.exports = { COUNTRIES, ZONE_COUNTRIES, holidays, countryOf, countryName, countryOfZone, resolveCountry };
@@ -231,14 +231,43 @@ function createPageRoutes(ctx) {
@@ -231,14 +231,43 @@ function createPageRoutes(ctx) {
const imported = calendarImports
const imported = calendarImports
? await calendarImports.events(p.subject, { from: Date.parse(first.from), to: Date.parse(first.to), tz })
? await calendarImports.events(p.subject, { from: Date.parse(first.from), to: Date.parse(first.to), tz })
: { events: [], problems: [] };
: { events: [], problems: [] };
- const w = require('../week').build({ tz, start, now: s.now(), reminders: own, events, imports: imported.events });
+ // The country whose public holidays the week shows: the ?country= asked for, else the cookie, else the zone.
+ const holidaysLib = require('../holidays');
+ const country = holidaysLib.resolveCountry({ query: req.query.country, cookie: (req.cookies && req.cookies.td_country) || null, tz });
+ const fromYear = Number(String(first.from).slice(0, 4));
+ const toYear = Number(new Date(Date.parse(first.to) - 86400000).toISOString().slice(0, 4));
+ const holidays = fromYear === toYear
+ ? holidaysLib.holidays(country, fromYear)
+ : [...holidaysLib.holidays(country, fromYear), ...holidaysLib.holidays(country, toYear)];
+ const w = require('../week').build({ tz, start, now: s.now(), reminders: own, events, imports: imported.events, holidays });
return page(req, res, {
return page(req, res, {
title: 'Your week',
title: 'Your week',
description: 'Your reminders, your events and the streams of the channels you follow, day by day.',
description: 'Your reminders, your events and the streams of the channels you follow, day by day.',
- body: raw(views.week({ name: req.viewer.displayName || req.viewer.username, week: w, problems: imported.problems })),
+ body: raw(views.week({
+ name: req.viewer.displayName || req.viewer.username, week: w, problems: imported.problems,
+ country, countries: holidaysLib.COUNTRIES,
+ })),
});
});
});
});
+ // The country whose public holidays the week shows. A plain form: it posts here, the country is kept in a
+ // cookie, and the week is drawn again. Nothing here needs JavaScript.
+ r.post('/me/week/country', express.urlencoded({ extended: false, limit: '2kb' }), async (req, res) => {
+ if (!signedIn(req)) return res.redirect(303, `/auth/login?next=${encodeURIComponent('/me/week')}`);
+ if (!sameOrigin(req, config.baseUrl)) return page(req, res, { title: 'Not from this site', body: raw(views.notFound()) }, 403);
+ const b = req.body || {};
+ const asked = String(b.country || '').trim().toUpperCase();
+ const code = require('../holidays').countryOf(asked);
+ if (code) res.cookie('td_country', code, { sameSite: 'lax', secure: config.cookies.secure !== false, httpOnly: true, path: '/', maxAge: 365 * 24 * 60 * 60 * 1000 });
+ else if (!asked) res.clearCookie('td_country', { path: '/' });
+ const q = new URLSearchParams();
+ const start = typeof b.start === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(b.start) ? b.start : null;
+ if (start) q.set('start', start);
+ const tz = require('../when').validTz(b.tz) ? b.tz : null;
+ if (tz) q.set('tz', tz);
+ res.redirect(303, `/me/week${q.toString() ? '?' + q.toString() : ''}`);
+ });
+
// ── "Remind me" from anywhere (plan L48 R3): /remind?title=&at=&url=&from= from any OpenVibe site ──
// ── "Remind me" from anywhere (plan L48 R3): /remind?title=&at=&url=&from= from any OpenVibe site ──
const remindArgs = (q) => ({
const remindArgs = (q) => ({
title: String(q.title || '').replace(/\s+/g, ' ').trim().slice(0, 200),
title: String(q.title || '').replace(/\s+/g, ' ').trim().slice(0, 200),
@@ -8,7 +8,7 @@
@@ -8,7 +8,7 @@
* channel({ username, rows, now }) a channel's schedule and its calendar
* channel({ username, rows, now }) a channel's schedule and its calendar
* event({ e, end, reminder, own, signedIn, path }) one event: when, Remind, Add to calendar, Watch, Cancel (owner)
* event({ e, end, reminder, own, signedIn, path }) one event: when, Remind, Add to calendar, Watch, Cancel (owner)
* me({ name, agenda, reminders, feedUrl, now }) the person's agenda, reminders, calendars and private feed
* me({ name, agenda, reminders, feedUrl, now }) the person's agenda, reminders, calendars and private feed
- * week({ name, week, problems }) the person's week, day by day (server/week.js, plan L48 R3)
+ * week({ name, week, problems, country, countries }) the person's week, day by day (server/week.js, plan L48 R3)
* brief({ out, name, tz }) the day's brief
* brief({ out, name, tz }) the day's brief
* schedule({ values, problem, zones }) the schedule form
* schedule({ values, problem, zones }) the schedule form
* signIn({ title, text, next }) a page that needs an account
* signIn({ title, text, next }) a page that needs an account
@@ -302,11 +302,11 @@ ${reminders.length ? `<ul class="td-rems">${reminders.map((x) => `<li><a href="/
@@ -302,11 +302,11 @@ ${reminders.length ? `<ul class="td-rems">${reminders.map((x) => `<li><a href="/
* mark for its kind: a reminder (once or repeating), a repeating reminder's span for the day, their own event, a
* mark for its kind: a reminder (once or repeating), a repeating reminder's span for the day, their own event, a
* stream of a channel they follow, or an event of a calendar they imported.
* stream of a channel they follow, or an event of a calendar they imported.
*/
*/
-function week({ name, week: w, problems = [] }) {
- const KIND = { reminder: ['bell', 'Reminder'], band: ['retry', 'Repeats'], event: ['calendar', 'Your event'], stream: ['live', 'Stream'], import: ['calendar', 'Calendar'] };
+function week({ name, week: w, problems = [], country = null, countries = [] }) {
+ const KIND = { reminder: ['bell', 'Reminder'], band: ['retry', 'Repeats'], event: ['calendar', 'Your event'], stream: ['live', 'Stream'], import: ['calendar', 'Calendar'], holiday: ['gift', 'Holiday'] };
const item = (it) => {
const item = (it) => {
const [icon, word] = KIND[it.kind] || KIND.reminder;
const [icon, word] = KIND[it.kind] || KIND.reminder;
- const meta = it.kind === 'band' ? it.summary : it.kind === 'stream' ? (it.channel ? `@${it.channel}` : word) : it.kind === 'import' ? (it.calendar || word) : it.kind === 'reminder' && it.repeats ? 'Repeats' : word;
+ const meta = it.kind === 'band' ? it.summary : it.kind === 'stream' ? (it.channel ? `@${it.channel}` : word) : it.kind === 'import' ? (it.calendar || word) : it.kind === 'holiday' ? (it.country || word) : it.kind === 'reminder' && it.repeats ? 'Repeats' : word;
const body = `<span class="td-wi-k" aria-hidden="true">${kit.icon(icon)}</span><time datetime="${esc(it.at)}">${esc(it.time)}</time><span class="td-wi-t">${esc(it.title)}</span><span class="td-wi-m">${it.live ? '<span class="td-pill is-live">Live now</span>' : ''}<span class="ovk-sr">${esc(word)}: </span>${esc(meta)}</span>`;
const body = `<span class="td-wi-k" aria-hidden="true">${kit.icon(icon)}</span><time datetime="${esc(it.at)}">${esc(it.time)}</time><span class="td-wi-t">${esc(it.title)}</span><span class="td-wi-m">${it.live ? '<span class="td-pill is-live">Live now</span>' : ''}<span class="ovk-sr">${esc(word)}: </span>${esc(meta)}</span>`;
// An imported event has no page of its own here: it is shown, not linked.
// An imported event has no page of its own here: it is shown, not linked.
return `<li class="td-wi is-${esc(it.kind)}${it.live ? ' is-live' : ''}">${it.href ? `<a href="${esc(it.href)}">${body}</a>` : `<a>${body}</a>`}</li>`;
return `<li class="td-wi is-${esc(it.kind)}${it.live ? ' is-live' : ''}">${it.href ? `<a href="${esc(it.href)}">${body}</a>` : `<a>${body}</a>`}</li>`;
@@ -325,14 +325,19 @@ ${d.items.length ? `<ul class="td-wis">${d.items.map(item).join('')}</ul>` : '<p
@@ -325,14 +325,19 @@ ${d.items.length ? `<ul class="td-wis">${d.items.map(item).join('')}</ul>` : '<p
? `<li class="td-pd is-empty">Nothing on ${esc(list(r.days.map((d) => d.label)))}</li>`
? `<li class="td-pd is-empty">Nothing on ${esc(list(r.days.map((d) => d.label)))}</li>`
: r.days.map((d) => `<li class="td-pd${d.today ? ' is-today' : ''}"><h2 class="td-pd-h">${esc(d.long)}${d.today ? ' <i>Today</i>' : ''}</h2>${d.items.length ? `<ul class="td-wis">${d.items.map(item).join('')}</ul>` : '<p class="td-wd-none">Nothing yet</p>'}</li>`).join(''))).join('');
: r.days.map((d) => `<li class="td-pd${d.today ? ' is-today' : ''}"><h2 class="td-pd-h">${esc(d.long)}${d.today ? ' <i>Today</i>' : ''}</h2>${d.items.length ? `<ul class="td-wis">${d.items.map(item).join('')}</ul>` : '<p class="td-wd-none">Nothing yet</p>'}</li>`).join(''))).join('');
const count = w.days.reduce((a, d) => a + d.items.length, 0);
const count = w.days.reduce((a, d) => a + d.items.length, 0);
+ const countryLabel = country ? ((countries.find((c) => c.code === country) || {}).name || null) : null;
return `<div class="td-wrap td-page-pad" id="td-week" data-tz="${esc(w.tz)}">
return `<div class="td-wrap td-page-pad" id="td-week" data-tz="${esc(w.tz)}">
<header class="td-page-h"><p class="td-kicker">Your week</p><h1>${esc(w.title)}</h1>
<header class="td-page-h"><p class="td-kicker">Your week</p><h1>${esc(w.title)}</h1>
-<p class="td-lede">${count ? `${count} thing${count === 1 ? '' : 's'} this week` : 'A clear week so far'}: your reminders, your events and the streams of the channels you follow${name ? `, ${esc(name)}` : ''}. Times in ${esc(w.tz.replace(/_/g, ' '))}.</p>
+<p class="td-lede">${count ? `${count} thing${count === 1 ? '' : 's'} this week` : 'A clear week so far'}: your reminders, your events and the streams of the channels you follow${countryLabel ? ` and the public holidays of ${esc(countryLabel)}` : ''}${name ? `, ${esc(name)}` : ''}. Times in ${esc(w.tz.replace(/_/g, ' '))}.</p>
<nav class="td-weeknav" aria-label="Weeks"><a class="ovk-btn ovk-btn--quiet ovk-btn--small" href="/me/week?start=${esc(w.prev)}" rel="prev">${kit.icon('back')}<span>Last week</span></a>${w.thisWeek ? '' : '<a class="ovk-btn ovk-btn--quiet ovk-btn--small" href="/me/week">This week</a>'}<a class="ovk-btn ovk-btn--quiet ovk-btn--small" href="/me/week?start=${esc(w.next)}" rel="next"><span>Next week</span>${kit.icon('chevron')}</a><a class="ovk-btn ovk-btn--quiet ovk-btn--small" href="/me">As a list</a></nav>
<nav class="td-weeknav" aria-label="Weeks"><a class="ovk-btn ovk-btn--quiet ovk-btn--small" href="/me/week?start=${esc(w.prev)}" rel="prev">${kit.icon('back')}<span>Last week</span></a>${w.thisWeek ? '' : '<a class="ovk-btn ovk-btn--quiet ovk-btn--small" href="/me/week">This week</a>'}<a class="ovk-btn ovk-btn--quiet ovk-btn--small" href="/me/week?start=${esc(w.next)}" rel="next"><span>Next week</span>${kit.icon('chevron')}</a><a class="ovk-btn ovk-btn--quiet ovk-btn--small" href="/me">As a list</a></nav>
<form class="td-quick" method="post" action="/me/reminders"><input type="hidden" name="back" value="week"><input type="hidden" name="tz" value="${esc(w.tz)}" data-tz>
<form class="td-quick" method="post" action="/me/reminders"><input type="hidden" name="back" value="week"><input type="hidden" name="tz" value="${esc(w.tz)}" data-tz>
<label class="ovk-sr" for="td-quick-text">Add a reminder in plain words</label><input id="td-quick-text" name="text" maxlength="500" required placeholder="Remind me… every weekday at 9, tomorrow at 3pm, every 2 hours from 9 to 5" autocomplete="off">
<label class="ovk-sr" for="td-quick-text">Add a reminder in plain words</label><input id="td-quick-text" name="text" maxlength="500" required placeholder="Remind me… every weekday at 9, tomorrow at 3pm, every 2 hours from 9 to 5" autocomplete="off">
<button class="ovk-btn ovk-btn--primary ovk-btn--small" type="submit">${kit.icon('plus')}Add</button></form>
<button class="ovk-btn ovk-btn--primary ovk-btn--small" type="submit">${kit.icon('plus')}Add</button></form>
-<p class="td-fine">The calendars you import show here too. <a href="/me#calendars">Add or remove one</a>.</p>
+<form class="td-holsel" method="post" action="/me/week/country"><input type="hidden" name="start" value="${esc(w.start)}"><input type="hidden" name="tz" value="${esc(w.tz)}">
+<label for="td-hol-country">Holidays</label>
+<select id="td-hol-country" name="country"><option value=""${country ? '' : ' selected'}>None</option>${countries.map((c) => `<option value="${esc(c.code)}"${c.code === country ? ' selected' : ''}>${esc(c.name)}</option>`).join('')}</select>
+<button class="ovk-btn ovk-btn--quiet ovk-btn--small" type="submit">Save</button></form>
+<p class="td-fine">The public holidays of the country you choose, from that country's own list. The calendars you import show here too. <a href="/me#calendars">Add or remove one</a>.</p>
</header>
</header>
${problems.length ? `<p class="td-note is-bad" role="status">${problems.map((p) => esc(p.name) + ': ' + esc(p.message)).join(' — ')}</p>` : ''}
${problems.length ? `<p class="td-note is-bad" role="status">${problems.map((p) => esc(p.name) + ': ' + esc(p.message)).join(' — ')}</p>` : ''}
<ol class="td-week" aria-label="The week">${cols}</ol>
<ol class="td-week" aria-label="The week">${cols}</ol>
@@ -5,10 +5,11 @@
@@ -5,10 +5,11 @@
* - their own reminders (server/my-reminders.js): each time a rule fires that week, except a rule that fires every
* - their own reminders (server/my-reminders.js): each time a rule fires that week, except a rule that fires every
* few minutes or hours (interval), which is one line a day with its span ("Every 2 hours, 9:00–17:00");
* few minutes or hours (interval), which is one line a day with its span ("Every 2 hours, 9:00–17:00");
* - their events, the events they set a reminder on, and the events of the channels they follow (server/agenda.js);
* - their events, the events they set a reminder on, and the events of the channels they follow (server/agenda.js);
- * - the events of the calendars they imported (server/calendar-imports.js), each with the calendar's short name.
+ * - the events of the calendars they imported (server/calendar-imports.js), each with the calendar's short name;
+ * - the public holidays of their country (server/holidays.js), each at the top of its day.
*
*
- * build({ tz, start, now, reminders, events, imports }) → { tz, start, prev, next, thisWeek, days: [{ key, label,
- * date, today, items: [{ kind, at, time, title, href, … }] }] }
+ * build({ tz, start, now, reminders, events, imports, holidays }) → { tz, start, prev, next, thisWeek, days: [{ key,
+ * label, date, today, items: [{ kind, at, time, title, href, … }] }] }
* Pure: the callers fetch, this arranges. tz is an IANA zone (UTC when unknown); start is a YYYY-MM-DD (any day of the
* Pure: the callers fetch, this arranges. tz is an IANA zone (UTC when unknown); start is a YYYY-MM-DD (any day of the
* week it is in) or null for this week.
* week it is in) or null for this week.
*/
*/
@@ -26,7 +27,7 @@ function addDays(y, m, d, n) {
@@ -26,7 +27,7 @@ function addDays(y, m, d, n) {
}
}
const clock = (p) => `${p.h}:${pad(p.min)}`;
const clock = (p) => `${p.h}:${pad(p.min)}`;
-function build({ tz = 'UTC', start = null, now = Date.now(), reminders = [], events = [], imports = [] } = {}) {
+function build({ tz = 'UTC', start = null, now = Date.now(), reminders = [], events = [], imports = [], holidays = [] } = {}) {
const zone = when.validTz(tz) ? tz : 'UTC';
const zone = when.validTz(tz) ? tz : 'UTC';
const today = when.partsIn(new Date(now), zone);
const today = when.partsIn(new Date(now), zone);
// The Monday of the week asked for (or this one).
// The Monday of the week asked for (or this one).
@@ -79,6 +80,19 @@ function build({ tz = 'UTC', start = null, now = Date.now(), reminders = [], eve
@@ -79,6 +80,19 @@ function build({ tz = 'UTC', start = null, now = Date.now(), reminders = [], eve
if (!day) continue;
if (!day) continue;
day.items.push({ kind: e.own ? 'event' : 'stream', at: new Date(at).toISOString(), time: clock(when.partsIn(new Date(at), zone)), title: e.title, channel: e.channel || null, live: e.status === 'live', reminded: !!e.reminded, href: `/e/${e.id}`, id: e.id });
day.items.push({ kind: e.own ? 'event' : 'stream', at: new Date(at).toISOString(), time: clock(when.partsIn(new Date(at), zone)), title: e.title, channel: e.channel || null, live: e.status === 'live', reminded: !!e.reminded, href: `/e/${e.id}`, id: e.id });
}
}
+ // The public holidays of their country: an all-day item at the top of its day, like an imported all-day event.
+ for (const h of holidays || []) {
+ if (!h || typeof h.date !== 'string') continue;
+ const d = /^(\d{4})-(\d{2})-(\d{2})$/.exec(h.date);
+ if (!d) continue;
+ const day = days.find((x) => x.key === h.date);
+ if (!day) continue;
+ day.items.push({
+ kind: 'holiday', at: when.atLocal(+d[1], +d[2], +d[3], 0, 0, zone).toISOString(),
+ time: 'All day', allDay: true, title: h.name || 'A public holiday',
+ country: h.country || null, href: null,
+ });
+ }
// The calendars they imported: their own kind, in the person's zone. An all-day event sits at the top of its day.
// The calendars they imported: their own kind, in the person's zone. An all-day event sits at the top of its day.
for (const it of imports || []) {
for (const it of imports || []) {
if (!it) continue;
if (!it) continue;
@@ -102,7 +116,9 @@ function build({ tz = 'UTC', start = null, now = Date.now(), reminders = [], eve
@@ -102,7 +116,9 @@ function build({ tz = 'UTC', start = null, now = Date.now(), reminders = [], eve
}
}
day.items.push({ kind: 'import', at, time, allDay: !!it.allDay, title: it.title || 'An event', calendar: it.calendar || null, href: null });
day.items.push({ kind: 'import', at, time, allDay: !!it.allDay, title: it.title || 'An event', calendar: it.calendar || null, href: null });
}
}
- days.forEach((d) => d.items.sort((a, b) => (!!a.allDay !== !!b.allDay ? (a.allDay ? -1 : 1) : (a.at < b.at ? -1 : a.at > b.at ? 1 : 0))));
+ // The order of a day: the holidays first, then the all-day events, then the hours.
+ const rank = (it) => (it.allDay ? (it.kind === 'holiday' ? 0 : 1) : 2);
+ days.forEach((d) => d.items.sort((a, b) => (rank(a) - rank(b) || (a.at < b.at ? -1 : a.at > b.at ? 1 : 0))));
const prev = addDays(mon.y, mon.m, mon.d, -7), next = addDays(mon.y, mon.m, mon.d, 7);
const prev = addDays(mon.y, mon.m, mon.d, -7), next = addDays(mon.y, mon.m, mon.d, 7);
const thisMon = addDays(today.y, today.m, today.d, -((today.dow || 1) - 1));
const thisMon = addDays(today.y, today.m, today.d, -((today.dow || 1) - 1));
return {
return {
new file mode 100644
new file mode 100644
@@ -0,0 +1,163 @@
@@ -0,0 +1,163 @@
+'use strict';
+/**
+ * The public holidays of a country (server/holidays.js) and the week that shows them: the rules for several
+ * years, the country a zone says, the ?country= and the td_country cookie, an unknown code ignored, and the
+ * week with a holiday at the top of its day.
+ */
+const assert = require('assert');
+const { boot, check, done } = require('./helpers/boot');
+const holidays = require('../server/holidays');
+const week = require('../server/week');
+
+/** The dates of the holidays of a country in a year that carry a name. */
+const dates = (country, year, name) => holidays.holidays(country, year)
+ .filter((h) => !name || h.name.includes(name))
+ .map((h) => h.date);
+
+/** The dates of the holidays of a country in a year whose name is exactly this one. */
+const named = (country, year, name) => holidays.holidays(country, year)
+ .filter((h) => h.name === name)
+ .map((h) => h.date);
+
+(async () => {
+ await check('the rules: Thanksgiving and Memorial Day in the United States, several years', () => {
+ assert.deepStrictEqual(dates('US', 2026, 'Thanksgiving'), ['2026-11-26'], 'the fourth Thursday of November');
+ assert.deepStrictEqual(dates('US', 2025, 'Thanksgiving'), ['2025-11-27']);
+ assert.deepStrictEqual(dates('US', 2024, 'Thanksgiving'), ['2024-11-28']);
+ assert.deepStrictEqual(dates('US', 2026, 'Memorial'), ['2026-05-25'], 'the last Monday of May');
+ assert.deepStrictEqual(dates('US', 2025, 'Memorial'), ['2025-05-26']);
+ assert.deepStrictEqual(dates('US', 2027, 'Memorial'), ['2027-05-31']);
+ assert.deepStrictEqual(dates('US', 2026, "Washington"), ['2026-02-16'], 'the third Monday of February');
+ assert.deepStrictEqual(dates('US', 2026, 'Labor'), ['2026-09-07'], 'the first Monday of September');
+ });
+
+ await check('a holiday on a weekend is taken the Friday before or the Monday after, in the United States', () => {
+ assert.deepStrictEqual(named('US', 2026, 'Independence Day'), ['2026-07-03'], 'July 4 on a Saturday is taken Friday');
+ assert.deepStrictEqual(named('US', 2027, 'Independence Day'), ['2027-07-05'], 'July 4 on a Sunday is taken Monday');
+ assert.deepStrictEqual(named('US', 2028, 'Independence Day'), ['2028-07-04'], 'a weekday is the day itself');
+ assert.deepStrictEqual(dates('US', 2022, "New Year"), ['2021-12-31'], 'New Year\'s Day on a Saturday is the Friday before');
+ assert.deepStrictEqual(dates('US', 2022, 'Christmas'), ['2022-12-26'], 'Christmas on a Sunday is taken Monday');
+ });
+
+ await check('Great Britain: Easter Monday and the August bank holiday', () => {
+ assert.deepStrictEqual(dates('GB', 2026, 'Easter Monday'), ['2026-04-06']);
+ assert.deepStrictEqual(dates('GB', 2024, 'Easter Monday'), ['2024-04-01']);
+ assert.deepStrictEqual(dates('GB', 2025, 'Easter Monday'), ['2025-04-21']);
+ assert.deepStrictEqual(dates('GB', 2026, 'Summer'), ['2026-08-31'], 'the last Monday of August');
+ assert.deepStrictEqual(dates('GB', 2024, 'Summer'), ['2024-08-26']);
+ assert.deepStrictEqual(dates('GB', 2026, 'Early May'), ['2026-05-04'], 'the first Monday of May');
+ });
+
+ await check('Germany: Pfingstmontag, fifty days after Easter', () => {
+ assert.deepStrictEqual(dates('DE', 2026, 'Pfingstmontag'), ['2026-05-25']);
+ assert.deepStrictEqual(dates('DE', 2025, 'Pfingstmontag'), ['2025-06-09']);
+ assert.deepStrictEqual(dates('DE', 2024, 'Pfingstmontag'), ['2024-05-20']);
+ assert.deepStrictEqual(dates('DE', 2026, 'Ostermontag'), ['2026-04-06']);
+ });
+
+ await check('Japan: a fixed holiday, and the Monday ones', () => {
+ assert.deepStrictEqual(dates('JP', 2026, '山の日'), ['2026-08-11'], 'Mountain Day is August 11');
+ assert.deepStrictEqual(dates('JP', 2026, '海の日'), ['2026-07-20'], 'the third Monday of July');
+ assert.deepStrictEqual(dates('JP', 2026, '成人の日'), ['2026-01-19'], 'the third Monday of January');
+ });
+
+ await check('Brazil: Carnaval, the days before Easter', () => {
+ assert.deepStrictEqual(dates('BR', 2026, 'Carnaval'), ['2026-02-16', '2026-02-17']);
+ assert.deepStrictEqual(dates('BR', 2025, 'Carnaval'), ['2025-03-03', '2025-03-04']);
+ });
+
+ await check('the zone says the country, and a zone that says nothing says none', () => {
+ assert.strictEqual(holidays.countryOfZone('America/New_York'), 'US');
+ assert.strictEqual(holidays.countryOfZone('Europe/London'), 'GB');
+ assert.strictEqual(holidays.countryOfZone('Europe/Berlin'), 'DE');
+ assert.strictEqual(holidays.countryOfZone('Asia/Tokyo'), 'JP');
+ assert.strictEqual(holidays.countryOfZone('Asia/Kolkata'), 'IN');
+ assert.strictEqual(holidays.countryOfZone('Australia/Sydney'), 'AU');
+ assert.strictEqual(holidays.countryOfZone('Pacific/Auckland'), 'NZ');
+ assert.strictEqual(holidays.countryOfZone('Europe/Paris'), 'FR');
+ assert.strictEqual(holidays.countryOfZone('UTC'), null, 'UTC says nothing');
+ assert.strictEqual(holidays.countryOfZone('Europe/Lisbon'), null, 'a country without a list says nothing');
+ assert.strictEqual(holidays.countryOfZone(''), null);
+ });
+
+ await check('the country: the query, else the cookie, else the zone; an unknown code is ignored', () => {
+ assert.strictEqual(holidays.resolveCountry({ query: 'GB', cookie: 'JP', tz: 'Europe/Berlin' }), 'GB');
+ assert.strictEqual(holidays.resolveCountry({ query: null, cookie: 'JP', tz: 'Europe/Berlin' }), 'JP');
+ assert.strictEqual(holidays.resolveCountry({ query: null, cookie: null, tz: 'Europe/Berlin' }), 'DE');
+ assert.strictEqual(holidays.resolveCountry({ query: 'ZZ', cookie: null, tz: 'Europe/Berlin' }), 'DE', 'an unknown code is ignored');
+ assert.strictEqual(holidays.resolveCountry({ query: null, cookie: 'ZZ', tz: 'UTC' }), null);
+ assert.strictEqual(holidays.resolveCountry({}), null);
+ assert.strictEqual(holidays.countryOf('us'), 'US', 'the code is read whatever its case');
+ assert.strictEqual(holidays.countryOf('ZZ'), null);
+ assert.strictEqual(holidays.countryName('DE'), 'Germany');
+ });
+
+ await check('the week: a holiday is an all-day item at the top of its day, with its own kind', () => {
+ const w = week.build({
+ tz: 'America/New_York', now: Date.parse('2026-11-25T12:00:00Z'),
+ holidays: [{ date: '2026-11-26', name: 'Thanksgiving Day', country: 'US' }],
+ imports: [{ date: '2026-11-26', allDay: true, title: 'Team offsite', calendar: 'Work' }],
+ });
+ const day = w.days.find((d) => d.key === '2026-11-26');
+ assert.deepStrictEqual(day.items.map((i) => i.kind), ['holiday', 'import'], 'the holiday comes first');
+ assert.strictEqual(day.items[0].title, 'Thanksgiving Day');
+ assert.strictEqual(day.items[0].time, 'All day');
+ assert.strictEqual(day.items[0].allDay, true);
+ assert.strictEqual(day.items[0].country, 'US');
+ assert.strictEqual(day.items[0].href, null, 'a holiday has no page of its own');
+ assert.ok(!w.days.find((d) => d.key === '2026-11-24').items.length, 'a holiday outside the week is not shown');
+ });
+
+ const t = await boot();
+ const ana = t.network.addUser('ana');
+ try {
+ await check('the page: the country asked for, the cookie, and the holiday at the top of its day', async () => {
+ const us = await t.get('/me/week?start=2026-11-23&country=US', { as: ana });
+ assert.strictEqual(us.status, 200);
+ assert.ok(us.text.includes('Thanksgiving Day'), 'the holiday of the country asked for');
+ assert.ok(us.text.includes('is-holiday'), 'its own kind');
+ assert.match(us.text, /<li class="td-wi is-holiday"/, 'a holiday is a line of its own');
+ assert.ok(us.text.includes('<option value="US" selected>United States</option>'), 'the select shows the country');
+ assert.ok(us.text.includes('<option value="">None</option>'), 'and the choice of none');
+
+ const jp = await t.get('/me/week?start=2026-11-23', { as: ana, headers: { cookie: 'td_country=JP' } });
+ assert.ok(jp.text.includes('勤労感謝の日'), 'the cookie says the country');
+ assert.ok(!jp.text.includes('Thanksgiving Day'), 'and only that country\'s holidays');
+
+ const none = await t.get('/me/week?start=2026-11-23', { as: ana });
+ assert.ok(!none.text.includes('Thanksgiving Day'), 'no country, no holidays');
+ assert.ok(!none.text.includes('is-holiday'));
+
+ const unknown = await t.get('/me/week?start=2026-11-23&country=ZZ', { as: ana });
+ assert.ok(!unknown.text.includes('Thanksgiving Day'), 'an unknown code is ignored');
+ assert.ok(unknown.text.includes('<option value="" selected>None</option>'), 'and no country is chosen');
+ });
+
+ await check('the page: the form that sets the country, and the week it comes back to', async () => {
+ const set = await t.get('/me/week/country', { as: ana, form: { country: 'GB', start: '2026-08-31', tz: 'Europe/London' } });
+ assert.strictEqual(set.status, 303);
+ assert.strictEqual(set.headers.get('location'), '/me/week?start=2026-08-31&tz=Europe%2FLondon');
+ const cookie = set.headers.get('set-cookie') || '';
+ assert.match(cookie, /td_country=GB/, 'the country is kept in a cookie');
+ assert.match(cookie, /HttpOnly/, 'and the page does not read it');
+ assert.match(cookie, /SameSite=Lax/);
+
+ const week2 = await t.get('/me/week?start=2026-08-31', { as: ana, headers: { cookie: 'td_country=GB' } });
+ assert.ok(week2.text.includes('Summer bank holiday'), 'the country\'s holiday shows');
+ assert.ok(week2.text.includes('<option value="GB" selected>United Kingdom</option>'));
+
+ const none = await t.get('/me/week/country', { as: ana, form: { country: '' } });
+ assert.strictEqual(none.status, 303);
+ assert.match(none.headers.get('set-cookie') || '', /td_country=;/, 'none clears the cookie');
+
+ const unknown = await t.get('/me/week/country', { as: ana, form: { country: 'ZZ' } });
+ assert.strictEqual(unknown.status, 303);
+ assert.strictEqual(unknown.headers.get('set-cookie'), null, 'an unknown code changes nothing');
+
+ const away = await t.get('/me/week/country', { as: ana, form: { country: 'GB' }, headers: { origin: 'https://elsewhere.example' } });
+ assert.strictEqual(away.status, 403, 'a write from another site is refused');
+ });
+ } finally {
+ await done(t);
+ }
+})();