Skip to content

Commit 3dd47f2

Browse files
CopilotdavidohneeCopilot
authored
Add set-based scoring support for matches (#43)
* Initial plan * Initial exploration of codebase Co-authored-by: davidohnee <48208462+davidohnee@users.noreply.github.com> * Add set-based scoring support to data types and UI Co-authored-by: davidohnee <48208462+davidohnee@users.noreply.github.com> * refactor sets implementation (#42) * Update src/components/modals/MatchEditorModal.vue Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update src/components/modals/MatchEditorModal.vue Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Apply suggestions from code review --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: davidohnee <48208462+davidohnee@users.noreply.github.com> Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
1 parent 6cf48ae commit 3dd47f2

7 files changed

Lines changed: 280 additions & 14 deletions

File tree

package-lock.json

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/assets/css/dialogs.css

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
dialog[open] {
22
border-radius: 1em;
33
border: 1px solid var(--color-border);
4+
max-height: 80vh;
45

56
&::backdrop {
67
background-color: rgba(0, 0, 0, 0.5);
@@ -25,6 +26,7 @@ dialog[open] {
2526
top: 0.5em;
2627
right: 0.5em;
2728
cursor: pointer;
29+
font-size: 1.5em;
2830
}
2931
}
3032

src/components/modals/MatchEditorModal.vue

Lines changed: 170 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
<script setup lang="ts">
22
import { formatPlacement } from "@/helpers/common";
3-
import type { Match, MatchTeam, Tournament, Ref, MatchStatus } from "@/types/tournament";
3+
import type { Match, MatchTeam, Tournament, Ref, MatchStatus, SetScore } from "@/types/tournament";
44
import { computed, ref } from "vue";
55
import SegmentPicker from "../SegmentPicker.vue";
6+
import { calculateScoresFromSets } from "@/helpers/scoring";
67
78
const props = defineProps<{
89
modelValue: Match;
@@ -24,9 +25,59 @@ const onStatusChanged = () => {
2425
const scores = ref(match.value.teams.map((team) => team.score) ?? []);
2526
const onScoreChanged = (teamIndex: number) => {
2627
match.value.teams[teamIndex]!.score = scores.value[teamIndex]!;
28+
if (useSets.value && sets.value.length > 0) {
29+
scoreInSyncWithSets.value = false;
30+
}
2731
onChanged();
2832
};
2933
34+
const useSets = computed(() => props.tournament.config.useSets ?? false);
35+
const sets = ref<SetScore[]>(match.value.sets ?? []);
36+
37+
const addSet = () => {
38+
sets.value.push({ team1: 0, team2: 0 });
39+
onSetsChanged();
40+
};
41+
42+
const removeSet = (index: number) => {
43+
sets.value.splice(index, 1);
44+
onSetsChanged();
45+
};
46+
47+
const scoreFromSets = computed(() => {
48+
return calculateScoresFromSets(sets.value);
49+
});
50+
51+
const scoreInSyncWithSets = ref(
52+
sets.value.length > 0
53+
? scores.value.every((score, index) => score === scoreFromSets.value[index])
54+
: true,
55+
);
56+
57+
const onSetsChanged = () => {
58+
match.value.sets = sets.value;
59+
if (sets.value.length > 0 && scoreInSyncWithSets.value) {
60+
// Auto-calculate scores from sets
61+
const [team1Score, team2Score] = scoreFromSets.value;
62+
scores.value = [team1Score, team2Score];
63+
64+
match.value.teams[0]!.score = team1Score;
65+
match.value.teams[1]!.score = team2Score;
66+
}
67+
onChanged();
68+
};
69+
70+
const syncScoresWithSets = () => {
71+
scoreInSyncWithSets.value = true;
72+
onSetsChanged();
73+
};
74+
75+
const scoresOutOfSync = computed(() => {
76+
if (!useSets.value || sets.value.length === 0) return false;
77+
const [team1Score, team2Score] = scoreFromSets.value;
78+
return scores.value[0] !== team1Score || scores.value[1] !== team2Score;
79+
});
80+
3081
const matcheditor = ref<HTMLDialogElement | null>(null);
3182
const openMatchEditor = () => {
3283
if (matcheditor.value) {
@@ -95,6 +146,64 @@ defineExpose({
95146
</div>
96147
</div>
97148

149+
<div
150+
v-if="useSets"
151+
class="sets-section card"
152+
>
153+
<div class="set-and-sync">
154+
<h3>Sets</h3>
155+
<button
156+
v-if="scoresOutOfSync"
157+
class="ghost text-sm"
158+
@click="syncScoresWithSets"
159+
>
160+
Sync Scores
161+
</button>
162+
</div>
163+
<div class="sets-list">
164+
<div
165+
v-for="(set, index) in sets"
166+
:key="index"
167+
class="set-row"
168+
>
169+
<span class="set-label">Set {{ index + 1 }}</span>
170+
<div class="set-scores">
171+
<input
172+
type="number"
173+
min="0"
174+
v-model.number="set.team1"
175+
@input="onSetsChanged"
176+
class="set-score-input"
177+
/>
178+
<span class="separator">:</span>
179+
<input
180+
type="number"
181+
min="0"
182+
v-model.number="set.team2"
183+
@input="onSetsChanged"
184+
class="set-score-input"
185+
/>
186+
</div>
187+
<button
188+
class="ghost"
189+
@click="removeSet(index)"
190+
title="Remove set"
191+
>
192+
<ion-icon name="trash-outline"></ion-icon>
193+
</button>
194+
</div>
195+
</div>
196+
<div class="add-set">
197+
<button
198+
class="ghost"
199+
@click="addSet"
200+
>
201+
<ion-icon name="add-outline"></ion-icon>
202+
Add Set
203+
</button>
204+
</div>
205+
</div>
206+
98207
<div class="match-status">
99208
<SegmentPicker
100209
v-model="match.status"
@@ -160,6 +269,66 @@ defineExpose({
160269
}
161270
}
162271
272+
.sets-section {
273+
margin-top: 2em;
274+
padding-top: 1em;
275+
276+
h3 {
277+
margin-bottom: 1em;
278+
font-size: var(--typography-heading-fontSize-m);
279+
}
280+
281+
.set-and-sync {
282+
display: flex;
283+
justify-content: space-between;
284+
align-items: baseline;
285+
}
286+
287+
.add-set {
288+
display: flex;
289+
justify-content: center;
290+
}
291+
}
292+
293+
.sets-list {
294+
display: flex;
295+
flex-direction: column;
296+
gap: 0.75em;
297+
margin-bottom: 1em;
298+
}
299+
300+
.set-row {
301+
display: grid;
302+
grid-template-columns: 4rem 1fr auto;
303+
align-items: center;
304+
gap: 1em;
305+
306+
.set-label {
307+
font-size: 0.9em;
308+
color: var(--color-text-secondary);
309+
}
310+
311+
.set-scores {
312+
display: flex;
313+
align-items: center;
314+
gap: 0.5em;
315+
justify-content: center;
316+
317+
.set-score-input {
318+
width: 5ch;
319+
text-align: center;
320+
padding: 0.25em;
321+
font-size: 1.1em;
322+
margin-bottom: 0;
323+
}
324+
325+
.separator {
326+
font-size: 1.1em;
327+
color: var(--color-text-secondary);
328+
}
329+
}
330+
}
331+
163332
.match-status {
164333
width: max-content;
165334
margin-left: auto;

src/helpers/defaults.ts

Lines changed: 34 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -8,16 +8,22 @@ interface Default {
88
icon: string;
99
matchDuration: number;
1010
breakDuration: number;
11-
courtLabel: string;
12-
courtLabelPlural?: string;
11+
courtLabel: {
12+
singular: string;
13+
plural: string;
14+
};
15+
sets: {
16+
supported: boolean;
17+
default: boolean;
18+
};
1319
}
1420

1521
export const getCourtType = (sport: string, plural: boolean, capitalised: boolean): string => {
1622
const sportDefaults = DEFAULTS[sport] || DEFAULTS.other!;
1723

1824
const courtLabel = plural
19-
? sportDefaults.courtLabelPlural || sportDefaults.courtLabel + "s"
20-
: sportDefaults.courtLabel;
25+
? sportDefaults.courtLabel.plural || sportDefaults.courtLabel.singular + "s"
26+
: sportDefaults.courtLabel.singular;
2127
return capitalised ? capitalise(courtLabel) : courtLabel;
2228
};
2329

@@ -29,8 +35,14 @@ export const DEFAULTS: Record<string, Default> = {
2935
icon: "beer-outline",
3036
matchDuration: 10,
3137
breakDuration: 5,
32-
courtLabel: "table",
33-
courtLabelPlural: "tables",
38+
courtLabel: {
39+
singular: "table",
40+
plural: "tables",
41+
},
42+
sets: {
43+
supported: false,
44+
default: false,
45+
},
3446
},
3547
foosball: {
3648
name: "Foosball",
@@ -39,8 +51,14 @@ export const DEFAULTS: Record<string, Default> = {
3951
icon: "football-outline",
4052
matchDuration: 15,
4153
breakDuration: 5,
42-
courtLabel: "table",
43-
courtLabelPlural: "tables",
54+
courtLabel: {
55+
singular: "table",
56+
plural: "tables",
57+
},
58+
sets: {
59+
supported: true,
60+
default: true,
61+
},
4462
},
4563
other: {
4664
name: "Other",
@@ -49,8 +67,14 @@ export const DEFAULTS: Record<string, Default> = {
4967
icon: "help-outline",
5068
matchDuration: 15,
5169
breakDuration: 5,
52-
courtLabel: "court",
53-
courtLabelPlural: "courts",
70+
courtLabel: {
71+
singular: "court",
72+
plural: "courts",
73+
},
74+
sets: {
75+
supported: true,
76+
default: false,
77+
},
5478
},
5579
};
5680

src/helpers/scoring.ts

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import type { TeamScore } from "@/types/tournament";
1+
import type { TeamScore, SetScore } from "@/types/tournament";
22

33
export const calculateTeamPoints = (teamScore: TeamScore): number => {
44
return teamScore.wins * 3 + teamScore.draws;
@@ -11,3 +11,18 @@ export const calculateDifference = (teamScore: TeamScore): string => {
1111
}
1212
return diff.toString();
1313
};
14+
15+
export const calculateScoresFromSets = (sets: SetScore[]): [number, number] => {
16+
let team1Score = 0;
17+
let team2Score = 0;
18+
19+
for (const set of sets) {
20+
if (set.team1 > set.team2) {
21+
team1Score++;
22+
} else if (set.team2 > set.team1) {
23+
team2Score++;
24+
}
25+
}
26+
27+
return [team1Score, team2Score];
28+
};

src/types/tournament.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,11 @@ export type DynamicTeamRef = {
3636
fromRound?: number;
3737
};
3838

39+
export type SetScore = {
40+
team1: number;
41+
team2: number;
42+
};
43+
3944
export type MatchTeam = {
4045
ref?: Ref;
4146
score: number;
@@ -55,6 +60,7 @@ export interface Match {
5560
date: Date;
5661
status: MatchStatus;
5762
round?: MatchRound; // used only for group phase matches
63+
sets?: SetScore[]; // set scores when using set-based scoring
5864
}
5965

6066
export interface RichMatch {
@@ -82,6 +88,7 @@ export interface TournamentConfigV2 {
8288
knockoutBreakDuration: number;
8389
startTime: Date;
8490
sport: string;
91+
useSets?: boolean; // whether to use set-based scoring
8592
}
8693

8794
export interface TournamentConfigV1 extends TournamentConfigV2 {

0 commit comments

Comments
 (0)