Skip to content

Commit be474d2

Browse files
committed
Merge beta into main
2 parents 6ead1a7 + 1bec234 commit be474d2

31 files changed

Lines changed: 1106 additions & 49 deletions

.env.example

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
VITE_APP_ENV=
2+
VITE_GA_MEASUREMENT_ID=
3+
VITE_UMAMI_SRC=
4+
VITE_UMAMI_WEBSITE_ID=

src/components/AppUpdatePrompt/AppUpdatePrompt.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ export function AppUpdatePrompt({ onUpdate }: AppUpdatePromptProps) {
99
const { t } = useTranslation();
1010

1111
return (
12-
<div className="pointer-events-none fixed inset-x-0 bottom-4 z-50 flex justify-center px-4">
12+
<div className="safe-area-fixed-bottom pointer-events-none fixed inset-x-0 z-50 flex justify-center px-4">
1313
<section className="pointer-events-auto w-full max-w-sm rounded-md border border-primary bg-panel p-4 shadow-lg">
1414
<div className="space-y-4">
1515
<div className="space-y-2">

src/components/CompetitionList/CompetitionList.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ interface CompetitionListFragmentProps {
1313
loading: boolean;
1414
liveCompetitionIds: string[];
1515
lastFetchedAt?: number;
16+
showWhenEmpty?: boolean;
1617
}
1718

1819
export function CompetitionListFragment({
@@ -21,11 +22,12 @@ export function CompetitionListFragment({
2122
loading,
2223
liveCompetitionIds,
2324
lastFetchedAt,
25+
showWhenEmpty = false,
2426
}: CompetitionListFragmentProps) {
2527
const { t } = useTranslation();
2628
const { pinnedCompetitions } = usePinnedCompetitions();
2729

28-
if (!competitions.length && !loading) {
30+
if (!showWhenEmpty && !competitions.length && !loading) {
2931
return null;
3032
}
3133

src/components/NotifyCompRemoteBar/NotifyCompRemoteBar.tsx

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,11 @@ export function NotifyCompRemoteBar({ competitionId }: NotifyCompRemoteBarProps)
2929
const notifyCompWebSocketStatus = useNotifyCompWebSocketStatus();
3030
const now = useNow();
3131

32-
if (!remote.isAuthenticated || !remote.competition || remote.scheduledActivities.length === 0) {
32+
if (
33+
!remote.isAuthenticated ||
34+
!remote.isCompetitionImported ||
35+
remote.scheduledActivities.length === 0
36+
) {
3337
return null;
3438
}
3539

@@ -50,7 +54,9 @@ export function NotifyCompRemoteBar({ competitionId }: NotifyCompRemoteBarProps)
5054
});
5155
const activeActivities = remote.activeGroups.flatMap((group) => group.scheduledActivities);
5256
const controlsDisabled =
53-
remote.isSaving || !canUseNotifyCompRemoteControls(notifyCompWebSocketStatus.status);
57+
remote.isLoading ||
58+
remote.isSaving ||
59+
!canUseNotifyCompRemoteControls(notifyCompWebSocketStatus.status);
5460

5561
const confirmNextGroup = (group: RemoteActivityGroup) =>
5662
confirm({
@@ -129,10 +135,10 @@ export function NotifyCompRemoteBar({ competitionId }: NotifyCompRemoteBarProps)
129135
return (
130136
<nav
131137
aria-label="Remote control"
132-
className="z-20 w-full border-t border-tertiary-weak bg-panel shadow-md shadow-tertiary-dark print:hidden">
138+
className="safe-area-bottom z-20 w-full border-t border-tertiary-weak bg-panel shadow-md shadow-tertiary-dark print:hidden">
133139
<Container
134140
fullWidth
135-
className="relative flex-row min-h-16 items-center justify-center px-2 py-2">
141+
className="relative flex-row min-h-16 items-center justify-center px-2 pb-0 pt-2">
136142
<div
137143
className={classNames(
138144
'grid w-full max-w-4xl grid-cols-[minmax(0,1fr)_auto_minmax(0,1fr)] items-center gap-2 md:gap-4',

src/containers/About/About.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ export function AboutContainer() {
1616
}, [t]);
1717

1818
return (
19-
<Container className="overflow-auto">
19+
<Container className="safe-area-scroll-bottom overflow-auto">
2020
<div className="flex flex-col items-center">
2121
<div className="flex flex-col space-y-4 p-2 pt-2 type-body">
2222
<p>{t('about.welcome')}</p>
Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
2+
import { renderHook, waitFor } from '@testing-library/react';
3+
import { PropsWithChildren } from 'react';
4+
import { fetchUserWithCompetitions } from '@/lib/api';
5+
import { useMyCompetitionsQuery } from './MyCompetitions.query';
6+
7+
jest.mock('@/hooks/UsePinnedCompetitions', () => ({
8+
usePinnedCompetitions: () => ({
9+
pinnedCompetitions: [],
10+
}),
11+
}));
12+
13+
jest.mock('@/lib/api', () => ({
14+
fetchUserWithCompetitions: jest.fn(),
15+
}));
16+
17+
jest.mock('@/lib/localStorage', () => {
18+
const storage = new Map<string, string>();
19+
20+
return {
21+
getLocalStorage: jest.fn((key: string) => storage.get(key) ?? null),
22+
setLocalStorage: jest.fn((key: string, value: string) => {
23+
storage.set(key, value);
24+
}),
25+
};
26+
});
27+
28+
const user = { id: 13, name: 'Cailyn Sinclair' } as User;
29+
const upcomingCompetition = {
30+
id: 'KentSpring2026',
31+
name: 'Kent Spring 2026',
32+
city: 'Kent, Washington',
33+
country_iso2: 'US',
34+
start_date: '2026-05-30',
35+
end_date: '2026-05-30',
36+
} as ApiCompetition;
37+
const ongoingCompetition = {
38+
id: 'OngoingOpen2026',
39+
name: 'Ongoing Open 2026',
40+
city: 'Seattle, Washington',
41+
country_iso2: 'US',
42+
start_date: '2026-05-29',
43+
end_date: '2026-05-30',
44+
} as ApiCompetition;
45+
46+
const createWrapper = () => {
47+
const queryClient = new QueryClient({
48+
defaultOptions: {
49+
queries: {
50+
retry: false,
51+
},
52+
},
53+
});
54+
55+
return function Wrapper({ children }: PropsWithChildren) {
56+
return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>;
57+
};
58+
};
59+
60+
beforeEach(() => {
61+
jest.clearAllMocks();
62+
});
63+
64+
it('loads my competitions from the public user competitions endpoint', async () => {
65+
jest.mocked(fetchUserWithCompetitions).mockResolvedValue({
66+
user,
67+
upcoming_competitions: [upcomingCompetition],
68+
ongoing_competitions: [ongoingCompetition],
69+
});
70+
71+
const { result } = renderHook(() => useMyCompetitionsQuery(user.id), {
72+
wrapper: createWrapper(),
73+
});
74+
75+
await waitFor(() =>
76+
expect(result.current.competitions).toEqual([ongoingCompetition, upcomingCompetition]),
77+
);
78+
79+
expect(fetchUserWithCompetitions).toHaveBeenCalledWith(String(user.id));
80+
});
81+
82+
it('uses cached competitions as stale startup data and still refetches', async () => {
83+
const { setLocalStorage } = jest.requireMock('@/lib/localStorage') as {
84+
setLocalStorage: (key: string, value: string) => void;
85+
};
86+
87+
setLocalStorage('user', JSON.stringify(user));
88+
setLocalStorage('my.upcoming_competitions', JSON.stringify([upcomingCompetition]));
89+
setLocalStorage('my.ongoing_competitions', JSON.stringify([]));
90+
jest.mocked(fetchUserWithCompetitions).mockResolvedValue({
91+
user,
92+
upcoming_competitions: [],
93+
ongoing_competitions: [ongoingCompetition],
94+
});
95+
96+
const { result } = renderHook(() => useMyCompetitionsQuery(user.id), {
97+
wrapper: createWrapper(),
98+
});
99+
100+
expect(result.current.competitions).toEqual([upcomingCompetition]);
101+
102+
await waitFor(() => expect(result.current.competitions).toEqual([ongoingCompetition]));
103+
expect(fetchUserWithCompetitions).toHaveBeenCalledWith(String(user.id));
104+
});

src/containers/MyCompetitions/MyCompetitions.query.ts

Lines changed: 33 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -5,41 +5,50 @@ import { fetchUserWithCompetitions, UserCompsResponse } from '@/lib/api';
55
import { FIVE_MINUTES } from '@/lib/constants';
66
import { getLocalStorage, setLocalStorage } from '@/lib/localStorage';
77

8+
const normalizeUserCompetitions = (res: UserCompsResponse): UserCompsResponse => ({
9+
user: res.user,
10+
upcoming_competitions: res.upcoming_competitions || [],
11+
ongoing_competitions: res.ongoing_competitions || [],
12+
});
13+
14+
const saveUserCompetitions = (res: UserCompsResponse) => {
15+
setLocalStorage('my.upcoming_competitions', JSON.stringify(res.upcoming_competitions));
16+
setLocalStorage('my.ongoing_competitions', JSON.stringify(res.ongoing_competitions));
17+
};
18+
19+
const getCachedUserCompetitions = (userId?: number) => {
20+
const user = JSON.parse(getLocalStorage('user') || 'null') as User | null;
21+
if (!user || user.id !== userId) {
22+
return undefined;
23+
}
24+
25+
const rawUpcomingCompetitions = getLocalStorage('my.upcoming_competitions');
26+
const rawOngoingCompetitions = getLocalStorage('my.ongoing_competitions');
27+
if (!rawUpcomingCompetitions && !rawOngoingCompetitions) {
28+
return undefined;
29+
}
30+
31+
const upcoming_competitions = JSON.parse(rawUpcomingCompetitions || '[]') as ApiCompetition[];
32+
const ongoing_competitions = JSON.parse(rawOngoingCompetitions || '[]') as ApiCompetition[];
33+
34+
return { user, upcoming_competitions, ongoing_competitions };
35+
};
36+
837
export const useMyCompetitionsQuery = (userId?: number, options: { enabled?: boolean } = {}) => {
938
const { data, ...props } = useQuery<UserCompsResponse, string>({
1039
queryKey: ['userCompetitions', userId],
1140
queryFn: async () => {
12-
const res = await fetchUserWithCompetitions(userId!.toString());
41+
const res = normalizeUserCompetitions(await fetchUserWithCompetitions(userId!.toString()));
1342

14-
setLocalStorage('my.upcoming_competitions', JSON.stringify(res.upcoming_competitions));
15-
setLocalStorage('my.ongoing_competitions', JSON.stringify(res.ongoing_competitions));
43+
saveUserCompetitions(res);
1644

1745
return res;
1846
},
1947
staleTime: FIVE_MINUTES,
2048
gcTime: FIVE_MINUTES,
2149
enabled: Boolean(userId && (options.enabled ?? true)),
22-
initialData: () => {
23-
const user = JSON.parse(getLocalStorage('user') || 'null') as User;
24-
if (user?.id !== userId) {
25-
return undefined;
26-
}
27-
28-
const rawUpcomingCompetitions = getLocalStorage('my.upcoming_competitions');
29-
const rawOngoingCompetitions = getLocalStorage('my.ongoing_competitions');
30-
if (!rawUpcomingCompetitions && !rawOngoingCompetitions) {
31-
return undefined;
32-
}
33-
34-
const upcoming_competitions = JSON.parse(rawUpcomingCompetitions || '[]') as ApiCompetition[];
35-
const ongoing_competitions = JSON.parse(rawOngoingCompetitions || '[]') as ApiCompetition[];
36-
37-
if (!upcoming_competitions.length && !ongoing_competitions.length) {
38-
return undefined;
39-
}
40-
41-
return { user: user, upcoming_competitions, ongoing_competitions };
42-
},
50+
initialData: () => getCachedUserCompetitions(userId),
51+
initialDataUpdatedAt: 0,
4352
networkMode: 'offlineFirst',
4453
});
4554

src/containers/MyCompetitions/MyCompetitions.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ export function MyCompetitions() {
2222
competitions={[...pinnedCompetitions, ...competitions]}
2323
loading={isLoading && !pinnedCompetitions.length}
2424
liveCompetitionIds={NotifyCompetitions?.competitions?.map((c) => c.id) || []}
25+
showWhenEmpty={Boolean(user)}
2526
/>
2627
</>
2728
);

src/containers/Support/Support.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ export function SupportContainer() {
77
}, []);
88

99
return (
10-
<Container className="overflow-auto">
10+
<Container className="safe-area-scroll-bottom overflow-auto">
1111
<div className="flex flex-col items-center">
1212
<div className="flex w-full max-w-3xl flex-col space-y-4 p-2 pt-2 type-body md:px-0 md:type-body">
1313
<p className="type-body">Thanks for being a user of Competition Groups!</p>
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export * from './useCompetitionAnalytics';

0 commit comments

Comments
 (0)