Skip to content

Commit 1e9a49c

Browse files
authored
feat: multi task duels (#30)
1 parent a624f6b commit 1e9a49c

File tree

21 files changed

+340
-70
lines changed

21 files changed

+340
-70
lines changed

src/app/store.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ const codeEditorPersistConfig = {
2525
key: "codeEditor",
2626
storage,
2727
version: 1,
28-
whitelist: ["codeByDuelId", "languageByDuelId"],
28+
whitelist: ["codeByTaskKey", "languageByTaskKey"],
2929
};
3030

3131
const themePersistConfig = {

src/entities/duel/index.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ export { useGetDuelQuery, useGetAllUserDuelsQuery, useGetCurrentDuelQuery } from
55
export { DuelResult } from "./ui/DuelResult/DuelResult";
66
export { DuelHistory } from "./ui/DuelHistory/DuelHistory";
77

8-
export type { DuelResultType, Duel } from "./model/types";
8+
export type { DuelResultType, Duel, DuelTaskRef } from "./model/types";
99

1010
export { getDuelResultForUser } from "./lib/duelResultHelpers";
11+
export { useDuelTaskSelection } from "./lib/useDuelTaskSelection";
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
import { useEffect, useMemo } from "react";
2+
import { useSearchParams } from "react-router-dom";
3+
import type { Duel } from "../model/types";
4+
5+
const TASK_QUERY_PARAM = "task";
6+
7+
export interface DuelTaskOption {
8+
key: string;
9+
id: string;
10+
}
11+
12+
const getDuelTaskOptions = (duel?: Duel): DuelTaskOption[] => {
13+
if (duel?.tasks && Object.keys(duel.tasks).length > 0) {
14+
return Object.entries(duel.tasks)
15+
.map(([key, value]) => ({ key, id: value.id }))
16+
.sort((a, b) => a.key.localeCompare(b.key));
17+
}
18+
19+
if (duel?.task_id) {
20+
return [{ key: "A", id: duel.task_id }];
21+
}
22+
23+
return [];
24+
};
25+
26+
export const useDuelTaskSelection = (duel?: Duel) => {
27+
const [searchParams, setSearchParams] = useSearchParams();
28+
29+
const tasks = useMemo(() => getDuelTaskOptions(duel), [duel]);
30+
const taskKeys = useMemo(() => new Set(tasks.map((task) => task.key)), [tasks]);
31+
32+
const selectedKeyFromQuery = searchParams.get(TASK_QUERY_PARAM) ?? "";
33+
const selectedTaskKey = taskKeys.has(selectedKeyFromQuery)
34+
? selectedKeyFromQuery
35+
: (tasks[0]?.key ?? "");
36+
const selectedTaskId = tasks.find((task) => task.key === selectedTaskKey)?.id ?? null;
37+
38+
useEffect(() => {
39+
if (!selectedTaskKey) return;
40+
if (selectedKeyFromQuery === selectedTaskKey) return;
41+
42+
const nextParams = new URLSearchParams(searchParams);
43+
nextParams.set(TASK_QUERY_PARAM, selectedTaskKey);
44+
setSearchParams(nextParams, { replace: true });
45+
}, [searchParams, selectedKeyFromQuery, selectedTaskKey, setSearchParams]);
46+
47+
const setSelectedTaskKey = (key: string) => {
48+
if (!taskKeys.has(key)) return;
49+
50+
const nextParams = new URLSearchParams(searchParams);
51+
nextParams.set(TASK_QUERY_PARAM, key);
52+
setSearchParams(nextParams);
53+
};
54+
55+
return {
56+
tasks,
57+
selectedTaskKey: selectedTaskKey || null,
58+
selectedTaskId,
59+
setSelectedTaskKey,
60+
};
61+
};

src/entities/duel/model/types.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,14 @@ export interface DuelParticipant {
55
created_at: string;
66
}
77

8+
export interface DuelTaskRef {
9+
id: string;
10+
}
11+
812
export interface Duel {
913
id: number;
10-
task_id: string;
14+
task_id?: string;
15+
tasks?: Record<string, DuelTaskRef>;
1116
participants: [DuelParticipant, DuelParticipant];
1217
winner_id?: number;
1318
status: "InProgress" | "Finished";

src/entities/duel/ui/DuelHistory/DuelHistory.tsx

Lines changed: 25 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -33,13 +33,23 @@ export const DuelHistory = ({ duels, currentUserId }: DuelHistoryProps) => {
3333

3434
<tbody>
3535
{duels.map((duel) => {
36-
const opponent = duel.participants.find((p) => p.id !== currentUserId)!;
37-
38-
const duelResult = getDuelResultForUser(duel, currentUserId);
39-
const delta = duel.rating_changes[currentUserId][duelResult];
36+
const isParticipant = duel.participants.some((p) => p.id === currentUserId);
37+
const opponent = isParticipant
38+
? (duel.participants.find((p) => p.id !== currentUserId) ??
39+
duel.participants[0])
40+
: duel.participants[0];
41+
const duelResult = isParticipant
42+
? getDuelResultForUser(duel, currentUserId)
43+
: null;
44+
const delta = duelResult
45+
? duel.rating_changes[currentUserId]?.[duelResult]
46+
: null;
4047

4148
return (
42-
<tr key={duel.id} onClick={() => navigate(`/duel/${duel.id}`)}>
49+
<tr
50+
key={duel.id}
51+
onClick={isParticipant ? () => navigate(`/duel/${duel.id}`) : undefined}
52+
>
4353
<td className={styles.opponentCell}>
4454
{opponent.nickname}
4555
<span className={styles.ratingWithIcon}>
@@ -55,8 +65,16 @@ export const DuelHistory = ({ duels, currentUserId }: DuelHistoryProps) => {
5565
)}
5666
</td>
5767

58-
<td className={deltaClassName[duelResult]}>
59-
{delta > 0 ? `+${delta}` : delta}
68+
<td
69+
className={
70+
duelResult ? deltaClassName[duelResult] : styles.neutralDelta
71+
}
72+
>
73+
{delta !== null && delta !== undefined
74+
? delta > 0
75+
? `+${delta}`
76+
: delta
77+
: "-"}
6078
</td>
6179

6280
<td>{new Date(duel.start_time).toLocaleDateString("ru-RU")}</td>

src/features/duel-configuration/ui/DuelConfigurationManager.tsx

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
import { createPortal } from "react-dom";
33

44
import {
5+
DuelConfiguration,
56
DuelTaskConfiguration,
67
DuelTasksOrder,
78
useCreateDuelConfigurationMutation,
@@ -59,7 +60,8 @@ const createTask = (): TaskFormState => ({
5960

6061
const buildTaskConfigurations = (tasks: TaskFormState[]) => {
6162
return tasks.reduce<Record<string, DuelTaskConfiguration>>((acc, task, index) => {
62-
acc[String(index + 1)] = {
63+
const taskKey = String.fromCharCode(65 + index);
64+
acc[taskKey] = {
6365
level: Number(task.level),
6466
topics: task.topics.length > 0 ? task.topics : [],
6567
};
@@ -68,6 +70,22 @@ const buildTaskConfigurations = (tasks: TaskFormState[]) => {
6870
}, {});
6971
};
7072

73+
const taskKeyToIndex = (key: string) => {
74+
const normalizedKey = key.trim().toUpperCase();
75+
const asNumber = Number(normalizedKey);
76+
77+
if (Number.isFinite(asNumber)) {
78+
return asNumber;
79+
}
80+
81+
const code = normalizedKey.charCodeAt(0);
82+
if (code >= 65 && code <= 90) {
83+
return code - 64;
84+
}
85+
86+
return Number.MAX_SAFE_INTEGER;
87+
};
88+
7189
const orderLabels: Record<DuelTasksOrder, string> = {
7290
Sequential: "Задачи открываются одна за другой.",
7391
Parallel: "Все задачи доступны сразу.",
@@ -153,7 +171,7 @@ export const DuelConfigurationManager = ({
153171
const applyConfigToForm = (config: StoredDuelConfiguration) => {
154172
const entries = Object.entries(config.tasks_configurations ?? {});
155173
const sortedEntries = entries.sort(
156-
([leftKey], [rightKey]) => Number(leftKey) - Number(rightKey),
174+
([leftKey], [rightKey]) => taskKeyToIndex(leftKey) - taskKeyToIndex(rightKey),
157175
);
158176

159177
const nextTasks = sortedEntries.map(([key, task]) => ({
@@ -322,7 +340,8 @@ export const DuelConfigurationManager = ({
322340
const tasksEntries = Object.entries(
323341
config.tasks_configurations ?? {},
324342
).sort(
325-
([leftKey], [rightKey]) => Number(leftKey) - Number(rightKey),
343+
([leftKey], [rightKey]) =>
344+
taskKeyToIndex(leftKey) - taskKeyToIndex(rightKey),
326345
);
327346
const taskSummary = tasksEntries.map(([, task], index) => {
328347
const taskKey = String.fromCharCode(65 + index);

src/features/duel-session/api/duelSessionApi.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,26 @@ export const duelSessionApiSlice = apiSlice.injectEndpoints({
116116
dispatch(setDuelCanceled(true));
117117
};
118118

119+
const duelChangedListener = (event: MessageEvent) => {
120+
const duelMessage =
121+
event.data && typeof event.data === "string"
122+
? (JSON.parse(event.data) as DuelMessage)
123+
: null;
124+
const duelId =
125+
duelMessage?.duel_id ??
126+
(getState() as RootState).duelSession.activeDuelId;
127+
128+
if (event.lastEventId) {
129+
dispatch(setLastEventId(event.lastEventId));
130+
}
131+
132+
if (duelId) {
133+
dispatch(
134+
duelApiSlice.util.invalidateTags([{ type: "Duel", id: duelId }]),
135+
);
136+
}
137+
};
138+
119139
const errorListener = async (event: MessageEvent) => {
120140
const sseReconnect = async () => {
121141
const state = getState() as RootState;
@@ -166,6 +186,8 @@ export const duelSessionApiSlice = apiSlice.injectEndpoints({
166186
eventSource.addEventListener("DuelFinished", duelFinishedListener);
167187
eventSource.addEventListener("duel_canceled", duelCanceledListener);
168188
eventSource.addEventListener("DuelCanceled", duelCanceledListener);
189+
eventSource.addEventListener("duel_changed", duelChangedListener);
190+
eventSource.addEventListener("DuelChanged", duelChangedListener);
169191

170192
eventSource.addEventListener("message", (event: MessageEvent) => {
171193
if (event.lastEventId) {

src/features/submit-code/api/submitCodeApi.ts

Lines changed: 40 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,24 @@ import {
66
SubmitCodeResponse,
77
} from "../model/types";
88

9+
interface SubmissionsQueryArg {
10+
duelId: string;
11+
taskKey?: string | null;
12+
}
13+
14+
const normalizeSubmissionsArg = (arg: string | SubmissionsQueryArg) => {
15+
if (typeof arg === "string") {
16+
return { duelId: arg, taskKey: null };
17+
}
18+
19+
return arg;
20+
};
21+
922
export const submitCodeApiSlice = apiSlice.injectEndpoints({
1023
endpoints: (builder) => ({
1124
submitCode: builder.mutation<
1225
SubmitCodeResponse,
13-
{ duelId: string; data: SubmitCodeRequestData }
26+
{ duelId: string; data: SubmitCodeRequestData; taskKey?: string | null }
1427
>({
1528
query: ({ duelId, data }) => ({
1629
url: `/duels/${duelId}/submissions`,
@@ -20,14 +33,14 @@ export const submitCodeApiSlice = apiSlice.injectEndpoints({
2033
invalidatesTags: (_result, _error, { duelId }) => [
2134
{ type: "Submission", id: `LIST-${duelId}` },
2235
],
23-
async onQueryStarted({ duelId, data }, { dispatch, queryFulfilled }) {
36+
async onQueryStarted({ duelId, data, taskKey }, { dispatch, queryFulfilled }) {
2437
try {
2538
const { data: result } = await queryFulfilled;
2639

2740
dispatch(
2841
submitCodeApiSlice.util.updateQueryData(
2942
"getSubmissions",
30-
duelId,
43+
{ duelId, taskKey: taskKey ?? null },
3144
(draft) => {
3245
const exists = draft.some(
3346
(s) => String(s.submission_id) === String(result.submission_id),
@@ -51,20 +64,28 @@ export const submitCodeApiSlice = apiSlice.injectEndpoints({
5164
},
5265
}),
5366

54-
getSubmissions: builder.query<SubmissionItem[], string>({
55-
query: (duelId: string) => ({
56-
url: `/duels/${duelId}/submissions`,
57-
}),
58-
providesTags: (result, _error, duelId) =>
59-
result
67+
getSubmissions: builder.query<SubmissionItem[], string | SubmissionsQueryArg>({
68+
query: (arg) => {
69+
const { duelId, taskKey } = normalizeSubmissionsArg(arg);
70+
return {
71+
url: `/duels/${duelId}/submissions`,
72+
params: {
73+
...(taskKey ? { taskKey } : {}),
74+
},
75+
};
76+
},
77+
providesTags: (result, _error, arg) => {
78+
const { duelId } = normalizeSubmissionsArg(arg);
79+
return result
6080
? [
6181
...result.map(({ submission_id }) => ({
6282
type: "Submission" as const,
6383
id: `${duelId}-${submission_id}`,
6484
})),
6585
{ type: "Submission", id: `LIST-${duelId}` },
6686
]
67-
: [{ type: "Submission", id: `LIST-${duelId}` }],
87+
: [{ type: "Submission", id: `LIST-${duelId}` }];
88+
},
6889
merge: (currentCache, newItems) => {
6990
if (!currentCache) return newItems;
7091

@@ -80,7 +101,14 @@ export const submitCodeApiSlice = apiSlice.injectEndpoints({
80101

81102
return Array.from(resultMap.values());
82103
},
83-
forceRefetch: ({ currentArg, previousArg }) => currentArg !== previousArg,
104+
forceRefetch: ({ currentArg, previousArg }) => {
105+
if (!currentArg || !previousArg) {
106+
return true;
107+
}
108+
const current = normalizeSubmissionsArg(currentArg);
109+
const previous = normalizeSubmissionsArg(previousArg);
110+
return current.duelId !== previous.duelId || current.taskKey !== previous.taskKey;
111+
},
84112
}),
85113

86114
getSubmissionDetail: builder.query<
@@ -99,7 +127,7 @@ export const submitCodeApiSlice = apiSlice.injectEndpoints({
99127
dispatch(
100128
submitCodeApiSlice.util.updateQueryData(
101129
"getSubmissions",
102-
duelId,
130+
{ duelId, taskKey: null },
103131
(draft) => {
104132
const submissionIndex = draft.findIndex(
105133
(s) => String(s.submission_id) === String(submissionId),

src/features/submit-code/model/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
export interface SubmitCodeRequestData {
22
solution: string;
33
language: string;
4+
task_key?: string | null;
45
}
56

67
export interface SubmitCodeResponse {

src/features/submit-code/ui/SubmitCodeButton.tsx

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { Button } from "shared/ui";
1+
import { Button } from "shared/ui";
22
import SubmitCodeIcon from "shared/assets/icons/submit-code.svg?react";
33
import { useSubmitCodeMutation } from "features/submit-code/api/submitCodeApi";
44
import type { LanguageValue } from "shared/config";
@@ -9,9 +9,10 @@ interface Props {
99
language: LanguageValue;
1010
onSubmissionStart: () => void;
1111
duelId: string;
12+
taskKey?: string | null;
1213
}
1314

14-
export const SubmitCodeButton = ({ code, language, onSubmissionStart, duelId }: Props) => {
15+
export const SubmitCodeButton = ({ code, language, onSubmissionStart, duelId, taskKey }: Props) => {
1516
const [submitCode, { isLoading: isSubmitting }] = useSubmitCodeMutation();
1617

1718
const handleSubmit = async () => {
@@ -20,11 +21,13 @@ export const SubmitCodeButton = ({ code, language, onSubmissionStart, duelId }:
2021
const submissionData = {
2122
solution: code,
2223
language: LANGUAGE_LABELS[language],
24+
task_key: taskKey ?? null,
2325
};
2426

2527
await submitCode({
2628
duelId,
2729
data: submissionData,
30+
taskKey,
2831
}).unwrap();
2932
};
3033

0 commit comments

Comments
 (0)