Split out common calendar package

This commit is contained in:
Quantum 2023-04-21 02:21:12 -04:00
parent 50f8443605
commit 17229a8c2e
43 changed files with 14724 additions and 5112 deletions

2
.gitignore vendored
View file

@ -1,5 +1,5 @@
# dependencies # dependencies
/node_modules node_modules
/.pnp /.pnp
.pnp.js .pnp.js

View file

@ -1,61 +1,7 @@
# French Republican Calendar # qcal
![demo](demo.png) This is **@quantum5**'s collection of calendar webapps, intend to cover all
calendars from major cultures.
## What is this? Currently, the following calendars are supported:
* [French Republican Calendar](frcal/README.md)
The [French Republican calendar][0] was a calendar created and implemented
during the French Revolution.
It was also frequently known as the *French Revolutionary Calendar*, but this
was a misnomer: year 1 of the calendar started on 22 September 1792, the day
after the [abolition of the monarchy][1] and the founding of the [French
First Republic][2].
## How does it work?
A year consists of 12 months of 30 days each, divided into three *décades* of
10 days each, followed by 5 complementary days (6 in leap years).
The year starts on the day of the autumnal equinox at the Paris Observatory
(longitude 2°2014.03″ E). A leap year follow directly from this definition:
a year is a leap year when the next autumnal equinox happens 366 days later
instead of the normal 365. By this definition, the year will **never** drift
with respect to the seasons.
The 12 months are: *Vendémiaire*, *Brumaire*, *Frimaire*, *Nivôse*,
*Pluviôse*, *Ventôse*, *Germinal*, *Floréal*, *Prairial*, *Messidor*,
*Thermidor*, *Fructidor.*
The complementary days are: *la Fête de la Vertu*, *la Fête du Génie*,
*la Fête du Travail*, *la Fête de l'Opinion*, *la Fête des Récompenses,*
and *la Fête de la Révolution* (leap years only).
## What's so special about this version?
Most versions of the calendar floating around doesn't use the original
definition above.
Most versions uses the so-called *Romme* method for leap years, using the
same leap year rules as the Gregorian calendar, i.e. every year divisible
by four, except century years not divisible by 400. This method might make
sense, except years 3, 7, and 11 were leap years under the original rules
and were observed as such in real life, but the *Romme* method instead makes
years 4, 8, 12 leap years instead.
This version uses the original rules. The [JPL's DE440 and DE441
ephemerides][3] were used to calculate the exact timings of the autumnal
equinoxes between the Gregorian years 13201 BCE and 17191 CE (corresponding
to the French Republican years -14991 to 15399). The times were then converted
to UT1+00:09:21, the exact local time at the Paris Observatory. UT1 was chosen
to keep track of the Earth's rotation without having to worry about the issues
posed by leap seconds in UTC. Note that due to the uncertainty over [ΔT][4] —
the difference between UT1 and Terrestrial Time (TT) used in the ephemerides —
it is theoretically possible for there to be inaccuracies when the equinox
occurs very close to midnight.
[0]: https://en.wikipedia.org/wiki/French_Republican_calendar
[1]: https://en.wikipedia.org/wiki/Proclamation_of_the_abolition_of_the_monarchy
[2]: https://en.wikipedia.org/wiki/French_First_Republic
[3]: https://ssd.jpl.nasa.gov/planets/eph_export.html
[4]: https://en.wikipedia.org/wiki/%CE%94T_(timekeeping)

View file

@ -83,7 +83,7 @@ def main():
'start_year': fr_year(int(equinoxes[0].ut1_calendar()[0])), 'start_year': fr_year(int(equinoxes[0].ut1_calendar()[0])),
'leap': [int(paris_jdn(b) - paris_jdn(a) == 366) for a, b in zip(equinoxes, equinoxes[1:])] 'leap': [int(paris_jdn(b) - paris_jdn(a) == 366) for a, b in zip(equinoxes, equinoxes[1:])]
} }
with open('src/cal.json', 'w') as f: with open('src/french/cal.json', 'w') as f:
json.dump(result, f, separators=(',', ':')) json.dump(result, f, separators=(',', ':'))

5
common/jest.config.js Normal file
View file

@ -0,0 +1,5 @@
/** @type {import('ts-jest').JestConfigWithTsJest} */
module.exports = {
preset: 'ts-jest',
testEnvironment: 'node',
};

4883
common/package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

32
common/package.json Normal file
View file

@ -0,0 +1,32 @@
{
"name": "qcal_common",
"version": "0.1.0",
"private": true,
"dependencies": {
"@testing-library/jest-dom": "^5.16.2",
"@testing-library/react": "^12.1.2",
"@testing-library/user-event": "^13.5.0",
"@types/jest": "^27.4.0",
"@types/node": "^16.11.24",
"@types/react": "^17.0.39",
"@types/react-dom": "^17.0.11",
"bootstrap": "^5.1.3",
"react": "^17.0.2",
"react-dom": "^17.0.2",
"typescript": "^4.5.5",
"web-vitals": "^2.1.4"
},
"scripts": {
"test": "jest"
},
"eslintConfig": {
"extends": [
"react-app",
"react-app/jest"
]
},
"devDependencies": {
"jest": "^29.5.0",
"ts-jest": "^29.1.0"
}
}

View file

@ -0,0 +1,93 @@
import {
dateName,
dateRuralName,
frIsLeap,
frJDN,
jdnFrench,
monthName
} from './index';
describe('frJDN', () => {
it('works for sample dates', () => {
expect(frJDN(1, 1, 1)).toBe(2375840);
expect(frJDN(8, 2, 18)).toBe(2378444);
});
it('works in years starting/ending near midnight', () => {
expect(frJDN( 111, 1, 1)).toBe(2416017); // equinox 1902-09-23T23:55:19 UT1
expect(frJDN( 206, 1, 1)).toBe(2450715); // equinox 1997-09-22T23:55:46 UT1
expect(frJDN(2490, 1, 1)).toBe(3284926); // equinox 4281-09-20T23:50:38 UT1
});
});
describe('frIsLeap', () => {
it('works for sample dates', () => {
expect(frIsLeap(1)).toBeFalsy();
expect(frIsLeap(8)).toBeFalsy();
expect(frIsLeap(3)).toBeTruthy();
expect(frIsLeap(7)).toBeTruthy();
expect(frIsLeap(11)).toBeTruthy();
});
it('works in years starting/ending near midnight', () => {
expect(frIsLeap(110)).toBeTruthy();
expect(frIsLeap(205)).toBeTruthy();
expect(frIsLeap(2489)).toBeFalsy();
expect(frIsLeap(111)).toBeFalsy();
expect(frIsLeap(206)).toBeFalsy();
expect(frIsLeap(2490)).toBeTruthy();
});
});
describe('jdnFrench', () => {
it('works for sample dates', () => {
expect(jdnFrench(2375840)).toEqual({year: 1, month: 1, day: 1});
expect(jdnFrench(2378444)).toEqual({year: 8, month: 2, day: 18});
});
it('works in years starting/ending near midnight', () => {
expect(jdnFrench(2416017)).toEqual({year: 111, month: 1, day: 1});
expect(jdnFrench(2450715)).toEqual({year: 206, month: 1, day: 1});
expect(jdnFrench(3284926)).toEqual({year: 2490, month: 1, day: 1});
expect(jdnFrench(2416016)).toEqual({year: 110, month: 13, day: 6});
expect(jdnFrench(2450714)).toEqual({year: 205, month: 13, day: 6});
expect(jdnFrench(3284925)).toEqual({year: 2489, month: 13, day: 5});
});
});
describe('monthName', () => {
it('works', () => {
expect(monthName(1)).toBe('Vendémiaire');
expect(monthName(12)).toBe('Fructidor');
expect(monthName(13)).toBe('Jours Complémentaires');
});
});
describe('dateName', () => {
it('works', () => {
expect(dateName(1, 1)).toBe('1 Vendémiaire');
expect(dateName(2, 18)).toBe('18 Brumaire');
expect(dateName(3, 11)).toBe('11 Frimaire');
expect(dateName(8, 16)).toBe('16 Floréal');
expect(dateName(12, 30)).toBe('30 Fructidor');
expect(dateName(13, 1)).toBe('La Fête de la Vertu');
expect(dateName(13, 6)).toBe('La Fête de la Révolution');
});
it('returns null for non-existent complimentary days', () => {
expect(dateName(13, 7)).toBeNull();
});
});
describe('dateRuralName', () => {
it('works', () => {
expect(dateRuralName(1, 1)).toEqual({name: 'Raisin', title: 'Grape'});
expect(dateRuralName(1, 30)).toEqual({name: 'Tonneau', title: 'Barrel'});
expect(dateRuralName(12, 1)).toEqual({name: 'Prune', title: 'Plum'});
expect(dateRuralName(12, 30)).toEqual({name: 'Panier', title: 'Pack Basket'});
});
it('returns null for complimentary days', () => {
expect(dateRuralName(13, 1)).toBeNull();
});
});

View file

@ -1,9 +1,10 @@
import data from './cal.json'; import data from './cal.json';
import ruralName from './rural-days.json'; import ruralName from './rural-days.json';
import {jdnGregorian} from '../gregorian';
// Month 13 is for the complementary days // Month 13 is for the complementary days
export type Month = 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13; export type FrenchMonth = 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13;
export type Day = export type FrenchDay =
1 1
| 2 | 2
| 3 | 3
@ -37,12 +38,12 @@ export type Day =
export type FrenchDate = { export type FrenchDate = {
year: number, year: number,
month: Month, month: FrenchMonth,
day: Day, day: FrenchDay,
}; };
const monthNames: { const monthNames: {
[key in Month]: string [key in FrenchMonth]: string
} = { } = {
1: 'Vendémiaire', 1: 'Vendémiaire',
2: 'Brumaire', 2: 'Brumaire',
@ -75,24 +76,12 @@ data.leap.forEach(leap => {
}); });
export const endYear = startYear + leaps.length - 1; export const endYear = startYear + leaps.length - 1;
export function frSupportedYear(year: number): boolean { export function frSupportedYear(year: number): boolean {
return startYear <= year && year <= endYear; return startYear <= year && year <= endYear;
} }
export function gregorianJDN(year: number, month: number, day: number): number { export function frJDN(year: number, month: FrenchMonth, day: FrenchDay): number {
const g = year + 4716 - (month <= 2 ? 1 : 0);
const f = (month + 9) % 12;
const e = Math.floor(1461 * g / 4) + day - 1402;
const J = e + Math.floor((153 * f + 2) / 5);
const dg = 38 - Math.floor(Math.floor((g + 184) / 100) * 3 / 4);
return J + dg;
}
export function dateJDN(date: Date) {
return gregorianJDN(date.getFullYear(), date.getMonth() + 1, date.getDate());
}
export function frJDN(year: number, month: Month, day: Day): number {
const dy = year - startYear; const dy = year - startYear;
const dd = month * 30 + day - 31; const dd = month * 30 + day - 31;
return startJD + 365 * dy + leaps[dy] + dd; return startJD + 365 * dy + leaps[dy] + dd;
@ -102,18 +91,7 @@ export function frIsLeap(year: number): boolean {
return !!data.leap[year - startYear]; return !!data.leap[year - startYear];
} }
export function jdnGregorian(jdn: number): Date { export const endJD = frJDN(endYear, 13, frIsLeap(endYear) ? 6 : 5);
const e = 4 * (jdn + 1401 + Math.floor(Math.floor((4 * jdn + 274277) / 146097) * 3 / 4) - 38) + 3;
const h = 5 * Math.floor((e % 1461 + 1461) % 1461 / 4) + 2;
const day = Math.floor((h % 153 + 153) % 153 / 5) + 1;
const month = (Math.floor(h / 153) + 2) % 12 + 1;
const year = Math.floor(e / 1461) - 4716 + Math.floor((14 - month) / 12);
return new Date(year, month - 1, day);
}
export const endJD = frJDN(endYear, 13, frIsLeap(endYear) ? 6: 5);
export const startGregorian = jdnGregorian(startJD);
export const endGregorian = jdnGregorian(endJD);
export function jdnFrench(jdn: number): FrenchDate { export function jdnFrench(jdn: number): FrenchDate {
let lo = 0; let lo = 0;
@ -130,35 +108,16 @@ export function jdnFrench(jdn: number): FrenchDate {
const dd = jdn - (startJD + 365 * lo + leaps[lo]); const dd = jdn - (startJD + 365 * lo + leaps[lo]);
return { return {
year: startYear + lo, year: startYear + lo,
month: Math.floor(dd / 30) + 1 as Month, month: Math.floor(dd / 30) + 1 as FrenchMonth,
day: dd % 30 + 1 as Day, day: dd % 30 + 1 as FrenchDay,
} }
} }
export function jdnLongCount(jdn: number): string | null { export function monthName(month: FrenchMonth): string {
let z = jdn - 584283;
if (z < 0)
return null;
const parts = [z % 20, Math.floor(z / 20) % 18];
z = Math.floor(z / 360);
while (z > 0) {
parts.push(z % 20);
z = Math.floor(z / 20);
}
while (parts.length < 5) {
parts.push(0);
}
return parts.reverse().join('.');
}
export function monthName(month: Month): string {
return monthNames[month]; return monthNames[month];
} }
export function dateName(month: Month, day: Day): string | null { export function dateName(month: FrenchMonth, day: FrenchDay): string | null {
if (month === 13) { if (month === 13) {
switch (day) { switch (day) {
case 1: case 1:
@ -180,10 +139,13 @@ export function dateName(month: Month, day: Day): string | null {
return `${day} ${monthNames[month]}`; return `${day} ${monthNames[month]}`;
} }
export function dateRuralName(month: Month, day: Day): {name: string, title: string} | null { export function dateRuralName(month: FrenchMonth, day: FrenchDay): { name: string, title: string } | null {
const rural = ruralName[month * 30 + day - 31]; const rural = ruralName[month * 30 + day - 31];
if (!rural) if (!rural)
return null; return null;
const [name, title] = rural; const [name, title] = rural;
return {name, title}; return {name, title};
} }
export const startGregorian = jdnGregorian(startJD);
export const endGregorian = jdnGregorian(endJD);

View file

@ -0,0 +1,39 @@
import {gregorianJDN, jdnGregorian} from './gregorian';
describe('gregorianJDN', () => {
it('works', () => {
expect(gregorianJDN(2000, 1, 1)).toBe(2451545);
expect(gregorianJDN(-4713, 11, 24)).toBe(0);
expect(gregorianJDN(11917, 9, 18)).toBe(6073915);
expect(gregorianJDN(-28565, 6, 17)).toBe(-8711925);
expect(gregorianJDN(-26650, 4, 13)).toBe(-8012550);
expect(gregorianJDN(17430, 3, 8)).toBe(8087303);
expect(gregorianJDN(3395, 7, 18)).toBe(2961257);
expect(gregorianJDN(4579, 3, 11)).toBe(3393575);
expect(gregorianJDN(-14851, 11, 22)).toBe(-3702831);
expect(gregorianJDN(8824, 11, 28)).toBe(4944292);
expect(gregorianJDN(19720, 8, 14)).toBe(8923868);
expect(gregorianJDN(7504, 7, 22)).toBe(4462042);
});
});
describe('jdnGregorian', () => {
it('works', () => {
expect(jdnGregorian(0)).toEqual(new Date(-4713, 10, 24));
expect(jdnGregorian(2299160)).toEqual(new Date(1582, 9, 14));
expect(jdnGregorian(2299161)).toEqual(new Date(1582, 9, 15));
expect(jdnGregorian(2361221)).toEqual(new Date(1752, 8, 13));
expect(jdnGregorian(2361222)).toEqual(new Date(1752, 8, 14));
expect(jdnGregorian(2451545)).toEqual(new Date(2000, 0, 1));
expect(jdnGregorian(-8512316)).toEqual(new Date(-28019, 11, 20));
expect(jdnGregorian(-8534852)).toEqual(new Date(-28080, 3, 8));
expect(jdnGregorian(2653462)).toEqual(new Date(2552, 9, 30));
expect(jdnGregorian(3271156)).toEqual(new Date(4244, 0, 8));
expect(jdnGregorian(-666477)).toEqual(new Date(-6537, 1, 23));
expect(jdnGregorian(2397854)).toEqual(new Date(1852, 11, 31));
expect(jdnGregorian(-1211235)).toEqual(new Date(-8029, 7, 26));
expect(jdnGregorian(-91680)).toEqual(new Date(-4964, 10, 20));
expect(jdnGregorian(-5605876)).toEqual(new Date(-20061, 6, 14));
expect(jdnGregorian(-295121)).toEqual(new Date(-5521, 10, 19));
});
});

21
common/src/gregorian.ts Normal file
View file

@ -0,0 +1,21 @@
export function gregorianJDN(year: number, month: number, day: number): number {
const g = year + 4716 - (month <= 2 ? 1 : 0);
const f = (month + 9) % 12;
const e = Math.floor(1461 * g / 4) + day - 1402;
const J = e + Math.floor((153 * f + 2) / 5);
const dg = 38 - Math.floor(Math.floor((g + 184) / 100) * 3 / 4);
return J + dg;
}
export function dateJDN(date: Date) {
return gregorianJDN(date.getFullYear(), date.getMonth() + 1, date.getDate());
}
export function jdnGregorian(jdn: number): Date {
const e = 4 * (jdn + 1401 + Math.floor(Math.floor((4 * jdn + 274277) / 146097) * 3 / 4) - 38) + 3;
const h = 5 * Math.floor((e % 1461 + 1461) % 1461 / 4) + 2;
const day = Math.floor((h % 153 + 153) % 153 / 5) + 1;
const month = (Math.floor(h / 153) + 2) % 12 + 1;
const year = Math.floor(e / 1461) - 4716 + Math.floor((14 - month) / 12);
return new Date(year, month - 1, day);
}

View file

@ -0,0 +1,62 @@
import {jdnLongCount} from './longCount';
describe('jdnLongCount', () => {
it('works for normal dates', () => {
expect(jdnLongCount(2456282)).toEqual('12.19.19.17.19');
expect(jdnLongCount(2459413)).toEqual('13.0.8.12.10');
expect(jdnLongCount(1708616)).toEqual('7.16.3.2.13');
expect(jdnLongCount(1787816)).toEqual('8.7.3.2.13');
expect(jdnLongCount(1709981)).toEqual('7.16.6.16.18');
expect(jdnLongCount(1725275)).toEqual('7.18.9.7.12');
expect(jdnLongCount(1727095)).toEqual('7.18.14.8.12');
expect(jdnLongCount(1731775)).toEqual('7.19.7.8.12');
expect(jdnLongCount(1734635)).toEqual('7.19.15.7.12');
expect(jdnLongCount(1751618)).toEqual('8.2.2.10.15');
expect(jdnLongCount(1758818)).toEqual('8.3.2.10.15');
expect(jdnLongCount(1767234)).toEqual('8.4.5.17.11');
expect(jdnLongCount(1773428)).toEqual('8.5.3.3.5');
expect(jdnLongCount(1778230)).toEqual('8.5.16.9.7');
expect(jdnLongCount(1780300)).toEqual('8.6.2.4.17');
expect(jdnLongCount(2283483)).toEqual('11.16.0.0.0');
expect(jdnLongCount(584283)).toEqual('0.0.0.0.0');
expect(jdnLongCount(728283)).toEqual('1.0.0.0.0');
expect(jdnLongCount(872283)).toEqual('2.0.0.0.0');
expect(jdnLongCount(1016283)).toEqual('3.0.0.0.0');
expect(jdnLongCount(1160283)).toEqual('4.0.0.0.0');
expect(jdnLongCount(1304283)).toEqual('5.0.0.0.0');
expect(jdnLongCount(1448283)).toEqual('6.0.0.0.0');
expect(jdnLongCount(1592283)).toEqual('7.0.0.0.0');
expect(jdnLongCount(1736283)).toEqual('8.0.0.0.0');
expect(jdnLongCount(1880283)).toEqual('9.0.0.0.0');
expect(jdnLongCount(2024283)).toEqual('10.0.0.0.0');
expect(jdnLongCount(2168283)).toEqual('11.0.0.0.0');
expect(jdnLongCount(2312283)).toEqual('12.0.0.0.0');
expect(jdnLongCount(2456283)).toEqual('13.0.0.0.0');
expect(jdnLongCount(2600283)).toEqual('14.0.0.0.0');
expect(jdnLongCount(2744283)).toEqual('15.0.0.0.0');
expect(jdnLongCount(2888283)).toEqual('16.0.0.0.0');
expect(jdnLongCount(3032283)).toEqual('17.0.0.0.0');
expect(jdnLongCount(3176283)).toEqual('18.0.0.0.0');
expect(jdnLongCount(3320283)).toEqual('19.0.0.0.0');
expect(jdnLongCount(3464283)).toEqual('1.0.0.0.0.0');
expect(jdnLongCount(1941383)).toEqual('9.8.9.13.0');
});
it('works for insane dates in the future', () => {
expect(jdnLongCount(591279564516)).toEqual('1.5.13.5.5.4.0.11.13.13');
expect(jdnLongCount(570988471138)).toEqual('1.4.15.12.19.13.13.3.8.15');
expect(jdnLongCount(166410754861)).toEqual('7.4.9.1.6.3.13.14.18');
expect(jdnLongCount(176632006419)).toEqual('7.13.6.10.7.1.19.4.16');
expect(jdnLongCount(652557304645)).toEqual('1.8.6.9.2.3.17.16.10.2');
expect(jdnLongCount(140305417242)).toEqual('6.1.15.16.19.2.7.1.19');
expect(jdnLongCount(805888002058)).toEqual('1.14.19.11.2.0.8.0.8.15');
expect(jdnLongCount(176433890202)).toEqual('7.13.3.1.11.5.16.7.19');
expect(jdnLongCount(331888546361)).toEqual('14.8.1.18.17.10.5.13.18');
expect(jdnLongCount(657363764536)).toEqual('1.8.10.12.11.2.1.14.0.13');
});
it('returns null for dates before inception of calendar', () => {
expect(jdnLongCount(0)).toBeNull();
expect(jdnLongCount(584282)).toBeNull();
});
})

18
common/src/longCount.ts Normal file
View file

@ -0,0 +1,18 @@
export function jdnLongCount(jdn: number): string | null {
let z = jdn - 584283;
if (z < 0)
return null;
const parts = [z % 20, Math.floor(z / 20) % 18];
z = Math.floor(z / 360);
while (z > 0) {
parts.push(z % 20);
z = Math.floor(z / 20);
}
while (parts.length < 5) {
parts.push(0);
}
return parts.reverse().join('.');
}

61
frcal/README.md Normal file
View file

@ -0,0 +1,61 @@
# French Republican Calendar
![demo](demo.png)
## What is this?
The [French Republican calendar][0] was a calendar created and implemented
during the French Revolution.
It was also frequently known as the *French Revolutionary Calendar*, but this
was a misnomer: year 1 of the calendar started on 22 September 1792, the day
after the [abolition of the monarchy][1] and the founding of the [French
First Republic][2].
## How does it work?
A year consists of 12 months of 30 days each, divided into three *décades* of
10 days each, followed by 5 complementary days (6 in leap years).
The year starts on the day of the autumnal equinox at the Paris Observatory
(longitude 2°2014.03″ E). A leap year follow directly from this definition:
a year is a leap year when the next autumnal equinox happens 366 days later
instead of the normal 365. By this definition, the year will **never** drift
with respect to the seasons.
The 12 months are: *Vendémiaire*, *Brumaire*, *Frimaire*, *Nivôse*,
*Pluviôse*, *Ventôse*, *Germinal*, *Floréal*, *Prairial*, *Messidor*,
*Thermidor*, *Fructidor.*
The complementary days are: *la Fête de la Vertu*, *la Fête du Génie*,
*la Fête du Travail*, *la Fête de l'Opinion*, *la Fête des Récompenses,*
and *la Fête de la Révolution* (leap years only).
## What's so special about this version?
Most versions of the calendar floating around doesn't use the original
definition above.
Most versions uses the so-called *Romme* method for leap years, using the
same leap year rules as the Gregorian calendar, i.e. every year divisible
by four, except century years not divisible by 400. This method might make
sense, except years 3, 7, and 11 were leap years under the original rules
and were observed as such in real life, but the *Romme* method instead makes
years 4, 8, 12 leap years instead.
This version uses the original rules. The [JPL's DE440 and DE441
ephemerides][3] were used to calculate the exact timings of the autumnal
equinoxes between the Gregorian years 13201 BCE and 17191 CE (corresponding
to the French Republican years -14991 to 15399). The times were then converted
to UT1+00:09:21, the exact local time at the Paris Observatory. UT1 was chosen
to keep track of the Earth's rotation without having to worry about the issues
posed by leap seconds in UTC. Note that due to the uncertainty over [ΔT][4] —
the difference between UT1 and Terrestrial Time (TT) used in the ephemerides —
it is theoretically possible for there to be inaccuracies when the equinox
occurs very close to midnight.
[0]: https://en.wikipedia.org/wiki/French_Republican_calendar
[1]: https://en.wikipedia.org/wiki/Proclamation_of_the_abolition_of_the_monarchy
[2]: https://en.wikipedia.org/wiki/French_First_Republic
[3]: https://ssd.jpl.nasa.gov/planets/eph_export.html
[4]: https://en.wikipedia.org/wiki/%CE%94T_(timekeeping)

View file

@ -0,0 +1,5 @@
const {aliasWebpack, aliasJest} = require('react-app-alias-ex');
const options = {};
module.exports = aliasWebpack(options);
module.exports.jest = aliasJest(options);

View file

Before

Width:  |  Height:  |  Size: 360 KiB

After

Width:  |  Height:  |  Size: 360 KiB

File diff suppressed because it is too large Load diff

View file

@ -20,9 +20,9 @@
"web-vitals": "^2.1.4" "web-vitals": "^2.1.4"
}, },
"scripts": { "scripts": {
"start": "react-scripts start", "start": "react-app-rewired start",
"build": "react-scripts build", "build": "react-app-rewired build",
"test": "react-scripts test", "test": "react-app-rewired test",
"eject": "react-scripts eject" "eject": "react-scripts eject"
}, },
"eslintConfig": { "eslintConfig": {
@ -44,6 +44,8 @@
] ]
}, },
"devDependencies": { "devDependencies": {
"@types/bootstrap": "^5.1.9" "@types/bootstrap": "^5.1.9",
"react-app-alias-ex": "^2.1.0",
"react-app-rewired": "^2.2.1"
} }
} }

View file

Before

Width:  |  Height:  |  Size: 97 KiB

After

Width:  |  Height:  |  Size: 97 KiB

View file

Before

Width:  |  Height:  |  Size: 7.1 KiB

After

Width:  |  Height:  |  Size: 7.1 KiB

View file

Before

Width:  |  Height:  |  Size: 19 KiB

After

Width:  |  Height:  |  Size: 19 KiB

View file

Before

Width:  |  Height:  |  Size: 76 KiB

After

Width:  |  Height:  |  Size: 76 KiB

View file

@ -1,21 +1,20 @@
import React, {FormEvent} from 'react'; import React, {FormEvent} from 'react';
import {Calendar} from './Calendar'; import {Calendar} from './Calendar';
import { import {
dateJDN,
endGregorian, endGregorian,
endJD, endJD,
FrenchMonth,
frSupportedYear, frSupportedYear,
gregorianJDN,
jdnFrench, jdnFrench,
Month,
startGregorian, startGregorian,
startJD startJD,
} from './dates'; } from '@common/french';
import {dateJDN, gregorianJDN} from '@common/gregorian';
import {TimeOfDay} from './TimeOfDay'; import {TimeOfDay} from './TimeOfDay';
type YearMonth = { type YearMonth = {
year: number; year: number;
month: Month; month: FrenchMonth;
} }
function parseURL(): YearMonth | null { function parseURL(): YearMonth | null {
@ -27,7 +26,7 @@ function parseURL(): YearMonth | null {
const year = +match[1]; const year = +match[1];
if (!frSupportedYear(year) || month < 1 || month > 13) if (!frSupportedYear(year) || month < 1 || month > 13)
return null; return null;
return {year: year, month: month as Month}; return {year: year, month: month as FrenchMonth};
} }
type AppState = YearMonth & { type AppState = YearMonth & {

View file

@ -3,26 +3,26 @@ import './Calendar.scss';
import { import {
dateName, dateName,
dateRuralName, dateRuralName,
Day,
decadeNames, decadeNames,
endYear, endYear,
FrenchDay,
FrenchMonth,
frIsLeap, frIsLeap,
frJDN, frJDN,
jdnFrench, jdnFrench,
jdnGregorian,
jdnLongCount,
Month,
monthName, monthName,
startYear startYear,
} from './dates'; } from '@common/french';
import {jdnGregorian} from '@common/gregorian';
import {jdnLongCount} from '@common/longCount';
type MonthProps = { type MonthProps = {
year: number; year: number;
month: Month; month: FrenchMonth;
}; };
type DateProps = MonthProps & { type DateProps = MonthProps & {
day: Day; day: FrenchDay;
}; };
function DecadeName({name}: { name: string }): JSX.Element { function DecadeName({name}: { name: string }): JSX.Element {
@ -54,7 +54,7 @@ function NormalMonth({year, month, todayJDN}: MonthProps & { todayJDN: number })
<div className="Month-decades">{ <div className="Month-decades">{
Array.from(Array(3).keys()).map(i => <div key={i} className="Month-decade">{ Array.from(Array(3).keys()).map(i => <div key={i} className="Month-decade">{
Array.from(Array(10).keys()).map(j => <React.Fragment key={j}> Array.from(Array(10).keys()).map(j => <React.Fragment key={j}>
<NormalDay year={year} month={month} day={i * 10 + j + 1 as Day} todayJDN={todayJDN}/> <NormalDay year={year} month={month} day={i * 10 + j + 1 as FrenchDay} todayJDN={todayJDN}/>
{j % 2 === 1 && <div className="Month-decadeSplitter-small"/>} {j % 2 === 1 && <div className="Month-decadeSplitter-small"/>}
{j === 4 && <div className="Month-decadeSplitter-medium"/>} {j === 4 && <div className="Month-decadeSplitter-medium"/>}
</React.Fragment>) </React.Fragment>)
@ -75,7 +75,7 @@ function ComplementaryDays({year, todayJDN}: { year: number, todayJDN: number })
const leap = frIsLeap(year); const leap = frIsLeap(year);
return <div className="ComplementaryDays">{ return <div className="ComplementaryDays">{
Array.from(Array(6).keys()).map(i => <React.Fragment key={i}> Array.from(Array(6).keys()).map(i => <React.Fragment key={i}>
{(i < 5 || leap) && <ComplementaryDay year={year} month={13} day={i + 1 as Day} todayJDN={todayJDN}/>} {(i < 5 || leap) && <ComplementaryDay year={year} month={13} day={i + 1 as FrenchDay} todayJDN={todayJDN}/>}
{i === 5 && !leap && <div className="ComplementaryDay-fake"/>} {i === 5 && !leap && <div className="ComplementaryDay-fake"/>}
{i % 2 === 1 && <div className="ComplementaryDays-splitter"/>} {i % 2 === 1 && <div className="ComplementaryDays-splitter"/>}
</React.Fragment>) </React.Fragment>)
@ -84,7 +84,7 @@ function ComplementaryDays({year, todayJDN}: { year: number, todayJDN: number })
export type CalendarProps = MonthProps & { export type CalendarProps = MonthProps & {
todayJDN: number; todayJDN: number;
onSwitch?: (year: number, month: Month) => void, onSwitch?: (year: number, month: FrenchMonth) => void,
}; };
type CalendarState = { type CalendarState = {
@ -131,7 +131,7 @@ export class Calendar extends React.Component<CalendarProps, CalendarState> {
month = 13; month = 13;
} }
this.props.onSwitch && this.props.onSwitch(year, month as Month); this.props.onSwitch && this.props.onSwitch(year, month as FrenchMonth);
} }
prevYear = () => { prevYear = () => {
@ -165,7 +165,7 @@ export class Calendar extends React.Component<CalendarProps, CalendarState> {
} }
monthChange = (event: any) => { monthChange = (event: any) => {
this.goToNormalized(this.props.year, +event.target.value as Month); this.goToNormalized(this.props.year, +event.target.value as FrenchMonth);
} }
yearChange = (event: any) => { yearChange = (event: any) => {
@ -210,7 +210,7 @@ export class Calendar extends React.Component<CalendarProps, CalendarState> {
<select className="Calendar-month-input form-control" onChange={this.monthChange} <select className="Calendar-month-input form-control" onChange={this.monthChange}
value={this.props.month}>{ value={this.props.month}>{
Array.from(Array(13).keys()).map(i => { Array.from(Array(13).keys()).map(i => {
const month = i + 1 as Month; const month = i + 1 as FrenchMonth;
return <option key={i} value={month}>{monthName(month)}</option>; return <option key={i} value={month}>{monthName(month)}</option>;
}) })
}</select> }</select>

View file

@ -1,6 +1,6 @@
import React from 'react'; import React from 'react';
import './TimeOfDay.scss'; import './TimeOfDay.scss';
import {dateJDN, gregorianJDN} from './dates'; import {dateJDN, gregorianJDN} from '@common/gregorian';
type TimeStamp = { type TimeStamp = {
hour: number, hour: number,

1
frcal/src/cal.json Normal file

File diff suppressed because one or more lines are too long

27
frcal/tsconfig.json Normal file
View file

@ -0,0 +1,27 @@
{
"extends": "./tsconfig.paths.json",
"compilerOptions": {
"target": "es5",
"lib": [
"dom",
"dom.iterable",
"esnext"
],
"allowJs": true,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"noFallthroughCasesInSwitch": true,
"module": "esnext",
"moduleResolution": "node",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx"
},
"include": [
"src"
]
}

View file

@ -0,0 +1,8 @@
{
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@common/*": ["../common/src/*"]
}
}
}

View file

@ -1,195 +0,0 @@
import {
dateName,
dateRuralName,
frIsLeap,
frJDN,
gregorianJDN,
jdnFrench,
jdnGregorian,
jdnLongCount,
monthName
} from './dates';
describe('gregorianJDN', () => {
it('works', () => {
expect(gregorianJDN(2000, 1, 1)).toBe(2451545);
expect(gregorianJDN(-4713, 11, 24)).toBe(0);
expect(gregorianJDN(11917, 9, 18)).toBe(6073915);
expect(gregorianJDN(-28565, 6, 17)).toBe(-8711925);
expect(gregorianJDN(-26650, 4, 13)).toBe(-8012550);
expect(gregorianJDN(17430, 3, 8)).toBe(8087303);
expect(gregorianJDN(3395, 7, 18)).toBe(2961257);
expect(gregorianJDN(4579, 3, 11)).toBe(3393575);
expect(gregorianJDN(-14851, 11, 22)).toBe(-3702831);
expect(gregorianJDN(8824, 11, 28)).toBe(4944292);
expect(gregorianJDN(19720, 8, 14)).toBe(8923868);
expect(gregorianJDN(7504, 7, 22)).toBe(4462042);
});
});
describe('frJDN', () => {
it('works for sample dates', () => {
expect(frJDN(1, 1, 1)).toBe(2375840);
expect(frJDN(8, 2, 18)).toBe(2378444);
});
it('works in years starting/ending near midnight', () => {
expect(frJDN( 111, 1, 1)).toBe(2416017); // equinox 1902-09-23T23:55:19 UT1
expect(frJDN( 206, 1, 1)).toBe(2450715); // equinox 1997-09-22T23:55:46 UT1
expect(frJDN(2490, 1, 1)).toBe(3284926); // equinox 4281-09-20T23:50:38 UT1
});
});
describe('frIsLeap', () => {
it('works for sample dates', () => {
expect(frIsLeap(1)).toBeFalsy();
expect(frIsLeap(8)).toBeFalsy();
expect(frIsLeap(3)).toBeTruthy();
expect(frIsLeap(7)).toBeTruthy();
expect(frIsLeap(11)).toBeTruthy();
});
it('works in years starting/ending near midnight', () => {
expect(frIsLeap(110)).toBeTruthy();
expect(frIsLeap(205)).toBeTruthy();
expect(frIsLeap(2489)).toBeFalsy();
expect(frIsLeap(111)).toBeFalsy();
expect(frIsLeap(206)).toBeFalsy();
expect(frIsLeap(2490)).toBeTruthy();
});
});
describe('jdnFrench', () => {
it('works for sample dates', () => {
expect(jdnFrench(2375840)).toEqual({year: 1, month: 1, day: 1});
expect(jdnFrench(2378444)).toEqual({year: 8, month: 2, day: 18});
});
it('works in years starting/ending near midnight', () => {
expect(jdnFrench(2416017)).toEqual({year: 111, month: 1, day: 1});
expect(jdnFrench(2450715)).toEqual({year: 206, month: 1, day: 1});
expect(jdnFrench(3284926)).toEqual({year: 2490, month: 1, day: 1});
expect(jdnFrench(2416016)).toEqual({year: 110, month: 13, day: 6});
expect(jdnFrench(2450714)).toEqual({year: 205, month: 13, day: 6});
expect(jdnFrench(3284925)).toEqual({year: 2489, month: 13, day: 5});
});
});
describe('jdnGregorian', () => {
it('works', () => {
expect(jdnGregorian(0)).toEqual(new Date(-4713, 10, 24));
expect(jdnGregorian(2299160)).toEqual(new Date(1582, 9, 14));
expect(jdnGregorian(2299161)).toEqual(new Date(1582, 9, 15));
expect(jdnGregorian(2361221)).toEqual(new Date(1752, 8, 13));
expect(jdnGregorian(2361222)).toEqual(new Date(1752, 8, 14));
expect(jdnGregorian(2451545)).toEqual(new Date(2000, 0, 1));
expect(jdnGregorian(-8512316)).toEqual(new Date(-28019, 11, 20));
expect(jdnGregorian(-8534852)).toEqual(new Date(-28080, 3, 8));
expect(jdnGregorian(2653462)).toEqual(new Date(2552, 9, 30));
expect(jdnGregorian(3271156)).toEqual(new Date(4244, 0, 8));
expect(jdnGregorian(-666477)).toEqual(new Date(-6537, 1, 23));
expect(jdnGregorian(2397854)).toEqual(new Date(1852, 11, 31));
expect(jdnGregorian(-1211235)).toEqual(new Date(-8029, 7, 26));
expect(jdnGregorian(-91680)).toEqual(new Date(-4964, 10, 20));
expect(jdnGregorian(-5605876)).toEqual(new Date(-20061, 6, 14));
expect(jdnGregorian(-295121)).toEqual(new Date(-5521, 10, 19));
});
});
describe('jdnLongCount', () => {
it('works for normal dates', () => {
expect(jdnLongCount(2456282)).toEqual('12.19.19.17.19');
expect(jdnLongCount(2459413)).toEqual('13.0.8.12.10');
expect(jdnLongCount(1708616)).toEqual('7.16.3.2.13');
expect(jdnLongCount(1787816)).toEqual('8.7.3.2.13');
expect(jdnLongCount(1709981)).toEqual('7.16.6.16.18');
expect(jdnLongCount(1725275)).toEqual('7.18.9.7.12');
expect(jdnLongCount(1727095)).toEqual('7.18.14.8.12');
expect(jdnLongCount(1731775)).toEqual('7.19.7.8.12');
expect(jdnLongCount(1734635)).toEqual('7.19.15.7.12');
expect(jdnLongCount(1751618)).toEqual('8.2.2.10.15');
expect(jdnLongCount(1758818)).toEqual('8.3.2.10.15');
expect(jdnLongCount(1767234)).toEqual('8.4.5.17.11');
expect(jdnLongCount(1773428)).toEqual('8.5.3.3.5');
expect(jdnLongCount(1778230)).toEqual('8.5.16.9.7');
expect(jdnLongCount(1780300)).toEqual('8.6.2.4.17');
expect(jdnLongCount(2283483)).toEqual('11.16.0.0.0');
expect(jdnLongCount(584283)).toEqual('0.0.0.0.0');
expect(jdnLongCount(728283)).toEqual('1.0.0.0.0');
expect(jdnLongCount(872283)).toEqual('2.0.0.0.0');
expect(jdnLongCount(1016283)).toEqual('3.0.0.0.0');
expect(jdnLongCount(1160283)).toEqual('4.0.0.0.0');
expect(jdnLongCount(1304283)).toEqual('5.0.0.0.0');
expect(jdnLongCount(1448283)).toEqual('6.0.0.0.0');
expect(jdnLongCount(1592283)).toEqual('7.0.0.0.0');
expect(jdnLongCount(1736283)).toEqual('8.0.0.0.0');
expect(jdnLongCount(1880283)).toEqual('9.0.0.0.0');
expect(jdnLongCount(2024283)).toEqual('10.0.0.0.0');
expect(jdnLongCount(2168283)).toEqual('11.0.0.0.0');
expect(jdnLongCount(2312283)).toEqual('12.0.0.0.0');
expect(jdnLongCount(2456283)).toEqual('13.0.0.0.0');
expect(jdnLongCount(2600283)).toEqual('14.0.0.0.0');
expect(jdnLongCount(2744283)).toEqual('15.0.0.0.0');
expect(jdnLongCount(2888283)).toEqual('16.0.0.0.0');
expect(jdnLongCount(3032283)).toEqual('17.0.0.0.0');
expect(jdnLongCount(3176283)).toEqual('18.0.0.0.0');
expect(jdnLongCount(3320283)).toEqual('19.0.0.0.0');
expect(jdnLongCount(3464283)).toEqual('1.0.0.0.0.0');
expect(jdnLongCount(1941383)).toEqual('9.8.9.13.0');
});
it('works for insane dates in the future', () => {
expect(jdnLongCount(591279564516)).toEqual('1.5.13.5.5.4.0.11.13.13');
expect(jdnLongCount(570988471138)).toEqual('1.4.15.12.19.13.13.3.8.15');
expect(jdnLongCount(166410754861)).toEqual('7.4.9.1.6.3.13.14.18');
expect(jdnLongCount(176632006419)).toEqual('7.13.6.10.7.1.19.4.16');
expect(jdnLongCount(652557304645)).toEqual('1.8.6.9.2.3.17.16.10.2');
expect(jdnLongCount(140305417242)).toEqual('6.1.15.16.19.2.7.1.19');
expect(jdnLongCount(805888002058)).toEqual('1.14.19.11.2.0.8.0.8.15');
expect(jdnLongCount(176433890202)).toEqual('7.13.3.1.11.5.16.7.19');
expect(jdnLongCount(331888546361)).toEqual('14.8.1.18.17.10.5.13.18');
expect(jdnLongCount(657363764536)).toEqual('1.8.10.12.11.2.1.14.0.13');
});
it('returns null for dates before inception of calendar', () => {
expect(jdnLongCount(0)).toBeNull();
expect(jdnLongCount(584282)).toBeNull();
});
})
describe('monthName', () => {
it('works', () => {
expect(monthName(1)).toBe('Vendémiaire');
expect(monthName(12)).toBe('Fructidor');
expect(monthName(13)).toBe('Jours Complémentaires');
});
});
describe('dateName', () => {
it('works', () => {
expect(dateName(1, 1)).toBe('1 Vendémiaire');
expect(dateName(2, 18)).toBe('18 Brumaire');
expect(dateName(3, 11)).toBe('11 Frimaire');
expect(dateName(8, 16)).toBe('16 Floréal');
expect(dateName(12, 30)).toBe('30 Fructidor');
expect(dateName(13, 1)).toBe('La Fête de la Vertu');
expect(dateName(13, 6)).toBe('La Fête de la Révolution');
});
it('returns null for non-existent complimentary days', () => {
expect(dateName(13, 7)).toBeNull();
});
});
describe('dateRuralName', () => {
it('works', () => {
expect(dateRuralName(1, 1)).toEqual({name: 'Raisin', title: 'Grape'});
expect(dateRuralName(1, 30)).toEqual({name: 'Tonneau', title: 'Barrel'});
expect(dateRuralName(12, 1)).toEqual({name: 'Prune', title: 'Plum'});
expect(dateRuralName(12, 30)).toEqual({name: 'Panier', title: 'Pack Basket'});
});
it('returns null for complimentary days', () => {
expect(dateRuralName(13, 1)).toBeNull();
});
});