[Web] Add dropdown for admin scoreboards
This commit is contained in:
parent
23c6711ad6
commit
27dc266353
7
web/src/routes/admin/scoreboard/+layout.server.ts
Normal file
7
web/src/routes/admin/scoreboard/+layout.server.ts
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
import { db } from '$lib/server/prisma';
|
||||||
|
import type { LayoutServerLoad } from './$types';
|
||||||
|
|
||||||
|
export const load = (async () => {
|
||||||
|
const contests = await db.contest.findMany({ select: { id: true, name: true } });
|
||||||
|
return { contests };
|
||||||
|
}) satisfies LayoutServerLoad;
|
41
web/src/routes/admin/scoreboard/+layout.svelte
Normal file
41
web/src/routes/admin/scoreboard/+layout.svelte
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
|
import type { LayoutData } from './$types';
|
||||||
|
import { selectedScoreboard } from './stores';
|
||||||
|
|
||||||
|
export let data: LayoutData;
|
||||||
|
|
||||||
|
let scoreboardSelectValue: number | null = null;
|
||||||
|
|
||||||
|
function onScoreboardSelect() {
|
||||||
|
console.log(scoreboardSelectValue);
|
||||||
|
if (scoreboardSelectValue !== null) {
|
||||||
|
goto(`/admin/scoreboard/${scoreboardSelectValue}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>Admin Scoreboards</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<h1 style="text-align:center" class="mb-1"><i class="bi bi-trophy"></i> Admin Scoreboards</h1>
|
||||||
|
|
||||||
|
<div class="d-flex flex-row-reverse gap-3 pb-2">
|
||||||
|
<select
|
||||||
|
class="form-select w-auto"
|
||||||
|
bind:value={scoreboardSelectValue}
|
||||||
|
on:change={onScoreboardSelect}
|
||||||
|
>
|
||||||
|
{#if $selectedScoreboard === null}
|
||||||
|
<option value={null} selected>Select</option>
|
||||||
|
{/if}
|
||||||
|
{#each data.contests as contest}
|
||||||
|
<option value={contest.id} selected={$selectedScoreboard === contest.id}
|
||||||
|
>{contest.name}</option
|
||||||
|
>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<slot />
|
@ -1,108 +0,0 @@
|
|||||||
import { db } from '$lib/server/prisma';
|
|
||||||
import type { PageServerLoad } from './$types';
|
|
||||||
|
|
||||||
export const load = (async () => {
|
|
||||||
const timestamp = new Date();
|
|
||||||
const contests = await db.contest.findMany({
|
|
||||||
include: { problems: true, teams: { include: { submissions: true } } }
|
|
||||||
});
|
|
||||||
const data = {
|
|
||||||
timestamp: timestamp,
|
|
||||||
contests: contests.map((contest) => {
|
|
||||||
return {
|
|
||||||
name: contest.name,
|
|
||||||
problems: contest.problems.map((problem) => {
|
|
||||||
return { id: problem.id, friendlyName: problem.friendlyName };
|
|
||||||
}),
|
|
||||||
teams: contest.teams
|
|
||||||
.map((team) => {
|
|
||||||
return {
|
|
||||||
name: team.name,
|
|
||||||
solves: team.submissions.filter((submission) => {
|
|
||||||
return submission.contestId === contest.id && submission.state === 'Correct';
|
|
||||||
}).length,
|
|
||||||
time: (() => {
|
|
||||||
const correctSubmissions = team.submissions.filter((submission) => {
|
|
||||||
return submission.contestId === contest.id && submission.state === 'Correct';
|
|
||||||
});
|
|
||||||
const penaltyTime =
|
|
||||||
team.submissions.filter((submission) => {
|
|
||||||
return (
|
|
||||||
submission.contestId === contest.id &&
|
|
||||||
submission.state === 'Incorrect' &&
|
|
||||||
correctSubmissions.find((correct) => {
|
|
||||||
return correct.problemId === submission.problemId;
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}).length * 10;
|
|
||||||
let time = penaltyTime;
|
|
||||||
correctSubmissions.forEach((correctSubmission) => {
|
|
||||||
const gradedAt = correctSubmission.gradedAt!.valueOf();
|
|
||||||
const min = (gradedAt - contest.startTime!.valueOf()) / 60000;
|
|
||||||
time += min;
|
|
||||||
});
|
|
||||||
return time;
|
|
||||||
})(),
|
|
||||||
problems: contest.problems.map((problem) => {
|
|
||||||
return {
|
|
||||||
id: problem.id,
|
|
||||||
attempts: team.submissions.filter((submission) => {
|
|
||||||
return (
|
|
||||||
submission.contestId === contest.id &&
|
|
||||||
submission.problemId === problem.id &&
|
|
||||||
(submission.state === 'Correct' || submission.state === 'Incorrect')
|
|
||||||
);
|
|
||||||
}).length,
|
|
||||||
graphic: team.submissions.find((submission) => {
|
|
||||||
return (
|
|
||||||
submission.contestId === contest.id &&
|
|
||||||
submission.problemId === problem.id &&
|
|
||||||
(submission.state === 'Correct' || submission.state === 'Incorrect')
|
|
||||||
);
|
|
||||||
})
|
|
||||||
? team.submissions.find((submission) => {
|
|
||||||
return (
|
|
||||||
submission.problemId === problem.id && submission.state === 'Correct'
|
|
||||||
);
|
|
||||||
})
|
|
||||||
? 'correct'
|
|
||||||
: 'incorrect'
|
|
||||||
: null,
|
|
||||||
min: (() => {
|
|
||||||
const correctSubmission = team.submissions.find((submission) => {
|
|
||||||
return (
|
|
||||||
submission.contestId === contest.id &&
|
|
||||||
submission.problemId === problem.id &&
|
|
||||||
submission.state === 'Correct'
|
|
||||||
);
|
|
||||||
});
|
|
||||||
if (correctSubmission) {
|
|
||||||
const gradedAt = correctSubmission.gradedAt!.valueOf();
|
|
||||||
return (gradedAt - contest.startTime!.valueOf()) / 60000;
|
|
||||||
}
|
|
||||||
return undefined;
|
|
||||||
})()
|
|
||||||
};
|
|
||||||
})
|
|
||||||
};
|
|
||||||
})
|
|
||||||
.sort((a, b) => {
|
|
||||||
if (a.solves > b.solves) {
|
|
||||||
return -1;
|
|
||||||
} else if (a.solves < b.solves) {
|
|
||||||
return 1;
|
|
||||||
} else {
|
|
||||||
if (a.time < b.time) {
|
|
||||||
return -1;
|
|
||||||
} else if (a.time > b.time) {
|
|
||||||
return 1;
|
|
||||||
} else {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
};
|
|
||||||
})
|
|
||||||
};
|
|
||||||
return data;
|
|
||||||
}) satisfies PageServerLoad;
|
|
@ -1,109 +1,4 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onDestroy, onMount } from 'svelte';
|
import { selectedScoreboard } from './stores';
|
||||||
import type { PageData } from './$types';
|
$selectedScoreboard = null;
|
||||||
import { invalidateAll } from '$app/navigation';
|
|
||||||
|
|
||||||
export let data: PageData;
|
|
||||||
|
|
||||||
let updateInterval: ReturnType<typeof setInterval>;
|
|
||||||
let updating = false;
|
|
||||||
|
|
||||||
onMount(() => {
|
|
||||||
updateInterval = setInterval(async () => {
|
|
||||||
updating = true;
|
|
||||||
await invalidateAll();
|
|
||||||
updating = false;
|
|
||||||
}, 10000);
|
|
||||||
});
|
|
||||||
|
|
||||||
onDestroy(() => {
|
|
||||||
clearInterval(updateInterval);
|
|
||||||
});
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
|
||||||
<title>Admin Scoreboards</title>
|
|
||||||
</svelte:head>
|
|
||||||
|
|
||||||
<h1 style="text-align:center" class="mb-1"><i class="bi bi-trophy"></i> Admin Scoreboards</h1>
|
|
||||||
|
|
||||||
<div class="text-end">
|
|
||||||
{#if updating}
|
|
||||||
<div class="spinner-border spinner-border-sm text-secondary" />
|
|
||||||
{/if}
|
|
||||||
<strong>Last Updated: </strong>{data.timestamp.toLocaleTimeString()}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{#each data.contests as contest}
|
|
||||||
<h2 style="text-align:center">{contest.name}</h2>
|
|
||||||
<div class="mb-3 row">
|
|
||||||
<div class="text-end" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<table class="table table-striped table-bordered">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>Place</th>
|
|
||||||
<th>Team Name</th>
|
|
||||||
<th>Solves</th>
|
|
||||||
<th>Time</th>
|
|
||||||
{#each contest.problems as problem}
|
|
||||||
<th>{problem.friendlyName}</th>
|
|
||||||
{/each}
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{#each contest.teams as team, i}
|
|
||||||
<tr>
|
|
||||||
<td style="text-align:center; font-size:24px;"><strong>{i + 1}</strong></td>
|
|
||||||
<td style="font-size:18px">{team.name}</td>
|
|
||||||
<td style="font-size:18px">{team.solves}</td>
|
|
||||||
<td style="font-size:18px">{team.time.toFixed(0)}</td>
|
|
||||||
{#each contest.problems as problem}
|
|
||||||
<td>
|
|
||||||
<div class="row">
|
|
||||||
<div class="col-3">
|
|
||||||
{#if team.problems.find((p) => {
|
|
||||||
return p.id === problem.id;
|
|
||||||
})?.graphic !== null}
|
|
||||||
<img
|
|
||||||
src={team.problems.find((p) => {
|
|
||||||
return p.id === problem.id;
|
|
||||||
})?.graphic === 'correct'
|
|
||||||
? '/correct.png'
|
|
||||||
: '/incorrect.png'}
|
|
||||||
alt="check or X"
|
|
||||||
width="30px"
|
|
||||||
/>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
<div class="col-9">
|
|
||||||
{#if team.problems.find((p) => {
|
|
||||||
return p.id === problem.id;
|
|
||||||
})?.attempts !== 0}
|
|
||||||
{team.problems.find((p) => {
|
|
||||||
return p.id === problem.id;
|
|
||||||
})?.attempts}
|
|
||||||
{team.problems.find((p) => {
|
|
||||||
return p.id === problem.id;
|
|
||||||
})?.attempts === 1
|
|
||||||
? 'Attempt'
|
|
||||||
: 'Attempts'}<br />{#if team.problems.find((p) => {
|
|
||||||
return p.id === problem.id;
|
|
||||||
})?.min}<span style="color:rgb(102,102,102)"
|
|
||||||
>{team.problems
|
|
||||||
.find((p) => {
|
|
||||||
return p.id === problem.id;
|
|
||||||
})
|
|
||||||
?.min?.toFixed(0)} min</span
|
|
||||||
>{/if}
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
{/each}
|
|
||||||
</tr>
|
|
||||||
{/each}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
{/each}
|
|
||||||
|
109
web/src/routes/admin/scoreboard/[contestId]/+page.server.ts
Normal file
109
web/src/routes/admin/scoreboard/[contestId]/+page.server.ts
Normal file
@ -0,0 +1,109 @@
|
|||||||
|
import { db } from '$lib/server/prisma';
|
||||||
|
import { redirect } from '@sveltejs/kit';
|
||||||
|
import type { PageServerLoad } from './$types';
|
||||||
|
|
||||||
|
export const load = (async ({ params }) => {
|
||||||
|
const timestamp = new Date();
|
||||||
|
const contest = await db.contest.findUnique({
|
||||||
|
where: { id: parseInt(params.contestId) },
|
||||||
|
include: { problems: true, teams: { include: { submissions: true } } }
|
||||||
|
});
|
||||||
|
if (contest === null) {
|
||||||
|
throw redirect(302, '/admin/scoreboard');
|
||||||
|
}
|
||||||
|
const data = {
|
||||||
|
timestamp: timestamp,
|
||||||
|
contest: {
|
||||||
|
name: contest.name,
|
||||||
|
problems: contest.problems.map((problem) => {
|
||||||
|
return { id: problem.id, friendlyName: problem.friendlyName };
|
||||||
|
}),
|
||||||
|
teams: contest.teams
|
||||||
|
.map((team) => {
|
||||||
|
return {
|
||||||
|
name: team.name,
|
||||||
|
solves: team.submissions.filter((submission) => {
|
||||||
|
return submission.contestId === contest.id && submission.state === 'Correct';
|
||||||
|
}).length,
|
||||||
|
time: (() => {
|
||||||
|
const correctSubmissions = team.submissions.filter((submission) => {
|
||||||
|
return submission.contestId === contest.id && submission.state === 'Correct';
|
||||||
|
});
|
||||||
|
const penaltyTime =
|
||||||
|
team.submissions.filter((submission) => {
|
||||||
|
return (
|
||||||
|
submission.contestId === contest.id &&
|
||||||
|
submission.state === 'Incorrect' &&
|
||||||
|
correctSubmissions.find((correct) => {
|
||||||
|
return correct.problemId === submission.problemId;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}).length * 10;
|
||||||
|
let time = penaltyTime;
|
||||||
|
correctSubmissions.forEach((correctSubmission) => {
|
||||||
|
const gradedAt = correctSubmission.gradedAt!.valueOf();
|
||||||
|
const min = (gradedAt - contest.startTime!.valueOf()) / 60000;
|
||||||
|
time += min;
|
||||||
|
});
|
||||||
|
return time;
|
||||||
|
})(),
|
||||||
|
problems: contest.problems.map((problem) => {
|
||||||
|
return {
|
||||||
|
id: problem.id,
|
||||||
|
attempts: team.submissions.filter((submission) => {
|
||||||
|
return (
|
||||||
|
submission.contestId === contest.id &&
|
||||||
|
submission.problemId === problem.id &&
|
||||||
|
(submission.state === 'Correct' || submission.state === 'Incorrect')
|
||||||
|
);
|
||||||
|
}).length,
|
||||||
|
graphic: team.submissions.find((submission) => {
|
||||||
|
return (
|
||||||
|
submission.contestId === contest.id &&
|
||||||
|
submission.problemId === problem.id &&
|
||||||
|
(submission.state === 'Correct' || submission.state === 'Incorrect')
|
||||||
|
);
|
||||||
|
})
|
||||||
|
? team.submissions.find((submission) => {
|
||||||
|
return submission.problemId === problem.id && submission.state === 'Correct';
|
||||||
|
})
|
||||||
|
? 'correct'
|
||||||
|
: 'incorrect'
|
||||||
|
: null,
|
||||||
|
min: (() => {
|
||||||
|
const correctSubmission = team.submissions.find((submission) => {
|
||||||
|
return (
|
||||||
|
submission.contestId === contest.id &&
|
||||||
|
submission.problemId === problem.id &&
|
||||||
|
submission.state === 'Correct'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
if (correctSubmission) {
|
||||||
|
const gradedAt = correctSubmission.gradedAt!.valueOf();
|
||||||
|
return (gradedAt - contest.startTime!.valueOf()) / 60000;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
})()
|
||||||
|
};
|
||||||
|
})
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.sort((a, b) => {
|
||||||
|
if (a.solves > b.solves) {
|
||||||
|
return -1;
|
||||||
|
} else if (a.solves < b.solves) {
|
||||||
|
return 1;
|
||||||
|
} else {
|
||||||
|
if (a.time < b.time) {
|
||||||
|
return -1;
|
||||||
|
} else if (a.time > b.time) {
|
||||||
|
return 1;
|
||||||
|
} else {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
};
|
||||||
|
return data;
|
||||||
|
}) satisfies PageServerLoad;
|
109
web/src/routes/admin/scoreboard/[contestId]/+page.svelte
Normal file
109
web/src/routes/admin/scoreboard/[contestId]/+page.svelte
Normal file
@ -0,0 +1,109 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onDestroy, onMount } from 'svelte';
|
||||||
|
import type { PageData } from './$types';
|
||||||
|
import { invalidateAll } from '$app/navigation';
|
||||||
|
import { selectedScoreboard } from '../stores';
|
||||||
|
import { page } from '$app/stores';
|
||||||
|
|
||||||
|
export let data: PageData;
|
||||||
|
|
||||||
|
let updateInterval: ReturnType<typeof setInterval>;
|
||||||
|
let updating = false;
|
||||||
|
|
||||||
|
$selectedScoreboard = parseInt($page.params.contestId);
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
updateInterval = setInterval(async () => {
|
||||||
|
updating = true;
|
||||||
|
await invalidateAll();
|
||||||
|
updating = false;
|
||||||
|
}, 10000);
|
||||||
|
});
|
||||||
|
|
||||||
|
onDestroy(() => {
|
||||||
|
clearInterval(updateInterval);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>Admin Scoreboards</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<div class="text-end">
|
||||||
|
{#if updating}
|
||||||
|
<div class="spinner-border spinner-border-sm text-secondary" />
|
||||||
|
{/if}
|
||||||
|
<strong>Last Updated: </strong>{data.timestamp.toLocaleTimeString()}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2 style="text-align:center">{data.contest.name}</h2>
|
||||||
|
<div class="mb-3 row">
|
||||||
|
<div class="text-end" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<table class="table table-striped table-bordered">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Place</th>
|
||||||
|
<th>Team Name</th>
|
||||||
|
<th>Solves</th>
|
||||||
|
<th>Time</th>
|
||||||
|
{#each data.contest.problems as problem}
|
||||||
|
<th>{problem.friendlyName}</th>
|
||||||
|
{/each}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{#each data.contest.teams as team, i}
|
||||||
|
<tr>
|
||||||
|
<td style="text-align:center; font-size:24px;"><strong>{i + 1}</strong></td>
|
||||||
|
<td style="font-size:18px">{team.name}</td>
|
||||||
|
<td style="font-size:18px">{team.solves}</td>
|
||||||
|
<td style="font-size:18px">{team.time.toFixed(0)}</td>
|
||||||
|
{#each data.contest.problems as problem}
|
||||||
|
<td>
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-3">
|
||||||
|
{#if team.problems.find((p) => {
|
||||||
|
return p.id === problem.id;
|
||||||
|
})?.graphic !== null}
|
||||||
|
<img
|
||||||
|
src={team.problems.find((p) => {
|
||||||
|
return p.id === problem.id;
|
||||||
|
})?.graphic === 'correct'
|
||||||
|
? '/correct.png'
|
||||||
|
: '/incorrect.png'}
|
||||||
|
alt="check or X"
|
||||||
|
width="30px"
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<div class="col-9">
|
||||||
|
{#if team.problems.find((p) => {
|
||||||
|
return p.id === problem.id;
|
||||||
|
})?.attempts !== 0}
|
||||||
|
{team.problems.find((p) => {
|
||||||
|
return p.id === problem.id;
|
||||||
|
})?.attempts}
|
||||||
|
{team.problems.find((p) => {
|
||||||
|
return p.id === problem.id;
|
||||||
|
})?.attempts === 1
|
||||||
|
? 'Attempt'
|
||||||
|
: 'Attempts'}<br />{#if team.problems.find((p) => {
|
||||||
|
return p.id === problem.id;
|
||||||
|
})?.min}<span style="color:rgb(102,102,102)"
|
||||||
|
>{team.problems
|
||||||
|
.find((p) => {
|
||||||
|
return p.id === problem.id;
|
||||||
|
})
|
||||||
|
?.min?.toFixed(0)} min</span
|
||||||
|
>{/if}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
{/each}
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
3
web/src/routes/admin/scoreboard/stores.ts
Normal file
3
web/src/routes/admin/scoreboard/stores.ts
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
import { writable } from 'svelte/store';
|
||||||
|
|
||||||
|
export const selectedScoreboard = writable<number | null>(null);
|
Loading…
Reference in New Issue
Block a user