Skip to content

Commit de1a265

Browse files
feat: add compact agenda page
1 parent cf299a6 commit de1a265

5 files changed

Lines changed: 415 additions & 1 deletion

File tree

next.config.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,5 +2,6 @@
22
const nextConfig = {
33
output: "export",
44
basePath: process.env.BASE_PATH,
5+
trailingSlash: true,
56
};
67
module.exports = nextConfig;

src/components/compact-agenda.tsx

Lines changed: 255 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,255 @@
1+
/** @jsxRuntime classic */
2+
/** @jsxImportSource theme-ui */
3+
import { format } from "date-fns";
4+
import { useEffect, useMemo, useState } from "react";
5+
import { Box, Flex, Text, jsx } from "theme-ui";
6+
7+
import type { Event } from "~/data/schedule";
8+
import { SPEAKERS } from "~/data/speakers";
9+
10+
type AgendaHighlight = {
11+
currentIndex: number | null;
12+
nextIndex: number | null;
13+
};
14+
15+
type ReferenceTime = {
16+
isFixed: boolean;
17+
time: Date | null;
18+
};
19+
20+
const getStartTime = (event: Event) =>
21+
event.when ? new Date(event.when).getTime() : Number.POSITIVE_INFINITY;
22+
23+
const getEndTime = (event: Event) =>
24+
event.until ? new Date(event.until).getTime() : getStartTime(event);
25+
26+
export const getAgendaHighlight = (
27+
schedule: Event[],
28+
referenceTime: Date,
29+
): AgendaHighlight => {
30+
const referenceTimestamp = referenceTime.getTime();
31+
const currentIndex = schedule.findIndex((event) => {
32+
const startTime = getStartTime(event);
33+
const endTime = getEndTime(event);
34+
35+
return startTime <= referenceTimestamp && referenceTimestamp < endTime;
36+
});
37+
const nextIndex = schedule.findIndex(
38+
(event) => getStartTime(event) > referenceTimestamp,
39+
);
40+
41+
return {
42+
currentIndex: currentIndex === -1 ? null : currentIndex,
43+
nextIndex: nextIndex === -1 ? null : nextIndex,
44+
};
45+
};
46+
47+
const getReferenceTime = () => {
48+
if (typeof window === "undefined") {
49+
return { isFixed: false, time: null };
50+
}
51+
52+
const highlightTime = new URLSearchParams(window.location.search).get(
53+
"highlightTime",
54+
);
55+
56+
if (highlightTime) {
57+
const normalizedHighlightTime = highlightTime.replace(
58+
/ (\d{2}:\d{2})$/,
59+
"+$1",
60+
);
61+
const parsedTime = new Date(normalizedHighlightTime);
62+
63+
if (!Number.isNaN(parsedTime.getTime())) {
64+
return { isFixed: true, time: parsedTime };
65+
}
66+
}
67+
68+
return { isFixed: false, time: new Date() };
69+
};
70+
71+
const formatTime = (time: string) => format(new Date(time), "HH:mm");
72+
73+
const getTitle = (event: Event) => event.title ?? event.label ?? "";
74+
75+
const getSpeakers = (event: Event) =>
76+
event.speakerIds
77+
?.map((speakerId) => SPEAKERS[speakerId]?.name)
78+
.filter(Boolean)
79+
.join(", ");
80+
81+
export const CompactAgenda: React.FC<{ schedule: Event[] }> = ({
82+
schedule,
83+
}) => {
84+
const [reference, setReference] = useState<ReferenceTime>({
85+
isFixed: false,
86+
time: null,
87+
});
88+
89+
useEffect(() => {
90+
const initialReference = getReferenceTime();
91+
92+
setReference(initialReference);
93+
94+
if (initialReference.isFixed) {
95+
return;
96+
}
97+
98+
let interval: number | undefined;
99+
const timeout = window.setTimeout(
100+
() => {
101+
setReference(getReferenceTime());
102+
interval = window.setInterval(() => {
103+
setReference(getReferenceTime());
104+
}, 60_000);
105+
},
106+
60_000 - (Date.now() % 60_000),
107+
);
108+
109+
return () => {
110+
window.clearTimeout(timeout);
111+
if (interval) {
112+
window.clearInterval(interval);
113+
}
114+
};
115+
}, []);
116+
117+
const highlight = useMemo(
118+
() =>
119+
reference.time
120+
? getAgendaHighlight(schedule, reference.time)
121+
: { currentIndex: null, nextIndex: null },
122+
[schedule, reference.time],
123+
);
124+
125+
return (
126+
<Box
127+
as="ol"
128+
sx={{
129+
listStyle: "none",
130+
display: "grid",
131+
gap: ".4rem",
132+
m: 0,
133+
p: 0,
134+
}}
135+
>
136+
{schedule.map((event, index) => {
137+
const isCurrent = highlight.currentIndex === index;
138+
const isNext = highlight.nextIndex === index;
139+
const speakers = getSpeakers(event);
140+
141+
return (
142+
<Box
143+
as="li"
144+
key={`${event.when}-${getTitle(event)}`}
145+
sx={{
146+
display: "grid",
147+
gridTemplateColumns: "4.2rem minmax(0, 1fr) auto",
148+
gridTemplateRows: "auto auto",
149+
alignItems: "start",
150+
gap: ".6rem",
151+
rowGap: ".15rem",
152+
minHeight: 0,
153+
px: ".75rem",
154+
py: ".5rem",
155+
border: "1px solid",
156+
borderColor: isCurrent || isNext ? "primary" : "#eee",
157+
borderRadius: ".8rem",
158+
backgroundColor: isCurrent
159+
? "primary"
160+
: isNext
161+
? "#fff4f2"
162+
: "white",
163+
boxShadow: isCurrent
164+
? "0 .8rem 2.4rem -1.6rem rgba(237, 67, 55, .75)"
165+
: undefined,
166+
color: isCurrent ? "white" : "text",
167+
}}
168+
>
169+
<Text
170+
sx={{
171+
fontFamily: "heading",
172+
fontSize: "1.2rem",
173+
fontWeight: "heading",
174+
lineHeight: 1,
175+
color: isCurrent ? "white" : "primary",
176+
whiteSpace: "nowrap",
177+
gridColumn: 1,
178+
gridRow: 1,
179+
}}
180+
>
181+
{event.when ? formatTime(event.when) : ""}
182+
</Text>
183+
184+
<Box
185+
sx={{
186+
minWidth: 0,
187+
gridColumn: "2 / span 2",
188+
gridRow: "1 / span 2",
189+
textAlign: "left",
190+
}}
191+
>
192+
{speakers && (
193+
<Text
194+
sx={{
195+
display: "block",
196+
pr: isCurrent || isNext ? "4.1rem" : 0,
197+
fontSize: "1.1rem",
198+
lineHeight: 1,
199+
opacity: isCurrent ? 0.92 : 0.72,
200+
overflowWrap: "anywhere",
201+
}}
202+
>
203+
{speakers}
204+
</Text>
205+
)}
206+
207+
<Text
208+
sx={{
209+
display: "block",
210+
mt: speakers ? ".2rem" : 0,
211+
fontFamily: "heading",
212+
fontSize: ["1.15rem", "1.3rem"],
213+
fontWeight: "heading",
214+
lineHeight: 1.15,
215+
overflowWrap: "anywhere",
216+
}}
217+
>
218+
{getTitle(event)}
219+
</Text>
220+
</Box>
221+
222+
<Flex
223+
sx={{
224+
alignItems: "center",
225+
justifyContent: "center",
226+
minWidth: "3.5rem",
227+
gridColumn: 3,
228+
gridRow: 1,
229+
}}
230+
>
231+
{(isCurrent || isNext) && (
232+
<Text
233+
sx={{
234+
px: ".45rem",
235+
py: ".2rem",
236+
borderRadius: "999px",
237+
backgroundColor: isCurrent ? "white" : "primary",
238+
color: isCurrent ? "primary" : "white",
239+
fontFamily: "heading",
240+
fontSize: ".9rem",
241+
fontWeight: "heading",
242+
lineHeight: 1,
243+
textTransform: "uppercase",
244+
}}
245+
>
246+
{isCurrent ? "Now" : "Next"}
247+
</Text>
248+
)}
249+
</Flex>
250+
</Box>
251+
);
252+
})}
253+
</Box>
254+
);
255+
};

src/components/header.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,7 @@ export const Header = () => (
129129
>
130130
<MenuLink href="#about">About 🍕</MenuLink>
131131
<MenuLink href="#cfp">CFP 🙋🏻‍♀️</MenuLink>
132+
<MenuLink href="/compact-agenda/">Compact agenda 🔗</MenuLink>
132133
<MenuLink href="#schedule">Program 📅</MenuLink>
133134
<MenuLink href="#organizers">Organizers 👩🏻</MenuLink>
134135
<MenuLink href="#venue">Venue 🏰</MenuLink>

0 commit comments

Comments
 (0)