feat(tour): added tournament history page :D

This commit is contained in:
Maieul BOYER 2026-01-13 14:09:25 +01:00 committed by Maix0
parent 75f3c2a769
commit aca2dbd070
13 changed files with 245 additions and 4 deletions

View file

@ -34,6 +34,12 @@ import {
* @interface TournamentData200ResponsePayloadData * @interface TournamentData200ResponsePayloadData
*/ */
export interface TournamentData200ResponsePayloadData { export interface TournamentData200ResponsePayloadData {
/**
*
* @type {number}
* @memberof TournamentData200ResponsePayloadData
*/
playerCount: number;
/** /**
* *
* @type {string} * @type {string}
@ -64,6 +70,7 @@ export interface TournamentData200ResponsePayloadData {
* Check if a given object implements the TournamentData200ResponsePayloadData interface. * Check if a given object implements the TournamentData200ResponsePayloadData interface.
*/ */
export function instanceOfTournamentData200ResponsePayloadData(value: object): value is TournamentData200ResponsePayloadData { export function instanceOfTournamentData200ResponsePayloadData(value: object): value is TournamentData200ResponsePayloadData {
if (!('playerCount' in value) || value['playerCount'] === undefined) return false;
if (!('owner' in value) || value['owner'] === undefined) return false; if (!('owner' in value) || value['owner'] === undefined) return false;
if (!('users' in value) || value['users'] === undefined) return false; if (!('users' in value) || value['users'] === undefined) return false;
if (!('games' in value) || value['games'] === undefined) return false; if (!('games' in value) || value['games'] === undefined) return false;
@ -81,6 +88,7 @@ export function TournamentData200ResponsePayloadDataFromJSONTyped(json: any, ign
} }
return { return {
'playerCount': json['playerCount'],
'owner': json['owner'], 'owner': json['owner'],
'users': ((json['users'] as Array<any>).map(TournamentData200ResponsePayloadDataUsersInnerFromJSON)), 'users': ((json['users'] as Array<any>).map(TournamentData200ResponsePayloadDataUsersInnerFromJSON)),
'games': ((json['games'] as Array<any>).map(PongHistory200ResponsePayloadDataInnerFromJSON)), 'games': ((json['games'] as Array<any>).map(PongHistory200ResponsePayloadDataInnerFromJSON)),
@ -99,6 +107,7 @@ export function TournamentData200ResponsePayloadDataToJSONTyped(value?: Tourname
return { return {
'playerCount': value['playerCount'],
'owner': value['owner'], 'owner': value['owner'],
'users': ((value['users'] as Array<any>).map(TournamentData200ResponsePayloadDataUsersInnerToJSON)), 'users': ((value['users'] as Array<any>).map(TournamentData200ResponsePayloadDataUsersInnerToJSON)),
'games': ((value['games'] as Array<any>).map(PongHistory200ResponsePayloadDataInnerToJSON)), 'games': ((value['games'] as Array<any>).map(PongHistory200ResponsePayloadDataInnerToJSON)),

View file

@ -19,6 +19,12 @@ import { mapValues } from '../runtime';
* @interface TournamentList200ResponsePayloadDataInner * @interface TournamentList200ResponsePayloadDataInner
*/ */
export interface TournamentList200ResponsePayloadDataInner { export interface TournamentList200ResponsePayloadDataInner {
/**
*
* @type {number}
* @memberof TournamentList200ResponsePayloadDataInner
*/
playerCount: number;
/** /**
* *
* @type {string} * @type {string}
@ -43,6 +49,7 @@ export interface TournamentList200ResponsePayloadDataInner {
* Check if a given object implements the TournamentList200ResponsePayloadDataInner interface. * Check if a given object implements the TournamentList200ResponsePayloadDataInner interface.
*/ */
export function instanceOfTournamentList200ResponsePayloadDataInner(value: object): value is TournamentList200ResponsePayloadDataInner { export function instanceOfTournamentList200ResponsePayloadDataInner(value: object): value is TournamentList200ResponsePayloadDataInner {
if (!('playerCount' in value) || value['playerCount'] === undefined) return false;
if (!('id' in value) || value['id'] === undefined) return false; if (!('id' in value) || value['id'] === undefined) return false;
if (!('owner' in value) || value['owner'] === undefined) return false; if (!('owner' in value) || value['owner'] === undefined) return false;
if (!('time' in value) || value['time'] === undefined) return false; if (!('time' in value) || value['time'] === undefined) return false;
@ -59,6 +66,7 @@ export function TournamentList200ResponsePayloadDataInnerFromJSONTyped(json: any
} }
return { return {
'playerCount': json['playerCount'],
'id': json['id'], 'id': json['id'],
'owner': json['owner'], 'owner': json['owner'],
'time': json['time'], 'time': json['time'],
@ -76,6 +84,7 @@ export function TournamentList200ResponsePayloadDataInnerToJSONTyped(value?: Tou
return { return {
'playerCount': value['playerCount'],
'id': value['id'], 'id': value['id'],
'owner': value['owner'], 'owner': value['owner'],
'time': value['time'], 'time': value['time'],

View file

@ -9,6 +9,7 @@ import './profile/profile.ts'
import './logout/logout.ts' import './logout/logout.ts'
import './pongHistory/pongHistory.ts' import './pongHistory/pongHistory.ts'
import './tttHistory/tttHistory.ts' import './tttHistory/tttHistory.ts'
import './tourHistory/tourHistory.ts'
// ---- Initial load ---- // ---- Initial load ----
setTitle(""); setTitle("");

View file

@ -0,0 +1,156 @@
import { addRoute, navigateTo, type RouteHandlerParams, type RouteHandlerReturn } from "@app/routing";
import pageAll from './tourHistoryAll.html?raw';
import pageSingle from './tourHistorySingle.html?raw';
import { isNullish } from "@app/utils";
import client from "@app/api";
import { showError, showInfo } from "@app/toast";
import { getUser } from "@app/auth";
function getHHMM(d: Date): string {
let h = d.getHours();
let m = d.getMinutes();
return `${h < 9 ? '0' : ''}${h}:${m < 9 ? '0' : ''}${m}`
}
async function tourHistoryAll(_url: string, args: RouteHandlerParams): Promise<RouteHandlerReturn> {
let data = await client.tournamentList();
if (data.kind !== 'success') {
showError(`Failed to fetch data: ${data.msg}`);
return {
html: "", postInsert: async () => navigateTo('/')
}
}
return {
html: pageAll, postInsert: async (app) => {
const tourDiv = app?.querySelector('#tourList');
if (!tourDiv || !app)
return showError('Fatal Error');
let childrens = await Promise.all(data.payload.data.map(async (tour) => {
const ownerUser = await client.getUser({ user: tour.owner });
const ownerUserName = ownerUser.kind === 'success' ? ownerUser.payload.name : "User Not Found";
return {
...tour,
ownerName: ownerUserName,
}
}))
childrens.sort((l, r) => ((new Date(r.time).getTime()) - (new Date(l.time)).getTime()));
childrens.map(tour => {
let time = new Date(tour.time);
let owner = { id: tour.owner, name: tour.ownerName };
let id = tour.id;
let playerCount = tour.playerCount;
const eventItem = document.createElement('div');
eventItem.classList.add(
'bg-white',
'p-4',
'rounded-lg',
'shadow-md',
'mb-4',
'flex',
'justify-between',
'items-center',
);
eventItem.innerHTML = `
<div class="text-left">
<p class="text-lg font-semibold text-gray-700"><strong>Time:</strong> ${time.toDateString()} ${getHHMM(time)}</p>
<p class="text-sm text-gray-500"><strong>Created By:</strong> ${owner.name} </p>
<p class="text-sm text-gray-500"><strong>Player Count:</strong> ${playerCount}</p>
</div>
<button class="bg-blue-500 text-white px-3 py-2 rounded-lg hover:bg-blue-600" onclick="navigateTo('/tour/${id}')">View</button>`;
return eventItem;
}).forEach(v => tourDiv.appendChild(v));
}
};
}
async function tourHistorySingle(_url: string, args: RouteHandlerParams): Promise<RouteHandlerReturn> {
if (isNullish(args.tourid)) {
showError(`No Tournament Specified`);
return {
html: "", postInsert: async () => navigateTo('/tour')
}
}
let data = await client.tournamentData({ id: args.tourid });
if (data.kind !== 'success') {
showError(`Failed to fetch data: ${data.msg}`);
return {
html: "", postInsert: async () => navigateTo('/')
}
}
let d = data.payload.data;
let user = getUser();
if (user === null) {
showError(`You must be logged in`);
return {
html: "", postInsert: async () => navigateTo('/')
}
}
return {
html: pageSingle, postInsert: async (app) => {
const tourOwner = app?.querySelector<HTMLSpanElement>('#tourOwner');
const tourTime = app?.querySelector<HTMLSpanElement>('#tourTime');
const tourCount = app?.querySelector<HTMLSpanElement>('#tourCount');
const tourPlayerList = app?.querySelector<HTMLTableElement>('#tourPlayerList');
const tourGames = app?.querySelector<HTMLDivElement>('#tourGames');
if (!tourOwner || !tourTime || !tourCount || !tourPlayerList || !tourGames || !app)
return showError('Fatal Error');
d.users.sort((l, r) => r.score - l.score);
let time = new Date(d.time);
const medals = ["🥇", "🥈", "🥉"];
tourPlayerList.innerHTML = d.users.map((player, idx) =>
`<tr class="${player.id === user.id ? "bg-amber-400 hover:bg-amber-500" : "hover:bg-gray-50"}" key="${player.id}">
<td class="px-4 py-2 text-sm text-gray-800 text-center border-b font-semibold min-w-100px">${idx < medals.length ? `<span class="font-lg">${medals[idx]}</span>` : ''}${player.nickname}</td>
<td class="px-4 py-2 text-sm text-gray-800 text-center border-b font-bold min-w-100px">${player.score}</td></tr>`)
.join("");
tourOwner.innerText = await (async () => {
let req = await client.getUser({ user: d.owner });
if (req.kind === 'success')
return req.payload.name;
return 'Unknown User';
})();
tourCount.innerText = d.playerCount.toString();
tourTime.innerText = `${time.toDateString()} ${getHHMM(time)}`;
let gameElement = d.games.map(g => {
let rdate = Date.parse(g.date);
if (Number.isNaN(rdate)) return undefined;
let date = new Date(rdate);
const e = document.createElement('div');
let color = 'bg-slate-300';
e.className =
'grid grid-cols-[1fr_auto_1fr] items-center rounded-lg px-4 py-3 ' + color;
e.innerHTML = `
<div class="text-right">
<div class="font-semibold ${g.outcome === 'winL' ? 'text-green-600' : 'text-red-600'}">${g.left.name}</div>
<div class="text-lg ${g.outcome === 'winL' ? 'text-green-600' : 'text-red-600'}">${g.left.score}</div>
</div>
<div class="text-center text-sm text-gray-800 px-4 whitespace-nowrap">${date.toDateString()}<br />${getHHMM(date)}</div>
<div class="text-left">
<div class="font-semibold ${g.outcome === 'winR' ? 'text-green-600' : 'text-red-600'}">${g.right.name}</div>
<div class="text-lg ${g.outcome === 'winR' ? 'text-green-600' : 'text-red-600'}">${g.right.score}</div>
</div>`;
return e;
}).filter(v => !isNullish(v));
gameElement.forEach(e => tourGames.appendChild(e));
}
}
}
addRoute('/tour', tourHistoryAll);
addRoute('/tour/:tourid', tourHistorySingle);

View file

@ -0,0 +1,9 @@
<div class="fixed inset-0 flex items-center justify-center bg-[#43536b]">
<div
class="fixed top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 bg-gray-200 w-212.5 p-6 rounded-xl shadow-2xl text-center z-50">
<h2 class="text-2xl font-bold text-center mb-4 text-gray-700">
List of All Tournaments
</h2>
<div id="tourList" class="max-w-3xl mx-auto space-y-2 max-h-[50dvh] overflow-scroll"></div>
</div>
</div>

View file

@ -0,0 +1,37 @@
<div class="fixed inset-0 flex items-center justify-center bg-[#43536b]">
<div
class="fixed top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 bg-gray-200 w-212.5 p-6 rounded-xl shadow-2xl text-center z-50">
<h2 class="text-2xl font-bold text-center mb-4 text-gray-700">
Tournament <span id="tour-date"> </span>
</h2>
<div id="tourInfo" class="bg-white p-4 rounded-lg shadow-md mb-4">
<p class="text-lg"> <strong class="text-gray-700">Created By:<strong> <span class="text-amber-500"
id="tourOwner"></span></p>
<p class="text-lg"> <strong class="text-gray-700">Finished At:<strong> <span class="text-amber-500"
id="tourTime"></span></p>
<p class="text-lg"> <strong class="text-gray-700">Players:<strong> <span class="text-amber-500"
id="tourCount"></span></p>
</div>
<div class="max-h-[320px] overflow-y-auto overflow-x-auto border rounded-lg mb-4">
<table class="min-w-full border-collapse table-fixed">
<thead class="sticky top-0 z-10 bg-gray-100">
<tr>
<th class="px-4 py-2 text-center text-lg font-semibold text-gray-700 border-b">
Name
</th>
<th class="px-4 py-2 text-center text-lg font-semibold text-gray-700 border-b">
Score
</th>
</tr>
</thead>
<tbody id="tourPlayerList">
<!-- rows -->
</tbody>
</table>
</div>
<div id="tourGames" class="max-w-3xl mx-auto space-y-2 max-h-[25dvh] overflow-scroll">
</div>
</div>
</div>

View file

@ -5,8 +5,6 @@
src: url("/fonts/DejaVuSansMono.woff2") format("woff2"); src: url("/fonts/DejaVuSansMono.woff2") format("woff2");
} }
.displaybox { .displaybox {
@apply @apply
fixed fixed

View file

@ -66,6 +66,7 @@ CREATE TABLE IF NOT EXISTS tournament (
id TEXT PRIMARY KEY NOT NULL, id TEXT PRIMARY KEY NOT NULL,
time TEXT NOT NULL default (datetime ('now')), time TEXT NOT NULL default (datetime ('now')),
owner TEXT NOT NULL, owner TEXT NOT NULL,
playerCount INTEGER NOT NULL,
FOREIGN KEY (owner) REFERENCES user (id) FOREIGN KEY (owner) REFERENCES user (id)
); );

View file

@ -36,7 +36,7 @@ export const TournamentImpl: Omit<ITournamentDb, keyof Database> = {
): TournamentData | null { ): TournamentData | null {
// Fetch tournament // Fetch tournament
const tournament = this const tournament = this
.prepare('SELECT id, time, owner FROM tournament WHERE id = @id') .prepare('SELECT * FROM tournament WHERE id = @id')
.get({ id }) as TournamentTable; .get({ id }) as TournamentTable;
if (!tournament) { if (!tournament) {
@ -90,7 +90,7 @@ export const TournamentImpl: Omit<ITournamentDb, keyof Database> = {
): void { ): void {
const tournamentId = newUUID() as TournamentId; const tournamentId = newUUID() as TournamentId;
this.prepare('INSERT INTO tournament (id, owner) VALUES (@id, @owner)').run({ id: tournamentId, owner }); this.prepare('INSERT INTO tournament (id, owner, playerCount) VALUES (@id, @owner, @count)').run({ id: tournamentId, owner, count: users.length });
for (const u of users) { for (const u of users) {
this.prepare('INSERT INTO tour_user (user, nickname, score, tournament) VALUES (@id, @name, @score, @tournament)').run({ id: u.id, name: u.name, score: u.score, tournament: tournamentId }); this.prepare('INSERT INTO tour_user (user, nickname, score, tournament) VALUES (@id, @name, @score, @tournament)').run({ id: u.id, name: u.name, score: u.score, tournament: tournamentId });
} }
@ -115,6 +115,7 @@ export interface TournamentTable {
id: TournamentId; id: TournamentId;
time: string; time: string;
owner: UserId; owner: UserId;
playerCount: number;
} }
export interface TournamentUser { export interface TournamentUser {

View file

@ -2497,12 +2497,16 @@
"data": { "data": {
"type": "object", "type": "object",
"required": [ "required": [
"playerCount",
"owner", "owner",
"users", "users",
"games", "games",
"time" "time"
], ],
"properties": { "properties": {
"playerCount": {
"type": "number"
},
"owner": { "owner": {
"type": "string", "type": "string",
"description": "ownerId" "description": "ownerId"
@ -2736,11 +2740,15 @@
"items": { "items": {
"type": "object", "type": "object",
"required": [ "required": [
"playerCount",
"id", "id",
"owner", "owner",
"time" "time"
], ],
"properties": { "properties": {
"playerCount": {
"type": "number"
},
"id": { "id": {
"type": "string", "type": "string",
"description": "tournamentId" "description": "tournamentId"

View file

@ -355,12 +355,16 @@
"data": { "data": {
"type": "object", "type": "object",
"required": [ "required": [
"playerCount",
"owner", "owner",
"users", "users",
"games", "games",
"time" "time"
], ],
"properties": { "properties": {
"playerCount": {
"type": "number"
},
"owner": { "owner": {
"type": "string", "type": "string",
"description": "ownerId" "description": "ownerId"
@ -591,11 +595,15 @@
"items": { "items": {
"type": "object", "type": "object",
"required": [ "required": [
"playerCount",
"id", "id",
"owner", "owner",
"time" "time"
], ],
"properties": { "properties": {
"playerCount": {
"type": "number"
},
"id": { "id": {
"type": "string", "type": "string",
"description": "tournamentId" "description": "tournamentId"

View file

@ -11,6 +11,7 @@ type TournamentDataParams = Static<typeof TournamentDataParams>;
const TournamentDataResponse = { const TournamentDataResponse = {
'200': typeResponse('success', 'tournamentData.success', { '200': typeResponse('success', 'tournamentData.success', {
data: Type.Object({ data: Type.Object({
playerCount: Type.Number(),
owner: Type.String({ description: 'ownerId' }), owner: Type.String({ description: 'ownerId' }),
users: Type.Array( users: Type.Array(
Type.Object({ Type.Object({
@ -65,8 +66,10 @@ const route: FastifyPluginAsync = async (fastify): Promise<void> => {
'tournamentData.failure.notFound', 'tournamentData.failure.notFound',
); );
} }
console.log(data);
const typed_res: TournamentDataResponse['200']['payload']['data'] = const typed_res: TournamentDataResponse['200']['payload']['data'] =
{ {
playerCount: data.playerCount,
owner: data.owner, owner: data.owner,
time: data.time, time: data.time,
users: data.users.map((v) => ({ users: data.users.map((v) => ({

View file

@ -6,6 +6,7 @@ const TournamentListResponse = {
'200': typeResponse('success', 'tournamentList.success', { '200': typeResponse('success', 'tournamentList.success', {
data: Type.Array( data: Type.Array(
Type.Object({ Type.Object({
playerCount: Type.Number(),
id: Type.String({ description: 'tournamentId' }), id: Type.String({ description: 'tournamentId' }),
owner: Type.String({ description: 'ownerId' }), owner: Type.String({ description: 'ownerId' }),
time: Type.String(), time: Type.String(),