Skip to content

Commit dd98ba9

Browse files
committed
Make remote bar progress time based
1 parent e18851e commit dd98ba9

3 files changed

Lines changed: 230 additions & 25 deletions

File tree

src/components/NotifyCompRemoteBar/NotifyCompRemoteBar.tsx

Lines changed: 49 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,18 @@ import classNames from 'classnames';
22
import { Link } from 'react-router-dom';
33
import { Container } from '@/components/Container';
44
import { useCompetitionRemoteControl } from '@/hooks/useCompetitionRemoteControl';
5+
import { useNow } from '@/hooks/useNow';
6+
import {
7+
formatElapsedMMSS,
8+
formatUntilNextActivity,
9+
getActiveGroupStartTime,
10+
getRemoteBarProgress,
11+
} from './remoteBarProgress';
512

613
interface NotifyCompRemoteBarProps {
714
competitionId: string;
815
}
916

10-
const groupLabel = (count: number) => `${count} active activit${count === 1 ? 'y' : 'ies'}`;
1117
const iconButtonClassName =
1218
'flex h-8 w-8 items-center justify-center rounded-full border border-tertiary-weak bg-panel text-base leading-none text-default shadow-sm hover-transition hover:bg-gray-100 disabled:cursor-not-allowed disabled:opacity-40 dark:hover:bg-gray-700 md:h-9 md:w-9 md:text-lg';
1319
const primaryButtonClassName =
@@ -18,22 +24,22 @@ const confirmNextGroup = (groupName: string) =>
1824

1925
export function NotifyCompRemoteBar({ competitionId }: NotifyCompRemoteBarProps) {
2026
const remote = useCompetitionRemoteControl({ competitionId });
27+
const now = useNow();
2128

2229
if (!remote.isAuthenticated || !remote.competition || remote.scheduledActivities.length === 0) {
2330
return null;
2431
}
2532

2633
const activeNames = remote.activeGroups.map((group) => group.name);
27-
const title = activeNames.length > 0 ? activeNames.join(', ') : 'No active activities';
28-
const detail =
29-
activeNames.length > 0
30-
? groupLabel(activeNames.length)
31-
: remote.nextGroup
32-
? `Next: ${remote.nextGroup.name}`
33-
: 'Remote overview';
34-
const completedGroups = remote.activityGroups.filter((group) => group.status === 'done').length;
35-
const progress =
36-
remote.activityGroups.length > 0 ? (completedGroups / remote.activityGroups.length) * 100 : 0;
34+
const currentTitle = activeNames.length > 0 ? activeNames.join(', ') : 'No active activity';
35+
const nextTitle = remote.nextGroup?.name || 'No next activity';
36+
const elapsed = formatElapsedMMSS(getActiveGroupStartTime(remote.activeGroups), now);
37+
const nextStatus = formatUntilNextActivity(remote.nextGroup, now);
38+
const progress = getRemoteBarProgress({
39+
activeGroups: remote.activeGroups,
40+
nextGroup: remote.nextGroup,
41+
now,
42+
});
3743

3844
const runSwitch = (direction: 'previous' | 'next') => {
3945
const group = direction === 'previous' ? remote.previousGroup : remote.nextGroup;
@@ -65,16 +71,31 @@ export function NotifyCompRemoteBar({ competitionId }: NotifyCompRemoteBarProps)
6571
<Container
6672
fullWidth
6773
className="relative flex-row min-h-16 items-center justify-center px-2 py-2">
68-
<Link
69-
to={`/competitions/${competitionId}/remote`}
70-
className="absolute left-2 top-1/2 hidden max-w-[min(32vw,20rem)] -translate-y-1/2 rounded px-1 py-1 hover-transition hover:bg-gray-100 dark:hover:bg-gray-700 sm:block">
71-
<div className="min-w-0 space-y-1">
72-
<div className="truncate text-sm font-medium text-default">{title}</div>
73-
<div className="truncate text-xs text-muted">{detail}</div>
74-
</div>
75-
</Link>
74+
<div className="w-full max-w-xl space-y-1">
75+
<Link
76+
to={`/competitions/${competitionId}/remote`}
77+
className={classNames(
78+
'grid grid-cols-2 gap-2 rounded px-1 py-1 hover-transition hover:bg-gray-100 dark:hover:bg-gray-700',
79+
{
80+
'opacity-60': remote.isLoading,
81+
},
82+
)}>
83+
<span className="min-w-0 space-y-0.5">
84+
<span className="block text-[0.625rem] font-medium uppercase leading-none text-muted">
85+
Current
86+
</span>
87+
<span className="block truncate text-xs font-medium text-default">
88+
{currentTitle}
89+
</span>
90+
</span>
91+
<span className="min-w-0 space-y-0.5 text-right">
92+
<span className="block text-[0.625rem] font-medium uppercase leading-none text-muted">
93+
Next
94+
</span>
95+
<span className="block truncate text-xs font-medium text-default">{nextTitle}</span>
96+
</span>
97+
</Link>
7698

77-
<div className="w-full max-w-sm space-y-1">
7899
<div className="flex items-center justify-center gap-2">
79100
<button
80101
type="button"
@@ -110,17 +131,20 @@ export function NotifyCompRemoteBar({ competitionId }: NotifyCompRemoteBarProps)
110131

111132
<Link
112133
to={`/competitions/${competitionId}/remote`}
113-
className={classNames('grid grid-cols-[44px_minmax(0,1fr)_44px] items-center gap-2', {
114-
'opacity-60': remote.isLoading,
115-
})}>
116-
<span className="text-right text-xs tabular-nums text-muted">{completedGroups}</span>
134+
className={classNames(
135+
'grid grid-cols-[44px_minmax(0,1fr)_minmax(88px,1.5fr)] items-center gap-2 rounded px-1 py-1 hover-transition hover:bg-gray-100 dark:hover:bg-gray-700',
136+
{
137+
'opacity-60': remote.isLoading,
138+
},
139+
)}>
140+
<span className="text-right text-xs tabular-nums text-muted">{elapsed}</span>
117141
<span className="h-1 overflow-hidden rounded-full bg-tertiary">
118142
<span
119143
className="block h-full rounded-full bg-blue-500 dark:bg-blue-400"
120144
style={{ width: `${progress}%` }}
121145
/>
122146
</span>
123-
<span className="text-xs tabular-nums text-muted">{remote.activityGroups.length}</span>
147+
<span className="truncate text-xs text-muted">{nextStatus}</span>
124148
</Link>
125149
</div>
126150
</Container>
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
import { RemoteActivityGroup } from '@/lib/notifyCompRemoteActivities';
2+
import {
3+
formatElapsedMMSS,
4+
formatUntilNextActivity,
5+
getRemoteBarProgress,
6+
} from './remoteBarProgress';
7+
8+
const group = (overrides: Partial<RemoteActivityGroup>): RemoteActivityGroup => ({
9+
id: '333-r1-g1',
10+
liveActivities: [],
11+
name: '3x3x3 Cube, Round 1, Group 1',
12+
scheduledActivities: [
13+
{
14+
activityCode: '333-r1-g1',
15+
childActivities: [],
16+
endTime: '2026-06-01T10:10:00Z',
17+
extensions: [],
18+
id: 101,
19+
name: '3x3x3 Cube, Round 1, Group 1',
20+
room: {
21+
id: 1,
22+
name: 'Stage 1',
23+
} as RemoteActivityGroup['scheduledActivities'][number]['room'],
24+
startTime: '2026-06-01T10:00:00Z',
25+
},
26+
],
27+
status: 'next',
28+
...overrides,
29+
});
30+
31+
describe('remoteBarProgress', () => {
32+
it('formats elapsed live activity time as MM:SS', () => {
33+
expect(formatElapsedMMSS('2026-06-01T10:01:05Z', new Date('2026-06-01T10:06:09Z'))).toBe(
34+
'05:04',
35+
);
36+
});
37+
38+
it('describes how many minutes remain until the next activity', () => {
39+
expect(
40+
formatUntilNextActivity(
41+
group({
42+
name: 'Clock, Round 1, Group 1',
43+
scheduledActivities: [
44+
{
45+
...group({}).scheduledActivities[0],
46+
startTime: '2026-06-01T10:14:15Z',
47+
},
48+
],
49+
}),
50+
new Date('2026-06-01T10:10:00Z'),
51+
),
52+
).toBe('5 minutes until Clock, Round 1, Group 1');
53+
});
54+
55+
it('fills progress based on live start time and next scheduled start time', () => {
56+
expect(
57+
getRemoteBarProgress({
58+
activeGroups: [
59+
group({
60+
liveActivities: [
61+
{
62+
activityId: 101,
63+
endTime: null,
64+
startTime: '2026-06-01T10:00:00Z',
65+
},
66+
],
67+
status: 'current',
68+
}),
69+
],
70+
nextGroup: group({
71+
id: '222-r1-g1',
72+
name: '2x2x2 Cube, Round 1, Group 1',
73+
scheduledActivities: [
74+
{
75+
...group({}).scheduledActivities[0],
76+
id: 102,
77+
startTime: '2026-06-01T10:20:00Z',
78+
},
79+
],
80+
}),
81+
now: new Date('2026-06-01T10:05:00Z'),
82+
}),
83+
).toBe(25);
84+
});
85+
});
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
import { RemoteActivityGroup } from '@/lib/notifyCompRemoteActivities';
2+
3+
const SECOND = 1000;
4+
const MINUTE = 60 * SECOND;
5+
6+
const clampPercent = (value: number) => Math.min(100, Math.max(0, value));
7+
8+
const getTime = (isoString?: string | null) => {
9+
if (!isoString) {
10+
return undefined;
11+
}
12+
13+
const time = new Date(isoString).getTime();
14+
return Number.isNaN(time) ? undefined : time;
15+
};
16+
17+
const getEarliestTime = (times: Array<number | undefined>) => {
18+
const validTimes = times.filter((time): time is number => time !== undefined);
19+
return validTimes.length ? Math.min(...validTimes) : undefined;
20+
};
21+
22+
export const formatElapsedMMSS = (startTime: number | string | undefined, now: Date) => {
23+
const start = typeof startTime === 'number' ? startTime : getTime(startTime);
24+
25+
if (start === undefined) {
26+
return '00:00';
27+
}
28+
29+
const elapsedSeconds = Math.max(0, Math.floor((now.getTime() - start) / SECOND));
30+
const minutes = Math.floor(elapsedSeconds / 60);
31+
const seconds = elapsedSeconds % 60;
32+
33+
return `${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`;
34+
};
35+
36+
export const getGroupScheduledStartTime = (group?: RemoteActivityGroup) =>
37+
getEarliestTime(group?.scheduledActivities.map((activity) => getTime(activity.startTime)) || []);
38+
39+
export const getActiveGroupStartTime = (groups: RemoteActivityGroup[]) =>
40+
getEarliestTime(
41+
groups.flatMap((group) => {
42+
const liveStartTimes = group.liveActivities
43+
.filter((activity) => activity.startTime && !activity.endTime)
44+
.map((activity) => getTime(activity.startTime));
45+
46+
if (liveStartTimes.length > 0) {
47+
return liveStartTimes;
48+
}
49+
50+
return group.scheduledActivities.map((activity) => getTime(activity.startTime));
51+
}),
52+
);
53+
54+
export const formatUntilNextActivity = (nextGroup: RemoteActivityGroup | undefined, now: Date) => {
55+
const nextStart = getGroupScheduledStartTime(nextGroup);
56+
57+
if (!nextGroup || nextStart === undefined) {
58+
return 'No next activity';
59+
}
60+
61+
const remainingMs = nextStart - now.getTime();
62+
63+
if (remainingMs <= 0) {
64+
return `${nextGroup.name} should start now`;
65+
}
66+
67+
const remainingMinutes = Math.ceil(remainingMs / MINUTE);
68+
const unit = remainingMinutes === 1 ? 'minute' : 'minutes';
69+
70+
return `${remainingMinutes} ${unit} until ${nextGroup.name}`;
71+
};
72+
73+
export const getRemoteBarProgress = ({
74+
activeGroups,
75+
nextGroup,
76+
now,
77+
}: {
78+
activeGroups: RemoteActivityGroup[];
79+
nextGroup?: RemoteActivityGroup;
80+
now: Date;
81+
}) => {
82+
const activeStart = getActiveGroupStartTime(activeGroups);
83+
const nextStart = getGroupScheduledStartTime(nextGroup);
84+
85+
if (activeStart === undefined || nextStart === undefined) {
86+
return nextStart !== undefined && now.getTime() >= nextStart ? 100 : 0;
87+
}
88+
89+
const duration = nextStart - activeStart;
90+
91+
if (duration <= 0) {
92+
return now.getTime() >= nextStart ? 100 : 0;
93+
}
94+
95+
return clampPercent(((now.getTime() - activeStart) / duration) * 100);
96+
};

0 commit comments

Comments
 (0)