diff --git a/apps/backend/src/routers/portal/divisions/index.ts b/apps/backend/src/routers/portal/divisions/index.ts index 7e3803a57..e9f8ad68c 100644 --- a/apps/backend/src/routers/portal/divisions/index.ts +++ b/apps/backend/src/routers/portal/divisions/index.ts @@ -101,4 +101,48 @@ router.get('/:divisionId/awards', async (req: PortalDivisionRequest, res: Respon res.status(200).json(awards.map(makePortalAwardsResponse)); }); +router.get('/:divisionId/current-activity', async (req: PortalDivisionRequest, res: Response) => { + const divisionState = await db.raw.mongo + .collection('division_states') + .findOne({ divisionId: req.divisionId }); + + const teams = await db.teams.byDivisionId(req.divisionId).getAll(); + const tables = await db.tables.byDivisionId(req.divisionId).getAll(); + const rooms = await db.rooms.byDivisionId(req.divisionId).getAll(); + + let activeMatch = null; + let loadedMatch = null; + + if (divisionState?.field?.activeMatch) { + const match = await db.robotGameMatches.byId(divisionState.field.activeMatch).get(); + if (match) { + activeMatch = makePortalMatchResponse(match, tables, teams); + } + } + + if (divisionState?.field?.loadedMatch) { + const match = await db.robotGameMatches.byId(divisionState.field.loadedMatch).get(); + if (match) { + loadedMatch = makePortalMatchResponse(match, tables, teams); + } + } + + // Get current judging sessions (sessions happening now) + const now = new Date(); + const allSessions = await db.judgingSessions.byDivision(req.divisionId).getAll(); + const currentSessions = allSessions + .filter(session => { + const sessionStart = new Date(session.scheduled_time); + const sessionEnd = new Date(sessionStart.getTime() + 30 * 60 * 1000); // 30 min sessions + return now >= sessionStart && now <= sessionEnd; + }) + .map(session => makePortalJudgingSessionResponse(session, rooms, teams)); + + res.status(200).json({ + activeMatch, + loadedMatch, + currentSessions + }); +}); + export default router; diff --git a/apps/portal/locale/en.json b/apps/portal/locale/en.json index a7fbe2d72..20f4075b8 100644 --- a/apps/portal/locale/en.json +++ b/apps/portal/locale/en.json @@ -193,6 +193,17 @@ "teams": "Teams" } }, + "current-activity": { + "title": "Current Activity", + "active-match": "Active Match", + "next-match": "Next Match", + "judging-sessions": "Judging Sessions", + "live": "LIVE", + "round": "Round", + "match": "Match", + "scheduled-at": "Scheduled at", + "no-team": "No Team" + }, "division-teams": "{divisionName} teams", "region": "Region" }, diff --git a/apps/portal/locale/he.json b/apps/portal/locale/he.json index d2cf68a49..884d2b7e0 100644 --- a/apps/portal/locale/he.json +++ b/apps/portal/locale/he.json @@ -193,6 +193,17 @@ "teams": "קבוצות" } }, + "current-activity": { + "title": "פעילות נוכחית", + "active-match": "מקצה פעיל", + "next-match": "המקצה הבא", + "judging-sessions": "מפגשי שיפוט", + "live": "שידור חי", + "round": "סבב", + "match": "מקצה", + "scheduled-at": "מתוכנן ל", + "no-team": "אין קבוצה" + }, "division-teams": "קבוצות {divisionName}", "region": "מדינה" }, diff --git a/apps/portal/src/app/[locale]/event/[slug]/components/current-activity.tsx b/apps/portal/src/app/[locale]/event/[slug]/components/current-activity.tsx new file mode 100644 index 000000000..362370bd8 --- /dev/null +++ b/apps/portal/src/app/[locale]/event/[slug]/components/current-activity.tsx @@ -0,0 +1,210 @@ +'use client'; + +import Link from 'next/link'; +import { useParams } from 'next/navigation'; +import { useTranslations } from 'next-intl'; +import dayjs from 'dayjs'; +import { Paper, Typography, Stack, Box, Chip } from '@mui/material'; +import { PlayArrow, Schedule, Gavel } from '@mui/icons-material'; +import { useMatchTranslations } from '@lems/localization'; +import { CurrentActivity } from '@lems/types/api/portal'; +import { useRealtimeData } from '../../../hooks/use-realtime-data'; +import { useDivision } from './division-data-context'; + +export const CurrentActivityDisplay = () => { + const t = useTranslations('pages.event'); + const { getStage } = useMatchTranslations(); + const division = useDivision(); + const params = useParams(); + const eventSlug = params.slug as string; + + const { data } = useRealtimeData( + `/portal/divisions/${division.id}/current-activity`, + { suspense: true } + ); + + if (!data) { + return null; + } + + const { activeMatch, loadedMatch, currentSessions } = data; + + // Don't show anything if there's no current activity + if (!activeMatch && !loadedMatch && currentSessions.length === 0) { + return null; + } + + return ( + + + + + {t('current-activity.title')} + + + + + {/* Active Match */} + {activeMatch && ( + + + + {t('current-activity.active-match')} + + + + + {getStage(activeMatch.stage)} {t('current-activity.round')} {activeMatch.round},{' '} + {t('current-activity.match')} #{activeMatch.number} + + + {activeMatch.participants.map((participant, idx) => ( + + {participant.team ? ( + + + {participant.table.name}: #{participant.team.number} {participant.team.name} + + + ) : ( + + {participant.table.name}: {t('current-activity.no-team')} + + )} + + ))} + + + )} + + {/* Loaded Match (Next Up) */} + {loadedMatch && !activeMatch && ( + + + + + {t('current-activity.next-match')} + + + + {getStage(loadedMatch.stage)} {t('current-activity.round')} {loadedMatch.round},{' '} + {t('current-activity.match')} #{loadedMatch.number} + + + {t('current-activity.scheduled-at')}:{' '} + {dayjs(loadedMatch.scheduledTime).format('HH:mm')} + + + {loadedMatch.participants.map((participant, idx) => ( + + {participant.team ? ( + + + {participant.table.name}: #{participant.team.number} {participant.team.name} + + + ) : ( + + {participant.table.name}: {t('current-activity.no-team')} + + )} + + ))} + + + )} + + {/* Current Judging Sessions */} + {currentSessions.length > 0 && ( + + + + + {t('current-activity.judging-sessions')} ({currentSessions.length}) + + + + {currentSessions.map(session => ( + + {session.team ? ( + + + {session.room.name}: #{session.team.number} {session.team.name} + + + ) : ( + + {session.room.name}: {t('current-activity.no-team')} + + )} + + ))} + + + )} + + + ); +}; diff --git a/apps/portal/src/app/[locale]/event/[slug]/components/event-header.tsx b/apps/portal/src/app/[locale]/event/[slug]/components/event-header.tsx index c339c0ab4..d8cbee26b 100644 --- a/apps/portal/src/app/[locale]/event/[slug]/components/event-header.tsx +++ b/apps/portal/src/app/[locale]/event/[slug]/components/event-header.tsx @@ -1,59 +1,85 @@ 'use client'; +import { Suspense } from 'react'; import dayjs from 'dayjs'; import Link from 'next/link'; import { useTranslations } from 'next-intl'; import { Box, Typography, Stack, Chip } from '@mui/material'; import { CalendarToday, LocationOn, Celebration as CelebrationIcon } from '@mui/icons-material'; import { EventDetails } from '@lems/types/api/portal'; +import { CurrentActivityDisplay } from './current-activity'; +import { DivisionProvider } from './division-data-context'; + interface EventHeaderProps { eventData: EventDetails; + divisionId?: string; } -export const EventHeader: React.FC = ({ eventData }) => { +export const EventHeader: React.FC = ({ eventData, divisionId }) => { const { seasonName, seasonSlug, name: eventName, startDate, location, official } = eventData; const t = useTranslations('pages.index.events'); return ( - - - - {seasonName} - - - - {eventName} - {!official && ( - } - label={t('unofficial-event')} - variant="outlined" - sx={{ fontWeight: 'medium' }} - /> - )} - + + + + + + {seasonName} + + + + {eventName} + {!official && ( + } + label={t('unofficial-event')} + variant="outlined" + sx={{ fontWeight: 'medium' }} + /> + )} + - - - - - {dayjs(startDate).format('MMMM DD, YYYY')} - - - - - - {location} - + + + + + {dayjs(startDate).format('MMMM DD, YYYY')} + + + + + + {location} + + + + + + + {divisionId && ( + + + + + + - - + )} + ); }; diff --git a/apps/portal/src/app/[locale]/event/[slug]/page.tsx b/apps/portal/src/app/[locale]/event/[slug]/page.tsx index e4068c6a1..d5ec58bc1 100644 --- a/apps/portal/src/app/[locale]/event/[slug]/page.tsx +++ b/apps/portal/src/app/[locale]/event/[slug]/page.tsx @@ -36,7 +36,7 @@ const EventPage = () => { return ( - + diff --git a/libs/types/src/lib/api/portal/divisions.ts b/libs/types/src/lib/api/portal/divisions.ts index 4ac80c8a6..886937038 100644 --- a/libs/types/src/lib/api/portal/divisions.ts +++ b/libs/types/src/lib/api/portal/divisions.ts @@ -76,3 +76,11 @@ export const PortalScoreboardEntrySchema = z.object({ export type ScoreboardEntry = z.infer; export const PortalScoreboardSchema = z.array(PortalScoreboardEntrySchema); + +export const PortalCurrentActivitySchema = z.object({ + activeMatch: PortalDivisionRobotGameMatchSchema.nullable(), + loadedMatch: PortalDivisionRobotGameMatchSchema.nullable(), + currentSessions: z.array(PortalDivisionJudgingSessionSchema) +}); + +export type CurrentActivity = z.infer;