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:
Bruce Berrios 2023-02-20 06:30:14 -05:00 committed by GitHub
parent 126e67811c
commit 5337a9bb99
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
5 changed files with 262 additions and 27 deletions

View file

@ -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);
});
});
});

View file

@ -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,
},
});

View file

@ -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;
}

View file

@ -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" : ""}`;
}

View file

@ -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;