diff --git a/src/App.tsx b/src/App.tsx index c9f70eb..648f747 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -5,6 +5,7 @@ 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'; @@ -36,6 +37,7 @@ 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'; @@ -86,6 +88,24 @@ 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 ; + } + + return ( + + ); +}; + const CompetitionRedirect = ({ to }: { to: string }) => { const { competitionId } = useParams() as { competitionId: string }; @@ -103,6 +123,14 @@ const Navigation = () => { }> } /> + } + /> + } + /> } /> } /> } /> @@ -142,6 +170,16 @@ const Navigation = () => { Path not resolved

} />
} /> + {FEATURE_FLAGS.personalUserPage && ( + <> + } /> + } + /> + } /> + + )} } /> } /> } /> diff --git a/src/containers/MyCompetitions/MyCompetitions.query.ts b/src/containers/MyCompetitions/MyCompetitions.query.ts index ecc0b8b..1be874c 100644 --- a/src/containers/MyCompetitions/MyCompetitions.query.ts +++ b/src/containers/MyCompetitions/MyCompetitions.query.ts @@ -5,9 +5,9 @@ import { fetchUserWithCompetitions, UserCompsResponse } from '@/lib/api'; import { FIVE_MINUTES } from '@/lib/constants'; import { getLocalStorage, setLocalStorage } from '@/lib/localStorage'; -export const useMyCompetitionsQuery = (userId?: number) => { +export const useMyCompetitionsQuery = (userId?: number, options: { enabled?: boolean } = {}) => { const { data, ...props } = useQuery({ - queryKey: ['userCompetitions'], + queryKey: ['userCompetitions', userId], queryFn: async () => { const res = await fetchUserWithCompetitions(userId!.toString()); @@ -16,10 +16,15 @@ export const useMyCompetitionsQuery = (userId?: number) => { return res; }, + staleTime: FIVE_MINUTES, gcTime: FIVE_MINUTES, - enabled: !!userId, + enabled: Boolean(userId && (options.enabled ?? true)), initialData: () => { const user = JSON.parse(getLocalStorage('user') || 'null') as User; + if (user?.id !== userId) { + return undefined; + } + const upcoming_competitions = JSON.parse( getLocalStorage('my.upcoming_competitions') || '[]', ) as ApiCompetition[]; diff --git a/src/hooks/queries/useWcif.ts b/src/hooks/queries/useWcif.ts index 7e6096c..2bca2c8 100644 --- a/src/hooks/queries/useWcif.ts +++ b/src/hooks/queries/useWcif.ts @@ -21,7 +21,10 @@ export const useWcif = (competitionId?: string) => queryClient .getQueryData>(['upcomingCompetitions']) ?.pages?.flat() || []; - const myUpcomingComps = queryClient?.getQueryData(['userCompetitions']); + const myUpcomingComps = queryClient + ?.getQueriesData({ queryKey: ['userCompetitions'] }) + .map(([, data]) => data) + .find(Boolean); const allComps = [ ...upcomingComps, diff --git a/src/layouts/RootLayout/Header.tsx b/src/layouts/RootLayout/Header.tsx index 260a2ee..b0cf4c2 100644 --- a/src/layouts/RootLayout/Header.tsx +++ b/src/layouts/RootLayout/Header.tsx @@ -4,6 +4,8 @@ import { useTranslation } from 'react-i18next'; import { Link, useParams } from 'react-router-dom'; import { Popover } from 'react-tiny-popover'; import { useCompetition } from '@/hooks/queries/useCompetition'; +import { UserCompsResponse } from '@/lib/api'; +import { FEATURE_FLAGS } from '@/lib/featureFlags'; import { useAuth } from '@/providers/AuthProvider'; import { useWCIF } from '@/providers/WCIFProvider'; @@ -30,13 +32,15 @@ export default function Header() { const upcomingCompetitions = queryClient.getQueryData<{ pages: CondensedApiCompetiton[][] }>([ 'upcomingCompetitions', ]); - const myCompetitions = queryClient.getQueryData<{ pages: CondensedApiCompetiton[][] }>([ - 'userCompetitions', - ]); + const myCompetitions = queryClient + .getQueriesData({ queryKey: ['userCompetitions'] }) + .map(([, data]) => data) + .find(Boolean); const allCompetitions = [ ...(upcomingCompetitions?.pages?.flat() || []), - ...(myCompetitions?.pages?.flat() || []), + ...(myCompetitions?.upcoming_competitions || []), + ...(myCompetitions?.ongoing_competitions || []), ]; const competition = allCompetitions?.find((c) => c.id === competitionId); @@ -73,6 +77,11 @@ export default function Header() {
setIsPopoverOpen(!isPopoverOpen)}> + {FEATURE_FLAGS.personalUserPage && ( + + My profile + + )} Settings diff --git a/src/lib/api.ts b/src/lib/api.ts index 5048d46..ed31359 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -70,9 +70,30 @@ export interface WcaCompetitionResult { attempts: number[]; } +export type WcaPersonCompetition = Pick< + ApiCompetition, + | 'id' + | 'name' + | 'short_name' + | 'city' + | 'country_iso2' + | 'start_date' + | 'end_date' + | 'announced_at' + | 'cancelled_at' + | 'latitude_degrees' + | 'longitude_degrees' + | 'venue_address' + | 'venue_details' + | 'website' +>; + export const fetchCompetitionResults = async (competitionId: string) => wcaApiFetch(`/competitions/${competitionId}/results`); +export const fetchPersonCompetitions = async (wcaId: string) => + wcaApiFetch(`/persons/${wcaId}/competitions`); + export const fetchCompetition = async (competitionId: string) => await wcaApiFetch(`/competitions/${competitionId}`); diff --git a/src/lib/featureFlags.ts b/src/lib/featureFlags.ts new file mode 100644 index 0000000..04ea327 --- /dev/null +++ b/src/lib/featureFlags.ts @@ -0,0 +1,8 @@ +const enabledValues = new Set(['1', 'true', 'yes', 'on']); + +const isEnabled = (value: string | undefined) => + value ? enabledValues.has(value.toLowerCase()) : false; + +export const FEATURE_FLAGS = { + personalUserPage: isEnabled(import.meta.env.VITE_FEATURE_PERSONAL_USER_PAGE), +}; diff --git a/src/pages/User/components/CompetitionsTab.tsx b/src/pages/User/components/CompetitionsTab.tsx new file mode 100644 index 0000000..c3187ab --- /dev/null +++ b/src/pages/User/components/CompetitionsTab.tsx @@ -0,0 +1,134 @@ +import { Link } from 'react-router-dom'; +import { WcaPersonCompetition } from '@/lib/api'; +import { getPersonResultsPath } from '../userProfileData'; + +interface CompetitionsTabProps { + competitions: ApiCompetition[]; + assignmentStatus: Record | undefined; + isCheckingAssignments: boolean; + pastCompetitions: WcaPersonCompetition[] | undefined; + isLoadingPastCompetitions: boolean; + wcaId?: string; +} + +const formatCompetitionDates = (competition: Pick) => { + const start = new Date(`${competition.start_date}T00:00:00`); + const end = new Date(`${competition.end_date}T00:00:00`); + + if (competition.start_date === competition.end_date) { + return start.toLocaleDateString(undefined, { + month: 'short', + day: 'numeric', + year: 'numeric', + }); + } + + return `${start.toLocaleDateString(undefined, { + month: 'short', + day: 'numeric', + })} - ${end.toLocaleDateString(undefined, { + month: 'short', + day: 'numeric', + year: 'numeric', + })}`; +}; + +export function CompetitionsTab({ + competitions, + assignmentStatus, + isCheckingAssignments, + pastCompetitions, + isLoadingPastCompetitions, + wcaId, +}: CompetitionsTabProps) { + const visibleCompetitionIds = new Set(competitions.map((competition) => competition.id)); + const sortedPastCompetitions = [...(pastCompetitions || [])] + .filter((competition) => !visibleCompetitionIds.has(competition.id)) + .sort((a, b) => new Date(b.start_date).getTime() - new Date(a.start_date).getTime()); + + return ( +
+
+

Upcoming competitions

+ {competitions.length === 0 ? ( +

No upcoming competitions.

+ ) : ( + competitions.map((competition) => { + const hasAssignments = assignmentStatus?.[competition.id]; + const statusText = + hasAssignments == null && isCheckingAssignments + ? 'Checking assignments' + : hasAssignments + ? 'Assignments generated' + : 'No assignments yet'; + + return ( + +
+
+
{competition.name}
+
+ {competition.city}, {competition.country_iso2} -{' '} + {formatCompetitionDates(competition)} +
+
+ + {statusText} + +
+ + ); + }) + )} +
+ +
+

Past competitions

+ {isLoadingPastCompetitions ? ( +

Loading past competitions...

+ ) : sortedPastCompetitions.length === 0 ? ( +

No past competitions.

+ ) : ( + sortedPastCompetitions.map((competition) => { + const to = getPersonResultsPath(competition.id, wcaId); + const content = ( +
+
{competition.name}
+
+ {formatCompetitionDates(competition)} +
+
+ ); + + if (!to) { + return ( +
+ {content} +
+ ); + } + + return ( + + {content} + + ); + }) + )} +
+
+ ); +} diff --git a/src/pages/User/components/ProfileHeader.tsx b/src/pages/User/components/ProfileHeader.tsx new file mode 100644 index 0000000..9b37675 --- /dev/null +++ b/src/pages/User/components/ProfileHeader.tsx @@ -0,0 +1,38 @@ +import { hasFlag } from 'country-flag-icons'; +import getUnicodeFlagIcon from 'country-flag-icons/unicode'; + +const fallbackAvatarUrl = + 'https://assets.worldcubeassociation.org/assets/326cd49/assets/missing_avatar_thumb-d77f478a307a91a9d4a083ad197012a391d5410f6dd26cb0b0e3118a5de71438.png'; + +interface ProfileHeaderProps { + countryIso2?: string; + user: User; +} + +export function ProfileHeader({ countryIso2, user }: ProfileHeaderProps) { + const avatarUrl = user.avatar?.thumb_url || user.avatar?.url || fallbackAvatarUrl; + + return ( +
+ + {user.name} + +
+
+

{user.name}

+
+
+ {countryIso2 && hasFlag(countryIso2) && ( +
+ {getUnicodeFlagIcon(countryIso2)} +
+ )} + {user.wca_id && {user.wca_id}} +
+
+
+ ); +} diff --git a/src/pages/User/components/ProfileTabs.tsx b/src/pages/User/components/ProfileTabs.tsx new file mode 100644 index 0000000..823bfee --- /dev/null +++ b/src/pages/User/components/ProfileTabs.tsx @@ -0,0 +1,42 @@ +import classNames from 'classnames'; +import { Link } from 'react-router-dom'; +import { UserPageTab } from '../userProfileData'; + +interface ProfileTabsProps { + activeTab: UserPageTab; +} + +const tabs: { id: UserPageTab; label: string }[] = [ + { id: 'competitions', label: 'Competitions' }, + { id: 'results', label: 'Results' }, + { id: 'records', label: 'Records' }, +]; + +const tabPath = (tab: UserPageTab) => `/me/${tab}`; + +export function ProfileTabs({ activeTab }: ProfileTabsProps) { + return ( + + ); +} diff --git a/src/pages/User/components/RecordsTab.tsx b/src/pages/User/components/RecordsTab.tsx new file mode 100644 index 0000000..a466295 --- /dev/null +++ b/src/pages/User/components/RecordsTab.tsx @@ -0,0 +1,146 @@ +import { events } from '@/lib/events'; +import { + formatUserResult, + getUserEventName, + WcaPersonalRecords, + WcaPersonalRecord, +} from '../userProfileData'; + +interface RecordsTabProps { + records: WcaPersonalRecords | undefined; + isLoading: boolean; + error: unknown; +} + +type RankingType = 'single' | 'average'; + +const eventOrder = new Map(events.map((event, index) => [event.id, index])); + +const rankValue = (rank: number | undefined) => (rank ? rank.toLocaleString() : '-'); + +const resultValue = ( + eventId: string, + rankingType: RankingType, + record: WcaPersonalRecord | undefined, +) => formatUserResult(eventId, rankingType, record?.best); + +export function RecordsTab({ records, isLoading, error }: RecordsTabProps) { + if (isLoading) { + return

Loading records...

; + } + + if (error) { + return

Unable to load records.

; + } + + const entries = Object.entries(records || {}).sort(([a], [b]) => { + const eventOrderDifference = + (eventOrder.get(a) ?? Number.MAX_SAFE_INTEGER) - + (eventOrder.get(b) ?? Number.MAX_SAFE_INTEGER); + + return eventOrderDifference || getUserEventName(a).localeCompare(getUserEventName(b)); + }); + + if (entries.length === 0) { + return

No records available.

; + } + + return ( +
+
+ + + + + + + + + + + + + + + + {entries.map(([eventId, record]) => ( + + + + + + + + + + + + ))} + +
EventSingleNRCRWRAverageWRCRNR
+ + {resultValue(eventId, 'single', record.single)} + + {rankValue(record.single?.country_rank)} + + {rankValue(record.single?.continent_rank)} + + {rankValue(record.single?.world_rank)} + + {resultValue(eventId, 'average', record.average)} + + {rankValue(record.average?.world_rank)} + + {rankValue(record.average?.continent_rank)} + + {rankValue(record.average?.country_rank)} +
+
+ +
+ {entries.map(([eventId, record]) => ( +
+
+
+
+ +
+
Single
+
+
+ {resultValue(eventId, 'single', record.single)} +
+
+ NR {rankValue(record.single?.country_rank)} / CR{' '} + {rankValue(record.single?.continent_rank)} / WR{' '} + {rankValue(record.single?.world_rank)} +
+
+ +
Average
+
+
+ {resultValue(eventId, 'average', record.average)} +
+
+ NR {rankValue(record.average?.country_rank)} / CR{' '} + {rankValue(record.average?.continent_rank)} / WR{' '} + {rankValue(record.average?.world_rank)} +
+
+
+
+
+ ))} +
+
+ ); +} diff --git a/src/pages/User/components/ResultsTab.tsx b/src/pages/User/components/ResultsTab.tsx new file mode 100644 index 0000000..b627890 --- /dev/null +++ b/src/pages/User/components/ResultsTab.tsx @@ -0,0 +1,163 @@ +import classNames from 'classnames'; +import { useEffect, useMemo, useState } from 'react'; +import { Link } from 'react-router-dom'; +import { WcaCompetitionResult } from '@/lib/api'; +import { + compareUserResultsByNewest, + formatUserResult, + getEventResultSummaries, + getHistoricalPrFlags, + getPersonResultsPath, + getRoundTypeName, + getUserEventName, +} from '../userProfileData'; + +interface ResultsTabProps { + results: WcaCompetitionResult[] | undefined; + isLoading: boolean; + error: unknown; + wcaId?: string; +} + +function EventIconPicker({ + eventIds, + selectedEventId, + onSelect, +}: { + eventIds: string[]; + selectedEventId: string; + onSelect: (eventId: string) => void; +}) { + return ( +
+ {eventIds.map((eventId) => { + const isSelected = eventId === selectedEventId; + + return ( + + ); + })} +
+ ); +} + +export function ResultsTab({ results, isLoading, error, wcaId }: ResultsTabProps) { + const eventSummaries = useMemo(() => getEventResultSummaries(results || []), [results]); + const eventIds = useMemo( + () => eventSummaries.map((summary) => summary.eventId), + [eventSummaries], + ); + const [selectedEventId, setSelectedEventId] = useState(''); + + useEffect(() => { + if (eventIds.length === 0) { + return; + } + + if (!selectedEventId || !eventIds.includes(selectedEventId)) { + setSelectedEventId(eventIds[0]); + } + }, [eventIds, selectedEventId]); + + if (isLoading) { + return

Loading results...

; + } + + if (error) { + return

Unable to load results.

; + } + + if (!results || results.length === 0) { + return

No results available.

; + } + + const selectedEventResults = [...results] + .filter((result) => result.event_id === selectedEventId) + .sort(compareUserResultsByNewest); + const historicalPrFlags = getHistoricalPrFlags(selectedEventResults); + const selectedEventName = selectedEventId ? getUserEventName(selectedEventId) : ''; + + return ( +
+ + + {selectedEventId && ( +
+ + + + + + + + + + + + + + + {selectedEventResults.map((result) => { + const to = getPersonResultsPath(result.competition_id, wcaId); + const prFlags = historicalPrFlags[result.id]; + + return ( + + + + + + + + ); + })} + +
CompetitionRoundPlaceSingleAverage
+ + {selectedEventName} +
+ {to ? ( + + {result.competition_id} + + ) : ( + result.competition_id + )} + {getRoundTypeName(result.round_type_id)}{result.pos} + {formatUserResult(result.event_id, 'single', result.best)} + + {formatUserResult(result.event_id, 'average', result.average)} +
+
+ )} +
+ ); +} diff --git a/src/pages/User/index.stories.tsx b/src/pages/User/index.stories.tsx new file mode 100644 index 0000000..fcc06b0 --- /dev/null +++ b/src/pages/User/index.stories.tsx @@ -0,0 +1,150 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { QueryClient } from '@tanstack/react-query'; +import { WcaCompetitionResult, WcaPersonCompetition } from '@/lib/api'; +import { + makeAppContainerDecorator, + storybookAppUser, + storybookUserCompetitions, +} from '@/storybook/appStorybook'; +import UserPage from './index'; +import { WcaPersonResponse } from './userProfileData'; + +const profile: WcaPersonResponse = { + person: { + country_iso2: 'US', + }, + personal_records: { + '333': { + single: { + best: 486, + world_rank: 236, + continent_rank: 62, + country_rank: 53, + }, + average: { + best: 675, + world_rank: 360, + continent_rank: 99, + country_rank: 83, + }, + }, + '222': { + single: { + best: 91, + world_rank: 693, + continent_rank: 194, + country_rank: 151, + }, + }, + }, +}; + +const results: WcaCompetitionResult[] = [ + { + id: 1, + pos: 5, + best: 958, + average: 1108, + name: storybookAppUser.name, + country_iso2: 'US', + competition_id: 'LakewoodSpring2026', + event_id: '333', + round_type_id: '1', + format_id: 'a', + wca_id: storybookAppUser.wca_id, + attempts: [], + }, + { + id: 2, + pos: 3, + best: 91, + average: 218, + name: storybookAppUser.name, + country_iso2: 'US', + competition_id: 'LakewoodSpring2026', + event_id: '222', + round_type_id: 'f', + format_id: 'a', + wca_id: storybookAppUser.wca_id, + attempts: [], + }, +]; + +const pastCompetitions: WcaPersonCompetition[] = [ + { + id: 'LakewoodSpring2026', + name: 'Lakewood Spring 2026', + short_name: 'Lakewood Spring 2026', + city: 'Lakewood, Washington', + country_iso2: 'US', + start_date: '2026-03-14', + end_date: '2026-03-15', + announced_at: '2025-12-10T00:00:00Z', + cancelled_at: '', + latitude_degrees: 47.1718, + longitude_degrees: -122.5185, + venue_address: '5000 Steilacoom Blvd SW', + venue_details: '', + website: 'https://www.worldcubeassociation.org/competitions/LakewoodSpring2026', + }, + { + id: 'TacomaWinter2025', + name: 'Tacoma Winter 2025', + short_name: 'Tacoma Winter 2025', + city: 'Tacoma, Washington', + country_iso2: 'US', + start_date: '2025-12-06', + end_date: '2025-12-06', + announced_at: '2025-08-01T00:00:00Z', + cancelled_at: '', + latitude_degrees: 47.2529, + longitude_degrees: -122.4443, + venue_address: '1500 Commerce St', + venue_details: '', + website: 'https://www.worldcubeassociation.org/competitions/TacomaWinter2025', + }, +]; + +const meta = { + title: 'Pages/User/Profile', + component: UserPage, + decorators: [ + makeAppContainerDecorator({ + currentUser: storybookAppUser, + userCompetitions: storybookUserCompetitions, + configureQueryClient: (queryClient: QueryClient) => { + queryClient.setQueryData(['user-profile', storybookAppUser.wca_id], profile); + queryClient.setQueryData(['user-results', storybookAppUser.wca_id], results); + queryClient.setQueryData( + ['user-past-competitions', storybookAppUser.wca_id], + pastCompetitions, + ); + queryClient.setQueryData( + [ + 'user-assignment-status', + storybookAppUser.id, + [ + ...storybookUserCompetitions.upcoming_competitions, + ...storybookUserCompetitions.ongoing_competitions, + ] + .map((competition) => competition.id) + .join(','), + ], + { + PortlandAutumn2026: true, + SeattleSummerOpen2026: false, + }, + ); + }, + }), + ], + parameters: { + layout: 'fullscreen', + }, +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = {}; diff --git a/src/pages/User/index.tsx b/src/pages/User/index.tsx new file mode 100644 index 0000000..5d79d59 --- /dev/null +++ b/src/pages/User/index.tsx @@ -0,0 +1,167 @@ +import { QueryClient, useQuery, useQueryClient } from '@tanstack/react-query'; +import { Navigate, useParams } from 'react-router-dom'; +import { Container } from '@/components'; +import { useMyCompetitionsQuery } from '@/containers/MyCompetitions/MyCompetitions.query'; +import { fetchPersonCompetitions, fetchWcif, WcaCompetitionResult, wcaApiFetch } from '@/lib/api'; +import { FIVE_MINUTES } from '@/lib/constants'; +import { useAuth } from '@/providers/AuthProvider'; +import { CompetitionsTab } from './components/CompetitionsTab'; +import { ProfileHeader } from './components/ProfileHeader'; +import { ProfileTabs } from './components/ProfileTabs'; +import { RecordsTab } from './components/RecordsTab'; +import { ResultsTab } from './components/ResultsTab'; +import { getPersonalRecords, UserPageTab, WcaPersonResponse } from './userProfileData'; + +const isUserPageTab = (tab: string | undefined): tab is UserPageTab => + tab === 'competitions' || tab === 'results' || tab === 'records'; + +const ONE_HOUR = 60 * 60 * 1000; +const ONE_DAY = 24 * ONE_HOUR; + +const getAssignmentStatus = async ( + competitions: ApiCompetition[], + userId: number, + queryClient: QueryClient, +) => { + const statuses = await Promise.all( + competitions.map(async (competition) => { + try { + const wcif = await queryClient.fetchQuery({ + queryKey: ['wcif', competition.id], + queryFn: () => fetchWcif(competition.id), + staleTime: FIVE_MINUTES, + gcTime: ONE_DAY, + }); + const person = wcif.persons.find((p) => p.wcaUserId === userId); + + return { + competitionId: competition.id, + hasAssignments: (person?.assignments?.length || 0) > 0, + }; + } catch { + return { + competitionId: competition.id, + hasAssignments: false, + }; + } + }), + ); + + return statuses.reduce>((acc, status) => { + acc[status.competitionId] = status.hasAssignments; + return acc; + }, {}); +}; + +export default function UserPage() { + const { tab: tabParam, resultsMode: resultsModeParam } = useParams<{ + tab?: string; + resultsMode?: string; + }>(); + const { user } = useAuth(); + const queryClient = useQueryClient(); + const tab = tabParam || 'competitions'; + const isCompetitionsTab = tab === 'competitions'; + const isResultsTab = tab === 'results'; + const isRecordsTab = tab === 'records'; + const { competitions, isLoading: isLoadingCompetitions } = useMyCompetitionsQuery(user?.id, { + enabled: isCompetitionsTab, + }); + const competitionIds = competitions.map((competition) => competition.id).join(','); + + const assignmentStatusQuery = useQuery({ + queryKey: ['user-assignment-status', user?.id, competitionIds], + enabled: Boolean(isCompetitionsTab && user?.id && competitions.length > 0), + queryFn: () => getAssignmentStatus(competitions, user!.id, queryClient), + staleTime: FIVE_MINUTES, + }); + + const resultsQuery = useQuery({ + queryKey: ['user-results', user?.wca_id], + enabled: Boolean(isResultsTab && user?.wca_id), + queryFn: () => wcaApiFetch(`/persons/${user!.wca_id}/results`), + staleTime: ONE_HOUR, + gcTime: ONE_DAY, + }); + + const pastCompetitionsQuery = useQuery({ + queryKey: ['user-past-competitions', user?.wca_id], + enabled: Boolean(isCompetitionsTab && user?.wca_id), + queryFn: () => fetchPersonCompetitions(user!.wca_id), + staleTime: ONE_DAY, + gcTime: ONE_DAY, + }); + + const recordsQuery = useQuery({ + queryKey: ['user-profile', user?.wca_id], + enabled: Boolean(user?.wca_id), + queryFn: () => wcaApiFetch(`/persons/${user!.wca_id}`), + staleTime: isRecordsTab ? ONE_HOUR : ONE_DAY, + gcTime: ONE_DAY, + }); + + if (!isUserPageTab(tab)) { + return ; + } + + if (tab !== 'results' && resultsModeParam) { + return ; + } + + if (tab === 'results' && resultsModeParam) { + return ; + } + + if (!user) { + return ( +
+ +

Please log in to view your profile.

+
+
+ ); + } + + return ( +
+ + + + + {tab === 'competitions' && ( + <> + {isLoadingCompetitions ? ( +

Loading competitions...

+ ) : ( + + )} + + )} + + {tab === 'results' && ( + + )} + + {tab === 'records' && ( + + )} +
+
+ ); +} diff --git a/src/pages/User/userProfileData.test.ts b/src/pages/User/userProfileData.test.ts new file mode 100644 index 0000000..c64e499 --- /dev/null +++ b/src/pages/User/userProfileData.test.ts @@ -0,0 +1,185 @@ +import { WcaCompetitionResult } from '@/lib/api'; +import { + compareUserResultsByNewest, + formatUserResult, + getCompetitionResultSummaries, + getEventResultSummaries, + getHistoricalPrFlags, + getPersonalRecords, + getRoundTypeName, +} from './userProfileData'; + +jest.mock('@/lib/events', () => ({ + getEventName: (eventId: string) => eventId, + isOfficialEventId: (eventId: string) => ['222', '333'].includes(eventId), + isRankedBySingle: () => false, +})); + +const result = (overrides: Partial): WcaCompetitionResult => ({ + id: 1, + pos: 1, + best: 1000, + average: 1200, + name: 'Test User', + country_iso2: 'US', + competition_id: 'TestComp2026', + event_id: '333', + round_type_id: 'f', + format_id: 'a', + wca_id: '2010TEST01', + attempts: [], + ...overrides, +}); + +describe('userProfileData', () => { + describe('getCompetitionResultSummaries', () => { + it('aggregates results by competition', () => { + const summaries = getCompetitionResultSummaries([ + result({ competition_id: 'Alpha2026', event_id: '333', best: 1200, average: 1300 }), + result({ competition_id: 'Alpha2026', event_id: '222', best: 300, average: 360 }), + result({ competition_id: 'Beta2026', event_id: '333', best: 1000 }), + ]); + + expect(summaries).toEqual([ + expect.objectContaining({ + competitionId: 'Alpha2026', + roundCount: 2, + eventCount: 2, + }), + expect.objectContaining({ + competitionId: 'Beta2026', + roundCount: 1, + eventCount: 1, + }), + ]); + expect(summaries[0].bestResult.event_id).toBe('222'); + }); + }); + + describe('getEventResultSummaries', () => { + it('aggregates results by event', () => { + const summaries = getEventResultSummaries([ + result({ competition_id: 'Alpha2026', event_id: '333', best: 1200, average: 1400 }), + result({ competition_id: 'Beta2026', event_id: '333', best: 1000, average: 1300 }), + result({ competition_id: 'Beta2026', event_id: '222', best: 280, average: 360 }), + ]); + + expect(summaries).toEqual([ + expect.objectContaining({ + eventId: '222', + roundCount: 1, + competitionCount: 1, + bestSingle: 280, + bestAverage: 360, + }), + expect.objectContaining({ + eventId: '333', + roundCount: 2, + competitionCount: 2, + bestSingle: 1000, + bestAverage: 1300, + }), + ]); + }); + }); + + describe('formatUserResult', () => { + it('formats timed results and missing results', () => { + expect(formatUserResult('333', 'single', 1234)).toBe('12.34'); + expect(formatUserResult('333', 'average', 0)).toBe('-'); + }); + }); + + describe('getRoundTypeName', () => { + it('formats WCA round type ids', () => { + expect(getRoundTypeName('1')).toBe('First round'); + expect(getRoundTypeName('f')).toBe('Final'); + }); + }); + + describe('compareUserResultsByNewest', () => { + it('sorts newer competition ids first', () => { + const sorted = [ + result({ competition_id: 'Alpha2025' }), + result({ competition_id: 'Beta2026' }), + ].sort(compareUserResultsByNewest); + + expect(sorted.map((item) => item.competition_id)).toEqual(['Beta2026', 'Alpha2025']); + }); + }); + + describe('getHistoricalPrFlags', () => { + it('flags singles and averages when they become new personal records', () => { + const flags = getHistoricalPrFlags([ + result({ id: 4, competition_id: 'Delta2026', round_type_id: '1', best: 1100, average: 0 }), + result({ + id: 2, + competition_id: 'Beta2025', + round_type_id: '1', + best: 1200, + average: 1400, + }), + result({ + id: 3, + competition_id: 'Charlie2025', + round_type_id: '1', + best: 1300, + average: 1300, + }), + result({ + id: 1, + competition_id: 'Alpha2025', + round_type_id: '1', + best: 1500, + average: 1600, + }), + ]); + + expect(flags).toEqual({ + 1: { single: true, average: true }, + 2: { single: true, average: true }, + 3: { single: false, average: true }, + 4: { single: true, average: false }, + }); + }); + }); + + describe('getPersonalRecords', () => { + it('returns top-level records from the WCA person response', () => { + expect( + getPersonalRecords({ + person: {}, + personal_records: { + '333': { + single: { + best: 1000, + world_rank: 1, + continent_rank: 1, + country_rank: 1, + }, + }, + }, + }), + ).toHaveProperty('333'); + }); + + it('falls back to nested records for seeded data', () => { + expect( + getPersonalRecords({ + person: { + personal_records: { + '222': { + single: { + best: 200, + world_rank: 2, + continent_rank: 2, + country_rank: 2, + }, + }, + }, + }, + }), + ).toHaveProperty('222'); + }); + }); +}); diff --git a/src/pages/User/userProfileData.ts b/src/pages/User/userProfileData.ts new file mode 100644 index 0000000..bdc9019 --- /dev/null +++ b/src/pages/User/userProfileData.ts @@ -0,0 +1,255 @@ +import { EventId, RankingType, AttemptResult } from '@wca/helpers'; +import { WcaCompetitionResult } from '@/lib/api'; +import { getEventName, isOfficialEventId, isRankedBySingle } from '@/lib/events'; +import { renderCentiseconds, renderResultByEventId } from '@/lib/results'; + +export type UserPageTab = 'competitions' | 'results' | 'records'; + +export interface WcaPersonalRecord { + id?: number; + person_id?: string; + event_id?: string; + best: number; + world_rank: number; + continent_rank: number; + country_rank: number; +} + +export type WcaPersonalRecords = Record< + string, + { + single?: WcaPersonalRecord; + average?: WcaPersonalRecord; + } +>; + +export interface WcaPersonResponse { + person: { + country_iso2?: string; + personal_records?: WcaPersonalRecords; + }; + personal_records?: WcaPersonalRecords; +} + +export const getPersonalRecords = (profile: WcaPersonResponse | undefined) => + profile?.personal_records || profile?.person.personal_records || {}; + +export const getPersonResultsPath = (competitionId: string, wcaId: string | undefined) => + wcaId ? `/competitions/${competitionId}/persons/wca/${wcaId}/results` : undefined; + +export interface CompetitionResultSummary { + competitionId: string; + roundCount: number; + eventCount: number; + bestResult: WcaCompetitionResult; +} + +export interface EventResultSummary { + eventId: string; + eventName: string; + roundCount: number; + competitionCount: number; + bestSingle: number; + bestAverage: number; +} + +export interface HistoricalPrFlags { + single: boolean; + average: boolean; +} + +const roundTypeLabels: Record = { + '0': 'Qualification round', + '1': 'First round', + '2': 'Second round', + '3': 'Third round', + b: 'B Final', + c: 'Combined round', + d: 'Combined first round', + e: 'Combined second round', + f: 'Final', +}; + +const resultValue = (result: WcaCompetitionResult) => + result.average > 0 && !isRankedBySingle(result.event_id as EventId) + ? result.average + : result.best; + +const compareResults = (a: WcaCompetitionResult, b: WcaCompetitionResult) => { + if (a.event_id === '333mbf' && b.event_id === '333mbf') { + return b.best - a.best; + } + + return resultValue(a) - resultValue(b); +}; + +const compareAttemptResults = (eventId: string, a: number, b: number) => { + if (eventId === '333mbf') { + return b - a; + } + + return a - b; +}; + +export const formatUserResult = ( + eventId: string, + rankingType: RankingType, + value: number | undefined, +) => { + if (!value || value <= 0) { + return '-'; + } + + if (isOfficialEventId(eventId)) { + return renderResultByEventId(eventId, rankingType, value as AttemptResult); + } + + return eventId === '333fm' && rankingType === 'average' + ? (value / 100).toFixed(2) + : renderCentiseconds(value); +}; + +export const getUserEventName = (eventId: string) => + isOfficialEventId(eventId) ? getEventName(eventId) : eventId.toUpperCase(); + +export const getRoundTypeName = (roundTypeId: string) => + roundTypeLabels[roundTypeId] ?? roundTypeId.toUpperCase(); + +export const compareUserResultsByNewest = (a: WcaCompetitionResult, b: WcaCompetitionResult) => + b.competition_id.localeCompare(a.competition_id) || + a.round_type_id.localeCompare(b.round_type_id); + +const compareUserResultsByOldest = (a: WcaCompetitionResult, b: WcaCompetitionResult) => + a.competition_id.localeCompare(b.competition_id) || + a.round_type_id.localeCompare(b.round_type_id); + +export const getHistoricalPrFlags = ( + results: WcaCompetitionResult[], +): Record => { + let bestSingle = 0; + let bestAverage = 0; + + return [...results] + .sort(compareUserResultsByOldest) + .reduce>((acc, result) => { + const isSinglePr = + result.best > 0 && + (!bestSingle || compareAttemptResults(result.event_id, result.best, bestSingle) < 0); + const isAveragePr = + result.average > 0 && + (!bestAverage || compareAttemptResults(result.event_id, result.average, bestAverage) < 0); + + if (isSinglePr) { + bestSingle = result.best; + } + + if (isAveragePr) { + bestAverage = result.average; + } + + acc[result.id] = { + single: isSinglePr, + average: isAveragePr, + }; + + return acc; + }, {}); +}; + +export const getCompetitionResultSummaries = ( + results: WcaCompetitionResult[], +): CompetitionResultSummary[] => { + const summariesByCompetition = new Map< + string, + { + roundCount: number; + eventIds: Set; + bestResult: WcaCompetitionResult; + } + >(); + + results.forEach((result) => { + const existing = summariesByCompetition.get(result.competition_id); + + if (!existing) { + summariesByCompetition.set(result.competition_id, { + roundCount: 1, + eventIds: new Set([result.event_id]), + bestResult: result, + }); + return; + } + + existing.roundCount += 1; + existing.eventIds.add(result.event_id); + + if (compareResults(result, existing.bestResult) < 0) { + existing.bestResult = result; + } + }); + + return Array.from(summariesByCompetition.entries()) + .map(([competitionId, summary]) => ({ + competitionId, + roundCount: summary.roundCount, + eventCount: summary.eventIds.size, + bestResult: summary.bestResult, + })) + .sort((a, b) => a.competitionId.localeCompare(b.competitionId)); +}; + +export const getEventResultSummaries = (results: WcaCompetitionResult[]): EventResultSummary[] => { + const summariesByEvent = new Map< + string, + { + roundCount: number; + competitionIds: Set; + bestSingle: number; + bestAverage: number; + } + >(); + + results.forEach((result) => { + const existing = summariesByEvent.get(result.event_id); + + if (!existing) { + summariesByEvent.set(result.event_id, { + roundCount: 1, + competitionIds: new Set([result.competition_id]), + bestSingle: result.best, + bestAverage: result.average, + }); + return; + } + + existing.roundCount += 1; + existing.competitionIds.add(result.competition_id); + + if ( + result.best > 0 && + (!existing.bestSingle || + compareAttemptResults(result.event_id, result.best, existing.bestSingle) < 0) + ) { + existing.bestSingle = result.best; + } + + if ( + result.average > 0 && + (!existing.bestAverage || + compareAttemptResults(result.event_id, result.average, existing.bestAverage) < 0) + ) { + existing.bestAverage = result.average; + } + }); + + return Array.from(summariesByEvent.entries()) + .map(([eventId, summary]) => ({ + eventId, + eventName: getUserEventName(eventId), + roundCount: summary.roundCount, + competitionCount: summary.competitionIds.size, + bestSingle: summary.bestSingle, + bestAverage: summary.bestAverage, + })) + .sort((a, b) => a.eventName.localeCompare(b.eventName)); +}; diff --git a/src/providers/AuthProvider/AuthProvider.tsx b/src/providers/AuthProvider/AuthProvider.tsx index 5343e95..e46ac1c 100644 --- a/src/providers/AuthProvider/AuthProvider.tsx +++ b/src/providers/AuthProvider/AuthProvider.tsx @@ -49,21 +49,22 @@ export function AuthProvider({ children }: PropsWithChildren) { localStorage.removeItem(localStorageKey('user')); localStorage.removeItem(localStorageKey('my.upcoming_competitions')); localStorage.removeItem(localStorageKey('my.ongoing_competitions')); + queryClient.removeQueries({ queryKey: ['userCompetitions'] }); + queryClient.removeQueries({ queryKey: ['user-results'] }); + queryClient.removeQueries({ queryKey: ['user-past-competitions'] }); + queryClient.removeQueries({ queryKey: ['user-profile'] }); + queryClient.removeQueries({ queryKey: ['user-assignment-status'] }); }; const signInAs = useCallback( (userId: number) => { - queryClient - .getQueryCache() - .find({ - queryKey: ['userCompetitions'], - }) - ?.reset(); + queryClient.removeQueries({ queryKey: ['userCompetitions'] }); fetchUserWithCompetitions(userId.toString()).then( ({ user, ongoing_competitions, upcoming_competitions }) => { setUser(user); - queryClient.setQueryData(['userCompetitions'], { + queryClient.setQueryData(['userCompetitions', user.id], { + user, ongoing_competitions, upcoming_competitions, }); @@ -91,7 +92,8 @@ export function AuthProvider({ children }: PropsWithChildren) { String(Date.now() + Number(hashParams.get('expires_in') ?? 0) * 1000), ); setUserAndSave(me); - queryClient.setQueryData(['userCompetitions'], { + queryClient.setQueryData(['userCompetitions', me.id], { + user: me, ongoing_competitions, upcoming_competitions, }); diff --git a/src/storybook/appStorybook.tsx b/src/storybook/appStorybook.tsx index 4ce1928..9f3bf4f 100644 --- a/src/storybook/appStorybook.tsx +++ b/src/storybook/appStorybook.tsx @@ -21,6 +21,7 @@ const competitionsQueryDocument = gql` `; interface AppStorybookOptions { + configureQueryClient?: (queryClient: QueryClient) => void; currentUser?: User | null; online?: boolean; pinnedCompetitions?: ApiCompetition[]; @@ -43,6 +44,7 @@ interface AppStorybookOptions { type AppStorybookParameters = AppStorybookOptions; const buildStorybookQueryClient = ({ + configureQueryClient, competitionDetails, currentUser, userCompetitions, @@ -57,7 +59,7 @@ const buildStorybookQueryClient = ({ }); if (currentUser && userCompetitions) { - queryClient.setQueryData(['userCompetitions'], { + queryClient.setQueryData(['userCompetitions', currentUser.id], { user: currentUser, upcoming_competitions: userCompetitions.upcoming_competitions, ongoing_competitions: userCompetitions.ongoing_competitions, @@ -75,10 +77,13 @@ const buildStorybookQueryClient = ({ } satisfies InfiniteData); } + configureQueryClient?.(queryClient); + return queryClient; }; function AppStorybookProviders({ + configureQueryClient, currentUser, online, pinnedCompetitions, @@ -92,11 +97,18 @@ function AppStorybookProviders({ () => buildStorybookQueryClient({ competitionDetails, + configureQueryClient, currentUser, userCompetitions, upcomingCompetitionsPages, }), - [competitionDetails, currentUser, upcomingCompetitionsPages, userCompetitions], + [ + competitionDetails, + configureQueryClient, + currentUser, + upcomingCompetitionsPages, + userCompetitions, + ], ); const competitionMocks = useMemo( @@ -257,6 +269,7 @@ export const storybookUpcomingCompetitionsPages: CondensedApiCompetiton[][] = [ export const makeAppContainerDecorator = ({ currentUser = storybookAppUser, + configureQueryClient, online = true, pinnedCompetitions = storybookPinnedCompetitions, competitionDetails = storybookPinnedCompetitions, @@ -270,6 +283,7 @@ export const makeAppContainerDecorator = ({ return (