diff --git a/gcal/README.md b/gcal/README.md
new file mode 100644
index 0000000..d3d2b5d
--- /dev/null
+++ b/gcal/README.md
@@ -0,0 +1 @@
+# Gregorian Calendar
diff --git a/gcal/config-overrides.js b/gcal/config-overrides.js
new file mode 100644
index 0000000..5d15766
--- /dev/null
+++ b/gcal/config-overrides.js
@@ -0,0 +1,9 @@
+const {aliasWebpack, aliasJest} = require('react-app-alias-ex');
+
+const options = {};
+module.exports = aliasWebpack(options);
+module.exports.jest = function (config) {
+ const result = aliasJest(options)(config);
+ result.moduleDirectories.unshift(require('path').resolve(__dirname, '../node_modules'));
+ return result;
+};
diff --git a/gcal/package.json b/gcal/package.json
new file mode 100644
index 0000000..21fac2d
--- /dev/null
+++ b/gcal/package.json
@@ -0,0 +1,51 @@
+{
+ "name": "gcal",
+ "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",
+ "react-scripts": "5.0.0",
+ "sass": "^1.49.7",
+ "sass-loader": "^12.4.0",
+ "typescript": "^4.5.5",
+ "web-vitals": "^2.1.4"
+ },
+ "scripts": {
+ "start": "react-app-rewired start",
+ "build": "react-app-rewired build",
+ "test": "react-app-rewired test",
+ "eject": "react-scripts eject"
+ },
+ "eslintConfig": {
+ "extends": [
+ "react-app",
+ "react-app/jest"
+ ]
+ },
+ "browserslist": {
+ "production": [
+ ">0.2%",
+ "not dead",
+ "not op_mini all"
+ ],
+ "development": [
+ "last 1 chrome version",
+ "last 1 firefox version",
+ "last 1 safari version"
+ ]
+ },
+ "devDependencies": {
+ "@types/bootstrap": "^5.1.9",
+ "react-app-alias-ex": "^2.1.0",
+ "react-app-rewired": "^2.2.1"
+ }
+}
diff --git a/gcal/public/index.html b/gcal/public/index.html
new file mode 100644
index 0000000..74d85f8
--- /dev/null
+++ b/gcal/public/index.html
@@ -0,0 +1,53 @@
+
+
+
+
+
+
+
+ Gregorian Calendar
+
+
+
+
+
+
+
+
+
diff --git a/gcal/public/robots.txt b/gcal/public/robots.txt
new file mode 100644
index 0000000..e9e57dc
--- /dev/null
+++ b/gcal/public/robots.txt
@@ -0,0 +1,3 @@
+# https://www.robotstxt.org/robotstxt.html
+User-agent: *
+Disallow:
diff --git a/gcal/src/App.tsx b/gcal/src/App.tsx
new file mode 100644
index 0000000..4b5a1a4
--- /dev/null
+++ b/gcal/src/App.tsx
@@ -0,0 +1,30 @@
+import React from 'react';
+import {Calendar} from './Calendar';
+import {JulianMonth} from '@common/gregorian';
+import {DayChanger} from '@common/ui/DayChanger';
+import {jdnJulian} from '@common/julian';
+import MonthBasedApp from '@common/ui/MonthBasedApp';
+
+export default class App extends MonthBasedApp {
+ override parseYearMonth(year: string, month: string) {
+ if (+month < 1 || +month > 12)
+ return null;
+ return {year: +year, month: +month as JulianMonth};
+ }
+
+ override defaultSelector(todayJDN: number) {
+ const [year, month] = jdnJulian(todayJDN);
+ return {year, month};
+ }
+
+ render() {
+ const {selector: {year, month}, todayJDN} = this.state;
+ return <>
+ this.setState({selector: {year, month}})}/>
+
+
+ >;
+ }
+}
diff --git a/gcal/src/Calendar.scss b/gcal/src/Calendar.scss
new file mode 100644
index 0000000..be0744b
--- /dev/null
+++ b/gcal/src/Calendar.scss
@@ -0,0 +1,7 @@
+@import 'bootstrap/scss/functions';
+@import 'bootstrap/scss/variables';
+@import 'bootstrap/scss/mixins';
+@import 'bootstrap/scss/grid';
+@import '@common/ui/consts.scss';
+@import '@common/ui/MonthBasedCalendar.scss';
+@import '@common/ui/SevenDayWeek.scss';
diff --git a/gcal/src/Calendar.tsx b/gcal/src/Calendar.tsx
new file mode 100644
index 0000000..15cf03d
--- /dev/null
+++ b/gcal/src/Calendar.tsx
@@ -0,0 +1,157 @@
+import React from 'react';
+import './Calendar.scss';
+import {
+ formatJG,
+ gregorianJDN,
+ gregorianMonthDays,
+ jdnGregorian,
+ JulianDay,
+ JulianMonth,
+ monthName,
+ weekdayNames,
+} from '@common/gregorian';
+import {jdnLongCount} from '@common/longCount';
+import {jdnJulian, julianJDN, julianMonthDays} from '@common/julian';
+import {frDateFormat, frEndJD, frStartJD, jdnFrench} from '@common/french';
+import {useMobileTooltipProps} from '@common/ui/MobileTooltip';
+import {MonthBasedCalendar} from '@common/ui/MonthBasedCalendar';
+
+type JulianYear = number;
+
+type MonthProps = {
+ year: JulianYear;
+ month: JulianMonth;
+};
+
+type DateProps = MonthProps & {
+ day: JulianDay;
+};
+
+function WeekdayName({name}: { name: string }): JSX.Element {
+ return {name}
;
+}
+
+function DayDetail({jdn}: { jdn: number }): JSX.Element {
+ const lc = jdnLongCount(jdn);
+ const mobile = useMobileTooltipProps();
+ return
+
+
+
J.{' '}
+ {formatJG(jdnJulian(jdn))}
+
+ {lc &&
+
LC{' '}
+ {lc.join('.\u200b')}
+
}
+ {jdn >= frStartJD && jdn <= frEndJD &&
+
FR{' '}
+ {frDateFormat(jdnFrench(jdn))}
+
}
+
;
+}
+
+function Day({year, month, day, todayJDN}: DateProps & { todayJDN: number }): JSX.Element {
+ const jdn = gregorianJDN(year, month, day);
+ return
+
{day}
+
{weekdayNames[(jdn + 1) % 7]}
+
+ ;
+}
+
+function Month({year, month, todayJDN}: MonthProps & { todayJDN: number }): JSX.Element {
+ const decadeHeads = weekdayNames.map((name, i) => );
+ const firstJDN = gregorianJDN(year, month, 1);
+ const firstWeekday = (firstJDN + 1) % 7;
+ const daysTotal = gregorianMonthDays(year, month);
+ return
+
{decadeHeads}
+
{
+ Array.from(Array(6).keys()).flatMap(i => {
+ if (i * 7 - firstWeekday + 1 > daysTotal)
+ return [];
+ return Array.from(Array(7).keys()).map(j => {
+ const day = i * 7 + j - firstWeekday + 1 as JulianDay;
+ if (day < 1 || day > daysTotal)
+ return
;
+ return
+
+
;
+ });
+ })
+ }
+
;
+}
+
+export class Calendar extends MonthBasedCalendar {
+ override parseYear(year: string): JulianYear {
+ return +year;
+ }
+
+ override parseMonth(month: string): JulianMonth {
+ return +month as JulianMonth;
+ }
+
+ override yearToString(year: JulianYear): string {
+ return year.toString();
+ }
+
+ override monthToString(month: JulianMonth): string {
+ return month.toString();
+ }
+
+ private goToNormalized(year: number, month: number) {
+ while (month < 1) {
+ --year;
+ month += 12;
+ }
+
+ while (month > 12) {
+ ++year;
+ month -= 12;
+ }
+
+ this.goTo(year, month as JulianMonth);
+ }
+
+ override prevYear = () => {
+ this.goToNormalized(this.props.year - 1, this.props.month);
+ };
+
+ override prevMonth = () => {
+ this.goToNormalized(this.props.year, this.props.month - 1);
+ };
+
+ override nextYear = () => {
+ this.goToNormalized(this.props.year + 1, this.props.month);
+ };
+
+ override nextMonth = () => {
+ this.goToNormalized(this.props.year, this.props.month + 1);
+ };
+
+ override isValidYear(year: string): boolean {
+ return /^-?\d+/.test(year);
+ }
+
+ override jdnLookup(jdn: number): { year: JulianYear; month: JulianMonth } {
+ const [year, month] = jdnGregorian(jdn);
+ return {year, month};
+ }
+
+ override monthName(year: JulianYear, month: JulianMonth): string {
+ return `${monthName(month)} ${year}`;
+ }
+
+ override renderMonthOptions(): JSX.Element[] {
+ return Array.from(Array(12).keys()).map(i => {
+ const month = i + 1 as JulianMonth;
+ return ;
+ });
+ }
+
+ override renderBody(): JSX.Element {
+ return
+ }
+}
diff --git a/gcal/src/index.tsx b/gcal/src/index.tsx
new file mode 100644
index 0000000..8578ae3
--- /dev/null
+++ b/gcal/src/index.tsx
@@ -0,0 +1,21 @@
+import React from 'react';
+import ReactDOM from 'react-dom';
+import 'bootstrap/js/dist/collapse';
+import '@common/ui/index.scss';
+import App from './App';
+import reportWebVitals from '@common/ui/reportWebVitals';
+import {MobileTooltipProvider} from '@common/ui/MobileTooltip';
+
+ReactDOM.render(
+
+
+
+
+ ,
+ document.getElementById('root'),
+);
+
+// If you want to start measuring performance in your app, pass a function
+// to log results (for example: reportWebVitals(console.log))
+// or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals
+reportWebVitals();
diff --git a/gcal/src/react-app-env.d.ts b/gcal/src/react-app-env.d.ts
new file mode 100644
index 0000000..6431bc5
--- /dev/null
+++ b/gcal/src/react-app-env.d.ts
@@ -0,0 +1 @@
+///
diff --git a/gcal/src/setupTests.ts b/gcal/src/setupTests.ts
new file mode 100644
index 0000000..8f2609b
--- /dev/null
+++ b/gcal/src/setupTests.ts
@@ -0,0 +1,5 @@
+// jest-dom adds custom jest matchers for asserting on DOM nodes.
+// allows you to do things like:
+// expect(element).toHaveTextContent(/react/i)
+// learn more: https://github.com/testing-library/jest-dom
+import '@testing-library/jest-dom';
diff --git a/gcal/tsconfig.json b/gcal/tsconfig.json
new file mode 100644
index 0000000..9d6c7c6
--- /dev/null
+++ b/gcal/tsconfig.json
@@ -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"
+ ]
+}
diff --git a/gcal/tsconfig.paths.json b/gcal/tsconfig.paths.json
new file mode 100644
index 0000000..1cacae9
--- /dev/null
+++ b/gcal/tsconfig.paths.json
@@ -0,0 +1,8 @@
+{
+ "compilerOptions": {
+ "baseUrl": ".",
+ "paths": {
+ "@common/*": ["../common/src/*"]
+ }
+ }
+}
\ No newline at end of file