when-am-i / files / src / utils / calendars.ts
calendars.ts
| 1 | // Calendar conversion registry. |
| 2 | // |
| 3 | // `convert` turns a (proleptic-Gregorian) date into the local year for a named |
| 4 | // calendar system. Two gates (see plan.md, Q3): a calendar is *accepted* into |
| 5 | // the app as a data decision; here we only decide whether it is *convertible* — |
| 6 | // i.e. a deterministic epoch+rule yields a numeric local year. Calendars without |
| 7 | // one (e.g. an observational lunar calendar) are descriptive-only: we return the |
| 8 | // Gregorian year for reference and convertible:false, never a wrong number. |
| 9 | // |
| 10 | // Gregorian and Julian are hand-rolled (julianDay.ts); the remaining |
| 11 | // library-backed calendars go through @js-temporal/polyfill. |
| 12 | |
| 13 | import { Temporal } from '@js-temporal/polyfill'; |
| 14 | import type { AnchorEvent, CalendarConversion, HistoricalDate } from '../types'; |
| 15 | import { gregorianToJulianDay, julianDayToJulian, parseGregorianDate } from './julianDay'; |
| 16 | |
| 17 | /** Year label in astronomical numbering: year <= 0 renders as "N BC". */ |
| 18 | function yearLabel(year: number): string { |
| 19 | return year > 0 ? String(year) : `${1 - year} BC`; |
| 20 | } |
| 21 | |
| 22 | const MONTHS = [ |
| 23 | 'January', 'February', 'March', 'April', 'May', 'June', |
| 24 | 'July', 'August', 'September', 'October', 'November', 'December', |
| 25 | ]; |
| 26 | |
| 27 | // Spelled-out date for the local-voice answer cards. Uses AD/BC (not the |
| 28 | // modern CE/BCE the page header/slider use) because the cards represent what a |
| 29 | // local would actually have said — "Anno Domini" was the live convention for |
| 30 | // the Julian/Gregorian Christian calendars this label serves. |
| 31 | function dateLabel({ year, month, day }: HistoricalDate): string { |
| 32 | const era = year > 0 ? 'AD' : 'BC'; |
| 33 | const displayYear = year > 0 ? year : 1 - year; |
| 34 | return `${MONTHS[month - 1]} ${day}, ${displayYear} ${era}`; |
| 35 | } |
| 36 | |
| 37 | function descriptiveOnly(date: HistoricalDate, system: string, note: string): CalendarConversion { |
| 38 | return { system, localYearLabel: yearLabel(date.year), convertible: false, note }; |
| 39 | } |
| 40 | |
| 41 | // ── Lunar phase (for descriptive lunar calendars) ──────────────────────────── |
| 42 | // A lunar calendar has no continuous numeric year, but it does reckon the moon |
| 43 | // and the night within it — which is what "When am I?" asks. The synodic month |
| 44 | // and the reference new moon below are standard astronomical constants; we work |
| 45 | // from the integer Julian Day Number (julianDay.ts), so this is a |
| 46 | // date-granularity approximation: enough to say which night of the moon it is, |
| 47 | // not a precise instant. No culture-specific moon *names* are asserted here — |
| 48 | // those need sourced data (see the project's acceptance gate). |
| 49 | const SYNODIC_MONTH = 29.530588853; // mean days between successive new moons |
| 50 | const REFERENCE_NEW_MOON_JD = 2451550.1; // new moon of 2000-01-06 18:14 UTC |
| 51 | |
| 52 | /** A computed, name-free description of the moon on `date`, e.g. "About the 8th night of the current moon (a waxing crescent)". */ |
| 53 | function lunarPhaseLabel(date: HistoricalDate): string { |
| 54 | let frac = ((gregorianToJulianDay(date) - REFERENCE_NEW_MOON_JD) / SYNODIC_MONTH) % 1; |
| 55 | if (frac < 0) frac += 1; |
| 56 | const night = Math.floor(frac * SYNODIC_MONTH) + 1; // 1-based night of this moon |
| 57 | |
| 58 | if (frac < 0.02 || frac > 0.98) return 'A new moon — the first night of a new moon'; |
| 59 | if (frac >= 0.48 && frac < 0.52) return `A full moon — about night ${night} of the current moon`; |
| 60 | |
| 61 | let phase: string; |
| 62 | if (frac < 0.23) phase = 'a waxing crescent'; |
| 63 | else if (frac < 0.27) phase = 'the first quarter'; |
| 64 | else if (frac < 0.48) phase = 'a waxing gibbous'; |
| 65 | else if (frac < 0.73) phase = 'a waning gibbous'; |
| 66 | else if (frac < 0.77) phase = 'the last quarter'; |
| 67 | else phase = 'a waning crescent'; |
| 68 | |
| 69 | return `About night ${night} of the current moon (${phase})`; |
| 70 | } |
| 71 | |
| 72 | // ── "Winters since" reckoning (per-region anchor events) ───────────────────── |
| 73 | // Calendars with no numbered year can still place you in time by counting from a |
| 74 | // remembered event ("about 84 winters since the great dying"). The events are |
| 75 | // curated, sourced per-region data (Era.anchorEvents): we only count whole years |
| 76 | // elapsed, never invent the event. We count completed calendar years (not |
| 77 | // days/365) so an exact anniversary reads as a whole number, not off-by-one from |
| 78 | // accumulated leap days. |
| 79 | |
| 80 | /** Completed years from an earlier proleptic-Gregorian date to a later one. */ |
| 81 | function completedYears(from: HistoricalDate, to: HistoricalDate): number { |
| 82 | let years = to.year - from.year; |
| 83 | if (to.month < from.month || (to.month === from.month && to.day < from.day)) years -= 1; |
| 84 | return years; |
| 85 | } |
| 86 | |
| 87 | /** |
| 88 | * The most recent anchor event at or before `date`, expressed as "about N |
| 89 | * winters since <event>" with that event's sources. Returns undefined when no |
| 90 | * anchor precedes the date (or none are given). |
| 91 | */ |
| 92 | export function wintersSince( |
| 93 | date: HistoricalDate, |
| 94 | anchorEvents?: AnchorEvent[], |
| 95 | ): { label: string; sources: string[] } | undefined { |
| 96 | if (!anchorEvents || anchorEvents.length === 0) return undefined; |
| 97 | |
| 98 | const jd = gregorianToJulianDay(date); |
| 99 | let best: { event: AnchorEvent; date: HistoricalDate; jd: number } | undefined; |
| 100 | for (const event of anchorEvents) { |
| 101 | const eventDate = parseGregorianDate(event.date); |
| 102 | const eventJd = gregorianToJulianDay(eventDate); |
| 103 | if (eventJd <= jd && (!best || eventJd > best.jd)) best = { event, date: eventDate, jd: eventJd }; |
| 104 | } |
| 105 | if (!best) return undefined; |
| 106 | |
| 107 | const winters = completedYears(best.date, date); |
| 108 | const count = |
| 109 | winters <= 0 ? 'less than a year' : winters === 1 ? 'about 1 winter' : `about ${winters} winters`; |
| 110 | return { label: `${count} since ${best.event.name}`, sources: best.event.sources }; |
| 111 | } |
| 112 | |
| 113 | type Converter = (date: HistoricalDate) => CalendarConversion; |
| 114 | |
| 115 | // Temporal calendar identifiers that resolve to a single continuous year count. |
| 116 | // `eraLabel` prefixes the year (e.g. "AH 1420"); the system names the calendar. |
| 117 | interface TemporalCalendarDef { |
| 118 | calendarId: string; |
| 119 | system: string; |
| 120 | eraLabel: string; |
| 121 | } |
| 122 | |
| 123 | const TEMPORAL_CALENDARS: { names: string[]; def: TemporalCalendarDef }[] = [ |
| 124 | { names: ['islamic', 'islamic-umalqura', 'hijri'], def: { calendarId: 'islamic-umalqura', system: 'Islamic (Umm al-Qura)', eraLabel: 'AH' } }, |
| 125 | { names: ['hebrew'], def: { calendarId: 'hebrew', system: 'Hebrew', eraLabel: 'AM' } }, |
| 126 | { names: ['persian', 'solar hijri'], def: { calendarId: 'persian', system: 'Persian (Solar Hijri)', eraLabel: 'SH' } }, |
| 127 | { names: ['indian', 'saka'], def: { calendarId: 'indian', system: 'Indian (Saka)', eraLabel: 'Saka' } }, |
| 128 | { names: ['buddhist'], def: { calendarId: 'buddhist', system: 'Buddhist', eraLabel: 'BE' } }, |
| 129 | ]; |
| 130 | |
| 131 | function temporalConverter(def: TemporalCalendarDef): Converter { |
| 132 | return (date) => { |
| 133 | try { |
| 134 | const local = Temporal.PlainDate |
| 135 | .from({ year: date.year, month: date.month, day: date.day }) |
| 136 | .withCalendar(def.calendarId); |
| 137 | return { |
| 138 | system: def.system, |
| 139 | localYearLabel: `${def.eraLabel} ${local.year}`, |
| 140 | // Spell out the month (e.g. "Muharram 3, 1112 AH") so the answer |
| 141 | // reads like a local date rather than a bare numeric "1112-01-03". |
| 142 | // Intl carries each calendar's own month names and era, and |
| 143 | // handles oddities like the Hebrew leap month (Adar I/II). Locale |
| 144 | // is pinned to en-US so the label is the same for every visitor. |
| 145 | localDateLabel: local.toLocaleString('en-US', { |
| 146 | calendar: def.calendarId, |
| 147 | year: 'numeric', |
| 148 | month: 'long', |
| 149 | day: 'numeric', |
| 150 | }), |
| 151 | convertible: true, |
| 152 | }; |
| 153 | } catch { |
| 154 | // e.g. a date before the calendar's supported range. |
| 155 | return descriptiveOnly(date, def.system, 'Conversion is unavailable for this date; showing the Gregorian year for reference.'); |
| 156 | } |
| 157 | }; |
| 158 | } |
| 159 | |
| 160 | // ── Japanese (imperial nengō era) ──────────────────────────────────────────── |
| 161 | // Temporal's `japanese` calendar works, but PlainDate.year returns the ISO year; |
| 162 | // the regnal-era year a Japanese local cites is `eraYear` under an `era` (e.g. |
| 163 | // Reiwa 6). Intl only knows the modern nengō (Meiji onward), so a region using |
| 164 | // this converter should scope it to 1873+ (Japan's Gregorian adoption) — earlier |
| 165 | // Japanese dating used the lunisolar calendar (descriptive-only). |
| 166 | function japaneseConverter(date: HistoricalDate): CalendarConversion { |
| 167 | try { |
| 168 | const local = Temporal.PlainDate |
| 169 | .from({ year: date.year, month: date.month, day: date.day }) |
| 170 | .withCalendar('japanese'); |
| 171 | const era = local.era ?? ''; |
| 172 | // Intl only knows the modern nengō (Meiji+); older dates fall into a |
| 173 | // generic 'japanese' bucket with the ISO year, which we don't label as a |
| 174 | // regnal era. Regions should scope this converter to 1873+ regardless. |
| 175 | if (era === 'japanese') { |
| 176 | return descriptiveOnly(date, 'Japanese (nengō)', 'The modern regnal-era (nengō) numbering is defined from the Meiji era (1868); showing the Gregorian year for reference.'); |
| 177 | } |
| 178 | const eraName = era.charAt(0).toUpperCase() + era.slice(1); // 'reiwa' → 'Reiwa' |
| 179 | return { |
| 180 | system: 'Japanese (nengō)', |
| 181 | localYearLabel: `${eraName} ${local.eraYear}`, |
| 182 | localDateLabel: local.toLocaleString('en-US', { |
| 183 | calendar: 'japanese', era: 'long', year: 'numeric', month: 'long', day: 'numeric', |
| 184 | }), |
| 185 | convertible: true, |
| 186 | }; |
| 187 | } catch { |
| 188 | return descriptiveOnly(date, 'Japanese (nengō)', 'Conversion is unavailable for this date; showing the Gregorian year for reference.'); |
| 189 | } |
| 190 | } |
| 191 | |
| 192 | // ── Ethiopian calendar (hand-rolled) ───────────────────────────────────────── |
| 193 | // @js-temporal/polyfill@0.5.1 throws on the `ethiopic` calendar ("Era am … was |
| 194 | // not matched"), so we convert via the Julian Day Number ourselves. The |
| 195 | // Ethiopian (Amete Mihret) calendar: 12 months of 30 days + a 5- or 6-day 13th |
| 196 | // month (Pagume), leap year when year ≡ 3 (mod 4). Epoch 1 Meskerem 1 = JDN |
| 197 | // 1724221 (29 Aug 8 CE Julian); algorithm after Dershowitz & Reingold. |
| 198 | const ETHIOPIC_EPOCH_JDN = 1724221; |
| 199 | const ETHIOPIC_MONTHS = [ |
| 200 | 'Meskerem', 'Tikimt', 'Hidar', 'Tahsas', 'Tir', 'Yakatit', 'Megabit', |
| 201 | 'Miazia', 'Ginbot', 'Sene', 'Hamle', 'Nehase', 'Pagume', |
| 202 | ]; |
| 203 | |
| 204 | function ethiopicConverter(date: HistoricalDate): CalendarConversion { |
| 205 | const r = gregorianToJulianDay(date) - ETHIOPIC_EPOCH_JDN; |
| 206 | const year = Math.floor((4 * r + 1463) / 1461); |
| 207 | const dayInYear = r - (365 * (year - 1) + Math.floor(year / 4)); // 0-based |
| 208 | const month = Math.floor(dayInYear / 30) + 1; |
| 209 | const day = (dayInYear % 30) + 1; |
| 210 | return { |
| 211 | system: 'Ethiopian', |
| 212 | localYearLabel: `${year} E.C.`, |
| 213 | localDateLabel: `${ETHIOPIC_MONTHS[month - 1]} ${day}, ${year} E.C.`, |
| 214 | convertible: true, |
| 215 | }; |
| 216 | } |
| 217 | |
| 218 | // ── Chinese sexagenary cycle (hand-rolled, descriptive) ────────────────────── |
| 219 | // The lunisolar `chinese` calendar throws in the polyfill, and its year is named, |
| 220 | // not numbered — a repeating 60-year cycle of a Heavenly Stem + Earthly Branch, |
| 221 | // which pairs with one of five elements and twelve zodiac animals. That cycle is |
| 222 | // deterministic from the year, so we name it; but because it has no continuous |
| 223 | // numbered year, it is descriptive-only (we still show the Gregorian reference). |
| 224 | const STEMS = ['Jiǎ', 'Yǐ', 'Bǐng', 'Dīng', 'Wù', 'Jǐ', 'Gēng', 'Xīn', 'Rén', 'Guǐ']; |
| 225 | const BRANCHES = ['Zǐ', 'Chǒu', 'Yín', 'Mǎo', 'Chén', 'Sì', 'Wǔ', 'Wèi', 'Shēn', 'Yǒu', 'Xū', 'Hài']; |
| 226 | const ELEMENTS = ['Wood', 'Fire', 'Earth', 'Metal', 'Water']; |
| 227 | const ANIMALS = ['Rat', 'Ox', 'Tiger', 'Rabbit', 'Dragon', 'Snake', 'Horse', 'Goat', 'Monkey', 'Rooster', 'Dog', 'Pig']; |
| 228 | |
| 229 | function sexagenaryConverter(date: HistoricalDate): CalendarConversion { |
| 230 | // The sexagenary year turns at Chinese New Year (late Jan–Feb), which needs |
| 231 | // the lunisolar calendar to place exactly; we approximate with the Gregorian |
| 232 | // year and flag the caveat. Year n: stem = (n−4) mod 10, branch = (n−4) mod 12. |
| 233 | const n = date.year; |
| 234 | const stem = (((n - 4) % 10) + 10) % 10; |
| 235 | const branch = (((n - 4) % 12) + 12) % 12; |
| 236 | const name = `${STEMS[stem]}-${BRANCHES[branch]} — year of the ${ELEMENTS[Math.floor(stem / 2)]} ${ANIMALS[branch]}`; |
| 237 | return { |
| 238 | system: 'Chinese sexagenary cycle', |
| 239 | localYearLabel: yearLabel(date.year), // Gregorian reference year |
| 240 | localDateLabel: name, |
| 241 | convertible: false, |
| 242 | note: 'Years are named in a repeating 60-year cycle (a Heavenly Stem + Earthly Branch, with one of five elements and twelve zodiac animals), not numbered. The year turns at Chinese New Year (late January–February), so a date in January or early February may still belong to the previous year shown here.', |
| 243 | }; |
| 244 | } |
| 245 | |
| 246 | // ── Aztec/Mexica year-bearer (hand-rolled, descriptive) ────────────────────── |
| 247 | // The Aztec/Mexica Xiuhpōhualli (365-day solar year) has no continuous year |
| 248 | // number — only a name from the 52-year cycle (xiuhmolpilli), combining one of |
| 249 | // 4 day-signs (Ācatl/Reed, Tēcpatl/Flint, Calli/House, Tōchtli/Rabbit) with a |
| 250 | // number 1–13, e.g. "1 Ācatl". There is no Aztec equivalent of the Maya Long |
| 251 | // Count (no continuous count from a fixed epoch), so this stays descriptive |
| 252 | // even though the cycle itself is fully deterministic. Anchored at 1519 CE = |
| 253 | // "1 Ācatl" (the year of Cortés's arrival, widely documented because of its |
| 254 | // association with the Quetzalcōātl-return prophecy) — cross-checked against |
| 255 | // the 1507 New Fire Ceremony, documented as "2 Ācatl" (1507 + 12 = 1519, and |
| 256 | // 2 Ācatl + 12 in the 13-number sequence wraps to 1 Ācatl, consistent). The |
| 257 | // exact day the Xiuhpōhualli year turned over is disputed among sources |
| 258 | // (accounts place it anywhere from February to March), so we approximate with |
| 259 | // a Gregorian-year turnover and flag the caveat, as with the Chinese |
| 260 | // sexagenary cycle above. |
| 261 | const YEAR_BEARERS = ['Calli', 'Tōchtli', 'Ācatl', 'Tēcpatl']; // House, Rabbit, Reed, Flint |
| 262 | const YEAR_BEARER_ANCHOR_YEAR = 1519; // "1 Ācatl" |
| 263 | const YEAR_BEARER_ANCHOR_NUMBER = 1; |
| 264 | const YEAR_BEARER_ANCHOR_SIGN_INDEX = 2; // Ācatl |
| 265 | |
| 266 | function aztecYearBearerConverter(date: HistoricalDate): CalendarConversion { |
| 267 | const offset = date.year - YEAR_BEARER_ANCHOR_YEAR; |
| 268 | const number = (((YEAR_BEARER_ANCHOR_NUMBER - 1 + offset) % 13) + 13) % 13 + 1; |
| 269 | const signIndex = (((YEAR_BEARER_ANCHOR_SIGN_INDEX + offset) % 4) + 4) % 4; |
| 270 | const name = `${number} ${YEAR_BEARERS[signIndex]}`; |
| 271 | return { |
| 272 | system: 'Aztec/Mexica year-bearer', |
| 273 | localYearLabel: yearLabel(date.year), // Gregorian reference year |
| 274 | localDateLabel: name, |
| 275 | convertible: false, |
| 276 | note: 'Years are named in a repeating 52-year cycle (one of four day-signs — Ācatl/Reed, Tēcpatl/Flint, Calli/House, Tōchtli/Rabbit — with a number 1–13), not numbered continuously; there is no Aztec equivalent of the Maya Long Count. The exact day the year turned over is disputed among historical sources, so this approximates with a Gregorian-year turnover.', |
| 277 | }; |
| 278 | } |
| 279 | |
| 280 | // ── Maya Long Count (hand-rolled) ──────────────────────────────────────────── |
| 281 | // The Long Count is the Maya system with a continuous day count from a fixed |
| 282 | // epoch, unlike the cyclical Tzolk'in (260-day) and Haab' (365-day) calendars |
| 283 | // it ran alongside (those have no continuous numbered year and are |
| 284 | // descriptive-only). Epoch 0.0.0.0.0 = JDN 584283 under the GMT |
| 285 | // (Goodman-Martinez-Thompson) correlation, the most widely accepted constant. |
| 286 | const LONG_COUNT_EPOCH_JDN = 584283; |
| 287 | const LONG_COUNT_UNITS = [144000, 7200, 360, 20, 1]; // baktun, katun, tun, winal, k'in |
| 288 | |
| 289 | function longCountConverter(date: HistoricalDate): CalendarConversion { |
| 290 | let r = gregorianToJulianDay(date) - LONG_COUNT_EPOCH_JDN; |
| 291 | const digits = LONG_COUNT_UNITS.map((unit) => { |
| 292 | const v = Math.floor(r / unit); |
| 293 | r -= v * unit; |
| 294 | return v; |
| 295 | }); |
| 296 | const label = digits.join('.'); |
| 297 | return { |
| 298 | system: 'Maya Long Count', |
| 299 | localYearLabel: label, |
| 300 | localDateLabel: `${label} (baktun.katun.tun.winal.kʼin since creation)`, |
| 301 | convertible: true, |
| 302 | }; |
| 303 | } |
| 304 | |
| 305 | // ── French Republican calendar (hand-rolled, lookup table) ────────────────── |
| 306 | // The Republican year began at the Paris autumnal equinox — an astronomical |
| 307 | // observation, not a fixed arithmetic rule (years III, VII and XI were the |
| 308 | // only leap years actually observed before Napoleon abolished the calendar on |
| 309 | // 1 January 1806). Rather than invent an equinox-prediction formula, we use |
| 310 | // the sourced table of each year's actual Gregorian start date (Wikipedia, |
| 311 | // "French Republican calendar") to determine the year number; we do not |
| 312 | // attempt the month/day (those depend on the same per-year equinox timing we |
| 313 | // aren't computing). Year LXXIX is the brief 1871 Paris Commune revival. |
| 314 | const FRENCH_REPUBLICAN_YEAR_STARTS: { start: string; end: string; label: string }[] = [ |
| 315 | { start: '1792-09-22', end: '1793-09-22', label: 'An I' }, |
| 316 | { start: '1793-09-22', end: '1794-09-22', label: 'An II' }, |
| 317 | { start: '1794-09-22', end: '1795-09-23', label: 'An III' }, |
| 318 | { start: '1795-09-23', end: '1796-09-22', label: 'An IV' }, |
| 319 | { start: '1796-09-22', end: '1797-09-22', label: 'An V' }, |
| 320 | { start: '1797-09-22', end: '1798-09-22', label: 'An VI' }, |
| 321 | { start: '1798-09-22', end: '1799-09-23', label: 'An VII' }, |
| 322 | { start: '1799-09-23', end: '1800-09-23', label: 'An VIII' }, |
| 323 | { start: '1800-09-23', end: '1801-09-23', label: 'An IX' }, |
| 324 | { start: '1801-09-23', end: '1802-09-23', label: 'An X' }, |
| 325 | { start: '1802-09-23', end: '1803-09-24', label: 'An XI' }, |
| 326 | { start: '1803-09-24', end: '1804-09-23', label: 'An XII' }, |
| 327 | { start: '1804-09-23', end: '1805-09-23', label: 'An XIII' }, |
| 328 | { start: '1805-09-23', end: '1806-01-01', label: 'An XIV' }, |
| 329 | { start: '1870-09-23', end: '1871-05-24', label: 'An LXXIX' }, |
| 330 | ]; |
| 331 | |
| 332 | function frenchRepublicanConverter(date: HistoricalDate): CalendarConversion { |
| 333 | const jd = gregorianToJulianDay(date); |
| 334 | const hit = FRENCH_REPUBLICAN_YEAR_STARTS.find( |
| 335 | ({ start, end }) => jd >= gregorianToJulianDay(parseGregorianDate(start)) && jd < gregorianToJulianDay(parseGregorianDate(end)), |
| 336 | ); |
| 337 | if (!hit) { |
| 338 | return descriptiveOnly(date, 'French Republican calendar', 'Outside the calendar’s documented periods of use; showing the Gregorian year for reference.'); |
| 339 | } |
| 340 | return { |
| 341 | system: 'French Republican calendar', |
| 342 | localYearLabel: hit.label, |
| 343 | convertible: true, |
| 344 | note: 'The Republican year began at the autumnal equinox observed at the Paris Observatory, not a fixed arithmetic date, so only the year (from a sourced table of each year’s actual start date) is computed here — not the month or day.', |
| 345 | }; |
| 346 | } |
| 347 | |
| 348 | // ── Dangi (Dangun era) and Juche calendars (hand-rolled, arithmetic offset) ── |
| 349 | // Both are simple year offsets from the Gregorian year, turning over 1 January |
| 350 | // like Gregorian — not at the lunisolar new year (Seollal) — since that is how |
| 351 | // they were paired with Gregorian dates in official use (Dangi 1948–1961 South |
| 352 | // Korean government documents; Juche 1997–2024 North Korean state media), and |
| 353 | // no source asserts a Seollal turnover for either. Dangi year 1 = 2333 BCE |
| 354 | // (Dangun's legendary founding of Gojoseon); Juche year 1 = 1912 CE (Kim |
| 355 | // Il-sung's birth year). |
| 356 | function dangiConverter(date: HistoricalDate): CalendarConversion { |
| 357 | return { |
| 358 | system: 'Dangi (Dangun era)', |
| 359 | localYearLabel: `Dangi ${date.year + 2333}`, |
| 360 | convertible: true, |
| 361 | note: 'A solar-year offset from the Gregorian year (Dangun’s legendary founding of Gojoseon, 2333 BC); only the year is computed, not the lunisolar month and day used alongside it traditionally.', |
| 362 | }; |
| 363 | } |
| 364 | |
| 365 | function jucheConverter(date: HistoricalDate): CalendarConversion { |
| 366 | return { |
| 367 | system: 'Juche', |
| 368 | localYearLabel: `Juche ${date.year - 1911}`, |
| 369 | convertible: true, |
| 370 | note: 'A solar-year offset from the Gregorian year (Kim Il-sung’s birth year, 1912, as Juche 1).', |
| 371 | }; |
| 372 | } |
| 373 | |
| 374 | // ── Ab urbe condita and the Seleucid era (hand-rolled, arithmetic offset) ──── |
| 375 | // Two ancient year counts that map to the astronomical year by a fixed offset, |
| 376 | // like Dangi/Juche above. Ab urbe condita counts from Rome's legendary founding |
| 377 | // in 753 BC (AD 1 = AUC 754); the Seleucid era from 312/311 BC (AD 1 ≈ SE 312). |
| 378 | // Both are year-only — the day and month followed other calendars (the Julian |
| 379 | // calendar in the Roman world; lunisolar reckonings in the Seleucid Near East). |
| 380 | function abUrbeConditaConverter(date: HistoricalDate): CalendarConversion { |
| 381 | return { |
| 382 | system: 'Ab urbe condita (AUC)', |
| 383 | localYearLabel: `AUC ${date.year + 753}`, |
| 384 | convertible: true, |
| 385 | note: 'A year count from the legendary founding of Rome (753 BC). In daily life Romans more often named the year by its two consuls or an emperor’s regnal year; only the year is computed here, not the Julian month and day.', |
| 386 | }; |
| 387 | } |
| 388 | |
| 389 | function seleucidEraConverter(date: HistoricalDate): CalendarConversion { |
| 390 | return { |
| 391 | system: 'Seleucid era (Anno Graecorum)', |
| 392 | localYearLabel: `SE ${date.year + 311}`, |
| 393 | convertible: true, |
| 394 | note: 'A year count from the Seleucid epoch (312/311 BC), used across the Hellenistic Near East and by Jews as the “Era of Contracts” (minyan shtarot). Its year began in autumn (Babylonian/Jewish reckoning) or spring (Macedonian), so near the turn of the year the count can differ by one; only the year is computed.', |
| 395 | }; |
| 396 | } |
| 397 | |
| 398 | const CONVERTERS: Record<string, Converter> = { |
| 399 | gregorian: (date) => ({ |
| 400 | system: 'Gregorian', |
| 401 | localYearLabel: yearLabel(date.year), |
| 402 | localDateLabel: dateLabel(date), |
| 403 | convertible: true, |
| 404 | }), |
| 405 | julian: (date) => { |
| 406 | const julian = julianDayToJulian(gregorianToJulianDay(date)); |
| 407 | return { |
| 408 | system: 'Julian', |
| 409 | localYearLabel: yearLabel(julian.year), |
| 410 | localDateLabel: dateLabel(julian), |
| 411 | convertible: true, |
| 412 | }; |
| 413 | }, |
| 414 | japanese: japaneseConverter, |
| 415 | ethiopian: ethiopicConverter, |
| 416 | ethiopic: ethiopicConverter, |
| 417 | 'chinese sexagenary cycle': sexagenaryConverter, |
| 418 | chinese: sexagenaryConverter, |
| 419 | 'maya long count': longCountConverter, |
| 420 | 'french republican calendar': frenchRepublicanConverter, |
| 421 | 'dangi': dangiConverter, |
| 422 | 'dangun era': dangiConverter, |
| 423 | 'juche': jucheConverter, |
| 424 | 'aztec/mexica year-bearer': aztecYearBearerConverter, |
| 425 | 'aztec year-bearer': aztecYearBearerConverter, |
| 426 | 'ab urbe condita': abUrbeConditaConverter, |
| 427 | 'auc': abUrbeConditaConverter, |
| 428 | 'seleucid era': seleucidEraConverter, |
| 429 | 'anno graecorum': seleucidEraConverter, |
| 430 | }; |
| 431 | |
| 432 | for (const { names, def } of TEMPORAL_CALENDARS) { |
| 433 | const converter = temporalConverter(def); |
| 434 | for (const name of names) CONVERTERS[name] = converter; |
| 435 | } |
| 436 | |
| 437 | // Observational lunar calendars: no continuous year (convertible:false), but we |
| 438 | // can compute the moon and night to answer "When am I?" beyond just the year. |
| 439 | CONVERTERS['algonquian lunar calendar'] = (date) => ({ |
| 440 | system: 'Algonquian lunar calendar', |
| 441 | localYearLabel: yearLabel(date.year), // Gregorian reference year |
| 442 | localDateLabel: lunarPhaseLabel(date), |
| 443 | convertible: false, |
| 444 | note: 'This calendar reckons time by moons and seasons, not a numbered year. The moon above is computed astronomically; the season-based names the Massachusett gave their moons aren’t recorded in this dataset yet.', |
| 445 | }); |
| 446 | |
| 447 | // Korea's traditional civil calendar before 1896: Chinese-derived lunisolar |
| 448 | // months (same convertibility problem as Chinese sexagenary cycle, above — |
| 449 | // Temporal's `chinese` calendar is broken in this polyfill, and the Korean |
| 450 | // calendar can diverge from the Chinese one by up to a month), with years |
| 451 | // historically named by Chinese imperial era names or the sexagenary cycle |
| 452 | // rather than a continuous count of their own. Descriptive-only. |
| 453 | CONVERTERS['korean lunisolar calendar'] = (date) => ({ |
| 454 | system: 'Korean lunisolar calendar', |
| 455 | localYearLabel: yearLabel(date.year), // Gregorian reference year |
| 456 | localDateLabel: lunarPhaseLabel(date), |
| 457 | convertible: false, |
| 458 | note: 'This calendar reckons months by the moon (with an intercalary month some years), not a continuous numbered year; traditional Korean records instead cited Chinese imperial era names or the 60-year sexagenary cycle.', |
| 459 | }); |
| 460 | |
| 461 | // Vietnam's traditional civil calendar (pre-1954) and continuing cultural |
| 462 | // calendar (Tết Nguyên Đán, Tết Trung Thu): a Chinese-derived lunisolar |
| 463 | // calendar, but computed independently rather than borrowed wholesale — since |
| 464 | // Vietnam's 1967/1975 shift to UTC+7 (versus China's UTC+8) it can place its |
| 465 | // new year and intercalary months up to a month away from the Chinese |
| 466 | // calendar. No continuous numbered year of its own; years were historically |
| 467 | // cited by the sexagenary cycle or imperial era names. Descriptive-only, like |
| 468 | // the Korean and Chinese lunisolar entries above. |
| 469 | CONVERTERS['vietnamese lunisolar calendar'] = (date) => ({ |
| 470 | system: 'Vietnamese lunisolar calendar', |
| 471 | localYearLabel: yearLabel(date.year), // Gregorian reference year |
| 472 | localDateLabel: lunarPhaseLabel(date), |
| 473 | convertible: false, |
| 474 | note: 'This calendar reckons months by the moon (with an intercalary month some years), not a continuous numbered year; traditional Vietnamese records instead cited the 60-year sexagenary cycle or the reigning emperor’s era name. Computed independently of the Chinese calendar (using Vietnam’s own time zone), it can place its new year and leap months up to a month apart from China’s.', |
| 475 | }); |
| 476 | |
| 477 | /** Convert `date` into the named calendar's local year. */ |
| 478 | export function convert(date: HistoricalDate, calendarName: string): CalendarConversion { |
| 479 | const converter = CONVERTERS[calendarName.trim().toLowerCase()]; |
| 480 | if (converter) return converter(date); |
| 481 | |
| 482 | // Descriptive-only: keep the original calendar name, show the Gregorian |
| 483 | // year for reference, and be explicit that no numeric year exists. |
| 484 | return descriptiveOnly( |
| 485 | date, |
| 486 | calendarName, |
| 487 | 'This calendar has no continuous numeric year to convert to; showing the Gregorian year for reference.', |
| 488 | ); |
| 489 | } |