diff --git a/backend/__tests__/utils/misc.spec.ts b/backend/__tests__/utils/misc.spec.ts index 409f275e6..7dd8f0dd1 100644 --- a/backend/__tests__/utils/misc.spec.ts +++ b/backend/__tests__/utils/misc.spec.ts @@ -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); + }); + }); }); diff --git a/backend/src/queues/later-queue.ts b/backend/src/queues/later-queue.ts index 815e529b6..8a556a67e 100644 --- a/backend/src/queues/later-queue.ts +++ b/backend/src/queues/later-queue.ts @@ -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 { max: 100, }); + private async scheduleTask( + taskName: string, + task: LaterTask, + jobId: string, + delay: number + ): Promise { + 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 { + 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 { }, }; - 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, }, }); diff --git a/backend/src/services/weekly-xp-leaderboard.ts b/backend/src/services/weekly-xp-leaderboard.ts index 25c1614dc..30b28fec6 100644 --- a/backend/src/services/weekly-xp-leaderboard.ts +++ b/backend/src/services/weekly-xp-leaderboard.ts @@ -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; } diff --git a/backend/src/utils/misc.ts b/backend/src/utils/misc.ts index b5e5bdc81..a27ad1647 100644 --- a/backend/src/utils/misc.ts +++ b/backend/src/utils/misc.ts @@ -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" : ""}`; +} diff --git a/backend/src/workers/later-worker.ts b/backend/src/workers/later-worker.ts index db90eef40..dacf5c010 100644 --- a/backend/src/workers/later-worker.ts +++ b/backend/src/workers/later-worker.ts @@ -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 { @@ -100,6 +105,84 @@ async function handleDailyLeaderboardResults( ); } +async function handleWeeklyXpLeaderboardResults( + ctx: WeeklyXpLeaderboardResultContext +): Promise { + 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 { const { taskName, ctx }: LaterTask = job.data; Logger.info(`Starting job: ${taskName}`); @@ -108,6 +191,8 @@ async function jobHandler(job: Job): Promise { if (taskName === "daily-leaderboard-results") { await handleDailyLeaderboardResults(ctx); + } else if (taskName === "weekly-xp-leaderboard-results") { + await handleWeeklyXpLeaderboardResults(ctx); } const elapsed = performance.now() - start;