Skip to content

Commit 6b417f7

Browse files
committed
feat: implement ranking bonuses
bonus points are awarded hourly during the final week of each season
1 parent d8e132c commit 6b417f7

11 files changed

Lines changed: 148 additions & 15 deletions

File tree

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
-- AlterTable
2+
ALTER TABLE "CodamCoalitionRanking" ADD COLUMN "last_bonus_run" TIMESTAMP(3);

prisma/schema.prisma

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -40,22 +40,23 @@ model CodamCoalition {
4040
}
4141

4242
// A cross-coalition ranking for each fixed-point type.
43-
// At the end of the yearly tournament the #1 in each ranking will gain all ranking_bonus points towards their coalition, spicing up the last season.
43+
// During the last week of each season, every hour a specific amount of points will be awarded to the #1 spot(s) in each ranking.
4444
model CodamCoalitionRanking {
4545
type String @id
4646
name String // The name of the cross-coalition ranking that this type of score contributes to (e.g. Guiding Stars)
4747
description String @default("")
4848
top_title String // The title of the top of the ranking (e.g. #1 Guiding Star)
49-
bonus_points Int? // Bonus points for the #1 spot in the ranking at the end of the full tournament (not a season)
49+
bonus_points Int? // Total bonus points to distribute for this ranking (will be divided into 168 hours = 1 week and distributed each hour)
5050
disabled Boolean @default(false) // If disabled, this ranking will not be calculated at the end of a tournament
51+
last_bonus_run DateTime? // The last time bonus points were awarded for this ranking
5152
5253
fixed_types CodamCoalitionFixedType[] // The #1 is calculated by the sum of all fixed types of the same ranking type
5354
results CodamCoalitionRankingResult[]
5455
}
5556

5657
// Fixed type of score for the coalition system
5758
model CodamCoalitionFixedType {
58-
type String @id // should not contain spaces. e.g. "project", "eval", "point_donated", "logtime", "idle_logout", "exam", "basic_event", "intermediate_event", "advanced_event"
59+
type String @id // should not contain spaces. e.g. "project", "eval", "point_donated", "logtime", "idle_logout", "exam", "basic_event", "intermediate_event", "advanced_event", "ranking_bonus", etc.
5960
description String
6061
point_amount Int
6162
ranking_type String? // The ranking type that this fixed type of score contributes to (leave empty to disable ranking, does not need to be unique)

src/dev/create_rankings.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -27,11 +27,11 @@ const createRanking = async function(name: string, description: string, topTitle
2727
};
2828

2929
const createRankings = async function(): Promise<void> {
30-
await createRanking('Guiding Stars', 'Based on points gained through evaluations', 'Guiding Star', 100000, ['evaluation']);
31-
await createRanking('Top Performers', 'Based on points gained through projects', 'Top Performer', 100000, ['project', 'exam']);
32-
await createRanking('Top Endeavors', 'Based on points gained through logtime', 'Top Endeavor', 100000, ['logtime', 'idle_logout']);
33-
await createRanking('Philanthropists', 'Based on points gained through donating evaluation points to the pool', 'Philanthropist', 100000, ['point_donated']);
34-
await createRanking('Community Leaders', 'Based on points gained through organizing events', 'Community Leader', 100000, ['event_basic', 'event_intermediate', 'event_advanced']);
30+
await createRanking('Guiding Stars', 'Based on points gained through evaluations', 'Guiding Star', 12600, ['evaluation']);
31+
await createRanking('Top Performers', 'Based on points gained through projects', 'Top Performer', 8400, ['project', 'exam']);
32+
await createRanking('Top Endeavors', 'Based on points gained through logtime', 'Top Endeavor', 16800, ['logtime', 'idle_logout']);
33+
await createRanking('Philanthropists', 'Based on points gained through donating evaluation points to the pool', 'Philanthropist', 16800, ['point_donated']);
34+
await createRanking('Community Leaders', 'Based on points gained through organizing events', 'Community Leader', 12600, ['event_basic', 'event_intermediate', 'event_advanced']);
3535
};
3636

3737
const main = async function(): Promise<void> {

src/routes/home.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,7 @@ export const setupHomeRoutes = function(app: Express, prisma: PrismaClient): voi
8787
type: true,
8888
name: true,
8989
description: true,
90+
bonus_points: true,
9091
},
9192
orderBy: {
9293
type: 'asc',
@@ -203,12 +204,12 @@ export const setupHomeRoutes = function(app: Express, prisma: PrismaClient): voi
203204
{
204205
NOT: {
205206
fixed_type_id: {
206-
in: ['logtime', 'evaluation'], // Exclude logtime and evaluation scores, they are usually low
207+
in: ['logtime', 'evaluation', 'idle_logout', 'ranking_bonus'], // Exclude logtime, ranking bonus and evaluation scores, they are usually low individual scores
207208
}
208209
},
209210
},
210211
{
211-
fixed_type_id: null, // Include scores that are not fixed types
212+
fixed_type_id: null, // Do include scores that are not fixed types
212213
}
213214
],
214215
amount: {

src/sync/base.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import { syncProjects } from "./projects";
1212
import { syncCursusUsers } from './cursus_users';
1313
import { syncScores } from './scores';
1414
import { getBlocAtDate } from '../utils';
15+
import { handleRankingBonuses } from './rankings';
1516
import { calculateResults } from './results';
1617

1718
export const prisma = new PrismaClient();
@@ -229,6 +230,7 @@ export const syncWithIntra = async function(api: Fast42): Promise<void> {
229230
await syncCursusUsers(api, lastSync, now);
230231
await syncBlocs(api, now); // also syncs coalitions
231232
await syncCoalitionUsers(api, lastSync, now);
233+
await handleRankingBonuses();
232234
await calculateResults();
233235
await cleanupDB(api);
234236

src/sync/fixed_point_types.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,11 @@ export const initCodamCoalitionFixedTypes = async function(): Promise<void> {
7979
desc: "Each advanced event organized will grant the student with this amount of points.",
8080
points: 6000, // recommended
8181
},
82+
{
83+
type: "ranking_bonus",
84+
desc: "Bonus points awarded hourly during the final week of a season to top ranking users. This value is not used directly, as the actual points awarded depend on the ranking settings.",
85+
points: 0, // not used directly
86+
}
8287
];
8388

8489
for (const fixedType of fixedTypes) {

src/sync/rankings.ts

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
import { getRanking, RANKING_MAX, getBlocAtDate } from '../utils';
2+
import { handleFixedPointScore } from '../handlers/points';
3+
import { prisma } from './base';
4+
import { CodamCoalitionRanking } from '@prisma/client';
5+
6+
const handleRankingBonusForDateTime = async function(ranking: CodamCoalitionRanking, atDateTime: Date = new Date()): Promise<void> {
7+
console.log(` - Applying bonus points for ranking ${ranking.name} at ${atDateTime.toISOString()}...`);
8+
9+
ranking.last_bonus_run = atDateTime;
10+
await prisma.codamCoalitionRanking.update({
11+
where: {
12+
type: ranking.type,
13+
},
14+
data: {
15+
last_bonus_run: ranking.last_bonus_run,
16+
},
17+
});
18+
19+
// Get top users in this ranking at the specified date and time
20+
const topUsers = await getRanking(prisma, ranking.type, atDateTime, RANKING_MAX);
21+
if (topUsers.length === 0) {
22+
console.log(` - No users found in ranking ${ranking.name}, skipping...`);
23+
return;
24+
}
25+
26+
// Calculate bonus points to award per hour
27+
const bonusPointsPerHour = Math.floor((ranking.bonus_points || 0) / 168); // 168 hours in a week
28+
29+
if (bonusPointsPerHour <= 0) {
30+
console.warn(` - Bonus points to award this hour is 0 for ranking ${ranking.name}, skipping...`);
31+
return;
32+
}
33+
34+
// Get fixed type for ranking bonus
35+
const fixedType = await prisma.codamCoalitionFixedType.findUnique({
36+
where: {
37+
type: 'ranking_bonus',
38+
},
39+
});
40+
if (!fixedType) {
41+
console.error(` - Fixed type "ranking_bonus" not found, cannot award ranking bonus points for ranking ${ranking.name}, skipping...`);
42+
return;
43+
}
44+
45+
// Award bonus points to the #1 spot (can be multiple users!)
46+
const topRankings = topUsers.filter(user => user.rank === 1);
47+
const pointsPerUser = Math.floor(bonusPointsPerHour / topRankings.length);
48+
for (const topUser of topRankings) {
49+
await handleFixedPointScore(prisma, fixedType, null, topUser.user.id, pointsPerUser, `Bonus points for being in${topRankings.length > 1 ? ' shared' : ''} first place on the ${ranking.name} Ranking`, atDateTime);
50+
console.log(` - Awarded ${pointsPerUser} bonus points to ${topUser.user.login} (coalition ${(topUser.coalition ? topUser.coalition?.name : 'N/A')}) for ranking ${ranking.name}`);
51+
}
52+
};
53+
54+
export const handleRankingBonuses = async function(): Promise<void> {
55+
console.log('Checking if any ranking bonuses need to be applied...');
56+
const now = new Date();
57+
const currentBloc = await getBlocAtDate(prisma, now);
58+
59+
// Check if we're in the final week of the current season
60+
if (currentBloc === null) {
61+
console.log(' - No ongoing season, skipping ranking bonus application.');
62+
return;
63+
}
64+
const oneWeekBeforeEnd = new Date(currentBloc.end_at.getTime() - (7 * 24 * 60 * 60 * 1000));
65+
if (now < oneWeekBeforeEnd) {
66+
console.log(` - Not in the final week of the season yet, skipping ranking bonus application. Final week starts at ${oneWeekBeforeEnd.toISOString()}`);
67+
return;
68+
}
69+
70+
// Get all rankings that are not disabled and have bonus points defined
71+
const rankings = await prisma.codamCoalitionRanking.findMany({
72+
where: {
73+
disabled: false,
74+
bonus_points: {
75+
gt: 0,
76+
},
77+
},
78+
});
79+
80+
for (const ranking of rankings) {
81+
if (!ranking.last_bonus_run || ranking.last_bonus_run === undefined || ranking.last_bonus_run < oneWeekBeforeEnd) {
82+
console.log(` - Setting last bonus run to 1 week before the end of the current season...`);
83+
ranking.last_bonus_run = oneWeekBeforeEnd;
84+
await prisma.codamCoalitionRanking.update({
85+
where: {
86+
type: ranking.type,
87+
},
88+
data: {
89+
last_bonus_run: ranking.last_bonus_run,
90+
},
91+
});
92+
}
93+
94+
// Apply bonus points for each hour since last run up until now
95+
for (let bonusDateTime = new Date(ranking.last_bonus_run.getTime() + 60 * 60 * 1000); bonusDateTime <= now; bonusDateTime = new Date(bonusDateTime.getTime() + 60 * 60 * 1000)) {
96+
await handleRankingBonusForDateTime(ranking, bonusDateTime);
97+
}
98+
}
99+
};

templates/admin/hooks/catchup.njk

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,10 @@
2222
<form method="post">
2323
<p>Use this tool to catch up on missed points or recalculate all already handled points between to specific dates. It will use the current point system settings for calculation.</p>
2424

25+
<div class="mt-4 alert alert-info" role="alert">
26+
Note: past results are <b>not</b> affected when already eternalized in the <a href="/results">Results</a> section. If the selected date range includes the final week of a season, bonus points for rankings will <b>not</b> be recalculated.
27+
</div>
28+
2529
<fieldset class="row">
2630
<div class="col-md-6">
2731
<label for="catchup_start" class="form-label">Start date</label>

templates/admin/ranking_edit.njk

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,8 +27,16 @@
2727
</div>
2828

2929
<div class="mb-2">
30-
<label for="bonus_points" class="form-label">Bonus points</label>
30+
<label for="bonus_points" class="form-label">Total bonus points</label>
3131
<input type="number" id="bonus_points" name="bonus_points" class="form-control" placeholder="Point amount" value="{{ ranking.bonus_points }}" required min="0">
32+
<small class="form-text text-muted">Total amount of bonus points to be distributed during the final week of a season - the actually awarded points are divided by 168 (the amount of hours in 1 week). For the inputted amount, that means <code id="bonus_points_hours">{{ (ranking.bonus_points / 168) | round(2) }}</code> points are awarded every hour.</small>
33+
<script>
34+
document.getElementById('bonus_points').addEventListener('input', function(event) {
35+
const totalPoints = parseInt(event.target.value) || 0;
36+
const pointsPerHour = Math.floor(totalPoints / 168);
37+
document.getElementById('bonus_points_hours').textContent = pointsPerHour;
38+
});
39+
</script>
3240
</div>
3341

3442
<div class="mb-2 mt-3">

templates/admin/rankings.njk

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,15 +9,16 @@
99
</ol>
1010
</nav>
1111

12-
<p>Here you can edit the rankings system. Rankings are leaderboards spanning all coalitions. At the end of a tournament (not a season - these rankings span across all seasons), each #1 of each ranking will receive a bonus amount of points.</p>
12+
<p>Here you can edit the rankings system. Rankings are leaderboards spanning all coalitions. During the final week of a season, every hour, bonus points are awarded to the top-ranked students.</p>
1313
<p>Rankings are automatically calculated based on fixed point types. These fixed point types are the same as the ones used in the coalition scores. The students with the most points to a specified collection of fixed point types becomes #1 of a ranking.</p>
1414

1515
<table class="table table-striped">
1616
<thead>
1717
<th scope="col">Type</th>
1818
<th scope="col">Name</th>
1919
<th scope="col">Description</th>
20-
<th scope="col">Bonus points</th>
20+
<th scope="col">Total bonus points</th>
21+
<th scope="col">Hourly bonus points</th>
2122
<th scope="col">Disabled</th>
2223
<th scope="col">Linked to fixed point types</th>
2324
<th scope="col text-end" style="text-align: end;">Options</th> <!-- for some reason the text-end class doesn't work here -->
@@ -29,6 +30,7 @@
2930
<td>{{ ranking.name | striptags(true) | escape }}</td>
3031
<td>{{ ranking.description | striptags(true) | escape | nl2br }}</td>
3132
<td>{{ ranking.bonus_points | thousands }}</td>
33+
<td>{{ (ranking.bonus_points / 168) | round(0, 'floor') | thousands }}</td>
3234
<td>{{ ranking.disabled | bool }}</td>
3335
<td>{{ ranking.fixed_types | keyjoin('type', ', ') }}</td>
3436
<td class="text-end">
@@ -64,8 +66,16 @@
6466
</div>
6567

6668
<div class="mb-2">
67-
<label for="bonus_points" class="form-label">Bonus points</label>
69+
<label for="bonus_points" class="form-label">Total bonus points</label>
6870
<input type="number" id="bonus_points" name="bonus_points" class="form-control" placeholder="Bonus point amount" required min="1">
71+
<small class="form-text text-muted">Total amount of bonus points to be distributed during the final week of a season - the actually awarded points are divided by 168 (the amount of hours in 1 week). For the inputted amount, that means <code id="bonus_points_hours">0</code> points are awarded every hour.</small>
72+
<script>
73+
document.getElementById('bonus_points').addEventListener('input', function(event) {
74+
const totalPoints = parseInt(event.target.value) || 0;
75+
const pointsPerHour = Math.floor(totalPoints / 168);
76+
document.getElementById('bonus_points_hours').textContent = pointsPerHour;
77+
});
78+
</script>
6979
</div>
7080

7181
<div class="mt-4">

0 commit comments

Comments
 (0)