mirror of
https://github.com/monkeytypegame/monkeytype.git
synced 2025-11-09 21:51:29 +08:00
Add weekly xp rewards results job (#3939) bruception
* Add weekly xp rewards results job * Fix formatting * Pluralize --------- Co-authored-by: Jack <jack@monkeytype.com>
This commit is contained in:
parent
126e67811c
commit
5337a9bb99
5 changed files with 262 additions and 27 deletions
|
|
@ -380,4 +380,57 @@ describe("Misc Utils", () => {
|
|||
).toEqual(expected);
|
||||
});
|
||||
});
|
||||
|
||||
it("formatSeconds", () => {
|
||||
const testCases = [
|
||||
{
|
||||
seconds: 5,
|
||||
expected: "5 seconds",
|
||||
},
|
||||
{
|
||||
seconds: 65,
|
||||
expected: "1.08 minutes",
|
||||
},
|
||||
{
|
||||
seconds: misc.HOUR_IN_SECONDS,
|
||||
expected: "1 hour",
|
||||
},
|
||||
{
|
||||
seconds: misc.DAY_IN_SECONDS,
|
||||
expected: "1 day",
|
||||
},
|
||||
{
|
||||
seconds: misc.WEEK_IN_SECONDS,
|
||||
expected: "1 week",
|
||||
},
|
||||
{
|
||||
seconds: misc.YEAR_IN_SECONDS,
|
||||
expected: "1 year",
|
||||
},
|
||||
{
|
||||
seconds: 2 * misc.YEAR_IN_SECONDS,
|
||||
expected: "2 years",
|
||||
},
|
||||
{
|
||||
seconds: 4 * misc.YEAR_IN_SECONDS,
|
||||
expected: "4 years",
|
||||
},
|
||||
{
|
||||
seconds: 3 * misc.WEEK_IN_SECONDS,
|
||||
expected: "3 weeks",
|
||||
},
|
||||
{
|
||||
seconds: misc.MONTH_IN_SECONDS * 4,
|
||||
expected: "4 months",
|
||||
},
|
||||
{
|
||||
seconds: misc.MONTH_IN_SECONDS * 11,
|
||||
expected: "11 months",
|
||||
},
|
||||
];
|
||||
|
||||
testCases.forEach(({ seconds, expected }) => {
|
||||
expect(misc.formatSeconds(seconds)).toBe(expected);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,11 +1,11 @@
|
|||
import LRUCache from "lru-cache";
|
||||
import Logger from "../utils/logger";
|
||||
import { MonkeyQueue } from "./monkey-queue";
|
||||
import { getCurrentDayTimestamp } from "../utils/misc";
|
||||
import { getCurrentDayTimestamp, getCurrentWeekTimestamp } from "../utils/misc";
|
||||
|
||||
const QUEUE_NAME = "later";
|
||||
|
||||
type LaterTasks = "daily-leaderboard-results";
|
||||
type LaterTasks = "daily-leaderboard-results" | "weekly-xp-leaderboard-results";
|
||||
|
||||
export interface LaterTask {
|
||||
taskName: LaterTasks;
|
||||
|
|
@ -20,6 +20,55 @@ class LaterQueue extends MonkeyQueue<LaterTask> {
|
|||
max: 100,
|
||||
});
|
||||
|
||||
private async scheduleTask(
|
||||
taskName: string,
|
||||
task: LaterTask,
|
||||
jobId: string,
|
||||
delay: number
|
||||
): Promise<void> {
|
||||
await this.add(taskName, task, {
|
||||
delay,
|
||||
jobId, // Prevent duplicate jobs
|
||||
backoff: 60 * ONE_MINUTE_IN_MILLISECONDS, // Try again every hour on failure
|
||||
attempts: 23,
|
||||
});
|
||||
|
||||
this.scheduledJobCache.set(jobId, true);
|
||||
|
||||
Logger.info(
|
||||
`Scheduled ${task.taskName} for ${new Date(Date.now() + delay)}`
|
||||
);
|
||||
}
|
||||
|
||||
async scheduleForNextWeek(
|
||||
taskName: LaterTasks,
|
||||
taskId: string,
|
||||
taskContext?: any
|
||||
): Promise<void> {
|
||||
const currentWeekTimestamp = getCurrentWeekTimestamp();
|
||||
const jobId = `${taskName}:${currentWeekTimestamp}:${taskId}`;
|
||||
|
||||
if (this.scheduledJobCache.has(jobId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const task: LaterTask = {
|
||||
taskName,
|
||||
ctx: {
|
||||
...taskContext,
|
||||
lastWeekTimestamp: currentWeekTimestamp,
|
||||
},
|
||||
};
|
||||
|
||||
const delay =
|
||||
currentWeekTimestamp +
|
||||
7 * ONE_DAY_IN_MILLISECONDS -
|
||||
Date.now() +
|
||||
ONE_MINUTE_IN_MILLISECONDS;
|
||||
|
||||
await this.scheduleTask("todo-next-week", task, jobId, delay);
|
||||
}
|
||||
|
||||
async scheduleForTomorrow(
|
||||
taskName: LaterTasks,
|
||||
taskId: string,
|
||||
|
|
@ -40,30 +89,19 @@ class LaterQueue extends MonkeyQueue<LaterTask> {
|
|||
},
|
||||
};
|
||||
|
||||
const nowTimestamp = Date.now();
|
||||
|
||||
const delay =
|
||||
currentDayTimestamp +
|
||||
ONE_DAY_IN_MILLISECONDS -
|
||||
nowTimestamp +
|
||||
Date.now() +
|
||||
ONE_MINUTE_IN_MILLISECONDS;
|
||||
|
||||
await this.add("todo-tomorrow", task, {
|
||||
delay,
|
||||
jobId, // Prevent duplicate jobs
|
||||
backoff: 60 * ONE_MINUTE_IN_MILLISECONDS, // Try again every hour on failure
|
||||
attempts: 23,
|
||||
});
|
||||
|
||||
this.scheduledJobCache.set(jobId, true);
|
||||
|
||||
Logger.info(`Scheduled ${taskName} for ${new Date(nowTimestamp + delay)}`);
|
||||
await this.scheduleTask("todo-tomorrow", task, jobId, delay);
|
||||
}
|
||||
}
|
||||
|
||||
export default new LaterQueue(QUEUE_NAME, {
|
||||
defaultJobOptions: {
|
||||
removeOnComplete: true,
|
||||
removeOnFail: false,
|
||||
removeOnFail: true,
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import * as RedisClient from "../init/redis";
|
||||
import LaterQueue from "../queues/later-queue";
|
||||
import { getCurrentWeekTimestamp } from "../utils/misc";
|
||||
|
||||
interface InternalWeeklyXpLeaderboardEntry {
|
||||
|
|
@ -92,16 +93,22 @@ export class WeeklyXpLeaderboard {
|
|||
timeTypedSeconds +
|
||||
((currentEntry && JSON.parse(currentEntry)?.timeTypedSeconds) || 0);
|
||||
|
||||
// @ts-ignore
|
||||
const rank: number = await connection.addResultIncrement(
|
||||
2,
|
||||
weeklyXpLeaderboardScoresKey,
|
||||
weeklyXpLeaderboardResultsKey,
|
||||
weeklyXpLeaderboardExpirationTimeInSeconds,
|
||||
entry.uid,
|
||||
xpGained,
|
||||
JSON.stringify({ ...entry, timeTypedSeconds: totalTimeTypedSeconds })
|
||||
);
|
||||
const [rank]: [number, void] = await Promise.all([
|
||||
// @ts-ignore
|
||||
connection.addResultIncrement(
|
||||
2,
|
||||
weeklyXpLeaderboardScoresKey,
|
||||
weeklyXpLeaderboardResultsKey,
|
||||
weeklyXpLeaderboardExpirationTimeInSeconds,
|
||||
entry.uid,
|
||||
xpGained,
|
||||
JSON.stringify({ ...entry, timeTypedSeconds: totalTimeTypedSeconds })
|
||||
),
|
||||
LaterQueue.scheduleForNextWeek(
|
||||
"weekly-xp-leaderboard-results",
|
||||
"weekly-xp"
|
||||
),
|
||||
]);
|
||||
|
||||
return rank + 1;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -218,3 +218,55 @@ export function getCurrentWeekTimestamp(): number {
|
|||
const currentTime = Date.now();
|
||||
return getStartOfWeekTimestamp(currentTime);
|
||||
}
|
||||
|
||||
type TimeUnit =
|
||||
| "second"
|
||||
| "minute"
|
||||
| "hour"
|
||||
| "day"
|
||||
| "week"
|
||||
| "month"
|
||||
| "year";
|
||||
|
||||
export const MINUTE_IN_SECONDS = 1 * 60;
|
||||
export const HOUR_IN_SECONDS = 1 * 60 * MINUTE_IN_SECONDS;
|
||||
export const DAY_IN_SECONDS = 1 * 24 * HOUR_IN_SECONDS;
|
||||
export const WEEK_IN_SECONDS = 1 * 7 * DAY_IN_SECONDS;
|
||||
export const MONTH_IN_SECONDS = 1 * 30.4167 * DAY_IN_SECONDS;
|
||||
export const YEAR_IN_SECONDS = 1 * 12 * MONTH_IN_SECONDS;
|
||||
|
||||
export function formatSeconds(
|
||||
seconds: number
|
||||
): `${number} ${TimeUnit}${"s" | ""}` {
|
||||
let unit: TimeUnit;
|
||||
let secondsInUnit: number;
|
||||
|
||||
if (seconds < MINUTE_IN_SECONDS) {
|
||||
unit = "second";
|
||||
secondsInUnit = 1;
|
||||
} else if (seconds < HOUR_IN_SECONDS) {
|
||||
unit = "minute";
|
||||
secondsInUnit = MINUTE_IN_SECONDS;
|
||||
} else if (seconds < DAY_IN_SECONDS) {
|
||||
unit = "hour";
|
||||
secondsInUnit = HOUR_IN_SECONDS;
|
||||
} else if (seconds < WEEK_IN_SECONDS) {
|
||||
unit = "day";
|
||||
secondsInUnit = DAY_IN_SECONDS;
|
||||
} else if (seconds < YEAR_IN_SECONDS) {
|
||||
if (seconds < WEEK_IN_SECONDS * 4) {
|
||||
unit = "week";
|
||||
secondsInUnit = WEEK_IN_SECONDS;
|
||||
} else {
|
||||
unit = "month";
|
||||
secondsInUnit = MONTH_IN_SECONDS;
|
||||
}
|
||||
} else {
|
||||
unit = "year";
|
||||
secondsInUnit = YEAR_IN_SECONDS;
|
||||
}
|
||||
|
||||
const normalized = roundTo2(seconds / secondsInUnit);
|
||||
|
||||
return `${normalized} ${unit}${normalized > 1 ? "s" : ""}`;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,8 +7,9 @@ import GeorgeQueue from "../queues/george-queue";
|
|||
import { buildMonkeyMail } from "../utils/monkey-mail";
|
||||
import { DailyLeaderboard } from "../utils/daily-leaderboards";
|
||||
import { getCachedConfiguration } from "../init/configuration";
|
||||
import { getOrdinalNumberString, mapRange } from "../utils/misc";
|
||||
import { formatSeconds, getOrdinalNumberString, mapRange } from "../utils/misc";
|
||||
import LaterQueue, { LaterTask } from "../queues/later-queue";
|
||||
import { WeeklyXpLeaderboard } from "../services/weekly-xp-leaderboard";
|
||||
import { recordTimeToCompleteJob } from "../utils/prometheus";
|
||||
|
||||
interface DailyLeaderboardMailContext {
|
||||
|
|
@ -16,6 +17,10 @@ interface DailyLeaderboardMailContext {
|
|||
modeRule: MonkeyTypes.ValidModeRule;
|
||||
}
|
||||
|
||||
interface WeeklyXpLeaderboardResultContext {
|
||||
lastWeekTimestamp: number;
|
||||
}
|
||||
|
||||
async function handleDailyLeaderboardResults(
|
||||
ctx: DailyLeaderboardMailContext
|
||||
): Promise<void> {
|
||||
|
|
@ -100,6 +105,84 @@ async function handleDailyLeaderboardResults(
|
|||
);
|
||||
}
|
||||
|
||||
async function handleWeeklyXpLeaderboardResults(
|
||||
ctx: WeeklyXpLeaderboardResultContext
|
||||
): Promise<void> {
|
||||
const {
|
||||
leaderboards: { weeklyXp: weeklyXpConfig },
|
||||
users: { inbox: inboxConfig },
|
||||
} = await getCachedConfiguration(false);
|
||||
|
||||
const { enabled, xpRewardBrackets } = weeklyXpConfig;
|
||||
if (!enabled || xpRewardBrackets.length < 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { lastWeekTimestamp } = ctx;
|
||||
const weeklyXpLeaderboard = new WeeklyXpLeaderboard(lastWeekTimestamp);
|
||||
|
||||
const maxRankToGet = Math.max(
|
||||
...xpRewardBrackets.map((bracket) => bracket.maxRank)
|
||||
);
|
||||
|
||||
const allResults = await weeklyXpLeaderboard.getResults(
|
||||
0,
|
||||
maxRankToGet,
|
||||
weeklyXpConfig
|
||||
);
|
||||
|
||||
if (allResults.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const mailEntries: {
|
||||
uid: string;
|
||||
mail: MonkeyTypes.MonkeyMail[];
|
||||
}[] = [];
|
||||
|
||||
allResults.forEach((entry) => {
|
||||
const { uid, name, rank = maxRankToGet, totalXp, timeTypedSeconds } = entry;
|
||||
|
||||
const xp = Math.round(totalXp);
|
||||
const placementString = getOrdinalNumberString(rank);
|
||||
|
||||
const xpReward = _(xpRewardBrackets)
|
||||
.filter((bracket) => rank >= bracket.minRank && rank <= bracket.maxRank)
|
||||
.map((bracket) =>
|
||||
mapRange(
|
||||
rank,
|
||||
bracket.minRank,
|
||||
bracket.maxRank,
|
||||
bracket.maxReward,
|
||||
bracket.minReward
|
||||
)
|
||||
)
|
||||
.max();
|
||||
|
||||
if (!xpReward) return;
|
||||
|
||||
const rewardMail = buildMonkeyMail({
|
||||
subject: "Weekly XP Leaderboard placement",
|
||||
body: `Congratulations ${name} on placing ${placementString} with ${xp} xp! Last week, you typed for a total of ${formatSeconds(
|
||||
timeTypedSeconds
|
||||
)}! Keep up the good work :)`,
|
||||
rewards: [
|
||||
{
|
||||
type: "xp",
|
||||
item: Math.round(xpReward),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
mailEntries.push({
|
||||
uid: uid,
|
||||
mail: [rewardMail],
|
||||
});
|
||||
});
|
||||
|
||||
await addToInboxBulk(mailEntries, inboxConfig);
|
||||
}
|
||||
|
||||
async function jobHandler(job: Job): Promise<void> {
|
||||
const { taskName, ctx }: LaterTask = job.data;
|
||||
Logger.info(`Starting job: ${taskName}`);
|
||||
|
|
@ -108,6 +191,8 @@ async function jobHandler(job: Job): Promise<void> {
|
|||
|
||||
if (taskName === "daily-leaderboard-results") {
|
||||
await handleDailyLeaderboardResults(ctx);
|
||||
} else if (taskName === "weekly-xp-leaderboard-results") {
|
||||
await handleWeeklyXpLeaderboardResults(ctx);
|
||||
}
|
||||
|
||||
const elapsed = performance.now() - start;
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue