Skip to content

Commit a76172e

Browse files
CopilotTechQuery
andauthored
refactor: replace countdown React hooks with MobX class approach
Agent-Logs-Url: https://github.com/Open-Source-Bazaar/Open-Source-Bazaar.github.io/sessions/c3d283c3-cef4-454d-bce8-b3bdd8c61cf8 Co-authored-by: TechQuery <19969570+TechQuery@users.noreply.github.com>
1 parent f62a40b commit a76172e

4 files changed

Lines changed: 173 additions & 133 deletions

File tree

components/Activity/Hackathon/Hero.tsx

Lines changed: 90 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
1+
import { computed, observable } from 'mobx';
12
import { TableCellValue } from 'mobx-lark';
2-
import { FC, useEffect, useMemo, useState } from 'react';
3+
import { observer } from 'mobx-react';
4+
import { Component, FC } from 'react';
35
import { Container } from 'react-bootstrap';
46

57
import { LarkImage } from '../../LarkImage';
@@ -74,77 +76,106 @@ const FloatingCard: FC<{
7476
</div>
7577
);
7678

77-
const useCountdown = (countdownTo?: string) => {
78-
const target = useMemo(() => {
79-
const value = countdownTo ? new Date(countdownTo).getTime() : NaN;
79+
const splitHeroTitle = (name: string, subtitle: string) => {
80+
const segments = name.split(/\s+/).filter(Boolean);
8081

81-
return Number.isFinite(value) ? value : NaN;
82-
}, [countdownTo]);
83-
const [now, setNow] = useState<number | null>(null);
82+
if (segments.length < 3)
83+
return {
84+
primary: name,
85+
secondary: subtitle,
86+
};
8487

85-
useEffect(() => {
86-
if (!Number.isFinite(target)) return;
88+
return {
89+
primary: segments.slice(0, Math.max(1, segments.length - 2)).join(' '),
90+
secondary: segments.slice(-2).join(' '),
91+
};
92+
};
93+
94+
@observer
95+
export class HackathonHero extends Component<HackathonHeroProps> {
96+
@observable
97+
accessor rest: number | null = null;
8798

88-
setNow(Date.now());
99+
private timer?: number;
100+
101+
private get target() {
102+
const { countdownTo } = this.props;
103+
const value = countdownTo ? new Date(countdownTo).getTime() : NaN;
89104

90-
const timer = window.setInterval(() => setNow(Date.now()), 1000);
105+
return Number.isFinite(value) ? value : NaN;
106+
}
91107

92-
return () => window.clearInterval(timer);
93-
}, [target]);
108+
@computed
109+
get countdown(): string[] {
110+
const { rest } = this;
94111

95-
return useMemo(() => {
96-
if (!Number.isFinite(target) || now === null) return ['--', '--', '--', '--'];
112+
if (rest === null) return ['--', '--', '--', '--'];
97113

98-
const rest = Math.max(0, target - now);
99-
const totalSeconds = Math.floor(rest / 1000);
114+
const totalSeconds = Math.floor(Math.max(0, rest) / 1000);
100115
const days = Math.floor(totalSeconds / 86400);
101116
const hours = Math.floor((totalSeconds % 86400) / 3600);
102117
const minutes = Math.floor((totalSeconds % 3600) / 60);
103118
const seconds = totalSeconds % 60;
104119

105120
return [days, hours, minutes, seconds].map(value => String(value).padStart(2, '0'));
106-
}, [now, target]);
107-
};
108-
109-
const splitHeroTitle = (name: string, subtitle: string) => {
110-
const segments = name.split(/\s+/).filter(Boolean);
121+
}
111122

112-
if (segments.length < 3)
113-
return {
114-
primary: name,
115-
secondary: subtitle,
116-
};
117-
118-
return {
119-
primary: segments.slice(0, Math.max(1, segments.length - 2)).join(' '),
120-
secondary: segments.slice(-2).join(' '),
123+
tick = () => {
124+
this.rest = Math.max(0, this.target - Date.now());
121125
};
122-
};
123126

124-
export const HackathonHero: FC<HackathonHeroProps> = ({
125-
badges,
126-
bottomCard,
127-
chips,
128-
countdownLabel,
129-
countdownUnitLabels,
130-
countdownTo,
131-
description,
132-
image,
133-
imageFallback,
134-
locationText,
135-
name,
136-
navigation,
137-
primaryAction,
138-
secondaryAction,
139-
subtitle,
140-
topCard,
141-
visualChip,
142-
visualCopy,
143-
visualKicker,
144-
visualTitle,
145-
}) => {
146-
const countdown = useCountdown(countdownTo);
147-
const title = splitHeroTitle(name, subtitle);
127+
componentDidMount() {
128+
if (Number.isFinite(this.target)) {
129+
this.tick();
130+
this.timer = window.setInterval(this.tick, 1000);
131+
}
132+
}
133+
134+
componentDidUpdate(prevProps: HackathonHeroProps) {
135+
if (prevProps.countdownTo !== this.props.countdownTo) {
136+
if (this.timer) {
137+
window.clearInterval(this.timer);
138+
this.timer = undefined;
139+
}
140+
141+
this.rest = null;
142+
143+
if (Number.isFinite(this.target)) {
144+
this.tick();
145+
this.timer = window.setInterval(this.tick, 1000);
146+
}
147+
}
148+
}
149+
150+
componentWillUnmount() {
151+
if (this.timer) window.clearInterval(this.timer);
152+
}
153+
154+
render() {
155+
const {
156+
badges,
157+
bottomCard,
158+
chips,
159+
countdownLabel,
160+
countdownUnitLabels,
161+
countdownTo,
162+
description,
163+
image,
164+
imageFallback,
165+
locationText,
166+
name,
167+
navigation,
168+
primaryAction,
169+
secondaryAction,
170+
subtitle,
171+
topCard,
172+
visualChip,
173+
visualCopy,
174+
visualKicker,
175+
visualTitle,
176+
} = this.props;
177+
const { countdown } = this;
178+
const title = splitHeroTitle(name, subtitle);
148179

149180
return (
150181
<section id="top" className={styles.hero}>
@@ -264,5 +295,6 @@ export const HackathonHero: FC<HackathonHeroProps> = ({
264295
</div>
265296
</Container>
266297
</section>
267-
);
268-
};
298+
);
299+
}
300+
}
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import { computed, observable } from 'mobx';
2+
import { TableCellValue } from 'mobx-lark';
3+
4+
import { CountdownWindow, firstTextOf, resolveCountdownState, timeOf } from './utility';
5+
6+
// Maximum safe delay for setTimeout (max 32-bit signed integer in ms)
7+
const MAX_TIMEOUT_DELAY = 2_147_483_647;
8+
9+
export class LiveCountdownStore<T extends CountdownWindow> {
10+
constructor(
11+
readonly items: T[],
12+
readonly startTime?: TableCellValue,
13+
readonly endTime?: TableCellValue,
14+
) {}
15+
16+
@observable
17+
accessor referenceTime: number | null = null;
18+
19+
@computed
20+
get countdownState() {
21+
const { referenceTime, items, startTime, endTime } = this;
22+
23+
return referenceTime === null
24+
? {
25+
nextItem: undefined as T | undefined,
26+
countdownTo: firstTextOf(startTime) || firstTextOf(endTime) || undefined,
27+
}
28+
: resolveCountdownState(items, referenceTime, startTime, endTime);
29+
}
30+
31+
private timer?: number;
32+
33+
tick = () => {
34+
this.referenceTime = Date.now();
35+
36+
const targetTime = timeOf(this.countdownState.countdownTo);
37+
38+
if (!Number.isFinite(targetTime)) return;
39+
40+
const delay = Math.min(MAX_TIMEOUT_DELAY, Math.max(1000, targetTime - Date.now() + 1000));
41+
42+
this.timer = window.setTimeout(this.tick, delay);
43+
};
44+
45+
dispose() {
46+
window.clearTimeout(this.timer);
47+
}
48+
}

components/Activity/Hackathon/useLiveCountdownState.ts

Lines changed: 0 additions & 44 deletions
This file was deleted.

pages/hackathon/[id].tsx

Lines changed: 35 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1,40 +1,17 @@
11
import { TableCellLocation, TableFormView } from 'mobx-lark';
22
import { observer } from 'mobx-react';
33
import { cache, compose, errorLogger } from 'next-ssr-middleware';
4-
import { FC, useContext } from 'react';
4+
import { FC, useContext, useEffect, useState } from 'react';
55

66
import {
77
HackathonActionHub,
88
HackathonActionHubLink,
99
} from '../../components/Activity/Hackathon/ActionHub';
1010
import { HackathonAwards } from '../../components/Activity/Hackathon/Awards';
11-
import { HackathonFAQ } from '../../components/Activity/Hackathon/FAQ';
12-
import { HackathonHero } from '../../components/Activity/Hackathon/Hero';
13-
import { HackathonOverview } from '../../components/Activity/Hackathon/Overview';
14-
import { HackathonParticipants } from '../../components/Activity/Hackathon/Participants';
15-
import { HackathonResources } from '../../components/Activity/Hackathon/Resources';
16-
import { HackathonSchedule } from '../../components/Activity/Hackathon/Schedule';
17-
import { useLiveCountdownState } from '../../components/Activity/Hackathon/useLiveCountdownState';
18-
import { PageHead } from '../../components/Layout/PageHead';
19-
import { Activity, ActivityModel } from '../../models/Activity';
20-
import {
21-
Agenda,
22-
AgendaModel,
23-
Organization,
24-
OrganizationModel,
25-
Person,
26-
PersonModel,
27-
Prize,
28-
PrizeModel,
29-
Project,
30-
ProjectModel,
31-
Template,
32-
TemplateModel,
33-
} from '../../models/Hackathon';
34-
import { I18nContext } from '../../models/Translation';
3511
import {
3612
buildCountdownUnitLabels,
3713
buildFAQItems,
14+
buildFormSectionMeta,
3815
buildHighlightCards,
3916
buildJudgingCriteria,
4017
buildOrganizationItems,
@@ -46,10 +23,16 @@ import {
4623
FormButtonBar,
4724
FormGroupKey,
4825
FormGroupView,
49-
buildFormSectionMeta,
5026
heroNavigation,
5127
RequiredTableKeys,
5228
} from '../../components/Activity/Hackathon/constant';
29+
import { HackathonFAQ } from '../../components/Activity/Hackathon/FAQ';
30+
import { HackathonHero } from '../../components/Activity/Hackathon/Hero';
31+
import { LiveCountdownStore } from '../../components/Activity/Hackathon/LiveCountdownStore';
32+
import { HackathonOverview } from '../../components/Activity/Hackathon/Overview';
33+
import { HackathonParticipants } from '../../components/Activity/Hackathon/Participants';
34+
import { HackathonResources } from '../../components/Activity/Hackathon/Resources';
35+
import { HackathonSchedule } from '../../components/Activity/Hackathon/Schedule';
5336
import {
5437
agendaTypeLabelOf,
5538
compactDateKeyOf,
@@ -63,6 +46,23 @@ import {
6346
previewText,
6447
timeOf,
6548
} from '../../components/Activity/Hackathon/utility';
49+
import { PageHead } from '../../components/Layout/PageHead';
50+
import { Activity, ActivityModel } from '../../models/Activity';
51+
import {
52+
Agenda,
53+
AgendaModel,
54+
Organization,
55+
OrganizationModel,
56+
Person,
57+
PersonModel,
58+
Prize,
59+
PrizeModel,
60+
Project,
61+
ProjectModel,
62+
Template,
63+
TemplateModel,
64+
} from '../../models/Hackathon';
65+
import { I18nContext } from '../../models/Translation';
6666

6767
interface HackathonDetailProps {
6868
activity: Activity;
@@ -189,11 +189,15 @@ const HackathonDetail: FC<HackathonDetailProps> = observer(({ activity, hackatho
189189
};
190190
})
191191
.filter(({ date, label }) => Boolean(date && label));
192-
const { nextItem: nextAgendaItem, countdownTo } = useLiveCountdownState(
193-
agendaItems,
194-
startTime,
195-
endTime,
196-
);
192+
const [countdownStore] = useState(() => new LiveCountdownStore(agendaItems, startTime, endTime));
193+
194+
useEffect(() => {
195+
countdownStore.tick();
196+
197+
return () => countdownStore.dispose();
198+
}, [countdownStore]);
199+
200+
const { nextItem: nextAgendaItem, countdownTo } = countdownStore.countdownState;
197201
const countdownLabel = nextAgendaItem
198202
? agendaTypeLabelOf(nextAgendaItem.type, t, t('agenda'))
199203
: t('event_duration');

0 commit comments

Comments
 (0)