Factor out common calendar app logic

This commit is contained in:
Quantum 2023-07-15 16:10:46 -04:00
parent 21f1110323
commit 89b3c6c4e1
4 changed files with 112 additions and 145 deletions

60
common/src/ui/BaseApp.tsx Normal file
View file

@ -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;
}

View file

@ -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}`;
}
}

View file

@ -1,99 +1,42 @@
import React from 'react'; import React from 'react';
import {Calendar} from './Calendar'; import {Calendar} from './Calendar';
import {FrenchMonth, frEndJD, frStartJD, frSupportedYear, jdnFrench} from '@common/french'; import {FrenchMonth, frEndJD, frStartJD, frSupportedYear, jdnFrench} from '@common/french';
import {dateJDN} from '@common/gregorian'; import {JulianMonth} from '@common/gregorian';
import {TimeOfDay} from './TimeOfDay'; import {TimeOfDay} from './TimeOfDay';
import {GregorianJumper} from '@common/dateJump'; import {GregorianJumper} from '@common/dateJump';
import MonthBasedApp from '@common/ui/MonthBasedApp';
type YearMonth = { export default class App extends MonthBasedApp<number, FrenchMonth> {
year: number; override parseYearMonth(year: string, month: string) {
month: FrenchMonth; if (!frSupportedYear(+year) || +month < 1 || +month > 13)
} return null;
return {year: +year, month: +month as JulianMonth};
}
function parseURL(): YearMonth | null { override defaultSelector(todayJDN: number) {
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());
const {year, month} = jdnFrench(todayJDN); const {year, month} = jdnFrench(todayJDN);
return {year, month};
this.state = {
...(parseURL() || {year, month}),
todayJDN,
};
this.updateStateFromURL = this.updateStateFromURL.bind(this);
} }
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) => { goToJDN = (jdn: number) => {
const {year, month} = jdnFrench(Math.min(Math.max(frStartJD, jdn), frEndJD)); const {year, month} = jdnFrench(Math.min(Math.max(frStartJD, jdn), frEndJD));
this.setState({year, month}); this.setState({selector: {year, month}});
}; };
render() { render() {
const {selector: {year, month}, todayJDN} = this.state;
return <> return <>
<Calendar <Calendar
year={this.state.year} month={this.state.month} todayJDN={this.state.todayJDN} year={year} month={month} todayJDN={todayJDN}
onSwitch={(year, month) => { onSwitch={(year, month) => this.setState({selector: {year, month}})}/>
this.setState({year, month});
}}/>
<TimeOfDay onDateChange={this.onDateChange}/> <TimeOfDay onDateChange={this.onDateChange}/>
<div className="navigate"> <div className="navigate">
<h4>Go to a date</h4> <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}/> onJump={this.goToJDN}/>
</div> </div>
</>; </>;
} }
} }
export default App;

View file

@ -1,103 +1,45 @@
import React from 'react'; import React from 'react';
import {Calendar} from './Calendar'; import {Calendar} from './Calendar';
import {dateJDN, gregorianJDN, JulianMonth} from '@common/gregorian'; import {gregorianJDN, JulianMonth} from '@common/gregorian';
import {DayChanger} from '@common/ui/DayChanger'; import {DayChanger} from '@common/ui/DayChanger';
import {jdnJulian} from '@common/julian'; import {jdnJulian} from '@common/julian';
import {GregorianJumper} from '@common/dateJump'; import {GregorianJumper} from '@common/dateJump';
import MonthBasedApp from '@common/ui/MonthBasedApp';
// Not real limitations other than JS number precision. // Not real limitations other than JS number precision.
const START_JDN = gregorianJDN(-10_000_000_000_000, 1, 1); const START_JDN = gregorianJDN(-10_000_000_000_000, 1, 1);
const END_JDN = gregorianJDN(10_000_000_000_000, 12, 31); const END_JDN = gregorianJDN(10_000_000_000_000, 12, 31);
type YearMonth = { export default class App extends MonthBasedApp<number, JulianMonth> {
year: number; override parseYearMonth(year: string, month: string) {
month: JulianMonth; if (+month < 1 || +month > 12)
} return null;
return {year: +year, month: +month as JulianMonth};
}
function parseURL(): YearMonth | null { override defaultSelector(todayJDN: number) {
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());
const [year, month] = jdnJulian(todayJDN); const [year, month] = jdnJulian(todayJDN);
return {year, month};
this.state = {
...(parseURL() || {year, month}),
todayJDN,
};
this.updateStateFromURL = this.updateStateFromURL.bind(this);
} }
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) => { goToJDN = (jdn: number) => {
const [year, month] = jdnJulian(jdn); const [year, month] = jdnJulian(jdn);
this.setState({year, month}); this.setState({selector: {year, month}});
}; };
render() { render() {
const {selector: {year, month}, todayJDN} = this.state;
return <> return <>
<Calendar <Calendar
year={this.state.year} month={this.state.month} todayJDN={this.state.todayJDN} year={year} month={month} todayJDN={todayJDN}
onSwitch={(year, month) => { onSwitch={(year, month) => this.setState({selector: {year, month}})}/>
this.setState({year, month});
}}/>
<DayChanger onDateChange={this.onDateChange}/> <DayChanger onDateChange={this.onDateChange}/>
<div className="navigate"> <div className="navigate">
<h4>Go to a date</h4> <h4>Go to a date</h4>
<GregorianJumper minJDN={START_JDN} maxJDN={END_JDN} todayJDN={this.state.todayJDN} <GregorianJumper minJDN={START_JDN} maxJDN={END_JDN} todayJDN={todayJDN} onJump={this.goToJDN}/>
onJump={this.goToJDN}/>
</div> </div>
</>; </>;
} }
} }
export default App;