diff --git a/src/containers/PersonalSchedule/utils.test.ts b/src/containers/PersonalSchedule/utils.test.ts index d8e6246..50cbbbc 100644 --- a/src/containers/PersonalSchedule/utils.test.ts +++ b/src/containers/PersonalSchedule/utils.test.ts @@ -38,8 +38,8 @@ 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', }, ], }, @@ -47,6 +47,62 @@ const worldsAssignmentsPerson = { ], } 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); @@ -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); + }); }); diff --git a/src/containers/PersonalSchedule/utils.ts b/src/containers/PersonalSchedule/utils.ts index 3e38e17..43ad8ff 100644 --- a/src/containers/PersonalSchedule/utils.ts +++ b/src/containers/PersonalSchedule/utils.ts @@ -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); @@ -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) => { @@ -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); @@ -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) => { @@ -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), + ), }; } @@ -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)), }; } }) diff --git a/src/lib/activityCodes.ts b/src/lib/activityCodes.ts index 78f2f2a..bf1f033 100644 --- a/src/lib/activityCodes.ts +++ b/src/lib/activityCodes.ts @@ -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); } @@ -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);