Skip to content
148 changes: 90 additions & 58 deletions components/Activity/Hackathon/Hero.tsx
Comment thread
TechQuery marked this conversation as resolved.
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { computed, observable } from 'mobx';
import { TableCellValue } from 'mobx-lark';
import { FC, useEffect, useMemo, useState } from 'react';
import { observer } from 'mobx-react';
import { Component, FC } from 'react';
import { Container } from 'react-bootstrap';

import { LarkImage } from '../../LarkImage';
Expand Down Expand Up @@ -74,77 +76,106 @@ const FloatingCard: FC<{
</div>
);

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

return Number.isFinite(value) ? value : NaN;
}, [countdownTo]);
const [now, setNow] = useState<number | null>(null);
if (segments.length < 3)
return {
primary: name,
secondary: subtitle,
};

useEffect(() => {
if (!Number.isFinite(target)) return;
return {
primary: segments.slice(0, Math.max(1, segments.length - 2)).join(' '),
secondary: segments.slice(-2).join(' '),
};
};

@observer
export class HackathonHero extends Component<HackathonHeroProps> {
@observable
accessor rest: number | null = null;

setNow(Date.now());
private timer?: number;

private get target() {
const { countdownTo } = this.props;
const value = countdownTo ? new Date(countdownTo).getTime() : NaN;

const timer = window.setInterval(() => setNow(Date.now()), 1000);
return Number.isFinite(value) ? value : NaN;
}

return () => window.clearInterval(timer);
}, [target]);
@computed
get countdown(): string[] {
const { rest } = this;

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

const rest = Math.max(0, target - now);
const totalSeconds = Math.floor(rest / 1000);
const totalSeconds = Math.floor(Math.max(0, rest) / 1000);
const days = Math.floor(totalSeconds / 86400);
const hours = Math.floor((totalSeconds % 86400) / 3600);
const minutes = Math.floor((totalSeconds % 3600) / 60);
const seconds = totalSeconds % 60;

return [days, hours, minutes, seconds].map(value => String(value).padStart(2, '0'));
}, [now, target]);
};

const splitHeroTitle = (name: string, subtitle: string) => {
const segments = name.split(/\s+/).filter(Boolean);
}

if (segments.length < 3)
return {
primary: name,
secondary: subtitle,
};

return {
primary: segments.slice(0, Math.max(1, segments.length - 2)).join(' '),
secondary: segments.slice(-2).join(' '),
tick = () => {
this.rest = Math.max(0, this.target - Date.now());
};
};

export const HackathonHero: FC<HackathonHeroProps> = ({
badges,
bottomCard,
chips,
countdownLabel,
countdownUnitLabels,
countdownTo,
description,
image,
imageFallback,
locationText,
name,
navigation,
primaryAction,
secondaryAction,
subtitle,
topCard,
visualChip,
visualCopy,
visualKicker,
visualTitle,
}) => {
const countdown = useCountdown(countdownTo);
const title = splitHeroTitle(name, subtitle);
componentDidMount() {
if (Number.isFinite(this.target)) {
this.tick();
this.timer = window.setInterval(this.tick, 1000);
}
}

componentDidUpdate(prevProps: HackathonHeroProps) {
if (prevProps.countdownTo !== this.props.countdownTo) {
if (this.timer) {
window.clearInterval(this.timer);
this.timer = undefined;
}

this.rest = null;

if (Number.isFinite(this.target)) {
this.tick();
this.timer = window.setInterval(this.tick, 1000);
}
}
}

componentWillUnmount() {
if (this.timer) window.clearInterval(this.timer);
}

render() {
const {
badges,
bottomCard,
chips,
countdownLabel,
countdownUnitLabels,
countdownTo,
description,
image,
imageFallback,
locationText,
name,
navigation,
primaryAction,
secondaryAction,
subtitle,
topCard,
visualChip,
visualCopy,
visualKicker,
visualTitle,
} = this.props;
const { countdown } = this;
const title = splitHeroTitle(name, subtitle);

return (
<section id="top" className={styles.hero}>
Expand Down Expand Up @@ -264,5 +295,6 @@ export const HackathonHero: FC<HackathonHeroProps> = ({
</div>
</Container>
</section>
);
};
);
}
}
48 changes: 48 additions & 0 deletions components/Activity/Hackathon/LiveCountdownStore.ts
Comment thread
TechQuery marked this conversation as resolved.
Outdated
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { computed, observable } from 'mobx';
import { TableCellValue } from 'mobx-lark';

import { CountdownWindow, firstTextOf, resolveCountdownState, timeOf } from './utility';

// Maximum safe delay for setTimeout (max 32-bit signed integer in ms)
const MAX_TIMEOUT_DELAY = 2_147_483_647;

export class LiveCountdownStore<T extends CountdownWindow> {
constructor(
readonly items: T[],
readonly startTime?: TableCellValue,
readonly endTime?: TableCellValue,
) {}

@observable
accessor referenceTime: number | null = null;

@computed
get countdownState() {
const { referenceTime, items, startTime, endTime } = this;

return referenceTime === null
? {
nextItem: undefined as T | undefined,
countdownTo: firstTextOf(startTime) || firstTextOf(endTime) || undefined,
}
: resolveCountdownState(items, referenceTime, startTime, endTime);
}

private timer?: number;

tick = () => {
this.referenceTime = Date.now();

const targetTime = timeOf(this.countdownState.countdownTo);

if (!Number.isFinite(targetTime)) return;

const delay = Math.min(MAX_TIMEOUT_DELAY, Math.max(1000, targetTime - Date.now() + 1000));

this.timer = window.setTimeout(this.tick, delay);
};

dispose() {
window.clearTimeout(this.timer);
}
}
44 changes: 0 additions & 44 deletions components/Activity/Hackathon/useLiveCountdownState.ts

This file was deleted.

66 changes: 35 additions & 31 deletions pages/hackathon/[id].tsx
Original file line number Diff line number Diff line change
@@ -1,40 +1,17 @@
import { TableCellLocation, TableFormView } from 'mobx-lark';
import { observer } from 'mobx-react';
import { cache, compose, errorLogger } from 'next-ssr-middleware';
import { FC, useContext } from 'react';
import { FC, useContext, useEffect, useState } from 'react';

import {
HackathonActionHub,
HackathonActionHubLink,
} from '../../components/Activity/Hackathon/ActionHub';
import { HackathonAwards } from '../../components/Activity/Hackathon/Awards';
import { HackathonFAQ } from '../../components/Activity/Hackathon/FAQ';
import { HackathonHero } from '../../components/Activity/Hackathon/Hero';
import { HackathonOverview } from '../../components/Activity/Hackathon/Overview';
import { HackathonParticipants } from '../../components/Activity/Hackathon/Participants';
import { HackathonResources } from '../../components/Activity/Hackathon/Resources';
import { HackathonSchedule } from '../../components/Activity/Hackathon/Schedule';
import { useLiveCountdownState } from '../../components/Activity/Hackathon/useLiveCountdownState';
import { PageHead } from '../../components/Layout/PageHead';
import { Activity, ActivityModel } from '../../models/Activity';
import {
Agenda,
AgendaModel,
Organization,
OrganizationModel,
Person,
PersonModel,
Prize,
PrizeModel,
Project,
ProjectModel,
Template,
TemplateModel,
} from '../../models/Hackathon';
import { I18nContext } from '../../models/Translation';
import {
buildCountdownUnitLabels,
buildFAQItems,
buildFormSectionMeta,
buildHighlightCards,
buildJudgingCriteria,
buildOrganizationItems,
Expand All @@ -46,10 +23,16 @@ import {
FormButtonBar,
FormGroupKey,
FormGroupView,
buildFormSectionMeta,
heroNavigation,
RequiredTableKeys,
} from '../../components/Activity/Hackathon/constant';
import { HackathonFAQ } from '../../components/Activity/Hackathon/FAQ';
import { HackathonHero } from '../../components/Activity/Hackathon/Hero';
import { LiveCountdownStore } from '../../components/Activity/Hackathon/LiveCountdownStore';
import { HackathonOverview } from '../../components/Activity/Hackathon/Overview';
import { HackathonParticipants } from '../../components/Activity/Hackathon/Participants';
import { HackathonResources } from '../../components/Activity/Hackathon/Resources';
import { HackathonSchedule } from '../../components/Activity/Hackathon/Schedule';
import {
agendaTypeLabelOf,
compactDateKeyOf,
Expand All @@ -63,6 +46,23 @@ import {
previewText,
timeOf,
} from '../../components/Activity/Hackathon/utility';
import { PageHead } from '../../components/Layout/PageHead';
import { Activity, ActivityModel } from '../../models/Activity';
import {
Agenda,
AgendaModel,
Organization,
OrganizationModel,
Person,
PersonModel,
Prize,
PrizeModel,
Project,
ProjectModel,
Template,
TemplateModel,
} from '../../models/Hackathon';
import { I18nContext } from '../../models/Translation';

interface HackathonDetailProps {
activity: Activity;
Expand Down Expand Up @@ -189,11 +189,15 @@ const HackathonDetail: FC<HackathonDetailProps> = observer(({ activity, hackatho
};
})
.filter(({ date, label }) => Boolean(date && label));
const { nextItem: nextAgendaItem, countdownTo } = useLiveCountdownState(
agendaItems,
startTime,
endTime,
);
const [countdownStore] = useState(() => new LiveCountdownStore(agendaItems, startTime, endTime));

useEffect(() => {
countdownStore.tick();

return () => countdownStore.dispose();
}, [countdownStore]);

const { nextItem: nextAgendaItem, countdownTo } = countdownStore.countdownState;
const countdownLabel = nextAgendaItem
? agendaTypeLabelOf(nextAgendaItem.type, t, t('agenda'))
: t('event_duration');
Expand Down
Loading