diff --git a/.gitignore b/.gitignore index 113a3e3..73e4801 100644 --- a/.gitignore +++ b/.gitignore @@ -41,3 +41,5 @@ next-env.d.ts public/images/users !public/images/users/unnamed.jpeg + +src/cli/data \ No newline at end of file diff --git a/README.md b/README.md index 83f8087..c319460 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ $ bun install Copy the .env.example file and add you own database url. ```bash -cp .env.example .env +$ cp .env.example .env ``` Before you start the server, make sure you've run the migrations: @@ -32,6 +32,36 @@ $ bun dev Open http://localhost:3000 with your browser to see the result. +## Getting results via the CLI + +You can get the results of the competition via the CLI. To do this, run the following command: + +```bash +$ bun src/cli/index.ts --dataDirPath +``` + +The `--dataDirPath` flag is required and should be the absolute path to the directory where the data files are stored. The data files are exports from the database and should be named `Match.json` and `User.json` + +### Excluding users + +You can also exclude users from the goals scored and goals conceded ranking by passing the `--excludeUsers` flag followed by a comma separated list of user ids. For example: + +```bash +$ bun src/cli/index.ts --dataDirPath --excludeUserIds 1,2,3 +``` + +Make sure no spaces are present between the user ids. + +### Defining a locale + +You can also define a locale for the output by passing the `--locale` flag followed by the locale you want to use. For example: + +```bash +$ bun src/cli/index.ts --dataDirPath --locale nl-NL +``` + +The locale should be a valid IETF BCP 47 language tag. The default locale is `en-US`. + ## Contributing Contibutions are welcome! If you want to add a cool feature or do some kind of improvement, feel free to open an [issue](https://github.com/brainstudnl/blaco/issues/new/choose) or [pull request](https://github.com/brainstudnl/blaco/compare)! diff --git a/bun.lockb b/bun.lockb index fc5dcb3..0ad654e 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/package.json b/package.json index a98a0af..1a65e17 100644 --- a/package.json +++ b/package.json @@ -25,10 +25,12 @@ "devDependencies": { "@mdx-js/loader": "3.0.1", "@trivago/prettier-plugin-sort-imports": "4.3.0", + "@types/bun": "1.1.9", "@types/mdx": "2.0.13", "@types/node": "20.14.2", "@types/react": "18.3.3", "@types/react-dom": "18.3.0", + "cli-table3": "0.6.5", "eslint": "8.57.0", "eslint-config-next": "14.2.3", "typescript": "5.4.5" diff --git a/src/cli/bestWorstGoalDifference.ts b/src/cli/bestWorstGoalDifference.ts new file mode 100644 index 0000000..dc14138 --- /dev/null +++ b/src/cli/bestWorstGoalDifference.ts @@ -0,0 +1,36 @@ +/** + * Calculate the user with the best and worst goal difference. + */ +export function calculateBestAndWorstGoalDifference( + goalsScored: Map, + goalsConceded: Map, +) { + let bestUserId: number | null = null; + let worstUserId: number | null = null; + let bestGoalDifference = 0; + let worstGoalDifference = 0; + + goalsScored.forEach((scored, userId) => { + const conceded = goalsConceded.get(userId) || 0; + const goalDifference = scored - conceded; + + // If the goal difference is better than the current best, update the best. + if (goalDifference > bestGoalDifference) { + bestGoalDifference = goalDifference; + bestUserId = userId; + } + + // If the goal difference is worse than the current worst, update the worst. + if (goalDifference < worstGoalDifference) { + worstGoalDifference = goalDifference; + worstUserId = userId; + } + }); + + return { + bestUserId, + worstUserId, + bestGoalDifference, + worstGoalDifference, + }; +} diff --git a/src/cli/config.ts b/src/cli/config.ts new file mode 100644 index 0000000..8f79eb3 --- /dev/null +++ b/src/cli/config.ts @@ -0,0 +1,15 @@ +let locale = 'en-GB'; + +/** + * Set the locale to use for formatting dates. + */ +export function setLocale(newLocale?: string) { + if (newLocale) locale = newLocale; +} + +/** + * Get the locale used for formatting dates. + */ +export function getLocale() { + return locale; +} diff --git a/src/cli/filterExcludedUsers.ts b/src/cli/filterExcludedUsers.ts new file mode 100644 index 0000000..3ce07dd --- /dev/null +++ b/src/cli/filterExcludedUsers.ts @@ -0,0 +1,15 @@ +import { Match } from '@prisma/client'; + +/** + * Filter out matches where the challenger or defender is in the excluded list. + */ +export function filterExcludedUsers( + matches: Array, + excludedIds: Array, +): Array { + return matches.filter( + (match) => + !excludedIds.includes(match.challenger_id) && + !excludedIds.includes(match.defender_id), + ); +} diff --git a/src/cli/getHumanReadableDate.ts b/src/cli/getHumanReadableDate.ts new file mode 100644 index 0000000..16ebc9e --- /dev/null +++ b/src/cli/getHumanReadableDate.ts @@ -0,0 +1,13 @@ +import { getLocale } from './config'; + +const options: Intl.DateTimeFormatOptions = { + weekday: 'long', + year: 'numeric', + month: 'long', + day: 'numeric', +}; + +export function getHumanReadableDate(date: string): string { + const locale = getLocale(); + return new Date(date).toLocaleDateString(locale, options); +} diff --git a/src/cli/goalsPerUser.ts b/src/cli/goalsPerUser.ts new file mode 100644 index 0000000..1a0f3bd --- /dev/null +++ b/src/cli/goalsPerUser.ts @@ -0,0 +1,41 @@ +import { Match } from '@prisma/client'; + +/** + * Helper function to update goals scored and conceded for a user. + * Combines goals scored and conceded update into a single operation. + */ +function updateGoalsMap( + userId: number, + scored: number, + conceded: number, + goalsMap: Map, +) { + const userStats = goalsMap.get(userId) || { scored: 0, conceded: 0 }; + userStats.scored += scored; + userStats.conceded += conceded; + goalsMap.set(userId, userStats); +} + +/** + * Calculate goals scored and conceded per user from a list of matches. + */ +export function calculateGoalsPerUser(matches: Array) { + const goalsMap = new Map(); + + matches.forEach( + ({ challenger_id, defender_id, score_challenger, score_defender }) => { + updateGoalsMap(challenger_id, score_challenger, score_defender, goalsMap); + updateGoalsMap(defender_id, score_defender, score_challenger, goalsMap); + }, + ); + + const goalsScored = new Map(); + const goalsConceded = new Map(); + + goalsMap.forEach((stats, userId) => { + goalsScored.set(userId, stats.scored); + goalsConceded.set(userId, stats.conceded); + }); + + return { goalsScored, goalsConceded }; +} diff --git a/src/cli/index.ts b/src/cli/index.ts new file mode 100644 index 0000000..6f3cc15 --- /dev/null +++ b/src/cli/index.ts @@ -0,0 +1,49 @@ +import { parseArgs } from 'util'; +import { setLocale } from './config'; +import { filterExcludedUsers } from './filterExcludedUsers'; +import { calculateGoalsPerUser } from './goalsPerUser'; +import { logResults } from './logResults'; + +async function loadFiles(dataDirPath?: string) { + const [matches, users] = await Promise.all([ + import(`${dataDirPath || ''}/Match.json`).then((mod) => mod.default), + import(`${dataDirPath || ''}/User.json`).then((mod) => mod.default), + ]); + + return { matches, users }; +} + +/** + * Import the data files, and log the results. + * This is the main function that is called when the script is run. + */ +async function calculateAndLogResults() { + const { + values: { dataDirPath, excludeUserIds = '', locale }, + } = parseArgs({ + args: Bun.argv, + options: { + dataDirPath: { type: 'string' }, + excludeUserIds: { type: 'string' }, + locale: { type: 'string' }, + }, + strict: true, + allowPositionals: true, + }); + + setLocale(locale); + + console.log('Excluding users for ranking:', excludeUserIds); + + const { matches, users } = await loadFiles(dataDirPath); + const excludedUsers = excludeUserIds.split(',').map((id) => parseInt(id, 10)); + const filteredMatches = filterExcludedUsers(matches, excludedUsers); + + // Only calculate the goals scored and conceded for the filtered matches. + // This is to ensure that the excluded users are not included in the results for whatever reason + const { goalsScored, goalsConceded } = calculateGoalsPerUser(filteredMatches); + + logResults({ matches, users, goalsScored, goalsConceded }); +} + +calculateAndLogResults(); diff --git a/src/cli/logResults.ts b/src/cli/logResults.ts new file mode 100644 index 0000000..263fa8b --- /dev/null +++ b/src/cli/logResults.ts @@ -0,0 +1,135 @@ +import { Match, User } from '@prisma/client'; +import Table from 'cli-table3'; +import { calculateBestAndWorstGoalDifference } from './bestWorstGoalDifference'; +import { getHumanReadableDate } from './getHumanReadableDate'; +import { calculateMatchStats } from './matchStats'; +import { calculateMatchesPerMatchDay } from './matchesPerMatchDay'; +import { calculateMostFrequentMatchup } from './mostFrequentMatchup'; +import { calculateMostGoalsAgainst } from './mostGoalsAgainst'; + +/** + * Get the user name from the user ID. + */ +function getUserName( + userId: number | null, + userLookup: Map, +): string { + return userId !== null ? userLookup.get(userId) || 'Unknown' : 'Unknown'; +} + +/** + * Get the user ID with the most statistics from a map of user IDs and statistics. + */ +function getEntryWithMostStatistics( + entries: Map, +): [number, number] { + return [...entries.entries()].reduce((a, b) => (b[1] > a[1] ? b : a), [0, 0]); +} + +type LogResultsArgs = { + matches: Array; + users: Array; + goalsScored: Map; + goalsConceded: Map; +}; + +export function logResults({ + matches, + users, + goalsConceded, + goalsScored, +}: LogResultsArgs) { + const userLookup = new Map(); + users.forEach((user) => userLookup.set(user.id, user.name)); + + const statistics = new Table({ head: ['Description', 'User'] }); + + statistics.push(['Number of matches played', matches.length]); + + const { matchDays, matchesPlayed, matchesLost, largestMatchResult } = + calculateMatchStats(matches); + + statistics.push([`Number of match days`, matchDays.size]); + + const { worstUserId, worstGoalDifference, bestUserId, bestGoalDifference } = + calculateBestAndWorstGoalDifference(goalsScored, goalsConceded); + + statistics.push([ + `Worst goal difference`, + `${getUserName(worstUserId, userLookup)} with goal difference ${worstGoalDifference}`, + ]); + + const [mostMatchesPlayedId, mostMatchesPlayedCount] = + getEntryWithMostStatistics(matchesPlayed); + const [mostMatchesLostId, mostMatchesLostCount] = + getEntryWithMostStatistics(matchesLost); + + statistics.push([ + `User with most matches played`, + `${userLookup.get(mostMatchesPlayedId)} with ${mostMatchesPlayedCount} matches`, + ]); + + statistics.push([ + `User with most matches lost`, + `${userLookup.get(mostMatchesLostId)} with ${mostMatchesLostCount} losses`, + ]); + + statistics.push([`Number of active players`, matchesPlayed.size]); + + const mostFrequentMatchup = calculateMostFrequentMatchup(matches); + if (mostFrequentMatchup.matchup) { + const [user1, user2] = mostFrequentMatchup.matchup; + statistics.push([ + `Archrivals`, + `${userLookup.get(user1)} vs ${userLookup.get(user2)} with ${mostFrequentMatchup.count} matches`, + ]); + } + + const king = getUserName( + users.find((user) => user.level === 1)?.id || null, + userLookup, + ); + + statistics.push([`King of BLACO`, king]); + + statistics.push([ + `Best goal difference`, + `${getUserName(bestUserId, userLookup)} with goal difference ${bestGoalDifference}`, + ]); + + const { mostGoalsAgainst, mostGoalsAgainstUser } = + calculateMostGoalsAgainst(goalsConceded); + + statistics.push([ + `User with most goals conceded`, + `${getUserName(mostGoalsAgainstUser, userLookup)} with ${mostGoalsAgainst} goals against`, + ]); + + console.log(statistics.toString()); + + if (largestMatchResult) { + const matchesTable = new Table({ + head: ['Date', 'Largest result difference'], + }); + + largestMatchResult.forEach((match) => { + matchesTable.push([ + `${getHumanReadableDate(match.date)}`, + `${userLookup.get(match.challenger)} ${match.challengerGoals} - ${match.defenderGoals} ${userLookup.get(match.defender)}`, + ]); + }); + + console.log(matchesTable.toString()); + } + + // Display the number of matches played per match day + const matchesPerMatchDayTable = new Table({ + head: ['Date', 'Number of matches'], + }); + + calculateMatchesPerMatchDay(matches).forEach((count, date) => { + matchesPerMatchDayTable.push([getHumanReadableDate(date), count]); + }); + + console.log(matchesPerMatchDayTable.toString()); +} diff --git a/src/cli/matchStats.ts b/src/cli/matchStats.ts new file mode 100644 index 0000000..c29ca86 --- /dev/null +++ b/src/cli/matchStats.ts @@ -0,0 +1,87 @@ +import { Match } from '@prisma/client'; + +type MatchStats = { + matchesPlayed: Map; + matchesLost: Map; + matchDays: Set; + largestMatchResult: Array | null; +}; + +export type LargestMatchResult = { + challenger: number; + defender: number; + date: string; + challengerGoals: number; + defenderGoals: number; +}; + +/** + * Calculate match statistics from a list of matches. + */ +export function calculateMatchStats(matches: Array): MatchStats { + const matchesPlayed: Map = new Map(); + const matchesLost: Map = new Map(); + const matchDays: Set = new Set(); + + let largestResult = 0; + let largestMatchResult: Array | null = null; + + matches.forEach((match) => { + // Add the match day to the set + const matchDay = match.played_at.toString().split('T')[0]; + matchDays.add(matchDay); + + // Increment the number of matches played for each challenger and defender + const challengerMatches = matchesPlayed.get(match.challenger_id) || 0; + matchesPlayed.set(match.challenger_id, challengerMatches + 1); + + const defenderMatches = matchesPlayed.get(match.defender_id) || 0; + matchesPlayed.set(match.defender_id, defenderMatches + 1); + + // Increment the number of matches lost for the defender and challenger + if (match.score_challenger > match.score_defender) { + const defenderLosses = matchesLost.get(match.defender_id) || 0; + matchesLost.set(match.defender_id, defenderLosses + 1); + } else if (match.score_defender > match.score_challenger) { + const challengerLosses = matchesLost.get(match.challenger_id) || 0; + matchesLost.set(match.challenger_id, challengerLosses + 1); + } + + // Update the largest result difference and store the match details + const resultDifference = Math.abs( + match.score_challenger - match.score_defender, + ); + + // If the score is the same, add the match to the list of largest results + // If the score is larger, update the largest result and store the match details + if (resultDifference === largestResult) { + if (largestMatchResult) { + largestMatchResult.push({ + challenger: match.challenger_id, + defender: match.defender_id, + date: match.played_at.toString(), + challengerGoals: match.score_challenger, + defenderGoals: match.score_defender, + }); + } + } else if (resultDifference > largestResult) { + largestResult = resultDifference; + largestMatchResult = [ + { + challenger: match.challenger_id, + defender: match.defender_id, + date: match.played_at.toString(), + challengerGoals: match.score_challenger, + defenderGoals: match.score_defender, + }, + ]; + } + }); + + return { + matchesPlayed, + matchesLost, + matchDays, + largestMatchResult, + }; +} diff --git a/src/cli/matchesPerMatchDay.ts b/src/cli/matchesPerMatchDay.ts new file mode 100644 index 0000000..cdce42a --- /dev/null +++ b/src/cli/matchesPerMatchDay.ts @@ -0,0 +1,16 @@ +import { Match } from '@prisma/client'; +import { getHumanReadableDate } from './getHumanReadableDate'; + +export function calculateMatchesPerMatchDay( + matches: Array, +): Map { + const matchDays = new Map(); + + matches.forEach((match) => { + const matchDay = match.played_at.toString().split(' ')[0]; + const count = matchDays.get(getHumanReadableDate(matchDay)) || 0; + matchDays.set(getHumanReadableDate(matchDay), count + 1); + }); + + return matchDays; +} diff --git a/src/cli/mostFrequentMatchup.ts b/src/cli/mostFrequentMatchup.ts new file mode 100644 index 0000000..398ba71 --- /dev/null +++ b/src/cli/mostFrequentMatchup.ts @@ -0,0 +1,31 @@ +import { Match } from '@prisma/client'; + +export type MostFrequentMatchup = { + matchup: [number, number] | null; + count: number; +}; + +export function calculateMostFrequentMatchup( + matches: Array, +): MostFrequentMatchup { + const matchupCounts = new Map(); + + matches.forEach(({ challenger_id, defender_id }) => { + const key = [challenger_id, defender_id].sort((a, b) => a - b).join('-'); + const count = (matchupCounts.get(key) || 0) + 1; + matchupCounts.set(key, count); + }); + + let mostFrequentMatchup: [number, number] | null = null; + let highestCount = 0; + + matchupCounts.forEach((count, key) => { + if (count > highestCount) { + highestCount = count; + const [user1, user2] = key.split('-').map(Number) as [number, number]; + mostFrequentMatchup = [user1, user2]; + } + }); + + return { matchup: mostFrequentMatchup, count: highestCount }; +} diff --git a/src/cli/mostGoalsAgainst.ts b/src/cli/mostGoalsAgainst.ts new file mode 100644 index 0000000..4ae54f7 --- /dev/null +++ b/src/cli/mostGoalsAgainst.ts @@ -0,0 +1,16 @@ +export function calculateMostGoalsAgainst(goalsConceded: Map) { + let mostGoalsAgainstUser: number | null = null; + let mostGoalsAgainst = 0; + + goalsConceded.forEach((conceded, userId) => { + if (conceded > mostGoalsAgainst) { + mostGoalsAgainst = conceded; + mostGoalsAgainstUser = userId; + } + }); + + return { + mostGoalsAgainstUser, + mostGoalsAgainst, + }; +} diff --git a/tsconfig.json b/tsconfig.json index ec8fdef..4daaaaf 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -12,6 +12,7 @@ "isolatedModules": true, "jsx": "preserve", "incremental": true, + "target": "ES2015", "paths": { "@blaco/*": ["./src/*"] }