fuzzyworld

when-am-i / files / src / utils / calendars.ts

calendars.ts

26.4 KB · history · blame

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
13import { Temporal } from '@js-temporal/polyfill';
14import type { AnchorEvent, CalendarConversion, HistoricalDate } from '../types';
15import { gregorianToJulianDay, julianDayToJulian, parseGregorianDate } from './julianDay';
16
17/** Year label in astronomical numbering: year <= 0 renders as "N BC". */
18function yearLabel(year: number): string {
19 return year > 0 ? String(year) : `${1 - year} BC`;
20}
21
22const 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.
31function 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
37function 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).
49const SYNODIC_MONTH = 29.530588853; // mean days between successive new moons
50const 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)". */
53function 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. */
81function 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 */
92export 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
113type 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.
117interface TemporalCalendarDef {
118 calendarId: string;
119 system: string;
120 eraLabel: string;
121}
122
123const 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
131function 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).
166function 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.
198const ETHIOPIC_EPOCH_JDN = 1724221;
199const ETHIOPIC_MONTHS = [
200 'Meskerem', 'Tikimt', 'Hidar', 'Tahsas', 'Tir', 'Yakatit', 'Megabit',
201 'Miazia', 'Ginbot', 'Sene', 'Hamle', 'Nehase', 'Pagume',
202];
203
204function 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).
224const STEMS = ['Jiǎ', 'Yǐ', 'Bǐng', 'Dīng', 'Wù', 'Jǐ', 'Gēng', 'Xīn', 'Rén', 'Guǐ'];
225const BRANCHES = ['Zǐ', 'Chǒu', 'Yín', 'Mǎo', 'Chén', 'Sì', 'Wǔ', 'Wèi', 'Shēn', 'Yǒu', 'Xū', 'Hài'];
226const ELEMENTS = ['Wood', 'Fire', 'Earth', 'Metal', 'Water'];
227const ANIMALS = ['Rat', 'Ox', 'Tiger', 'Rabbit', 'Dragon', 'Snake', 'Horse', 'Goat', 'Monkey', 'Rooster', 'Dog', 'Pig'];
228
229function 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.
261const YEAR_BEARERS = ['Calli', 'Tōchtli', 'Ācatl', 'Tēcpatl']; // House, Rabbit, Reed, Flint
262const YEAR_BEARER_ANCHOR_YEAR = 1519; // "1 Ācatl"
263const YEAR_BEARER_ANCHOR_NUMBER = 1;
264const YEAR_BEARER_ANCHOR_SIGN_INDEX = 2; // Ācatl
265
266function 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.
286const LONG_COUNT_EPOCH_JDN = 584283;
287const LONG_COUNT_UNITS = [144000, 7200, 360, 20, 1]; // baktun, katun, tun, winal, k'in
288
289function 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.
314const 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
332function 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).
356function 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
365function 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).
380function 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
389function 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
398const 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
432for (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.
439CONVERTERS['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.
453CONVERTERS['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.
469CONVERTERS['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. */
478export 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}