From 89b3c6c4e12275377dd95a3010f6b584e039843a Mon Sep 17 00:00:00 2001
From: Quantum <quantum2048@gmail.com>
Date: Sat, 15 Jul 2023 16:10:46 -0400
Subject: [PATCH] Factor out common calendar app logic

---
 common/src/ui/BaseApp.tsx       | 60 ++++++++++++++++++++++
 common/src/ui/MonthBasedApp.tsx | 22 +++++++++
 frcal/src/App.tsx               | 87 ++++++--------------------------
 jcal/src/App.tsx                | 88 ++++++---------------------------
 4 files changed, 112 insertions(+), 145 deletions(-)
 create mode 100644 common/src/ui/BaseApp.tsx
 create mode 100644 common/src/ui/MonthBasedApp.tsx

diff --git a/common/src/ui/BaseApp.tsx b/common/src/ui/BaseApp.tsx
new file mode 100644
index 0000000..d47c021
--- /dev/null
+++ b/common/src/ui/BaseApp.tsx
@@ -0,0 +1,60 @@
+import React from 'react';
+import {dateJDN} from '../gregorian';
+
+type AppState<Selector> = {
+    selector: Selector;
+    todayJDN: number,
+};
+
+export default abstract class BaseApp<Selector> extends React.Component<{}, AppState<Selector>> {
+    state: AppState<Selector>;
+
+    protected constructor(props: {}) {
+        super(props);
+        const todayJDN = dateJDN(new Date());
+
+        this.state = {
+            selector: this.parsePath() || this.defaultSelector(todayJDN),
+            todayJDN,
+        };
+        this.updateStateFromURL = this.updateStateFromURL.bind(this);
+    }
+
+    abstract parsePath(): Selector | null;
+
+    abstract generatePath(selector: Selector): string;
+
+    abstract defaultSelector(todayJDN: number): Selector;
+
+    componentDidMount() {
+        window.addEventListener('popstate', this.updateStateFromURL);
+    }
+
+    componentWillUnmount() {
+        window.removeEventListener('popstate', this.updateStateFromURL);
+    }
+
+    private updateStateFromURL(event: PopStateEvent) {
+        this.setState(event.state as Selector);
+    }
+
+    private updateURL() {
+        const path = this.generatePath(this.state.selector);
+        if (path !== window.location.pathname) {
+            window.history.pushState(this.state.selector, '', path);
+        }
+    }
+
+    setState(state: any, callback?: () => void) {
+        super.setState(state, () => {
+            this.updateURL();
+            callback?.();
+        });
+    }
+
+    onDateChange = (todayJDN: number) => {
+        this.setState({todayJDN});
+    };
+
+    abstract render(): JSX.Element;
+}
diff --git a/common/src/ui/MonthBasedApp.tsx b/common/src/ui/MonthBasedApp.tsx
new file mode 100644
index 0000000..f2d2560
--- /dev/null
+++ b/common/src/ui/MonthBasedApp.tsx
@@ -0,0 +1,22 @@
+import BaseApp from './BaseApp';
+
+
+type Selector<Year, Month> = {
+    year: Year;
+    month: Month;
+}
+
+export default abstract class MonthBasedApp<Year, Month> extends BaseApp<Selector<Year, Month>> {
+    abstract parseYearMonth(year: string, month: string): Selector<Year, Month> | null;
+
+    override parsePath(): Selector<Year, Month> | null {
+        const match = /\/(-?\d+)\/(\d+)/.exec(window.location.pathname);
+        if (!match)
+            return null;
+        return this.parseYearMonth(match[1], match[2]);
+    }
+
+    override generatePath({year, month}: Selector<Year, Month>) {
+        return `/${year}/${month}`;
+    }
+}
diff --git a/frcal/src/App.tsx b/frcal/src/App.tsx
index e833e7c..a855221 100644
--- a/frcal/src/App.tsx
+++ b/frcal/src/App.tsx
@@ -1,99 +1,42 @@
 import React from 'react';
 import {Calendar} from './Calendar';
 import {FrenchMonth, frEndJD, frStartJD, frSupportedYear, jdnFrench} from '@common/french';
-import {dateJDN} from '@common/gregorian';
+import {JulianMonth} from '@common/gregorian';
 import {TimeOfDay} from './TimeOfDay';
 import {GregorianJumper} from '@common/dateJump';
+import MonthBasedApp from '@common/ui/MonthBasedApp';
 
-type YearMonth = {
-    year: number;
-    month: FrenchMonth;
-}
+export default class App extends MonthBasedApp<number, FrenchMonth> {
+    override parseYearMonth(year: string, month: string) {
+        if (!frSupportedYear(+year) || +month < 1 || +month > 13)
+            return null;
+        return {year: +year, month: +month as JulianMonth};
+    }
 
-function parseURL(): YearMonth | null {
-    const match = /\/(-?\d+)\/(\d+)/.exec(window.location.pathname);
-    if (!match)
-        return null;
-
-    const month = +match[2];
-    const year = +match[1];
-    if (!frSupportedYear(year) || month < 1 || month > 13)
-        return null;
-    return {year: year, month: month as FrenchMonth};
-}
-
-type AppState = YearMonth & {
-    todayJDN: number,
-};
-
-class App extends React.Component<{}, AppState> {
-    state: AppState;
-
-    constructor(props: {}) {
-        super(props);
-        const todayJDN = dateJDN(new Date());
+    override defaultSelector(todayJDN: number) {
         const {year, month} = jdnFrench(todayJDN);
-
-        this.state = {
-            ...(parseURL() || {year, month}),
-            todayJDN,
-        };
-        this.updateStateFromURL = this.updateStateFromURL.bind(this);
+        return {year, month};
     }
 
-    componentDidMount() {
-        window.addEventListener('popstate', this.updateStateFromURL);
-    }
-
-    componentWillUnmount() {
-        window.removeEventListener('popstate', this.updateStateFromURL);
-    }
-
-    private updateStateFromURL(event: PopStateEvent) {
-        this.setState(event.state);
-    }
-
-    private updateURL() {
-        const {year, month} = this.state;
-        const path = `/${year}/${month}`;
-        if (path !== window.location.pathname) {
-            window.history.pushState({year, month}, '', path);
-        }
-    }
-
-    setState(state: any, callback?: () => void) {
-        super.setState(state, () => {
-            this.updateURL();
-            callback?.();
-        });
-    }
-
-    onDateChange = (todayJDN: number) => {
-        this.setState({todayJDN});
-    };
-
     goToJDN = (jdn: number) => {
         const {year, month} = jdnFrench(Math.min(Math.max(frStartJD, jdn), frEndJD));
-        this.setState({year, month});
+        this.setState({selector: {year, month}});
     };
 
     render() {
+        const {selector: {year, month}, todayJDN} = this.state;
         return <>
             <Calendar
-                year={this.state.year} month={this.state.month} todayJDN={this.state.todayJDN}
-                onSwitch={(year, month) => {
-                    this.setState({year, month});
-                }}/>
+                year={year} month={month} todayJDN={todayJDN}
+                onSwitch={(year, month) => this.setState({selector: {year, month}})}/>
 
             <TimeOfDay onDateChange={this.onDateChange}/>
 
             <div className="navigate">
                 <h4>Go to a date</h4>
-                <GregorianJumper minJDN={frStartJD} maxJDN={frEndJD} todayJDN={this.state.todayJDN}
+                <GregorianJumper minJDN={frStartJD} maxJDN={frEndJD} todayJDN={todayJDN}
                                  onJump={this.goToJDN}/>
             </div>
         </>;
     }
 }
-
-export default App;
diff --git a/jcal/src/App.tsx b/jcal/src/App.tsx
index 157d537..4fb5f00 100644
--- a/jcal/src/App.tsx
+++ b/jcal/src/App.tsx
@@ -1,103 +1,45 @@
 import React from 'react';
 import {Calendar} from './Calendar';
-import {dateJDN, gregorianJDN, JulianMonth} from '@common/gregorian';
+import {gregorianJDN, JulianMonth} from '@common/gregorian';
 import {DayChanger} from '@common/ui/DayChanger';
 import {jdnJulian} from '@common/julian';
 import {GregorianJumper} from '@common/dateJump';
+import MonthBasedApp from '@common/ui/MonthBasedApp';
 
 // Not real limitations other than JS number precision.
 const START_JDN = gregorianJDN(-10_000_000_000_000, 1, 1);
 const END_JDN = gregorianJDN(10_000_000_000_000, 12, 31);
 
-type YearMonth = {
-    year: number;
-    month: JulianMonth;
-}
+export default class App extends MonthBasedApp<number, JulianMonth> {
+    override parseYearMonth(year: string, month: string) {
+        if (+month < 1 || +month > 12)
+            return null;
+        return {year: +year, month: +month as JulianMonth};
+    }
 
-function parseURL(): YearMonth | null {
-    const match = /\/(-?\d+)\/(\d+)/.exec(window.location.pathname);
-    if (!match)
-        return null;
-
-    const month = +match[2];
-    const year = +match[1];
-    if (month < 1 || month > 23)
-        return null;
-    return {year: year, month: month as JulianMonth};
-}
-
-type AppState = YearMonth & {
-    todayJDN: number,
-};
-
-class App extends React.Component<{}, AppState> {
-    state: AppState;
-
-    constructor(props: {}) {
-        super(props);
-        const todayJDN = dateJDN(new Date());
+    override defaultSelector(todayJDN: number) {
         const [year, month] = jdnJulian(todayJDN);
-
-        this.state = {
-            ...(parseURL() || {year, month}),
-            todayJDN,
-        };
-        this.updateStateFromURL = this.updateStateFromURL.bind(this);
+        return {year, month};
     }
 
-    componentDidMount() {
-        window.addEventListener('popstate', this.updateStateFromURL);
-    }
-
-    componentWillUnmount() {
-        window.removeEventListener('popstate', this.updateStateFromURL);
-    }
-
-    private updateStateFromURL(event: PopStateEvent) {
-        this.setState(event.state);
-    }
-
-    private updateURL() {
-        const {year, month} = this.state;
-        const path = `/${year}/${month}`;
-        if (path !== window.location.pathname) {
-            window.history.pushState({year, month}, '', path);
-        }
-    }
-
-    setState(state: any, callback?: () => void) {
-        super.setState(state, () => {
-            this.updateURL();
-            callback?.();
-        });
-    }
-
-    onDateChange = (todayJDN: number) => {
-        this.setState({todayJDN});
-    };
-
     goToJDN = (jdn: number) => {
         const [year, month] = jdnJulian(jdn);
-        this.setState({year, month});
+        this.setState({selector: {year, month}});
     };
 
     render() {
+        const {selector: {year, month}, todayJDN} = this.state;
         return <>
             <Calendar
-                year={this.state.year} month={this.state.month} todayJDN={this.state.todayJDN}
-                onSwitch={(year, month) => {
-                    this.setState({year, month});
-                }}/>
+                year={year} month={month} todayJDN={todayJDN}
+                onSwitch={(year, month) => this.setState({selector: {year, month}})}/>
 
             <DayChanger onDateChange={this.onDateChange}/>
 
             <div className="navigate">
                 <h4>Go to a date</h4>
-                <GregorianJumper minJDN={START_JDN} maxJDN={END_JDN} todayJDN={this.state.todayJDN}
-                                 onJump={this.goToJDN}/>
+                <GregorianJumper minJDN={START_JDN} maxJDN={END_JDN} todayJDN={todayJDN} onJump={this.goToJDN}/>
             </div>
         </>;
     }
 }
-
-export default App;