文件
tennis-training-hub/server/matchStore.ts
2026-04-07 11:00:03 +08:00

890 行
29 KiB
TypeScript

import { and, asc, desc, eq, inArray, sql } from "drizzle-orm";
import {
matchParticipants,
matchScoreEvents,
matchSessions,
users,
type InsertMatchParticipant,
type InsertMatchScoreEvent,
type InsertMatchSession,
} from "../drizzle/schema";
import {
createNotification,
getDateKey,
getDb,
refreshAchievementsForUser,
refreshUserTrainingSummary,
upsertDailyTrainingAggregate,
upsertTrainingRecordBySource,
} from "./db";
import {
MATCH_PLAYER_SLOTS,
buildParticipantSettlement,
deriveSuggestedMatchState,
normalizeMatchMetrics,
normalizeMatchScoreboard,
type MatchPlayerSlot,
} from "./match";
type MatchMode = "daily" | "competitive";
type WorkflowStatus = "draft" | "recording" | "review_pending" | "reviewed" | "finalizing" | "finalized" | "cancelled";
type SuggestionStatus = "idle" | "queued" | "ready" | "failed";
type CameraStatus = "pending" | "bound" | "active" | "completed" | "failed";
type EventSource = "camera_a" | "camera_b" | "system" | "admin";
type EventType = "point" | "game" | "set" | "metric" | "score_suggestion" | "review_adjustment" | "finalized";
type ParticipantRow = Awaited<ReturnType<typeof listMatchParticipants>>[number];
function orderByIds<T extends { id: number }>(rows: T[], ids: number[]) {
const order = new Map(ids.map((id, index) => [id, index]));
return [...rows].sort((a, b) => (order.get(a.id) ?? 0) - (order.get(b.id) ?? 0));
}
function indexBySlot(rows: ParticipantRow[]) {
return {
player_a: rows.find((row) => row.playerSlot === "player_a") ?? null,
player_b: rows.find((row) => row.playerSlot === "player_b") ?? null,
};
}
async function loadParticipantsMap(matchIds: number[]) {
if (matchIds.length === 0) {
return new Map<number, ParticipantRow[]>();
}
const db = await getDb();
if (!db) return new Map<number, ParticipantRow[]>();
const rows = await db.select({
id: matchParticipants.id,
matchId: matchParticipants.matchId,
userId: matchParticipants.userId,
userName: users.name,
playerSlot: matchParticipants.playerSlot,
cameraSlot: matchParticipants.cameraSlot,
cameraStatus: matchParticipants.cameraStatus,
cameraLabel: matchParticipants.cameraLabel,
cameraVideoId: matchParticipants.cameraVideoId,
cameraVideoUrl: matchParticipants.cameraVideoUrl,
cameraSnapshot: matchParticipants.cameraSnapshot,
isWinner: matchParticipants.isWinner,
suggestedSetsWon: matchParticipants.suggestedSetsWon,
suggestedGamesWon: matchParticipants.suggestedGamesWon,
suggestedPointsWon: matchParticipants.suggestedPointsWon,
finalSetsWon: matchParticipants.finalSetsWon,
finalGamesWon: matchParticipants.finalGamesWon,
finalPointsWon: matchParticipants.finalPointsWon,
suggestedStats: matchParticipants.suggestedStats,
finalStats: matchParticipants.finalStats,
createdAt: matchParticipants.createdAt,
updatedAt: matchParticipants.updatedAt,
}).from(matchParticipants)
.leftJoin(users, eq(users.id, matchParticipants.userId))
.where(inArray(matchParticipants.matchId, matchIds))
.orderBy(asc(matchParticipants.matchId), asc(matchParticipants.id));
const grouped = new Map<number, ParticipantRow[]>();
for (const row of rows) {
const list = grouped.get(row.matchId) ?? [];
list.push(row);
grouped.set(row.matchId, list);
}
return grouped;
}
async function loadEventCounts(matchIds: number[]) {
if (matchIds.length === 0) {
return new Map<number, number>();
}
const db = await getDb();
if (!db) return new Map<number, number>();
const rows = await db.select({
matchId: matchScoreEvents.matchId,
}).from(matchScoreEvents).where(inArray(matchScoreEvents.matchId, matchIds));
const counts = new Map<number, number>();
for (const row of rows) {
counts.set(row.matchId, (counts.get(row.matchId) ?? 0) + 1);
}
return counts;
}
async function hydrateMatches(sessionIds: number[]) {
const db = await getDb();
if (!db || sessionIds.length === 0) return [];
const sessions = await db.select().from(matchSessions)
.where(inArray(matchSessions.id, sessionIds));
const participantsMap = await loadParticipantsMap(sessionIds);
const eventCounts = await loadEventCounts(sessionIds);
return orderByIds(sessions, sessionIds).map((session) => ({
...session,
participants: participantsMap.get(session.id) ?? [],
eventCount: eventCounts.get(session.id) ?? 0,
}));
}
export async function getMatchSessionById(matchId: number) {
const db = await getDb();
if (!db) return undefined;
const result = await db.select().from(matchSessions).where(eq(matchSessions.id, matchId)).limit(1);
return result[0];
}
export async function getMatchParticipantBySlot(matchId: number, playerSlot: MatchPlayerSlot) {
const db = await getDb();
if (!db) return undefined;
const result = await db.select().from(matchParticipants)
.where(and(eq(matchParticipants.matchId, matchId), eq(matchParticipants.playerSlot, playerSlot)))
.limit(1);
return result[0];
}
export async function listMatchParticipants(matchId: number) {
const db = await getDb();
if (!db) return [];
return db.select({
id: matchParticipants.id,
matchId: matchParticipants.matchId,
userId: matchParticipants.userId,
userName: users.name,
playerSlot: matchParticipants.playerSlot,
cameraSlot: matchParticipants.cameraSlot,
cameraStatus: matchParticipants.cameraStatus,
cameraLabel: matchParticipants.cameraLabel,
cameraVideoId: matchParticipants.cameraVideoId,
cameraVideoUrl: matchParticipants.cameraVideoUrl,
cameraSnapshot: matchParticipants.cameraSnapshot,
isWinner: matchParticipants.isWinner,
suggestedSetsWon: matchParticipants.suggestedSetsWon,
suggestedGamesWon: matchParticipants.suggestedGamesWon,
suggestedPointsWon: matchParticipants.suggestedPointsWon,
finalSetsWon: matchParticipants.finalSetsWon,
finalGamesWon: matchParticipants.finalGamesWon,
finalPointsWon: matchParticipants.finalPointsWon,
suggestedStats: matchParticipants.suggestedStats,
finalStats: matchParticipants.finalStats,
createdAt: matchParticipants.createdAt,
updatedAt: matchParticipants.updatedAt,
}).from(matchParticipants)
.leftJoin(users, eq(users.id, matchParticipants.userId))
.where(eq(matchParticipants.matchId, matchId))
.orderBy(asc(matchParticipants.id));
}
export async function listMatchScoreEvents(matchId: number) {
const db = await getDb();
if (!db) return [];
return db.select().from(matchScoreEvents)
.where(eq(matchScoreEvents.matchId, matchId))
.orderBy(asc(matchScoreEvents.eventIndex), asc(matchScoreEvents.id));
}
export async function getMatchDetail(matchId: number) {
const session = await getMatchSessionById(matchId);
if (!session) return undefined;
const participants = await listMatchParticipants(matchId);
const events = await listMatchScoreEvents(matchId);
return {
...session,
participants,
events,
eventCount: events.length,
};
}
export async function listAccessibleMatchSessions(params: {
viewerUserId: number;
isAdmin: boolean;
limit?: number;
workflowStatus?: WorkflowStatus | "all";
matchMode?: MatchMode | "all";
}) {
const db = await getDb();
if (!db) return [];
const limit = params.limit ?? 50;
const filters = [];
if (params.workflowStatus && params.workflowStatus !== "all") {
filters.push(eq(matchSessions.workflowStatus, params.workflowStatus));
}
if (params.matchMode && params.matchMode !== "all") {
filters.push(eq(matchSessions.matchMode, params.matchMode));
}
if (params.isAdmin) {
const sessions = await db.select({
id: matchSessions.id,
}).from(matchSessions)
.where(filters.length > 0 ? and(...filters) : undefined)
.orderBy(desc(matchSessions.updatedAt), desc(matchSessions.id))
.limit(limit);
return hydrateMatches(sessions.map((row) => row.id));
}
const conditions = [
eq(matchParticipants.userId, params.viewerUserId),
...filters,
];
const sessions = await db.select({
id: matchSessions.id,
}).from(matchParticipants)
.innerJoin(matchSessions, eq(matchSessions.id, matchParticipants.matchId))
.where(and(...conditions))
.orderBy(desc(matchSessions.updatedAt), desc(matchSessions.id))
.limit(limit);
return hydrateMatches(sessions.map((row) => row.id));
}
export async function createMatchSession(input: {
createdByUserId: number;
matchMode: MatchMode;
title: string;
courtName?: string | null;
notes?: string | null;
durationMinutes: number;
scheduledAt?: Date | null;
participantUserIds: [number, number];
}) {
if (input.participantUserIds[0] === input.participantUserIds[1]) {
throw new Error("两位参赛用户必须不同");
}
const db = await getDb();
if (!db) throw new Error("Database not available");
const matchId = await db.transaction(async (tx) => {
const sessionValues: InsertMatchSession = {
createdByUserId: input.createdByUserId,
matchMode: input.matchMode,
workflowStatus: "draft",
title: input.title,
courtName: input.courtName ?? null,
notes: input.notes ?? null,
durationMinutes: input.durationMinutes,
scheduledAt: input.scheduledAt ?? null,
suggestionStatus: "idle",
suggestionTaskId: null,
suggestedScore: null,
suggestedMetrics: null,
finalScore: null,
finalMetrics: null,
reviewNotes: null,
reviewSubmittedAt: null,
reviewedByUserId: null,
reviewedAt: null,
finalizedByUserId: null,
finalizedAt: null,
};
const inserted = await tx.insert(matchSessions).values(sessionValues);
const createdMatchId = inserted[0].insertId;
const participantValues: InsertMatchParticipant[] = [
{
matchId: createdMatchId,
userId: input.participantUserIds[0],
playerSlot: "player_a",
cameraSlot: "camera_a",
cameraStatus: "pending",
cameraLabel: null,
cameraVideoId: null,
cameraVideoUrl: null,
cameraSnapshot: null,
isWinner: 0,
suggestedSetsWon: 0,
suggestedGamesWon: 0,
suggestedPointsWon: 0,
finalSetsWon: 0,
finalGamesWon: 0,
finalPointsWon: 0,
suggestedStats: null,
finalStats: null,
},
{
matchId: createdMatchId,
userId: input.participantUserIds[1],
playerSlot: "player_b",
cameraSlot: "camera_b",
cameraStatus: "pending",
cameraLabel: null,
cameraVideoId: null,
cameraVideoUrl: null,
cameraSnapshot: null,
isWinner: 0,
suggestedSetsWon: 0,
suggestedGamesWon: 0,
suggestedPointsWon: 0,
finalSetsWon: 0,
finalGamesWon: 0,
finalPointsWon: 0,
suggestedStats: null,
finalStats: null,
},
];
await tx.insert(matchParticipants).values(participantValues);
return createdMatchId;
});
return getMatchDetail(matchId);
}
export async function updateMatchSession(matchId: number, patch: Partial<InsertMatchSession>) {
const db = await getDb();
if (!db) return;
await db.update(matchSessions).set(patch).where(eq(matchSessions.id, matchId));
}
export async function bindMatchCamera(input: {
matchId: number;
playerSlot: MatchPlayerSlot;
cameraStatus: CameraStatus;
cameraLabel?: string | null;
cameraVideoId?: number | null;
cameraVideoUrl?: string | null;
cameraSnapshot?: unknown;
}) {
const db = await getDb();
if (!db) throw new Error("Database not available");
const participant = await getMatchParticipantBySlot(input.matchId, input.playerSlot);
if (!participant) {
throw new Error("Match participant not found");
}
await db.update(matchParticipants).set({
cameraStatus: input.cameraStatus,
cameraLabel: input.cameraLabel === undefined ? participant.cameraLabel : input.cameraLabel,
cameraVideoId: input.cameraVideoId === undefined ? participant.cameraVideoId : input.cameraVideoId,
cameraVideoUrl: input.cameraVideoUrl === undefined ? participant.cameraVideoUrl : input.cameraVideoUrl,
cameraSnapshot: input.cameraSnapshot === undefined ? participant.cameraSnapshot : input.cameraSnapshot,
}).where(eq(matchParticipants.id, participant.id));
const session = await getMatchSessionById(input.matchId);
if (session?.workflowStatus === "draft" && input.cameraStatus !== "pending") {
await updateMatchSession(input.matchId, {
workflowStatus: "recording",
});
}
return getMatchDetail(input.matchId);
}
async function getNextEventIndex(matchId: number) {
const db = await getDb();
if (!db) return 1;
const result = await db.select({
maxEventIndex: sql<number>`coalesce(max(${matchScoreEvents.eventIndex}), 0)`,
}).from(matchScoreEvents).where(eq(matchScoreEvents.matchId, matchId));
return (result[0]?.maxEventIndex ?? 0) + 1;
}
export async function insertMatchScoreEvent(input: {
matchId: number;
source: EventSource;
eventType: EventType;
winnerSlot?: MatchPlayerSlot | null;
matchSecond?: number | null;
confidence?: number | null;
payload?: unknown;
createdByUserId?: number | null;
}) {
const db = await getDb();
if (!db) throw new Error("Database not available");
const eventIndex = await getNextEventIndex(input.matchId);
const values: InsertMatchScoreEvent = {
matchId: input.matchId,
eventIndex,
source: input.source,
eventType: input.eventType,
winnerSlot: input.winnerSlot ?? null,
matchSecond: input.matchSecond ?? null,
confidence: input.confidence ?? null,
payload: input.payload ?? null,
createdByUserId: input.createdByUserId ?? null,
};
const inserted = await db.insert(matchScoreEvents).values(values);
const rows = await db.select().from(matchScoreEvents).where(eq(matchScoreEvents.id, inserted[0].insertId)).limit(1);
const session = await getMatchSessionById(input.matchId);
if (session && session.workflowStatus === "draft") {
await updateMatchSession(input.matchId, {
workflowStatus: "recording",
});
}
return rows[0];
}
export async function saveMatchSuggestion(matchId: number, params: {
suggestionStatus: SuggestionStatus;
suggestionTaskId?: string | null;
suggestedScore?: unknown;
suggestedMetrics?: unknown;
}) {
const session = await getMatchSessionById(matchId);
if (!session) {
throw new Error("Match session not found");
}
const score = params.suggestedScore ? normalizeMatchScoreboard(params.suggestedScore) : normalizeMatchScoreboard(session.suggestedScore);
const metrics = params.suggestedMetrics ? normalizeMatchMetrics(params.suggestedMetrics) : normalizeMatchMetrics(session.suggestedMetrics);
const settlement = buildParticipantSettlement(score, metrics);
const participants = await listMatchParticipants(matchId);
await updateMatchSession(matchId, {
suggestionStatus: params.suggestionStatus,
suggestionTaskId: params.suggestionTaskId === undefined ? session.suggestionTaskId : params.suggestionTaskId,
suggestedScore: params.suggestedScore === undefined ? session.suggestedScore : score,
suggestedMetrics: params.suggestedMetrics === undefined ? session.suggestedMetrics : metrics,
workflowStatus: session.workflowStatus === "draft" || session.workflowStatus === "recording"
? (params.suggestionStatus === "ready" ? "review_pending" : session.workflowStatus)
: session.workflowStatus,
});
const rowsBySlot = indexBySlot(participants);
for (const slot of MATCH_PLAYER_SLOTS) {
const row = rowsBySlot[slot];
if (!row) continue;
const player = settlement.players[slot];
const dbConn = await getDb();
if (!dbConn) continue;
await dbConn.update(matchParticipants).set({
suggestedSetsWon: player.setsWon,
suggestedGamesWon: player.gamesWon,
suggestedPointsWon: player.pointsWon,
suggestedStats: player.stats,
}).where(eq(matchParticipants.id, row.id));
}
return getMatchDetail(matchId);
}
export async function submitMatchReview(input: {
matchId: number;
reviewedByUserId: number;
reviewNotes?: string | null;
finalScore?: unknown;
finalMetrics?: unknown;
}) {
const session = await getMatchSessionById(input.matchId);
if (!session) {
throw new Error("Match session not found");
}
const score = normalizeMatchScoreboard(input.finalScore ?? session.suggestedScore);
const metrics = normalizeMatchMetrics(input.finalMetrics ?? session.suggestedMetrics);
const settlement = buildParticipantSettlement(score, metrics);
const participants = await listMatchParticipants(input.matchId);
const db = await getDb();
if (!db) throw new Error("Database not available");
await updateMatchSession(input.matchId, {
finalScore: score,
finalMetrics: metrics,
reviewNotes: input.reviewNotes ?? session.reviewNotes,
reviewSubmittedAt: new Date(),
reviewedByUserId: input.reviewedByUserId,
reviewedAt: new Date(),
workflowStatus: "reviewed",
});
const rowsBySlot = indexBySlot(participants);
for (const slot of MATCH_PLAYER_SLOTS) {
const row = rowsBySlot[slot];
if (!row) continue;
const player = settlement.players[slot];
await db.update(matchParticipants).set({
isWinner: player.isWinner ? 1 : 0,
finalSetsWon: player.setsWon,
finalGamesWon: player.gamesWon,
finalPointsWon: player.pointsWon,
finalStats: player.stats,
}).where(eq(matchParticipants.id, row.id));
}
await insertMatchScoreEvent({
matchId: input.matchId,
source: "admin",
eventType: "review_adjustment",
winnerSlot: settlement.winnerSlot,
confidence: 1,
payload: {
score,
metrics,
reviewNotes: input.reviewNotes ?? null,
},
createdByUserId: input.reviewedByUserId,
});
return getMatchDetail(input.matchId);
}
export async function markMatchSuggestionQueued(matchId: number, taskId: string) {
const session = await getMatchSessionById(matchId);
if (!session) {
throw new Error("Match session not found");
}
await updateMatchSession(matchId, {
suggestionStatus: "queued",
suggestionTaskId: taskId,
workflowStatus: session.workflowStatus === "draft" ? "recording" : session.workflowStatus,
});
}
export async function markMatchFinalizing(matchId: number) {
const session = await getMatchSessionById(matchId);
if (!session) {
throw new Error("Match session not found");
}
await updateMatchSession(matchId, {
workflowStatus: "finalizing",
});
}
export async function cancelMatchSession(matchId: number, notes?: string | null) {
const session = await getMatchSessionById(matchId);
if (!session) {
throw new Error("Match session not found");
}
await updateMatchSession(matchId, {
workflowStatus: "cancelled",
notes: notes === undefined ? session.notes : notes,
});
return getMatchDetail(matchId);
}
export async function generateSuggestedMatchState(matchId: number) {
const detail = await getMatchDetail(matchId);
if (!detail) {
throw new Error("Match session not found");
}
const suggestion = deriveSuggestedMatchState(detail.events.map((event) => ({
eventType: event.eventType,
winnerSlot: event.winnerSlot,
confidence: event.confidence,
payload: event.payload,
source: event.source,
eventIndex: event.eventIndex,
})));
await saveMatchSuggestion(matchId, {
suggestionStatus: "ready",
suggestionTaskId: detail.suggestionTaskId,
suggestedScore: suggestion.score,
suggestedMetrics: suggestion.metrics,
});
await insertMatchScoreEvent({
matchId,
source: "system",
eventType: "score_suggestion",
winnerSlot: suggestion.score.winnerSlot,
confidence: suggestion.score.confidence,
payload: {
score: suggestion.score,
metrics: suggestion.metrics,
eventCount: suggestion.eventCount,
sourceCount: suggestion.sourceCount,
},
});
return {
score: suggestion.score,
metrics: suggestion.metrics,
eventCount: suggestion.eventCount,
sourceCount: suggestion.sourceCount,
};
}
export async function finalizeMatchSettlement(matchId: number, finalizedByUserId: number) {
const detail = await getMatchDetail(matchId);
if (!detail) {
throw new Error("Match session not found");
}
if (detail.workflowStatus === "finalized") {
return detail;
}
if (detail.workflowStatus === "cancelled") {
throw new Error("已取消的比赛不能结算");
}
const score = normalizeMatchScoreboard(detail.finalScore ?? detail.suggestedScore);
const metrics = normalizeMatchMetrics(detail.finalMetrics ?? detail.suggestedMetrics);
const settlement = buildParticipantSettlement(score, metrics);
const occurredAt = detail.endedAt ?? detail.reviewSubmittedAt ?? detail.startedAt ?? detail.scheduledAt ?? new Date();
const trainingDateKey = getDateKey(occurredAt);
const durationMinutes = Math.max(1, detail.durationMinutes || 90);
const participantsBySlot = indexBySlot(detail.participants);
const db = await getDb();
if (!db) {
throw new Error("Database not available");
}
await updateMatchSession(matchId, {
finalScore: score,
finalMetrics: metrics,
workflowStatus: "finalizing",
reviewedByUserId: detail.reviewedByUserId ?? finalizedByUserId,
reviewedAt: detail.reviewedAt ?? new Date(),
reviewSubmittedAt: detail.reviewSubmittedAt ?? new Date(),
});
for (const slot of MATCH_PLAYER_SLOTS) {
const participant = participantsBySlot[slot];
if (!participant) continue;
const opponent = MATCH_PLAYER_SLOTS
.filter((item) => item !== slot)
.map((item) => participantsBySlot[item])
.find(Boolean) ?? null;
const player = settlement.players[slot];
const matchLabel = detail.matchMode === "competitive" ? "竞赛双人比赛" : "日常双人比赛";
const sourceType = detail.matchMode === "competitive" ? "match_competitive" : "match_daily";
const resultLabel = settlement.winnerSlot == null ? "已确认" : player.isWinner ? "获胜" : "失利";
await db.update(matchParticipants).set({
isWinner: player.isWinner ? 1 : 0,
finalSetsWon: player.setsWon,
finalGamesWon: player.gamesWon,
finalPointsWon: player.pointsWon,
finalStats: player.stats,
cameraStatus: participant.cameraStatus === "active" ? "completed" : participant.cameraStatus,
}).where(eq(matchParticipants.id, participant.id));
const upsertResult = await upsertTrainingRecordBySource({
userId: participant.userId,
planId: null,
linkedPlanId: null,
matchConfidence: detail.suggestionStatus === "ready" ? score.confidence : null,
exerciseName: matchLabel,
exerciseType: "match",
sourceType,
sourceId: `${sourceType}:${matchId}:${slot}`,
videoId: participant.cameraVideoId ?? null,
actionCount: player.pointsWon,
durationMinutes,
completed: 1,
notes: `${detail.title} · ${resultLabel}`,
poseScore: null,
trainingDate: occurredAt,
metadata: {
matchId,
matchMode: detail.matchMode,
playerSlot: slot,
opponentUserId: opponent?.userId ?? null,
winnerSlot: settlement.winnerSlot,
finalScore: score,
finalMetrics: metrics,
courtName: detail.courtName ?? null,
cameraStatus: participant.cameraStatus,
},
});
if (detail.matchMode === "daily" && upsertResult.isNew) {
await upsertDailyTrainingAggregate({
userId: participant.userId,
trainingDate: trainingDateKey,
deltaMinutes: durationMinutes,
deltaSessions: 1,
deltaTotalActions: player.pointsWon,
deltaEffectiveActions: player.pointsWon,
metadata: {
latestDailyMatchId: matchId,
latestDailyMatchResult: resultLabel,
},
});
}
if (detail.matchMode === "competitive") {
await refreshUserTrainingSummary(participant.userId);
}
await createNotification({
userId: participant.userId,
notificationType: "match_settlement",
title: `比赛已入库 · ${detail.title}`.slice(0, 256),
message: `${matchLabel} 已完成审核入库,结果:${resultLabel},比分 ${player.setsWon}:${settlement.players[slot === "player_a" ? "player_b" : "player_a"].setsWon} 盘 / ${player.gamesWon}:${settlement.players[slot === "player_a" ? "player_b" : "player_a"].gamesWon} 局。`,
isRead: 0,
});
await refreshAchievementsForUser(participant.userId);
}
await insertMatchScoreEvent({
matchId,
source: "admin",
eventType: "finalized",
winnerSlot: settlement.winnerSlot,
confidence: 1,
payload: {
score,
metrics,
finalizedByUserId,
finalizedAt: new Date().toISOString(),
},
createdByUserId: finalizedByUserId,
});
await updateMatchSession(matchId, {
workflowStatus: "finalized",
finalizedByUserId,
finalizedAt: new Date(),
});
return getMatchDetail(matchId);
}
export async function getAccessibleMatchSummary(params: {
viewerUserId: number;
isAdmin: boolean;
}) {
const rows = await listAccessibleMatchSessions({
viewerUserId: params.viewerUserId,
isAdmin: params.isAdmin,
limit: 200,
});
let competitiveWins = 0;
let camerasBound = 0;
for (const match of rows) {
for (const participant of match.participants) {
if (participant.cameraStatus !== "pending") {
camerasBound += 1;
}
if (!params.isAdmin && participant.userId !== params.viewerUserId) {
continue;
}
if (match.matchMode === "competitive" && match.workflowStatus === "finalized" && participant.isWinner === 1) {
competitiveWins += 1;
}
}
}
return {
total: rows.length,
draft: rows.filter((row) => row.workflowStatus === "draft").length,
reviewPending: rows.filter((row) => row.workflowStatus === "review_pending" || row.workflowStatus === "reviewed").length,
finalized: rows.filter((row) => row.workflowStatus === "finalized").length,
daily: rows.filter((row) => row.matchMode === "daily").length,
competitive: rows.filter((row) => row.matchMode === "competitive").length,
competitiveWins,
camerasBound,
};
}
export async function getCompetitiveLeaderboard(sortBy: "wins" | "winRate" | "setsWon" | "pointsWon" | "matches" = "wins", limit = 50) {
const db = await getDb();
if (!db) return [];
const rows = await db.select({
matchId: matchParticipants.matchId,
userId: matchParticipants.userId,
userName: users.name,
skillLevel: users.skillLevel,
ntrpRating: users.ntrpRating,
isWinner: matchParticipants.isWinner,
finalSetsWon: matchParticipants.finalSetsWon,
finalGamesWon: matchParticipants.finalGamesWon,
finalPointsWon: matchParticipants.finalPointsWon,
}).from(matchParticipants)
.innerJoin(matchSessions, eq(matchSessions.id, matchParticipants.matchId))
.innerJoin(users, eq(users.id, matchParticipants.userId))
.where(and(
eq(matchSessions.matchMode, "competitive"),
eq(matchSessions.workflowStatus, "finalized"),
))
.orderBy(desc(matchParticipants.matchId), asc(matchParticipants.id));
const matchRows = new Map<number, typeof rows>();
for (const row of rows) {
const list = matchRows.get(row.matchId) ?? [];
list.push(row);
matchRows.set(row.matchId, list);
}
const leaderboard = new Map<number, {
id: number;
name: string | null;
ntrpRating: number | null;
skillLevel: string | null;
matches: number;
wins: number;
losses: number;
winRate: number;
setsWon: number;
setsLost: number;
gamesWon: number;
gamesLost: number;
pointsWon: number;
pointsLost: number;
}>();
for (const rowsOfMatch of Array.from(matchRows.values())) {
const byUser = rowsOfMatch;
for (const row of byUser) {
const opponent = byUser.find((item: (typeof rows)[number]) => item.userId !== row.userId) ?? null;
const current = leaderboard.get(row.userId) ?? {
id: row.userId,
name: row.userName ?? null,
ntrpRating: row.ntrpRating ?? 1.5,
skillLevel: row.skillLevel ?? "beginner",
matches: 0,
wins: 0,
losses: 0,
winRate: 0,
setsWon: 0,
setsLost: 0,
gamesWon: 0,
gamesLost: 0,
pointsWon: 0,
pointsLost: 0,
};
current.matches += 1;
current.wins += row.isWinner === 1 ? 1 : 0;
current.losses += row.isWinner === 1 ? 0 : 1;
current.setsWon += row.finalSetsWon || 0;
current.setsLost += opponent?.finalSetsWon || 0;
current.gamesWon += row.finalGamesWon || 0;
current.gamesLost += opponent?.finalGamesWon || 0;
current.pointsWon += row.finalPointsWon || 0;
current.pointsLost += opponent?.finalPointsWon || 0;
current.winRate = current.matches > 0 ? Math.round((current.wins / current.matches) * 1000) / 10 : 0;
leaderboard.set(row.userId, current);
}
}
const sorted = Array.from(leaderboard.values()).sort((a, b) => {
const primary = {
wins: [b.wins - a.wins, b.winRate - a.winRate, b.setsWon - a.setsWon, b.pointsWon - a.pointsWon],
winRate: [b.winRate - a.winRate, b.wins - a.wins, b.setsWon - a.setsWon, b.pointsWon - a.pointsWon],
setsWon: [b.setsWon - a.setsWon, b.wins - a.wins, b.winRate - a.winRate, b.pointsWon - a.pointsWon],
pointsWon: [b.pointsWon - a.pointsWon, b.wins - a.wins, b.winRate - a.winRate, b.setsWon - a.setsWon],
matches: [b.matches - a.matches, b.wins - a.wins, b.winRate - a.winRate, b.pointsWon - a.pointsWon],
}[sortBy];
for (const value of primary) {
if (value !== 0) return value;
}
return a.id - b.id;
});
return sorted.slice(0, limit);
}