fuzzyworld

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

historicalDate.ts

2.8 KB · history · blame

1// Bridge between the date-picker UI and the engine's HistoricalDate.
2//
3// The picker works in the way people actually say dates — a positive year plus
4// a BCE/CE choice — while the engine uses astronomical year numbering
5// (year 0 = 1 BCE). These helpers convert between the two and validate ranges.
6
7import type { HistoricalDate } from '../types';
8
9export type Era = 'CE' | 'BCE';
10
11/** A date as entered in the picker: a positive year plus an era. */
12export interface PickerDate {
13 year: number; // >= 1
14 month: number; // 1–12
15 day: number; // 1–31
16 era: Era;
17}
18
19/** Whether a proleptic-Gregorian year is a leap year. */
20export function isLeapYear(year: number): boolean {
21 return (year % 4 === 0 && year % 100 !== 0) || year % 400 === 0;
22}
23
24/** Days in a proleptic-Gregorian month (astronomical year numbering). */
25export function daysInMonth(year: number, month: number): number {
26 if (month === 2) return isLeapYear(year) ? 29 : 28;
27 return [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31][month - 1] ?? 31;
28}
29
30/** Days in a proleptic-Gregorian year (365 or 366). */
31export function daysInYear(year: number): number {
32 return isLeapYear(year) ? 366 : 365;
33}
34
35/** 1-based ordinal day within the year for a (month, day). */
36export function dayOfYear(year: number, month: number, day: number): number {
37 let doy = day;
38 for (let m = 1; m < month; m++) doy += daysInMonth(year, m);
39 return doy;
40}
41
42/** Convert a 1-based ordinal day-of-year back to a (month, day), clamped. */
43export function fromDayOfYear(year: number, doy: number): { month: number; day: number } {
44 let remaining = Math.max(1, Math.min(Math.round(doy), daysInYear(year)));
45 let month = 1;
46 while (month < 12 && remaining > daysInMonth(year, month)) {
47 remaining -= daysInMonth(year, month);
48 month++;
49 }
50 return { month, day: remaining };
51}
52
53/** Convert a picker date to the engine's astronomical HistoricalDate. */
54export function toHistoricalDate({ year, month, day, era }: PickerDate): HistoricalDate {
55 // BCE uses astronomical numbering: 1 BCE = 0, 2 BCE = -1, …
56 const astronomicalYear = era === 'BCE' ? 1 - year : year;
57 return { year: astronomicalYear, month, day };
58}
59
60/** Convert an engine HistoricalDate back to picker form. */
61export function fromHistoricalDate({ year, month, day }: HistoricalDate): PickerDate {
62 return year > 0
63 ? { year, month, day, era: 'CE' }
64 : { year: 1 - year, month, day, era: 'BCE' };
65}
66
67/** Clamp a picker date to a valid day-of-month (e.g. after changing month). */
68export function clampDay(date: PickerDate): PickerDate {
69 const astronomicalYear = date.era === 'BCE' ? 1 - date.year : date.year;
70 const max = daysInMonth(astronomicalYear, date.month);
71 return date.day > max ? { ...date, day: max } : date;
72}