This repository has been archived by the owner on Jun 17, 2024. It is now read-only.
-
Notifications
You must be signed in to change notification settings - Fork 2
/
Copy pathDisplayApp.tsx
399 lines (322 loc) · 14.9 KB
/
DisplayApp.tsx
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
// Copyright 2022 Peter Beverloo & AnimeCon. All rights reserved.
// Use of this source code is governed by a MIT license that can be
// found in the LICENSE file.
import { Component, h } from 'preact';
import { useEffect, useState } from 'preact/hooks';
import Alert from '@mui/material/Alert';
import Avatar from '@mui/material/Avatar';
import EventBusyIcon from '@mui/icons-material/EventBusy';
import Grid from '@mui/material/Grid';
import IconButton from '@mui/material/IconButton';
import LinearProgress from '@mui/material/LinearProgress';
import PlayCircleIcon from '@mui/icons-material/PlayCircle';
import RefreshIcon from '@mui/icons-material/Refresh';
import Snackbar from '@mui/material/Snackbar';
import Stack from '@mui/material/Stack';
import { SxProps, Theme } from '@mui/system';
import Timeline from '@mui/lab/Timeline';
import TimelineConnector from '@mui/lab/TimelineConnector';
import TimelineContent from '@mui/lab/TimelineContent';
import TimelineDot from '@mui/lab/TimelineDot';
import TimelineItem from '@mui/lab/TimelineItem';
import TimelineOppositeContent from '@mui/lab/TimelineOppositeContent';
import TimelineSeparator from '@mui/lab/TimelineSeparator';
import Typography from '@mui/material/Typography';
import type { IDisplayResponse } from '../api/IDisplay';
import { ApiRequestManager, ApiRequestObserver } from '../base/ApiRequestManager';
import { DateTime } from '../base/DateTime';
import { Timer } from '../base/Timer';
import { initials } from '../base/NameUtilities';
// https://github.com/mui/material-ui/issues/35287
declare global {
namespace React {
interface DOMAttributes<T> {
onResize?: ReactEventHandler<T> | undefined;
onResizeCapture?: ReactEventHandler<T> | undefined;
nonce?: string | undefined;
}
}
}
// Maximum value of int32-1, which gives us a timestamp far into 2038.
const kMaximumNextUpdateUnixTime = 2147483646;
// Interval, in milliseconds, between which we should request timeline updates from the network.
const kRefreshTimerIntervalMs = 15 /* =minutes */ * 60 * 1000;
// Customized styling for the <WelcomeApp> component.
const kStyles: { [key: string]: SxProps<Theme> } = {
root: {
backgroundColor: '#607d8b', // blueGrey[500]
height: '100vh',
},
container: {
backgroundColor: '#f3f7ec',
width: '800px',
height: '480px',
},
navigation: {
backgroundColor: '#1A237E',
color: '#ffffff',
px: 2,
py: 1,
},
pastItem: {
filter: 'grayscale(1) opacity(0.6)',
},
scroller: {
overflowY: 'scroll',
flexGrow: 1,
},
timelineDot: {
padding: 0,
borderWidth: 0,
margin: '6px 0',
},
};
// TODO: This should become part of the API.
interface TimelineEntry {
// URL to the avatar of the volunteer for this shift, if any.
avatar?: string;
// Name of the volunteer that will be active during this shift.
name: string;
// Role of the volunteer for this shift, i.e. what will they be doing?
role: string;
// Start time of the shift, as a DateTime object.
startTime: DateTime;
// End time of the shift, as a DateTime object.
endTime: DateTime;
// State of the timeline entry.
state: 'unknown' | 'pending' | 'active' | 'past';
}
// Properties made available to the <DisplayTimeline> component.
interface DisplayTimelineProps {
timeline: TimelineEntry[];
}
// Component that displays a timeline based on the given |props|. The Material UI timeline component
// is used. Performance may suffer for areas in which there are a lot of pending shifts.
function DisplayTimeline(props: DisplayTimelineProps) {
return (
<Timeline sx={{ width: '100%' }}>
{ props.timeline.map(item =>
<TimelineItem sx={ item.state === 'past' ? kStyles.pastItem : undefined }>
<TimelineOppositeContent sx={{ m: 'auto 0' }} color="text.secondary">
{ item.state === 'active' &&
<PlayCircleIcon color="warning"
fontSize="inherit"
sx={{ position: 'relative', top: 2, left: -4 }} /> }
{item.startTime.format('dayTime')}–{item.endTime.format('time')}
</TimelineOppositeContent>
<TimelineSeparator>
<TimelineConnector />
<TimelineDot sx={kStyles.timelineDot}>
<Avatar src={item.avatar}>
{initials(item.name)}
</Avatar>
</TimelineDot>
<TimelineConnector />
</TimelineSeparator>
<TimelineContent sx={{ m: 'auto 0' }}>
<Typography variant="subtitle1" component="span">
<strong>{item.name}</strong> ({item.role})
</Typography>
</TimelineContent>
</TimelineItem> )}
</Timeline>
);
}
// Component that displays the current time of the application, in a manner that maintains a timer
// to keep it actual for the lifetime of the component.
function CurrentTimeDisplay() {
const [ dateTime, setDateTime ] = useState<DateTime>(DateTime.local());
useEffect(() => {
const updateTimer = () => setDateTime(DateTime.local());
const intervalId = setInterval(updateTimer, 5000);
return () => clearInterval(intervalId);
});
return <>{dateTime.format('dayTime')}</>;
}
// Properties accepted by the <DisplayApp> component.
interface DisplayAppProps {
// Identifier of the display that should be shown, if any. Optional, the identifier will be
// asked for on first load as well, which is a more intuitive user interface.
identifier?: string;
}
// State maintained by the <DisplayApp> component. Each state update invalidates the layout.
interface DisplayAppState {
// Whether the application is currently fetching content.
loading: boolean;
// State of the refresh mechanism, which can be called by the device's host.
refreshState: 'unknown' | 'error' | 'success';
// The date and time at which the state was last updated.
dateTime: DateTime;
// The title for this particular display, shared by the server.
title: string;
// The timeline as it should be displayed on the display.
timeline: TimelineEntry[];
}
// The Display App powers the dedicated 7" displays we issue to various locations during the
// festival, enabling them to understand which volunteers (if any) are scheduled to appear at which
// times. While it shares an environment, most configuration is provided by the server.
export class DisplayApp extends Component<DisplayAppProps, DisplayAppState>
implements ApiRequestObserver<'IDisplay'> {
// The request manager is responsible for fetching information from the API.
#requestManager: ApiRequestManager<'IDisplay'>;
// Timer responsible for refreshing the scheduled shifts on the display.
#refreshDisplayTimer: Timer;
// Timer responsible for refreshing the event information from the network.
#refreshEventTimer: Timer;
state: DisplayAppState = {
loading: false,
refreshState: 'unknown',
dateTime: DateTime.local(),
title: 'Unknown Display (AnimeCon)',
timeline: [],
};
constructor() {
super();
this.#requestManager = new ApiRequestManager('IDisplay', this);
this.#refreshDisplayTimer = new Timer(this.updateTimelineState);
this.#refreshEventTimer = new Timer(this.requestRefresh);
}
// Called when the <DisplayApp /> component has been mounted. Initializes the data request.
componentDidMount() {
this.refresh();
}
// ---------------------------------------------------------------------------------------------
// Refreshes the information from the network. This will issue a network request to the API for
// the display as it has been configured.
async refresh() {
this.setState({ loading: true });
const parameters = new URLSearchParams(document.location.search);
const display = parameters.get('display');
if (display)
await this.#requestManager.issue({ identifier: display });
else
this.setState({ refreshState: 'error' });
this.#refreshEventTimer.start(kRefreshTimerIntervalMs);
this.setState({ loading: false });
}
// Request a refresh of the schedule. Safe to call multiple times, whereas calls will be ignored
// if a refresh is already in progress. (To not hammer the server.)
requestRefresh = () => {
if (!this.state.loading)
this.refresh();
};
// Resets the refresh state for the application, which will hide any snackbars that are being
// shown to the user.
resetRefreshState = () => {
this.setState({ refreshState: 'unknown' });
};
// ---------------------------------------------------------------------------------------------
// ApiRequestObserver interface
// ---------------------------------------------------------------------------------------------
onFailedResponse(error: Error): void {
this.setState({ refreshState: 'error' });
}
onSuccessResponse(response: IDisplayResponse): void {
if (response.error) {
console.error(response.error);
this.setState({ refreshState: 'error' });
} else {
const title = response.title || 'AnimeCon Volunteering Team';
const timeline: TimelineEntry[] = [];
for (const shift of response.shifts!) {
timeline.push({
avatar: shift.avatar,
name: shift.name,
role: shift.role,
startTime: DateTime.fromUnix(shift.time[0]),
endTime: DateTime.fromUnix(shift.time[1]),
state: 'unknown',
});
}
this.updateTimelineState(timeline);
this.setState({ refreshState: 'success', title });
}
}
// ---------------------------------------------------------------------------------------------
// Updates the timeline state, either based on |timeline| or the current locally stored state. A
// timer will be scheduled to automagically update the timeline state again once required.
updateTimelineState = (inputTimeline?: TimelineEntry[]) => {
const dateTime = DateTime.local();
const timeline = inputTimeline ?? this.state.timeline;
let nextUpdate = DateTime.fromUnix(kMaximumNextUpdateUnixTime);
// (1) Decide the activity state for all of the entries on the timeline.
for (let index = 0; index < timeline.length; ++index) {
const { startTime, endTime } = timeline[index];
if (dateTime.isBefore(startTime))
timeline[index].state = 'pending';
else if (dateTime.isBefore(endTime))
timeline[index].state = 'active';
else
timeline[index].state = 'past';
if (startTime.isBefore(nextUpdate))
nextUpdate = startTime;
else if (endTime.isBefore(nextUpdate))
nextUpdate = endTime;
}
// (2) Sort the timeline to list the active and soonest shifts at the top, moving past
// shifts to the bottom. This avoids us having to scroll.
timeline.sort((lhs, rhs) => {
if (lhs.state === 'past' && rhs.state !== 'past')
return 1;
if (lhs.state !== 'past' && rhs.state === 'past')
return -1;
const result = lhs.startTime.valueOf() - rhs.startTime.valueOf();
return result ? result
: lhs.endTime.valueOf() - rhs.endTime.valueOf();
});
// (3) Schedule a timer to update the timeline states again. Skip this step if there are no
// future updates, which can happen when the display is used after the convention ends.
if (nextUpdate.unix() !== kMaximumNextUpdateUnixTime) {
const absoluteDiff = nextUpdate.moment().diff(DateTime.local().moment());
const updateMs = Math.min(Math.max(absoluteDiff, 16), 60 * 60 * 1000);
this.#refreshDisplayTimer.start(updateMs);
}
this.setState({ dateTime, timeline });
};
// ---------------------------------------------------------------------------------------------
render() {
const { loading, refreshState, timeline, title } = this.state;
return (
<Grid container alignItems="center" justifyContent="center" sx={kStyles.root}>
<Stack alignItems="stretch" justifyContent="flex-start" sx={kStyles.container}>
<Stack direction="row" alignItems="center" sx={kStyles.navigation}>
<Typography variant="button" component="h1" sx={{ flexGrow: 1 }}>
{title}
</Typography>
<Typography variant="button" component="p" sx={{ pr: 1 }}>
<CurrentTimeDisplay />
</Typography>
<IconButton onClick={this.requestRefresh} color="inherit">
<RefreshIcon />
</IconButton>
</Stack>
{ loading && <LinearProgress color="primary" /> }
<Stack justifyContent="flex-start" alignItems="center" sx={kStyles.scroller}>
{ !timeline.length &&
<>
<EventBusyIcon color="primary" sx={{ mt: 20 }} />
<Typography variant="subtitle2" sx={{ pt: 2 }}>
No volunteers have been scheduled
</Typography>
</> }
{ timeline.length > 0 &&
<DisplayTimeline timeline={timeline} /> }
</Stack>
</Stack>
{ /** Snackbars related to the refresh functionality **/ }
<Snackbar open={refreshState === 'error'} autoHideDuration={4000}
onClose={this.resetRefreshState}>
<Alert severity="error" variant="filled">
Unable to refresh the schedule
</Alert>
</Snackbar>
<Snackbar open={refreshState === 'success'} autoHideDuration={4000}
onClose={this.resetRefreshState}>
<Alert severity="success" variant="filled">
The schedule has been updated
</Alert>
</Snackbar>
</Grid>
);
}
}