Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
71 commits
Select commit Hold shift + click to select a range
ef9d104
fix: show advancement details for participation rounds
coder13 Apr 17, 2026
57e5213
add participation condition typing and stories
coder13 Apr 20, 2026
ee5398b
Address CutoffTimeLimitPanel review feedback
coder13 Apr 20, 2026
cb09e3e
Refine advancement copy and linked round UI
coder13 Apr 20, 2026
6dd8595
Merge pull request #64 from coder13/participation-condition
coder13 Apr 20, 2026
23a15ad
fix: show FMC attempt activities for registered competitors without g…
Copilot May 4, 2026
cc1a434
refactor: parse activityCode only when needed in getNormalAssignments…
Copilot May 4, 2026
71a5cee
fix: show activity name for FMC attempt entries in personal schedule
Copilot May 4, 2026
47a746a
fix: FMC/MBLD extra rows show start time only, Submit Multi Cubes kee…
Copilot May 4, 2026
4708944
feat: use AssignmentLabel pill for competitor code in ExtraAssignment
Copilot May 4, 2026
3896f7e
chore: bump Node to 22 and update GitHub Actions
coder13 Mar 21, 2026
feedaf8
Polish competition select dropdown
coder13 May 9, 2026
7ed1eb7
Define shared animation curves
coder13 May 9, 2026
842cdc8
Scaffold competition results pages
coder13 May 9, 2026
47e1c3a
Add personal results overview
coder13 May 9, 2026
c165a93
Add WCA Live personal results polling
coder13 May 9, 2026
709afab
Add live competition results polling
coder13 May 9, 2026
35b791d
Fix group arrow key navigation
coder13 May 9, 2026
ecfe562
Fixed images in non prod envs
coder13 May 10, 2026
cb9b4a3
Improve competition round results UI
coder13 May 10, 2026
26d8cfa
Widen selected results layout
coder13 May 10, 2026
660caa0
Add WCA results fallback provider
coder13 May 10, 2026
acd8105
Centralize results source merging
coder13 May 10, 2026
321370f
Add competition sum of rankings page
coder13 May 11, 2026
0d51312
integrate notifycomp assignment push
coder13 May 11, 2026
a0eee06
Fix assignment notification enable flow
coder13 May 11, 2026
f9f5e05
Merge pull request #68 from coder13/codex/integrate-notifycomp-push
coder13 May 11, 2026
fea3281
add notifycomp remote controls
coder13 May 12, 2026
7642e05
switch remote auth to competitiongroups token exchange
coder13 May 12, 2026
32f4e2d
add remote control bar
coder13 May 12, 2026
b51aefd
make remote bar a horizontal player
coder13 May 12, 2026
46935a4
flatten remote page layout
coder13 May 12, 2026
4bd9ccc
add remote description
coder13 May 12, 2026
72b14c3
add notifycomp import and sign in copy
coder13 May 12, 2026
364aa64
move notifycomp sign out to settings
coder13 May 12, 2026
6eb10f5
theme remote bar for competitiongroups
coder13 May 12, 2026
3c68ec1
Simplify remote activity controls
coder13 May 12, 2026
39e1a6a
Confirm remote next activity advancement
coder13 May 12, 2026
567391d
Resume notifycomp remote sign in
coder13 May 12, 2026
eb0f2a3
Remove all-room remote selector option
coder13 May 12, 2026
8b97a12
Refine remote schedule overview
coder13 May 12, 2026
b5a2f4e
Use font awesome remote nav arrows
coder13 May 12, 2026
75e7268
Replace auto advance button with toggle
coder13 May 12, 2026
81ea2fd
Extend remote token lifetime
coder13 May 12, 2026
f57e51f
Fix remote auto advance toggle styling
coder13 May 12, 2026
8e538f1
Align remote toggle with app theme
coder13 May 12, 2026
6bb61d5
Label remote auto advance toggle
coder13 May 12, 2026
316b6ed
Center remote control group
coder13 May 12, 2026
e18851e
Center remote bar content in full width
coder13 May 12, 2026
dd98ba9
Make remote bar progress time based
coder13 May 12, 2026
557ba70
Reset current activity before previous remote start
coder13 May 12, 2026
65a0c54
Hide remote explanation after sign in
coder13 May 12, 2026
7f3571e
Fix events round selector navigation
coder13 May 12, 2026
ae07fed
Refine remote bar inline layout
coder13 May 12, 2026
915347b
Increase remote bar touch targets
coder13 May 12, 2026
6807d60
Show duration based remote progress
coder13 May 12, 2026
0c6ab0c
Use minute rounding on remote schedule
coder13 May 12, 2026
c42e726
Add off-day remote reset all action
coder13 May 12, 2026
d5a4d12
Improve live activity remote controls
coder13 May 12, 2026
d9576cd
Merge pull request #69 from coder13/codex/notifycomp-remote
coder13 May 12, 2026
385e07e
Normalize results container class order
coder13 May 13, 2026
596361e
Add competition admin section
coder13 May 13, 2026
b1450ff
Add personal user page with competitions, results, and records tabs
coder13 May 11, 2026
7aff285
Gate personal user page behind feature flag
coder13 May 14, 2026
8652fb8
Polish competition results display
coder13 May 26, 2026
4b0d165
Merge results polish into beta
coder13 May 26, 2026
efeaf6d
Merge main into beta
coder13 May 26, 2026
c75831e
Merge main into beta
coder13 May 26, 2026
6bf13e2
Document beta release workflow
coder13 May 26, 2026
cafdfb7
Poll for PWA updates
coder13 May 26, 2026
d19c7d6
Merge main into beta
coder13 May 27, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -27,3 +27,4 @@ yarn-error.log*
storybook-static

.cache
.netlify
9 changes: 9 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -1,2 +1,11 @@
Use space-y instead of mt-2 for better spacing between elements.
Always use spacing in multiples of 2 unless you need to use odd spacing for a specific reason. This helps maintain visual consistency across the app.

## Branch and deployment workflow

- `main` is the GitHub default branch and Netlify production branch.
- `beta` is the permanent integration and beta-testing branch.
- Feature and fix PRs should target `beta`, not `main`.
- Production releases happen by opening or updating the deploy PR from `beta` into `main`.
- Use the manual GitHub Actions `Deploy` workflow to create or update the `beta` -> `main` release PR.
- PRs into `main` are guarded and should only come from `beta`.
182 changes: 182 additions & 0 deletions netlify/functions/notify-comp-token.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
const crypto = require('crypto');

const headers = {
'Access-Control-Allow-Headers': 'Content-Type',
'Access-Control-Allow-Methods': 'POST, OPTIONS',
'Access-Control-Allow-Origin': '*',
'Content-Type': 'application/json',
};

const base64Url = (value) => Buffer.from(value).toString('base64url');
const REMOTE_SCOPE = 'notifycomp.remote';
const PUSH_SCOPE = 'assignment_notifications';
const REMOTE_TOKEN_TTL_SECONDS = 12 * 60 * 60;
const PUSH_TOKEN_TTL_SECONDS = 10 * 60;

const getCompetitionManagers = (competition) => {
const data = competition?.competition || competition;
return [...(data?.organizers || []), ...(data?.delegates || [])];
};

const isListedCompetitionManager = (competition, userId) =>
getCompetitionManagers(competition).some((user) => Number(user?.id) === Number(userId));

const getManagedCompetitionIds = (competitions, userId) =>
competitions
.filter((competition) => isListedCompetitionManager(competition, userId))
.map((competition) => competition.id)
.filter(Boolean);

const signJwt = (claims, secret) => {
const encodedHeader = base64Url(JSON.stringify({ alg: 'HS256', typ: 'JWT' }));
const encodedPayload = base64Url(JSON.stringify(claims));
const signature = crypto
.createHmac('sha256', secret)
.update(`${encodedHeader}.${encodedPayload}`)
.digest('base64url');

return `${encodedHeader}.${encodedPayload}.${signature}`;
};

exports.handler = async (event) => {
if (event.httpMethod === 'OPTIONS') {
return { statusCode: 204, headers };
}

if (event.httpMethod !== 'POST') {
return {
statusCode: 405,
headers,
body: JSON.stringify({ message: 'Method not allowed' }),
};
}

const secret = process.env.COMPETITION_GROUPS_JWT_SECRET;
if (!secret) {
return {
statusCode: 500,
headers,
body: JSON.stringify({ message: 'Notification token secret is not configured' }),
};
}

let body;
try {
body = JSON.parse(event.body || '{}');
} catch {
return {
statusCode: 400,
headers,
body: JSON.stringify({ message: 'Invalid JSON body' }),
};
}

const { accessToken, competitionId, scope } = body;
const tokenScope = scope === REMOTE_SCOPE ? REMOTE_SCOPE : PUSH_SCOPE;
if (!accessToken) {
return {
statusCode: 400,
headers,
body: JSON.stringify({ message: 'Missing WCA access token' }),
};
}

const wcaOrigin = process.env.WCA_ORIGIN || 'https://www.worldcubeassociation.org';
const meParams =
tokenScope === REMOTE_SCOPE ? '?upcoming_competitions=true&ongoing_competitions=true' : '';
const meResponse = await fetch(`${wcaOrigin}/api/v0/me${meParams}`, {
headers: {
Authorization: `Bearer ${accessToken}`,
},
});

if (!meResponse.ok) {
return {
statusCode: 401,
headers,
body: JSON.stringify({ message: 'Invalid WCA access token' }),
};
}

const { me, ongoing_competitions = [], upcoming_competitions = [] } = await meResponse.json();
if (!me?.id) {
return {
statusCode: 401,
headers,
body: JSON.stringify({ message: 'Unable to resolve WCA user' }),
};
}

const tokenTtlSeconds =
tokenScope === REMOTE_SCOPE ? REMOTE_TOKEN_TTL_SECONDS : PUSH_TOKEN_TTL_SECONDS;
if (tokenScope === REMOTE_SCOPE && !competitionId) {
return {
statusCode: 400,
headers,
body: JSON.stringify({ message: 'Missing competition ID for remote token' }),
};
}

let remoteCompetitionIds = [];

if (tokenScope === REMOTE_SCOPE) {
const competitionResponse = await fetch(`${wcaOrigin}/api/v0/competitions/${competitionId}`, {
headers: {
Authorization: `Bearer ${accessToken}`,
},
});

if (!competitionResponse.ok) {
return {
statusCode: competitionResponse.status === 404 ? 404 : 502,
headers,
body: JSON.stringify({ message: 'Unable to verify competition remote access' }),
};
}

const competition = await competitionResponse.json();
if (!isListedCompetitionManager(competition, me.id)) {
return {
statusCode: 403,
headers,
body: JSON.stringify({
message:
'Only listed competition delegates and organizers can use Live Activities Remote',
}),
};
}

// Remote tokens are scoped to competitions the WCA API says this user manages. The
// NotifyComp API must reject remote mutations for competition IDs outside this claim.
remoteCompetitionIds = [
...new Set([
competitionId,
...getManagedCompetitionIds([...ongoing_competitions, ...upcoming_competitions], me.id),
]),
];
}

const now = Math.floor(Date.now() / 1000);
const token = signJwt(
{
aud: process.env.COMPETITION_GROUPS_JWT_AUDIENCE || 'notifycomp',
competitionIds: tokenScope === REMOTE_SCOPE ? remoteCompetitionIds : undefined,
exp: now + tokenTtlSeconds,
iat: now,
iss: process.env.COMPETITION_GROUPS_JWT_ISSUER || 'competitiongroups.com',
name: me.name,
scope: tokenScope,
scopes: [tokenScope],
sub: `wca:${me.id}`,
wcaUserId: me.id,
wcaUserIds: [me.id],
},
secret,
);

return {
statusCode: 200,
headers,
body: JSON.stringify({ token }),
};
};
34 changes: 34 additions & 0 deletions public/notification-sw.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
self.addEventListener('push', (event) => {
if (!event.data) {
return;
}

const payload = event.data.json();
const title = payload.title || 'Assignment update';
const options = {
body: payload.body,
data: payload,
tag: payload.dedupeKey || 'assignment-change',
};

event.waitUntil(self.registration.showNotification(title, options));
});

self.addEventListener('notificationclick', (event) => {
event.notification.close();

const targetUrl = event.notification.data?.url || '/settings';
const url = new URL(targetUrl, self.location.origin).href;

event.waitUntil(
self.clients.matchAll({ includeUncontrolled: true, type: 'window' }).then((clients) => {
const existingClient = clients.find((client) => client.url === url);

if (existingClient) {
return existingClient.focus();
}

return self.clients.openWindow(url);
}),
);
});
79 changes: 72 additions & 7 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,9 @@ import client from './apolloClient';
import { usePageTracking } from './hooks/usePageTracking';
import { CompetitionLayout } from './layouts/CompetitionLayout';
import { RootLayout } from './layouts/RootLayout';
import { FEATURE_FLAGS } from './lib/featureFlags';
import About from './pages/About';
import CompetitionAdmin from './pages/Competition/Admin';
import CompetitionEvents from './pages/Competition/ByGroup/Events';
import CompetitionGroup from './pages/Competition/ByGroup/Group';
import CompetitionGroupList from './pages/Competition/ByGroup/GroupList';
Expand All @@ -18,6 +20,8 @@ import CompetitionLive from './pages/Competition/Live';
import CompetitionPerson from './pages/Competition/Person';
import CompetitionPersonalBests from './pages/Competition/Person/PersonalBests';
import { PsychSheetEvent } from './pages/Competition/PsychSheet/PsychSheetEvent';
import CompetitionRemote from './pages/Competition/Remote';
import CompetitionResults from './pages/Competition/Results';
import {
CompetitionActivity,
CompetitionRoom,
Expand All @@ -27,13 +31,18 @@ import {
import CompetitionScramblerSchedule from './pages/Competition/ScramblerSchedule';
import CompetitionStats from './pages/Competition/Stats';
import CompetitionStreamSchedule from './pages/Competition/StreamSchedule';
import CompetitionSumOfRanks from './pages/Competition/SumOfRanks';
import Home from './pages/Home';
import LiveActivitiesAbout from './pages/LiveActivities/About';
import Settings from './pages/Settings';
import Support from './pages/Support';
import Test from './pages/Test';
import UserPage from './pages/User';
import UserLogin from './pages/UserLogin';
import { AppProvider } from './providers/AppProvider';
import { AuthProvider, useAuth } from './providers/AuthProvider';
import { ConfirmProvider } from './providers/ConfirmProvider';
import { NotifyCompRemoteAuthProvider } from './providers/NotifyCompRemoteAuthProvider';
import { QueryProvider } from './providers/QueryProvider/QueryProvider';
import { UserSettingsProvider } from './providers/UserSettingsProvider';
import { useWCIF } from './providers/WCIFProvider';
Expand Down Expand Up @@ -79,6 +88,30 @@ const PsychSheet = () => {
return null;
};

const CompetitionPersonByWcaIdRedirect = ({ to }: { to: 'results' | 'records' }) => {
const { competitionId, wcaId } = useParams() as { competitionId: string; wcaId: string };
const { wcif } = useWCIF();
const person = wcif?.persons.find((p) => p.wcaId?.toUpperCase() === wcaId.toUpperCase());

if (!wcif) {
return null;
}

if (!person) {
return <Navigate to={`/competitions/${competitionId}`} replace />;
}

return (
<Navigate to={`/competitions/${competitionId}/persons/${person.registrantId}/${to}`} replace />
);
};

const CompetitionRedirect = ({ to }: { to: string }) => {
const { competitionId } = useParams() as { competitionId: string };

return <Navigate to={`/competitions/${competitionId}/${to}`} replace />;
};

const Navigation = () => {
usePageTracking(import.meta.env.VITE_GA_MEASUREMENT_ID);

Expand All @@ -90,7 +123,15 @@ const Navigation = () => {
<Route path="/competitions/:competitionId" element={<CompetitionLayout />}>
<Route index element={<CompetitionHome />} />

<Route path="persons/:registrantId" element={<CompetitionPerson />} />
<Route
path="persons/wca/:wcaId/results"
element={<CompetitionPersonByWcaIdRedirect to="results" />}
/>
<Route
path="persons/wca/:wcaId/records"
element={<CompetitionPersonByWcaIdRedirect to="records" />}
/>
<Route path="persons/:registrantId/*" element={<CompetitionPerson />} />
<Route path="personal-bests/:wcaId" element={<CompetitionPersonalBests />} />
<Route path="personal-records/:wcaId" element={<CompetitionPersonalBests />} />
<Route path="compare-schedules" element={<CompetitionCompareSchedules />} />
Expand All @@ -106,8 +147,16 @@ const Navigation = () => {

<Route path="psych-sheet" element={<PsychSheet />} />
<Route path="psych-sheet/:eventId" element={<PsychSheetEvent />} />

<Route path="scramblers" element={<CompetitionScramblerSchedule />} />
<Route path="results" element={<CompetitionResults />} />
<Route path="results/:roundId" element={<CompetitionResults />} />

<Route path="admin" element={<CompetitionAdmin />} />
<Route path="admin/remote" element={<CompetitionRemote />} />
<Route path="admin/scramblers" element={<CompetitionScramblerSchedule />} />
<Route path="admin/stats" element={<CompetitionStats />} />
<Route path="admin/sum-of-ranks" element={<CompetitionSumOfRanks />} />
<Route path="remote" element={<CompetitionRedirect to="admin/remote" />} />
<Route path="scramblers" element={<CompetitionRedirect to="admin/scramblers" />} />
<Route path="stream" element={<CompetitionStreamSchedule />} />
<Route path="information" element={<CompetitionInformation />} />
<Route path="live" element={<CompetitionLive />} />
Expand All @@ -116,11 +165,23 @@ const Navigation = () => {
<Route path="personal-schedule" element={<PersonalSchedule />} />
<Route path="explore" element={<CompetitionGroupsOverview />} />
<Route path="groups-schedule" element={<CompetitionGroupsSchedule />} />
<Route path="stats" element={<CompetitionStats />} />
<Route path="stats" element={<CompetitionRedirect to="admin/stats" />} />
<Route path="sum-of-ranks" element={<CompetitionRedirect to="admin/sum-of-ranks" />} />
<Route path="*" element={<p>Path not resolved</p>} />
</Route>
<Route path="/users/:userId" element={<UserLogin />} />
{FEATURE_FLAGS.personalUserPage && (
<>
<Route path="/me" element={<Navigate to="/me/competitions" replace />} />
<Route
path="/me/results/:resultsMode"
element={<Navigate to="/me/results" replace />}
/>
<Route path="/me/:tab" element={<UserPage />} />
</>
)}
<Route path="about" element={<About />} />
<Route path="live-activities" element={<LiveActivitiesAbout />} />
<Route path="settings" element={<Settings />} />
<Route path="support" element={<Support />} />
</Route>
Expand All @@ -136,9 +197,13 @@ const App = () => (
<QueryProvider>
<ApolloProvider client={client}>
<BrowserRouter>
<AuthProvider>
<Navigation />
</AuthProvider>
<ConfirmProvider>
<AuthProvider>
<NotifyCompRemoteAuthProvider>
<Navigation />
</NotifyCompRemoteAuthProvider>
</AuthProvider>
</ConfirmProvider>
</BrowserRouter>
</ApolloProvider>
</QueryProvider>
Expand Down
Loading
Loading