Skip to content
98 changes: 96 additions & 2 deletions src/containers/PersonalSchedule/utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,15 +38,71 @@ const worldsAssignmentsPerson = {
assignments: [
{
staff: 'Stage Stream - Main',
startTime: '2025-07-03T18:00:00Z',
endTime: '2025-07-03T19:00:00Z',
startTime: '2025-07-04T01:00:00Z',
endTime: '2025-07-04T02:00:00Z',
},
],
},
},
],
} as unknown as Person;

const fmcWcif = {
...wcif,
schedule: {
...wcif.schedule,
venues: [
{
...wcif.schedule.venues[0],
rooms: [
{
id: 1,
name: 'FMC Room',
color: '#ffffff',
extensions: [],
activities: [
{
id: 100,
name: 'Fewest Moves Attempt 1',
activityCode: '333fm-r1-a1',
startTime: '2025-07-03T18:00:00Z',
endTime: '2025-07-03T19:00:00Z',
extensions: [],
childActivities: [
{
id: 101,
name: 'Fewest Moves Attempt 1 Group 1',
activityCode: '333fm-r1-g1-a1',
startTime: '2025-07-03T18:00:00Z',
endTime: '2025-07-03T19:00:00Z',
extensions: [],
childActivities: [],
},
],
},
],
},
],
},
],
},
} as unknown as Competition;

const fmcPerson = {
registrantId: 216,
assignments: [
{
activityId: 101,
assignmentCode: 'competitor',
stationNumber: null,
},
],
registration: {
eventIds: ['333fm'],
},
extensions: [],
} as unknown as Person;

describe('PersonalSchedule utils', () => {
it('creates parse-safe activities for worlds assignments with free-form staff names', () => {
const [assignment] = getAllAssignments(wcif, worldsAssignmentsPerson);
Expand All @@ -66,4 +122,42 @@ describe('PersonalSchedule utils', () => {
expect(scheduleDay.assignments).toHaveLength(1);
expect(scheduleDay.assignments[0].assignment.assignmentCode).toBe('Stage Stream - Main');
});

it('keeps standard other activities in the other namespace', () => {
expect(parseActivityCodeFlexible('other-lunch-g2')).toMatchObject({
eventId: 'other-lunch',
roundNumber: 1,
groupNumber: 2,
attemptNumber: null,
});
expect(parseActivityCodeFlexible('other-misc')).toMatchObject({
eventId: 'other-misc',
});
});

it('only includes the FMC attempt activity assigned to the competitor', () => {
const fmcAssignments = getAllAssignments(fmcWcif, fmcPerson).filter(({ activity }) =>
activity?.activityCode.startsWith('333fm'),
);

expect(fmcAssignments).toHaveLength(1);
expect(fmcAssignments[0].activityId).toBe(101);
});

it('does not create FMC assignments without a matching activity assignment', () => {
const personWithoutFmcAssignment = {
...fmcPerson,
assignments: [],
} as Person;

expect(getAllAssignments(fmcWcif, personWithoutFmcAssignment)).toHaveLength(0);
});

it('includes a day that only has an assigned FMC attempt', () => {
const [scheduleDay] = getGroupedAssignmentsByDate(fmcWcif, fmcPerson);

expect(scheduleDay.date).toBe('Thursday, 7/3/2025');
expect(scheduleDay.assignments).toHaveLength(1);
expect(scheduleDay.assignments[0].assignment.activityId).toBe(101);
});
});
71 changes: 42 additions & 29 deletions src/containers/PersonalSchedule/utils.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,27 @@
import { Activity, Assignment, Competition, Person } from '@wca/helpers';
import { getWorldAssignmentsExtension } from '@/extensions/com.competitiongroups.worldsassignments';
import i18n from '@/i18n';
import { getAllActivities, getRooms } from '@/lib/activities';
import { getAllActivities } from '@/lib/activities';
import { isUnofficialParsedActivityCode, parseActivityCodeFlexible } from '@/lib/activityCodes';
import { eventById, isOfficialEventId } from '@/lib/events';
import { formatNumericDate, getNumericDateFormatter } from '@/lib/time';
import { byDate } from '@/lib/utils';

type ActivityWithLocation = Activity & {
room?: { id: number };
parent?: { room?: { id: number } };
};

const getActivityTimeZone = (
venues: Competition['schedule']['venues'],
activity: ActivityWithLocation,
) => {
const roomId = activity.room?.id ?? activity.parent?.room?.id;
const venue = venues.find((candidate) => candidate.rooms.some((room) => room.id === roomId));

return venue?.timezone ?? venues[0]?.timezone;
};

export const getNormalAssignments = (wcif: Competition, person: Person) => {
const allActivities = getAllActivities(wcif);

Expand Down Expand Up @@ -105,20 +120,24 @@ const getFmcAttemptAssignments = (wcif: Competition, person: Person) => {
return parsed.eventId === '333fm' && parsed.attemptNumber !== null;
});

return fmcAttemptActivities.map(
(
activity,
): Assignment & {
type: 'extra';
activity: Activity;
} => ({
type: 'extra',
assignmentCode: 'competitor',
activityId: activity.id,
stationNumber: null,
activity,
}),
);
const personActivities = person.assignments?.map((ass) => ass.activityId);

return fmcAttemptActivities
.filter((activity) => personActivities?.includes(activity.id))
.map(
(
activity,
): Assignment & {
type: 'extra';
activity: Activity;
} => ({
type: 'extra',
assignmentCode: 'competitor',
activityId: activity.id,
stationNumber: null,
activity,
}),
);
};

export const getAllAssignments = (wcif: Competition, person: Person) => {
Expand Down Expand Up @@ -157,18 +176,14 @@ export const getGroupedAssignmentsByDate = (wcif: Competition, person: Person) =
};
}

const roomId = a.activity && 'room' in a.activity && a.activity.room?.id;
const parent = a.activity && 'parent' in a.activity && a.activity.parent;

const venue = venues.find((v) => v.rooms.some((r) => r.id === roomId || parent));

const dateTime = new Date(a.activity?.startTime);
const date = formatNumericDate(dateTime, venue?.timezone);
const timeZone = getActivityTimeZone(venues, a.activity);
const date = formatNumericDate(dateTime, timeZone);

return {
approxDateTime: dateTime.getTime(),
date: date,
dateParts: getNumericDateFormatter(venue?.timezone).formatToParts(dateTime),
dateParts: getNumericDateFormatter(timeZone).formatToParts(dateTime),
};
})
.filter((v, i, arr) => arr.findIndex(({ date }) => date === v.date) === i);
Expand All @@ -186,7 +201,6 @@ export const getGroupedAssignmentsByDate = (wcif: Competition, person: Person) =
export const getAssignmentsWithParsedDate = (wcif: Competition, person: Person) => {
const allAssignments = getAllAssignments(wcif, person);
const venues = wcif.schedule.venues;
const rooms = getRooms(wcif);

return allAssignments
.map((assignment) => {
Expand All @@ -200,7 +214,10 @@ export const getAssignmentsWithParsedDate = (wcif: Competition, person: Person)
return {
assignment,
activity,
date: formatNumericDate(new Date(activity.startTime), venues[0].timezone),
date: formatNumericDate(
new Date(activity.startTime),
getActivityTimeZone(venues, activity),
),
};
}

Expand All @@ -212,16 +229,12 @@ export const getAssignmentsWithParsedDate = (wcif: Competition, person: Person)
};
}

const roomId = (activity.room || activity.parent?.room)?.id;

const venue = activity?.room?.id ? rooms.find((r) => r.id === roomId)?.venue : venues[0];

const dateTime = new Date(activity.startTime);

return {
assignment,
activity,
date: formatNumericDate(dateTime, venue?.timezone),
date: formatNumericDate(dateTime, getActivityTimeZone(venues, activity)),
};
}
})
Expand Down
18 changes: 17 additions & 1 deletion src/lib/activityCodes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,10 @@ export const parseActivityCodeFlexible = (
activityCode: string,
): ParsedActivityCode | UnofficialParsedActivityCode => {
const eventId = activityCode.split('-')[0];
if (activityCode.startsWith('other') || !isOfficialEventId(eventId)) {
if (activityCode.startsWith('other')) {
return parseOtherActivityCode(activityCode);
}
if (!isOfficialEventId(eventId)) {
return parseUnofficialActivityCode(activityCode);
}

Expand All @@ -62,6 +65,19 @@ export const parseActivityCodeFlexible = (
const normalizeEventId = (eventId: string): string =>
eventId.replace('other-', '').replace('unofficial-', '');

export const parseOtherActivityCode = (activityCode: string): UnofficialParsedActivityCode => {
const regex = /other-(?:(\w+))?(?:-g(\d+))?/;
const [, e, g] = activityCode.match(regex) as string[];

return {
rawEventId: e,
eventId: `other-${e}`,
roundNumber: 1,
groupNumber: g ? parseInt(g, 10) : null,
attemptNumber: null,
};
};

export const parseUnofficialActivityCode = (activityCode: string): UnofficialParsedActivityCode => {
const regex = /^([\w-]+?)(?:-r(\d+))?(?:-g(\d+))?(?:-a(\d+))?$/;
const matches = activityCode.match(regex);
Expand Down
Loading