Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -41,3 +41,5 @@ next-env.d.ts

public/images/users
!public/images/users/unnamed.jpeg

src/cli/data
32 changes: 31 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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 <absolute-path-to-data-dir>
```

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 <absolute-path-to-data-dir> --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 <absolute-path-to-data-dir> --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)!
Binary file modified bun.lockb
Binary file not shown.
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
36 changes: 36 additions & 0 deletions src/cli/bestWorstGoalDifference.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
/**
* Calculate the user with the best and worst goal difference.
*/
export function calculateBestAndWorstGoalDifference(
goalsScored: Map<number, number>,
goalsConceded: Map<number, number>,
) {
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,
};
}
15 changes: 15 additions & 0 deletions src/cli/config.ts
Original file line number Diff line number Diff line change
@@ -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;
}
15 changes: 15 additions & 0 deletions src/cli/filterExcludedUsers.ts
Original file line number Diff line number Diff line change
@@ -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<Match>,
excludedIds: Array<number>,
): Array<Match> {
return matches.filter(
(match) =>
!excludedIds.includes(match.challenger_id) &&
!excludedIds.includes(match.defender_id),
);
}
13 changes: 13 additions & 0 deletions src/cli/getHumanReadableDate.ts
Original file line number Diff line number Diff line change
@@ -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);
}
41 changes: 41 additions & 0 deletions src/cli/goalsPerUser.ts
Original file line number Diff line number Diff line change
@@ -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<number, { scored: number; conceded: number }>,
) {
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<Match>) {
const goalsMap = new Map<number, { scored: number; conceded: number }>();

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<number, number>();
const goalsConceded = new Map<number, number>();

goalsMap.forEach((stats, userId) => {
goalsScored.set(userId, stats.scored);
goalsConceded.set(userId, stats.conceded);
});

return { goalsScored, goalsConceded };
}
49 changes: 49 additions & 0 deletions src/cli/index.ts
Original file line number Diff line number Diff line change
@@ -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();
135 changes: 135 additions & 0 deletions src/cli/logResults.ts
Original file line number Diff line number Diff line change
@@ -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<number, string>,
): 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>,
): [number, number] {
return [...entries.entries()].reduce((a, b) => (b[1] > a[1] ? b : a), [0, 0]);
}

type LogResultsArgs = {
matches: Array<Match>;
users: Array<User>;
goalsScored: Map<number, number>;
goalsConceded: Map<number, number>;
};

export function logResults({
matches,
users,
goalsConceded,
goalsScored,
}: LogResultsArgs) {
const userLookup = new Map<number, string>();
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());
}
Loading