diff --git a/api/dailies.ts b/api/dailies.ts index 7e13fae..e692555 100644 --- a/api/dailies.ts +++ b/api/dailies.ts @@ -80,17 +80,31 @@ async function executeDailyWebhook( ): Promise { // Get the daily question. const question = await lcClient.getDailyQuestion(); + const questionDate = new Date(`${question.date} GMT`); + const isSunday = questionDate.getDay() === 0; - // Get the season data if a season ID is provided or if it is Sunday. - const isSunday = new Date(question.date).getDay() === 0; - const season = seasonID + // Get the stored season. + const storedSeason = seasonID ? await leaderboardClient.getSeason(seasonID) - : isSunday - ? await leaderboardClient.getLatestSeason() + : await leaderboardClient.getLatestSeason(); + + // If the season is ongoing, then sync it. + const referenceDate = new Date(); + const isLatestSeason = storedSeason && leaderboard.checkDateInWeek( + new Date(storedSeason.start_date).getTime(), + referenceDate.getTime(), + ); + const syncedSeason = isLatestSeason + ? await leaderboardClient + .sync(storedSeason.id) + .then((response) => response.season) : null; // Format the webhook embed. - const embeds = makeDailyWebhookEmbeds({ question, season }); + const embeds = makeDailyWebhookEmbeds({ + question, + season: isSunday ? (syncedSeason ?? storedSeason) : null, + }); // Execute the webhook. await discord.executeWebhook({ @@ -98,6 +112,11 @@ async function executeDailyWebhook( data: { embeds }, }); + // If the season is not synced, then sync it to set up the next season. + if (!syncedSeason) { + await leaderboardClient.sync(undefined, referenceDate); + } + // Acknowledge the request. return new Response("OK"); } @@ -109,7 +128,7 @@ export interface DailyWebhookOptions { /** * question is the daily question. */ - question: api.LCQuestion; + question: api.Question; /** * season is the season to recap. @@ -140,7 +159,7 @@ export function makeDailyWebhookEmbeds( }, { name: - "Submit your solution by typing `/lc submit YOUR_SUBMISSION_URL` below!", + "Register to play by typing `/lc register YOUR_LC_USERNAME` below!", value: "[See more…](https://acmcsuf.com/lc-dailies-handbook)", }, ], @@ -149,71 +168,9 @@ export function makeDailyWebhookEmbeds( if (options.season) { embed.fields?.push({ name: `Leaderboard for week of ${options.season.start_date}`, - value: formatScores(options.season), + value: leaderboard.formatScores(options.season), }); } return [embed]; } - -/** - * formatScores formats the scores of all players in a season. - */ -export function formatScores(season: api.Season): string { - return [ - "```", - ...Object.entries(season.scores) - .sort(({ 1: scoreA }, { 1: scoreB }) => scoreB - scoreA) - .map(([playerID, score], i) => { - const player = season.players[playerID]; - const formattedScore = String(score).padStart(3, " "); - const formattedRank = formatRank(i + 1); - return `${formattedScore} ${player.lc_username} (${formattedRank})`; - }), - "```", - ].join("\n"); -} - -/** - * formatRank formats the rank of a player in a season. - */ -export function formatRank(rank: number): string { - switch (rank) { - case 1: { - return "🥇"; - } - - case 2: { - return "🥈"; - } - - case 3: { - return "🥉"; - } - - case 11: - case 12: - case 13: { - return `${rank}th`; - } - } - - const lastDigit = rank % 10; - switch (lastDigit) { - case 1: { - return `${rank}st`; - } - - case 2: { - return `${rank}nd`; - } - - case 3: { - return `${rank}rd`; - } - - default: { - return `${rank}th`; - } - } -} diff --git a/api/discord_app/app.ts b/api/discord_app/app.ts index dcebf15..9aa4b6c 100644 --- a/api/discord_app/app.ts +++ b/api/discord_app/app.ts @@ -14,7 +14,6 @@ import { } from "lc-dailies/deps.ts"; import * as router from "lc-dailies/lib/router/mod.ts"; import * as discord from "lc-dailies/lib/discord/mod.ts"; -import * as lc from "lc-dailies/lib/lc/mod.ts"; import * as leaderboard from "lc-dailies/lib/leaderboard/mod.ts"; import { makeRegisterInteractionResponse, @@ -23,11 +22,11 @@ import { SUB_REGISTER, } from "./sub/register.ts"; import { - makeSubmitInteractionResponse, - parseSubmitOptions, - SUB_SUBMIT, - SUBMIT, -} from "./sub/submit.ts"; + makeSyncInteractionResponse, + parseSyncOptions, + SUB_SYNC, + SYNC, +} from "./sub/sync.ts"; export const LC = "lc"; export const LC_DESCRIPTION = @@ -39,7 +38,7 @@ export const LC_DESCRIPTION = export const APP_LC: RESTPostAPIApplicationCommandsJSONBody = { name: LC, description: LC_DESCRIPTION, - options: [SUB_REGISTER, SUB_SUBMIT], + options: [SUB_REGISTER, SUB_SYNC], }; /** @@ -108,39 +107,34 @@ export function makeDiscordAppHandler( // Handle the subcommand. switch (name) { case REGISTER: { - const handleRegisterSubcommand = makeRegisterSubcommandHandler( + const registerResponse = await handleRegisterSubcommand( leaderboardClient, + interaction.member.user, + parseRegisterOptions(interaction.data.options), ); - return Response.json( - await handleRegisterSubcommand( - interaction.member.user, - parseRegisterOptions(interaction.data.options), - ), - ); + + return Response.json(registerResponse); } - case SUBMIT: { - const handleSubmitSubcommand = makeSubmitSubcommandHandler( + case SYNC: { + const syncResponse = await handleSyncSubcommand( leaderboardClient, + parseSyncOptions(interaction.data.options), ); + + return Response.json(syncResponse); + } + + default: { + // Acknowledge the interaction. return Response.json( - await handleSubmitSubcommand( - interaction.member.user, - parseSubmitOptions(interaction.data.options), - ), + { + type: InteractionResponseType.DeferredChannelMessageWithSource, + data: { flags: MessageFlags.Ephemeral }, + } satisfies APIInteractionResponseDeferredChannelMessageWithSource, ); } } - - // Acknowledge the interaction. - return Response.json( - { - type: InteractionResponseType.DeferredChannelMessageWithSource, - data: { - flags: MessageFlags.Ephemeral, - }, - } satisfies APIInteractionResponseDeferredChannelMessageWithSource, - ); } default: { @@ -150,42 +144,34 @@ export function makeDiscordAppHandler( }; } -function makeRegisterSubcommandHandler( +/** + * handleRegisterSubcommand handles the register subcommand. + */ +async function handleRegisterSubcommand( leaderboardClient: leaderboard.LeaderboardClient, -) { - /** - * handleRegisterSubcommand handles the register subcommand. - */ - return async function handleRegisterSubcommand( - user: APIUser, - options: ReturnType, - ): Promise { - const registerResponse = await leaderboardClient.register( - user.id, - options.lc_username, - ); - - return makeRegisterInteractionResponse(registerResponse); - }; + user: APIUser, + options: ReturnType, +): Promise { + const registerResponse = await leaderboardClient.register( + user.id, + options.lc_username, + ); + + return makeRegisterInteractionResponse(registerResponse); } -function makeSubmitSubcommandHandler( +/** + * handleSyncSubcommand handles the sync subcommand. + */ +async function handleSyncSubcommand( leaderboardClient: leaderboard.LeaderboardClient, -) { - /** - * handleSubmitSubcommand handles the submit subcommand. - */ - return async function handleSubmitSubcommand( - user: APIUser, - options: ReturnType, - ): Promise { - const submitResponse = await leaderboardClient.submit( - user.id, - lc.parseSubmissionID(options.submission_url), - ); + options: ReturnType, +): Promise { + const syncResponse = await leaderboardClient.sync( + options.season_id, + ); - return makeSubmitInteractionResponse(submitResponse); - }; + return makeSyncInteractionResponse(syncResponse); } /** diff --git a/api/discord_app/sub/register.ts b/api/discord_app/sub/register.ts index c11ec10..23831d5 100644 --- a/api/discord_app/sub/register.ts +++ b/api/discord_app/sub/register.ts @@ -36,9 +36,7 @@ export const SUB_REGISTER: APIApplicationCommandOption = { */ export function parseRegisterOptions( options: APIApplicationCommandInteractionDataOption[], -): { - [REGISTER_LC_USERNAME]: string; -} { +) { const registerOption = options.find((option) => option.name === REGISTER); if (!registerOption) { throw new Error("No options provided"); @@ -59,9 +57,7 @@ export function parseRegisterOptions( throw new Error("Expected a string for the username option."); } - return { - [REGISTER_LC_USERNAME]: usernameOption.value, - }; + return { [REGISTER_LC_USERNAME]: usernameOption.value }; } /** diff --git a/api/discord_app/sub/submit.ts b/api/discord_app/sub/submit.ts deleted file mode 100644 index d53f05e..0000000 --- a/api/discord_app/sub/submit.ts +++ /dev/null @@ -1,80 +0,0 @@ -import type { - APIApplicationCommandInteractionDataOption, - APIApplicationCommandOption, - APIInteractionResponse, -} from "lc-dailies/deps.ts"; -import { - ApplicationCommandOptionType, - InteractionResponseType, -} from "lc-dailies/deps.ts"; -import * as api from "lc-dailies/api/mod.ts"; - -export const SUBMIT = "submit"; -export const SUBMIT_DESCRIPTION = - "Submit your Leetcode solution for today's challenge"; -export const SUBMIT_SUBMISSION_URL = "submission_url"; -export const SUBMIT_SUBMISSION_URL_DESCRIPTION = "Your Leetcode submission URL"; - -/** - * SUB_SUBMIT is the subcommand for the LC-Dailies command. - */ -export const SUB_SUBMIT: APIApplicationCommandOption = { - name: SUBMIT, - description: SUBMIT_DESCRIPTION, - type: ApplicationCommandOptionType.Subcommand, - options: [ - { - name: SUBMIT_SUBMISSION_URL, - description: SUBMIT_SUBMISSION_URL_DESCRIPTION, - type: ApplicationCommandOptionType.String, - required: true, - }, - ], -}; - -/** - * parseSubmitOptions parses the options for the submit subcommand. - */ -export function parseSubmitOptions( - options: APIApplicationCommandInteractionDataOption[], -): { - [SUBMIT_SUBMISSION_URL]: string; -} { - const submitOption = options.find((option) => option.name === SUBMIT); - if (!submitOption) { - throw new Error("No options provided"); - } - if ( - submitOption.type !== ApplicationCommandOptionType.Subcommand - ) { - throw new Error("Invalid option type"); - } - if (!submitOption.options) { - throw new Error("No options provided"); - } - - const submissionURLOption = submitOption.options.find((option) => - option.name === SUBMIT_SUBMISSION_URL - ); - if (submissionURLOption?.type !== ApplicationCommandOptionType.String) { - throw new Error("Expected a string for the submission URL option"); - } - - return { - [SUBMIT_SUBMISSION_URL]: submissionURLOption.value, - }; -} - -/** - * makeSubmitInteractionResponse makes the interaction response for the register subcommand. - */ -export function makeSubmitInteractionResponse( - r: api.SubmitResponse, -): APIInteractionResponse { - return { - type: InteractionResponseType.ChannelMessageWithSource, - data: { - content: `Your submission was ${r.ok ? "successful" : "unsuccessful"}.`, - }, - }; -} diff --git a/api/discord_app/sub/sync.ts b/api/discord_app/sub/sync.ts new file mode 100644 index 0000000..cd52b48 --- /dev/null +++ b/api/discord_app/sub/sync.ts @@ -0,0 +1,97 @@ +import type { + APIApplicationCommandInteractionDataOption, + APIApplicationCommandOption, + APIInteractionResponse, +} from "lc-dailies/deps.ts"; +import { + ApplicationCommandOptionType, + InteractionResponseType, + SECOND, +} from "lc-dailies/deps.ts"; +import * as api from "lc-dailies/api/mod.ts"; +import { formatScores } from "lc-dailies/lib/leaderboard/mod.ts"; + +export const SYNC = "sync"; +export const SYNC_DESCRIPTION = "Sync and display your season scores"; +export const SEASON_ID = "season_id"; +export const SEASON_ID_DESCRIPTION = "The season ID to sync"; + +/** + * SUB_SYNC is the subcommand for the LC-Dailies command for syncing a season. + */ +export const SUB_SYNC: APIApplicationCommandOption = { + name: SYNC, + description: SYNC_DESCRIPTION, + type: ApplicationCommandOptionType.Subcommand, + options: [ + { + name: SEASON_ID, + description: SEASON_ID_DESCRIPTION, + type: ApplicationCommandOptionType.String, + }, + ], +}; + +/** + * parseSyncOptions parses the options for the sync subcommand. + */ +export function parseSyncOptions( + options: APIApplicationCommandInteractionDataOption[], +) { + const syncOption = options.find((option) => option.name === SYNC); + if (!syncOption) { + throw new Error("No options provided"); + } + if ( + syncOption.type !== ApplicationCommandOptionType.Subcommand + ) { + throw new Error("Invalid option type"); + } + if (!syncOption.options) { + throw new Error("No options provided"); + } + + const seasonIDOption = syncOption.options.find((option) => + option.name === SEASON_ID + ); + if ( + seasonIDOption && + seasonIDOption.type !== ApplicationCommandOptionType.String + ) { + throw new Error("Expected a string for the season ID option."); + } + + return { [SEASON_ID]: seasonIDOption?.value }; +} + +/** + * makeSyncInteractionResponse makes the interaction response for the sync subcommand. + */ +export function makeSyncInteractionResponse( + r: api.SyncResponse, +): APIInteractionResponse { + return { + type: InteractionResponseType.ChannelMessageWithSource, + data: { + embeds: [ + { + title: `Season \`${r.season.id}\` synced ${ + toDiscordTimestamp(new Date(r.season.synced_at!)) + }`, + description: formatScores(r.season), + }, + ], + }, + }; +} + +/** + * toDiscordTimestamp converts a date to a Discord timestamp. + * + * Reference: + * - https://gist.github.com/LeviSnoot/d9147767abeef2f770e9ddcd91eb85aa + * - https://github.com/acmcsufoss/shorter/blob/dbaac9a020a621be0c349a8b9a870b936b988265/main.ts#L235 + */ +function toDiscordTimestamp(date: Date) { + return ``; +} diff --git a/api/types.ts b/api/types.ts index e50d796..bfeccc8 100644 --- a/api/types.ts +++ b/api/types.ts @@ -1,7 +1,7 @@ /** - * LCPlayer is a registered player from Leetcode. + * Player is a registered player from Leetcode. */ -export interface LCPlayer { +export interface Player { /** * discord_user_id is the Discord user ID of the player. */ @@ -14,9 +14,16 @@ export interface LCPlayer { } /** - * LCSubmission is a Leetcode submission. + * Players is a map of players by Discord user ID. */ -export interface LCSubmission { +export interface Players { + [discord_user_id: string]: Player; +} + +/** + * Submission is a Leetcode submission. + */ +export interface Submission { /** * id is the ID of the submission. */ @@ -29,9 +36,19 @@ export interface LCSubmission { } /** - * LCQuestion is a Leetcode question. + * Submissions is a map of submissions by question name + * by Discord user ID. */ -export interface LCQuestion { +export interface Submissions { + [discord_user_id: string]: { + [question_name: string]: Submission; + }; +} + +/** + * Question is a Leetcode question. + */ +export interface Question { /** * name is the name of the daily question. */ @@ -58,6 +75,20 @@ export interface LCQuestion { url: string; } +/** + * Questions is a map of questions by question name. + */ +export interface Questions { + [question_name: string]: Question; +} + +/** + * Scores is a map of scores by Discord user ID. + */ +export interface Scores { + [discord_user_id: string]: number; +} + /** * Season is a season of the leaderboard. */ @@ -75,26 +106,29 @@ export interface Season { /** * scores is the map of scores in the season. */ - scores: { [discord_user_id: string]: number }; + scores: Scores; /** * players is the map of players in the season. */ - players: { [discord_user_id: string]: LCPlayer }; + players: Players; /** * questions is the map of questions in the season. */ - questions: { [lc_question_name: string]: LCQuestion }; + questions: Questions; /** * submissions is the map of submissions in the season. */ - submissions: { - [discord_user_id: string]: { - [lc_question_name: string]: LCSubmission; - }; - }; + submissions: Submissions; + + /** + * synced_at is the date the season was synced. + * + * The field is undefined if the season has not been synced. + */ + synced_at?: string; } /** @@ -108,11 +142,11 @@ export interface RegisterResponse { } /** - * SubmitResponse is the response for the submit subcommand. + * SyncResponse is the response for the sync subcommand. */ -export interface SubmitResponse { +export interface SyncResponse { /** - * ok is whether the submission was successful. + * season is the season that was synced. */ - ok: boolean; + season: Season; } diff --git a/deno.lock b/deno.lock index fd43248..2daeb6e 100644 --- a/deno.lock +++ b/deno.lock @@ -3,40 +3,40 @@ "remote": { "https://cdn.skypack.dev/-/tweetnacl@v1.0.3-G4yM3nQ8lnXXlGGQADqJ/dist=es2019,mode=imports/optimized/tweetnacl.js": "d26554516df57e5cb58954e90c633c8871b4e66016b9fe4e07a36db5430bc8c7", "https://cdn.skypack.dev/tweetnacl@1.0.3": "6610aad2ac175c2d575995fc7de8ed552c2e5e05aef80ed8588cf3c6e2db61d7", - "https://deno.land/std@0.203.0/assert/_constants.ts": "8a9da298c26750b28b326b297316cdde860bc237533b07e1337c021379e6b2a9", - "https://deno.land/std@0.203.0/assert/_diff.ts": "1a3c044aedf77647d6cac86b798c6417603361b66b54c53331b312caeb447aea", - "https://deno.land/std@0.203.0/assert/_format.ts": "a69126e8a469009adf4cf2a50af889aca364c349797e63174884a52ff75cf4c7", - "https://deno.land/std@0.203.0/assert/assert.ts": "9a97dad6d98c238938e7540736b826440ad8c1c1e54430ca4c4e623e585607ee", - "https://deno.land/std@0.203.0/assert/assert_almost_equals.ts": "e15ca1f34d0d5e0afae63b3f5d975cbd18335a132e42b0c747d282f62ad2cd6c", - "https://deno.land/std@0.203.0/assert/assert_array_includes.ts": "6856d7f2c3544bc6e62fb4646dfefa3d1df5ff14744d1bca19f0cbaf3b0d66c9", - "https://deno.land/std@0.203.0/assert/assert_equals.ts": "d8ec8a22447fbaf2fc9d7c3ed2e66790fdb74beae3e482855d75782218d68227", - "https://deno.land/std@0.203.0/assert/assert_exists.ts": "407cb6b9fb23a835cd8d5ad804e2e2edbbbf3870e322d53f79e1c7a512e2efd7", - "https://deno.land/std@0.203.0/assert/assert_false.ts": "0ccbcaae910f52c857192ff16ea08bda40fdc79de80846c206bfc061e8c851c6", - "https://deno.land/std@0.203.0/assert/assert_greater.ts": "ae2158a2d19313bf675bf7251d31c6dc52973edb12ac64ac8fc7064152af3e63", - "https://deno.land/std@0.203.0/assert/assert_greater_or_equal.ts": "1439da5ebbe20855446cac50097ac78b9742abe8e9a43e7de1ce1426d556e89c", - "https://deno.land/std@0.203.0/assert/assert_instance_of.ts": "3aedb3d8186e120812d2b3a5dea66a6e42bf8c57a8bd927645770bd21eea554c", - "https://deno.land/std@0.203.0/assert/assert_is_error.ts": "c21113094a51a296ffaf036767d616a78a2ae5f9f7bbd464cd0197476498b94b", - "https://deno.land/std@0.203.0/assert/assert_less.ts": "aec695db57db42ec3e2b62e97e1e93db0063f5a6ec133326cc290ff4b71b47e4", - "https://deno.land/std@0.203.0/assert/assert_less_or_equal.ts": "5fa8b6a3ffa20fd0a05032fe7257bf985d207b85685fdbcd23651b70f928c848", - "https://deno.land/std@0.203.0/assert/assert_match.ts": "c4083f80600bc190309903c95e397a7c9257ff8b5ae5c7ef91e834704e672e9b", - "https://deno.land/std@0.203.0/assert/assert_not_equals.ts": "9f1acab95bd1f5fc9a1b17b8027d894509a745d91bac1718fdab51dc76831754", - "https://deno.land/std@0.203.0/assert/assert_not_instance_of.ts": "0c14d3dfd9ab7a5276ed8ed0b18c703d79a3d106102077ec437bfe7ed912bd22", - "https://deno.land/std@0.203.0/assert/assert_not_match.ts": "3796a5b0c57a1ce6c1c57883dd4286be13a26f715ea662318ab43a8491a13ab0", - "https://deno.land/std@0.203.0/assert/assert_not_strict_equals.ts": "ca6c6d645e95fbc873d25320efeb8c4c6089a9a5e09f92d7c1c4b6e935c2a6ad", - "https://deno.land/std@0.203.0/assert/assert_object_match.ts": "d8fc2867cfd92eeacf9cea621e10336b666de1874a6767b5ec48988838370b54", - "https://deno.land/std@0.203.0/assert/assert_rejects.ts": "45c59724de2701e3b1f67c391d6c71c392363635aad3f68a1b3408f9efca0057", - "https://deno.land/std@0.203.0/assert/assert_strict_equals.ts": "b1f538a7ea5f8348aeca261d4f9ca603127c665e0f2bbfeb91fa272787c87265", - "https://deno.land/std@0.203.0/assert/assert_string_includes.ts": "b821d39ebf5cb0200a348863c86d8c4c4b398e02012ce74ad15666fc4b631b0c", - "https://deno.land/std@0.203.0/assert/assert_throws.ts": "63784e951475cb7bdfd59878cd25a0931e18f6dc32a6077c454b2cd94f4f4bcd", - "https://deno.land/std@0.203.0/assert/assertion_error.ts": "4d0bde9b374dfbcbe8ac23f54f567b77024fb67dbb1906a852d67fe050d42f56", - "https://deno.land/std@0.203.0/assert/equal.ts": "9f1a46d5993966d2596c44e5858eec821859b45f783a5ee2f7a695dfc12d8ece", - "https://deno.land/std@0.203.0/assert/fail.ts": "c36353d7ae6e1f7933d45f8ea51e358c8c4b67d7e7502028598fe1fea062e278", - "https://deno.land/std@0.203.0/assert/mod.ts": "37c49a26aae2b254bbe25723434dc28cd7532e444cf0b481a97c045d110ec085", - "https://deno.land/std@0.203.0/assert/unimplemented.ts": "d56fbeecb1f108331a380f72e3e010a1f161baa6956fd0f7cf3e095ae1a4c75a", - "https://deno.land/std@0.203.0/assert/unreachable.ts": "4600dc0baf7d9c15a7f7d234f00c23bca8f3eba8b140286aaca7aa998cf9a536", - "https://deno.land/std@0.203.0/datetime/constants.ts": "b63a6b702e06fa028fb2ffa25e0cf775e3b21cf7f38e53a6f219e9641894dfbb", - "https://deno.land/std@0.203.0/dotenv/mod.ts": "1da8c6d0e7f7d8a5c2b19400b763bc11739df24acec235dda7ea2cfd3d300057", - "https://deno.land/std@0.203.0/fmt/colors.ts": "c51c4642678eb690dcf5ffee5918b675bf01a33fba82acf303701ae1a4f8c8d9", + "https://deno.land/std@0.204.0/assert/_constants.ts": "8a9da298c26750b28b326b297316cdde860bc237533b07e1337c021379e6b2a9", + "https://deno.land/std@0.204.0/assert/_diff.ts": "58e1461cc61d8eb1eacbf2a010932bf6a05b79344b02ca38095f9b805795dc48", + "https://deno.land/std@0.204.0/assert/_format.ts": "a69126e8a469009adf4cf2a50af889aca364c349797e63174884a52ff75cf4c7", + "https://deno.land/std@0.204.0/assert/assert.ts": "9a97dad6d98c238938e7540736b826440ad8c1c1e54430ca4c4e623e585607ee", + "https://deno.land/std@0.204.0/assert/assert_almost_equals.ts": "e15ca1f34d0d5e0afae63b3f5d975cbd18335a132e42b0c747d282f62ad2cd6c", + "https://deno.land/std@0.204.0/assert/assert_array_includes.ts": "6856d7f2c3544bc6e62fb4646dfefa3d1df5ff14744d1bca19f0cbaf3b0d66c9", + "https://deno.land/std@0.204.0/assert/assert_equals.ts": "d8ec8a22447fbaf2fc9d7c3ed2e66790fdb74beae3e482855d75782218d68227", + "https://deno.land/std@0.204.0/assert/assert_exists.ts": "407cb6b9fb23a835cd8d5ad804e2e2edbbbf3870e322d53f79e1c7a512e2efd7", + "https://deno.land/std@0.204.0/assert/assert_false.ts": "0ccbcaae910f52c857192ff16ea08bda40fdc79de80846c206bfc061e8c851c6", + "https://deno.land/std@0.204.0/assert/assert_greater.ts": "ae2158a2d19313bf675bf7251d31c6dc52973edb12ac64ac8fc7064152af3e63", + "https://deno.land/std@0.204.0/assert/assert_greater_or_equal.ts": "1439da5ebbe20855446cac50097ac78b9742abe8e9a43e7de1ce1426d556e89c", + "https://deno.land/std@0.204.0/assert/assert_instance_of.ts": "3aedb3d8186e120812d2b3a5dea66a6e42bf8c57a8bd927645770bd21eea554c", + "https://deno.land/std@0.204.0/assert/assert_is_error.ts": "c21113094a51a296ffaf036767d616a78a2ae5f9f7bbd464cd0197476498b94b", + "https://deno.land/std@0.204.0/assert/assert_less.ts": "aec695db57db42ec3e2b62e97e1e93db0063f5a6ec133326cc290ff4b71b47e4", + "https://deno.land/std@0.204.0/assert/assert_less_or_equal.ts": "5fa8b6a3ffa20fd0a05032fe7257bf985d207b85685fdbcd23651b70f928c848", + "https://deno.land/std@0.204.0/assert/assert_match.ts": "c4083f80600bc190309903c95e397a7c9257ff8b5ae5c7ef91e834704e672e9b", + "https://deno.land/std@0.204.0/assert/assert_not_equals.ts": "9f1acab95bd1f5fc9a1b17b8027d894509a745d91bac1718fdab51dc76831754", + "https://deno.land/std@0.204.0/assert/assert_not_instance_of.ts": "0c14d3dfd9ab7a5276ed8ed0b18c703d79a3d106102077ec437bfe7ed912bd22", + "https://deno.land/std@0.204.0/assert/assert_not_match.ts": "3796a5b0c57a1ce6c1c57883dd4286be13a26f715ea662318ab43a8491a13ab0", + "https://deno.land/std@0.204.0/assert/assert_not_strict_equals.ts": "ca6c6d645e95fbc873d25320efeb8c4c6089a9a5e09f92d7c1c4b6e935c2a6ad", + "https://deno.land/std@0.204.0/assert/assert_object_match.ts": "d8fc2867cfd92eeacf9cea621e10336b666de1874a6767b5ec48988838370b54", + "https://deno.land/std@0.204.0/assert/assert_rejects.ts": "45c59724de2701e3b1f67c391d6c71c392363635aad3f68a1b3408f9efca0057", + "https://deno.land/std@0.204.0/assert/assert_strict_equals.ts": "b1f538a7ea5f8348aeca261d4f9ca603127c665e0f2bbfeb91fa272787c87265", + "https://deno.land/std@0.204.0/assert/assert_string_includes.ts": "b821d39ebf5cb0200a348863c86d8c4c4b398e02012ce74ad15666fc4b631b0c", + "https://deno.land/std@0.204.0/assert/assert_throws.ts": "63784e951475cb7bdfd59878cd25a0931e18f6dc32a6077c454b2cd94f4f4bcd", + "https://deno.land/std@0.204.0/assert/assertion_error.ts": "4d0bde9b374dfbcbe8ac23f54f567b77024fb67dbb1906a852d67fe050d42f56", + "https://deno.land/std@0.204.0/assert/equal.ts": "9f1a46d5993966d2596c44e5858eec821859b45f783a5ee2f7a695dfc12d8ece", + "https://deno.land/std@0.204.0/assert/fail.ts": "c36353d7ae6e1f7933d45f8ea51e358c8c4b67d7e7502028598fe1fea062e278", + "https://deno.land/std@0.204.0/assert/mod.ts": "37c49a26aae2b254bbe25723434dc28cd7532e444cf0b481a97c045d110ec085", + "https://deno.land/std@0.204.0/assert/unimplemented.ts": "d56fbeecb1f108331a380f72e3e010a1f161baa6956fd0f7cf3e095ae1a4c75a", + "https://deno.land/std@0.204.0/assert/unreachable.ts": "4600dc0baf7d9c15a7f7d234f00c23bca8f3eba8b140286aaca7aa998cf9a536", + "https://deno.land/std@0.204.0/datetime/constants.ts": "b63a6b702e06fa028fb2ffa25e0cf775e3b21cf7f38e53a6f219e9641894dfbb", + "https://deno.land/std@0.204.0/dotenv/mod.ts": "1da8c6d0e7f7d8a5c2b19400b763bc11739df24acec235dda7ea2cfd3d300057", + "https://deno.land/std@0.204.0/fmt/colors.ts": "c51c4642678eb690dcf5ffee5918b675bf01a33fba82acf303701ae1a4f8c8d9", "https://deno.land/x/discord_api_types@0.37.60/gateway/common.ts": "fb67003adda424df76c2726e0624d709c5a16e3694d6b75facd587d121fe121f", "https://deno.land/x/discord_api_types@0.37.60/gateway/v10.ts": "1b0fa3d040825a7aab93d6acec63d457c79c445dcc934d4f511dc2c326988ddf", "https://deno.land/x/discord_api_types@0.37.60/globals.ts": "7d8879654c4741ac071668ad52f2659bcdb66694cfe7da306c8437ec752807a7", diff --git a/deps.ts b/deps.ts index 98a6e03..78f0fa5 100644 --- a/deps.ts +++ b/deps.ts @@ -1,10 +1,10 @@ export { assertEquals, assertRejects, -} from "https://deno.land/std@0.203.0/assert/mod.ts"; +} from "https://deno.land/std@0.204.0/assert/mod.ts"; -export { load } from "https://deno.land/std@0.203.0/dotenv/mod.ts"; -export * from "https://deno.land/std@0.203.0/datetime/constants.ts"; +export { load } from "https://deno.land/std@0.204.0/dotenv/mod.ts"; +export * from "https://deno.land/std@0.204.0/datetime/constants.ts"; export { ulid } from "https://deno.land/x/ulid@v0.3.0/mod.ts"; export type { APIApplicationCommandInteractionDataOption, diff --git a/docs/HANDBOOK.md b/docs/HANDBOOK.md index 6a23452..f4a8c80 100644 --- a/docs/HANDBOOK.md +++ b/docs/HANDBOOK.md @@ -62,29 +62,15 @@ weekly competitions. Once you have registered your Leetcode username, you are ready to participate in our weekly competitions. -- Type `/lc submit` in the `📚algo-chat` text channel to use the slash command. -- Populate the required field `submission_url` with the URL to your accepted - Leetcode solution of a daily challenge from the current season e.g. all - Leetcode dailies announced since the previous Sunday at midnight UTC. -- Press Enter to submit the solution. -- Wait for the response from the slash command to confirm the submission. This - may take a second. -- If the submission was successful, your submission will be added to - [the leaderboard](https://lc-dailies.deno.dev/seasons/latest). - -| Valid `submission_url` values | -| :----------------------------------------------------------------------------------: | -| `https://leetcode.com/problems/implement-stack-using-queues/submissions/1035629181/` | -| `https://leetcode.com/submissions/detail/1035629181/` | -| `1035629181` | - -#### Find your Leetcode submission URL +No further action is needed on your part to submit your Leetcode solutions. A +daily background process automatically syncs our stored leaderboard data with +the latest submissions data on Leetcode. -Once you solve a Leetcode daily challenge, you can find the URL to your accepted -solution in the `Submissions` tab of the problem page. Click the buttons as -shown in the image below to copy the URL to your clipboard. - -![Leetcode solution URL copy button](https://github.com/acmcsufoss/lc-dailies/assets/31261035/7c6b2425-d0fd-46b6-9484-7c34a013c175) +> **NOTE** +> +> Soon, our leaderboard will be visible on the ACM CSUF website at +> . Follow issue +> [#36](https://github.com/acmcsufoss/lc-dailies/issues/36) for updates. --- diff --git a/lib/lc/client.ts b/lib/lc/client.ts index 5eb8a97..bf8e58f 100644 --- a/lib/lc/client.ts +++ b/lib/lc/client.ts @@ -1,8 +1,11 @@ -import type { LCQuestion } from "lc-dailies/api/mod.ts"; +import type { Question } from "lc-dailies/api/mod.ts"; import { makeQuestionURL } from "./urls.ts"; import { gql } from "./gql.ts"; -export type { LCQuestion }; +/** + * LCQuestion is an alias interface for a Leetcode question. + */ +export type LCQuestion = Question; /** * LCSubmission is the representation of Leetcode's recent submission per user. @@ -119,8 +122,12 @@ export class LCClient { */ public async getRecentAcceptedSubmissions( username: string, - limit: number, + limit = MAX_SUBMISSIONS_LIMIT, ): Promise { + if (limit > MAX_SUBMISSIONS_LIMIT) { + limit = MAX_SUBMISSIONS_LIMIT; + } + return await gql( JSON.stringify({ operationName: "recentAcSubmissions", @@ -151,3 +158,8 @@ export class LCClient { ); } } + +/** + * MAX_SUBMISSIONS_LIMIT is the maximum amount of submissions to fetch from Leetcode. + */ +export const MAX_SUBMISSIONS_LIMIT = 20; diff --git a/lib/leaderboard/denokv/denokv_leaderboard_client.ts b/lib/leaderboard/denokv/denokv_leaderboard_client.ts index a46c32b..6d506fb 100644 --- a/lib/leaderboard/denokv/denokv_leaderboard_client.ts +++ b/lib/leaderboard/denokv/denokv_leaderboard_client.ts @@ -1,11 +1,8 @@ import { DAY, ulid, WEEK } from "lc-dailies/deps.ts"; import type * as api from "lc-dailies/api/mod.ts"; import type { LeaderboardClient } from "lc-dailies/lib/leaderboard/mod.ts"; -import { - calculateSeasonScores, - makeDefaultCalculateScoresOptions, -} from "lc-dailies/lib/leaderboard/mod.ts"; -import type { LCClient } from "lc-dailies/lib/lc/mod.ts"; +import { sync } from "lc-dailies/lib/leaderboard/mod.ts"; +import { LCClient } from "lc-dailies/lib/lc/mod.ts"; /** * DenoKvLeaderboardClient is the client for the leaderboard. @@ -29,6 +26,21 @@ export class DenoKvLeaderboardClient implements LeaderboardClient { private readonly restartMs = 0, ) {} + /** + * listPlayers lists all the registered players. + */ + private async listPlayers(): Promise { + const players: api.Players = {}; + const entries = this.kv + .list({ prefix: [LeaderboardKvPrefix.PLAYERS] }); + for await (const entry of entries) { + const playerID = entry.key[1] as string; + players[playerID] = entry.value; + } + + return players; + } + /** * getLatestSeasonFromKv reads the latest season from Deno KV. */ @@ -57,15 +69,9 @@ export class DenoKvLeaderboardClient implements LeaderboardClient { /** * updateLatestSeason updates the latest season in Deno KV. */ - private async updateLatestSeason( - season: api.Season, - prevSeasonResult: Deno.KvEntryMaybe | null, - ): Promise { + private async updateLatestSeason(season: api.Season): Promise { // Update the season. const updateSeasonOp = this.kv.atomic(); - if (prevSeasonResult) { - updateSeasonOp.check(prevSeasonResult); - } // Update the season. const updateSeasonResult = await updateSeasonOp.set( @@ -89,27 +95,29 @@ export class DenoKvLeaderboardClient implements LeaderboardClient { } public async register( - discord_user_id: string, - lc_username: string, + playerID: string, + lcUsername: string, ): Promise { - const key: Deno.KvKey = [LeaderboardKvPrefix.PLAYERS, discord_user_id]; - const playerResult = await this.kv.get(key); + const key: Deno.KvKey = [LeaderboardKvPrefix.PLAYERS, playerID]; + const playerResult = await this.kv.get(key); if (playerResult.value) { throw new Error("Player already registered"); } // Verify the user with Leetcode. - const isVerified = await this.lc.verifyUser(lc_username); + const isVerified = await this.lc.verifyUser(lcUsername); if (!isVerified) { throw new Error("Failed to verify user with Leetcode"); } // Register the player. - const player: api.LCPlayer = { discord_user_id, lc_username }; const registerResult = await this.kv .atomic() .check(playerResult) - .set(key, player) + .set( + key, + { discord_user_id: playerID, lc_username: lcUsername }, + ) .commit(); if (!registerResult.ok) { throw new Error("Failed to register player"); @@ -118,122 +126,70 @@ export class DenoKvLeaderboardClient implements LeaderboardClient { return { ok: true }; } - public async submit( - discord_user_id: string, - lc_submission_id: string, - currentDate = new Date(), - ): Promise { - // Check if the player is registered in our leaderboard. - const maybePlayerResult = await this.kv - .get([LeaderboardKvPrefix.PLAYERS, discord_user_id]); - if (!maybePlayerResult.value) { - throw new Error("Player not registered"); - } - - // Find the accepted submission in the recent submissions of the player. - const recentAcceptedSubmissions = await this.lc - .getRecentAcceptedSubmissions(maybePlayerResult.value.lc_username, 10); - const acceptedSubmission = recentAcceptedSubmissions - .find((s) => s.id === lc_submission_id); - if (!acceptedSubmission) { - throw new Error("Submission not found"); - } - - // The latest season. Default to empty season when not found or not latest. - // If current date is no longer in the "latest" season, create a new season. - const maybeSeasonResult = await this.getLatestSeasonFromKv(); - const isLatestSeason = !!(maybeSeasonResult?.value) && checkDateInWeek( - new Date(maybeSeasonResult.value.start_date).getTime(), - currentDate.getTime(), - ); - const season = isLatestSeason - ? maybeSeasonResult?.value - : makeEmptySeason(getStartOfWeek(this.restartMs, currentDate)); - - // Check if the submission is part of the current season. - const seasonStartDate = new Date(season.start_date); - const acceptedSubmissionDate = fromLCTimestamp( - acceptedSubmission.timestamp, - ); - const isSubmissionInSeason = checkDateInWeek( - seasonStartDate.getTime(), - acceptedSubmissionDate.getTime(), - ); - if (!isSubmissionInSeason) { - throw new Error("Submission not in season"); - } - - // Find the question in the duration of the season from the recent daily questions. - // Use the end date of the season as the end date of the duration. - const seasonEndDate = new Date(seasonStartDate.getTime() + WEEK); - const seasonYear = seasonEndDate.getUTCFullYear(); - const seasonMonth = seasonEndDate.getUTCMonth() + 1; - const recentDailyQuestions = await this.lc - .listDailyQuestions(10, seasonYear, seasonMonth); - - // Find the question in the recent daily questions. - const recentDailyQuestion = recentDailyQuestions - .find((q) => q.name === acceptedSubmission.name); - if (!recentDailyQuestion) { - throw new Error("Question not found"); - } - - // Check if the question is part of the season. - const isQuestionInSeason = checkDateInWeek( - new Date(season.start_date).getTime(), - new Date(recentDailyQuestion.date).getTime(), + public async sync( + seasonID?: string, + referenceDate = new Date(), + ): Promise { + // startOfWeekUTC is the start of the season in UTC. + const startOfWeekUTC = getStartOfWeek(this.restartMs, referenceDate); + const startOfWeekDate = new Date(startOfWeekUTC); + + // Get the season. + let season: api.Season; + let seasonStartDate: Date; + let isLatestSeason: boolean; + let seasonResult: Deno.KvEntryMaybe | null; + if (seasonID) { + seasonResult = await this.kv.get([ + LeaderboardKvPrefix.SEASONS, + seasonID, + ]); + if (!seasonResult.value) { + throw new Error("Season not found"); + } + + season = seasonResult.value; + seasonStartDate = new Date(season.start_date); + isLatestSeason = startOfWeekDate.getTime() === seasonStartDate.getTime(); + } else { + seasonResult = await this.getLatestSeasonFromKv(); + const isPresentAndLatest = seasonResult?.value && + new Date(seasonResult.value.start_date).getTime() === + startOfWeekDate.getTime(); + season = isPresentAndLatest + ? seasonResult?.value! + : makeEmptySeason(startOfWeekDate); + seasonStartDate = new Date(season.start_date); + isLatestSeason = true; + } + + // Sync the season. + const players = await this.listPlayers(); + season = await sync({ lcClient: this.lc, players, season }); + season.synced_at = referenceDate.toUTCString(); + + // Store the synced season. + await this.kv.set( + [LeaderboardKvPrefix.SEASONS, season.id], + season, ); - if (!isQuestionInSeason) { - throw new Error("Question not in season"); - } - - // Check if the question is already registered in the current season for the player. - const registeredQuestion = season.submissions[discord_user_id] - ?.[recentDailyQuestion.name]; - if (registeredQuestion) { - throw new Error("Question already registered"); - } - - // Add the submission to the player's submissions. - season.submissions[discord_user_id] ??= {}; - season.submissions[discord_user_id][recentDailyQuestion.name] = { - id: acceptedSubmission.id, - date: acceptedSubmissionDate.toUTCString(), - }; - - // Add player to the season if not already in the season. - season.players[discord_user_id] ??= maybePlayerResult.value; - - // Add the question to the season if not already in the season. - season.questions[recentDailyQuestion.name] ??= recentDailyQuestion; - // Add the calculated scores to the season. - season.scores = calculateSeasonScores(makeDefaultCalculateScoresOptions( - season.players, - season.questions, - season.submissions, - )); - - // Update the season in Deno KV. - await this.updateLatestSeason(season, maybeSeasonResult); - return { ok: true }; - } - - public async getLatestSeason(): Promise { - const seasonResult = await this.getLatestSeasonFromKv(); - if (!seasonResult?.value) { - return null; + // Update the season if it is the latest season. + startOfWeekDate.getTime() === seasonStartDate.getTime(); + if (isLatestSeason) { + await this.updateLatestSeason(season); } - return seasonResult.value; + // Return a sync response. + return { season }; } public async getSeason( - season_id: string, + seasonID: string, ): Promise { const seasonResult = await this.kv.get([ LeaderboardKvPrefix.SEASONS, - season_id, + seasonID, ]); return seasonResult.value; } @@ -248,6 +204,11 @@ export class DenoKvLeaderboardClient implements LeaderboardClient { return seasons; } + + public async getLatestSeason(): Promise { + const seasonResult = await this.getLatestSeasonFromKv(); + return seasonResult?.value ?? null; + } } /** @@ -262,8 +223,18 @@ export enum LeaderboardKvPrefix { SEASON_ID = "leaderboard_season_id", } -function checkDateInWeek(startOfWeek: number, date: number): boolean { - return date >= startOfWeek && date < startOfWeek + WEEK; +/** + * makeEmptySeason creates an empty season. + */ +export function makeEmptySeason(startOfWeek: Date): api.Season { + return { + id: ulid(startOfWeek.getTime()), + start_date: startOfWeek.toUTCString(), + scores: {}, + players: {}, + questions: {}, + submissions: {}, + }; } function getStartOfWeek(restartMs = 0, date = new Date()): number { @@ -276,19 +247,3 @@ function getStartOfWeek(restartMs = 0, date = new Date()): number { return startOfWeek; } - -function makeEmptySeason(startOfWeek: number): api.Season { - return { - id: ulid(startOfWeek), - start_date: new Date(startOfWeek).toUTCString(), - scores: {}, - players: {}, - questions: {}, - submissions: {}, - }; -} - -function fromLCTimestamp(timestamp: string): Date { - const utcSeconds = parseInt(timestamp); - return new Date(utcSeconds * 1e3); -} diff --git a/lib/leaderboard/denokv/denokv_leaderboard_client_test.ts b/lib/leaderboard/denokv/denokv_leaderboard_client_test.ts index 9962bf5..6ecf64b 100644 --- a/lib/leaderboard/denokv/denokv_leaderboard_client_test.ts +++ b/lib/leaderboard/denokv/denokv_leaderboard_client_test.ts @@ -1,4 +1,4 @@ -import { assertEquals, assertRejects } from "lc-dailies/deps.ts"; +import { assertEquals, assertRejects, DAY } from "lc-dailies/deps.ts"; import * as fake_lc from "lc-dailies/lib/lc/fake_client.ts"; import type { Season } from "lc-dailies/api/mod.ts"; import { DenoKvLeaderboardClient } from "./denokv_leaderboard_client.ts"; @@ -54,13 +54,13 @@ Deno.test("DenoKvLeaderboardClient", async (t) => { }); }); - await t.step("submit", async () => { - const result = await client.submit( - FAKE_DISCORD_USER_ID, - fake_lc.FAKE_RECENT_SUBMISSION_ID, - new Date(fake_lc.FAKE_LC_QUESTION_DATE), + await t.step("sync", async () => { + const twoDaysAfterFakeSeasonStartDate = new Date( + FAKE_SEASON_START_DATE.getTime() + 2 * DAY, ); - assertEquals(result.ok, true); + const syncResponse = await client + .sync(undefined, twoDaysAfterFakeSeasonStartDate); + assertSeasonsEqual(syncResponse.season, FAKE_SEASON); }); let seasonID: string | undefined; diff --git a/lib/leaderboard/leaderboard_client.ts b/lib/leaderboard/leaderboard_client.ts index f57f415..e6e1afc 100644 --- a/lib/leaderboard/leaderboard_client.ts +++ b/lib/leaderboard/leaderboard_client.ts @@ -13,14 +13,13 @@ export interface LeaderboardClient { ): Promise; /** - * submit registers a new submission to the leaderboard. + * sync syncs the leaderboard with Leetcode. * - * Returns the season of the submission. + * Throws an error if the season is unable to be synced. + * + * Returns the synced season. */ - submit( - discord_user_id: string, - lc_submission_id: string, - ): Promise; + sync(season_id?: string, reference_date?: Date): Promise; /** * getLatestSeason gets the latest season. diff --git a/lib/leaderboard/mod.ts b/lib/leaderboard/mod.ts index a905c37..4eb2c75 100644 --- a/lib/leaderboard/mod.ts +++ b/lib/leaderboard/mod.ts @@ -1,2 +1,3 @@ export * from "./leaderboard_client.ts"; export * from "./scores.ts"; +export * from "./sync.ts"; diff --git a/lib/leaderboard/scores.ts b/lib/leaderboard/scores.ts index 70290a4..eb8bdc9 100644 --- a/lib/leaderboard/scores.ts +++ b/lib/leaderboard/scores.ts @@ -8,25 +8,25 @@ export interface CalculateScoresOptions { /** * submissions are the submissions in the season. */ - submissions: api.Season["submissions"]; + submissions: api.Submissions; /** * questions are the questions in the season. */ - questions: api.Season["questions"]; + questions: api.Questions; /** * players are the players in the season. */ - players: api.Season["players"]; + players: api.Players; /** - * possibleHighestScore is the highest possible score. + * possibleHighestScore is the highest possible score per question. */ possibleHighestScore: number; /** - * possibleLowestScore is the lowest possible score. + * possibleLowestScore is the lowest possible score per question. */ possibleLowestScore: number; @@ -35,24 +35,32 @@ export interface CalculateScoresOptions { * between the highest and lowest possible scores. */ duration: number; + + /** + * modifyScore modifies the score of a player. + */ + modifyScore?: (score: number) => number; } /** * calculateSubmissionScore calculates the score of a submission. */ export function calculateSubmissionScore( - submission: api.LCSubmission, - question: api.LCQuestion, + submission: api.Submission, + question: api.Question, options: CalculateScoresOptions, ): number { const questionDate = new Date(`${question.date} GMT`); const submissionDate = new Date(submission.date); const msElapsed = submissionDate.getTime() - questionDate.getTime(); const ratio = Math.min(Math.max(msElapsed / options.duration, 0), 1); - const questionScore = - ((options.possibleHighestScore - options.possibleLowestScore) * - ratio) + options.possibleLowestScore; - return Math.ceil(questionScore); + const score = ((options.possibleHighestScore - options.possibleLowestScore) * + ratio) + options.possibleLowestScore; + if (!options.modifyScore) { + return score; + } + + return options.modifyScore(score); } /** @@ -88,27 +96,31 @@ export function calculatePlayerScore( } /** - * calculateSeasonScores calculates the scores of all players in a season. + * calculateScores calculates the scores of all players in a season. * * Returns a map of player ID to score. */ -export function calculateSeasonScores( +export function calculateScores( options: CalculateScoresOptions, -): Record { +): api.Scores { return Object.keys(options.players) .reduce((scores, playerID) => { - scores[playerID] = calculatePlayerScore(playerID, options); + const score = calculatePlayerScore(playerID, options); + if (score > 0) { + scores[playerID] = score; + } + return scores; - }, {} as Record); + }, {} as api.Scores); } /** * makeDefaultCalculateScoresOptions creates a default CalculateScoresOptions. */ export function makeDefaultCalculateScoresOptions( - players: api.Season["players"], - questions: api.Season["questions"], - submissions: api.Season["submissions"], + players: api.Players, + questions: api.Questions, + submissions: api.Submissions, ): CalculateScoresOptions { return { players, @@ -117,5 +129,75 @@ export function makeDefaultCalculateScoresOptions( possibleHighestScore: 100, possibleLowestScore: 50, duration: DAY, + modifyScore: defaultModifyScore, }; } + +/** + * defaultModifyScore is the default score modifier. + */ +export function defaultModifyScore(score: number): number { + return Math.ceil(score); +} + +/** + * formatScores formats the scores of all players in a season. + */ +export function formatScores(season: api.Season): string { + return [ + "```", + ...Object.entries(season.scores) + .sort(({ 1: scoreA }, { 1: scoreB }) => scoreB - scoreA) + .map(([playerID, score], i) => { + const player = season.players[playerID]; + const formattedScore = String(score).padStart(3, " "); + const formattedRank = formatRank(i + 1); + return `${formattedScore} ${player.lc_username} (${formattedRank})`; + }), + "```", + ].join("\n"); +} + +/** + * formatRank formats the rank of a player in a season. + */ +export function formatRank(rank: number): string { + switch (rank) { + case 1: { + return "🥇"; + } + + case 2: { + return "🥈"; + } + + case 3: { + return "🥉"; + } + + case 11: + case 12: + case 13: { + return `${rank}th`; + } + } + + const lastDigit = rank % 10; + switch (lastDigit) { + case 1: { + return `${rank}st`; + } + + case 2: { + return `${rank}nd`; + } + + case 3: { + return `${rank}rd`; + } + + default: { + return `${rank}th`; + } + } +} diff --git a/lib/leaderboard/scores_test.ts b/lib/leaderboard/scores_test.ts index 3862882..a40e0b4 100644 --- a/lib/leaderboard/scores_test.ts +++ b/lib/leaderboard/scores_test.ts @@ -1,7 +1,7 @@ import { assertEquals } from "lc-dailies/deps.ts"; import { calculatePlayerScore, - calculateSeasonScores, + calculateScores, makeDefaultCalculateScoresOptions, } from "./scores.ts"; @@ -73,7 +73,7 @@ Deno.test("calculatePlayerScore calculates the score of a player", () => { }); Deno.test("calculateSeasonScores calculates the scores of a season", () => { - const seasonScores = calculateSeasonScores( + const seasonScores = calculateScores( makeDefaultCalculateScoresOptions( FAKE_SEASON.players, FAKE_SEASON.questions, diff --git a/lib/leaderboard/sync.ts b/lib/leaderboard/sync.ts new file mode 100644 index 0000000..fd8778a --- /dev/null +++ b/lib/leaderboard/sync.ts @@ -0,0 +1,142 @@ +import { SECOND, WEEK } from "lc-dailies/deps.ts"; +import type * as api from "lc-dailies/api/mod.ts"; +import type { LCClient } from "lc-dailies/lib/lc/mod.ts"; +import { + calculateScores, + makeDefaultCalculateScoresOptions, +} from "lc-dailies/lib/leaderboard/mod.ts"; + +/** + * SyncOptions are the required options for the sync operation. + */ +export interface SyncOptions { + /** + * season is the season to sync. + */ + season: api.Season; + + /** + * players are the registered players. + */ + players: api.Players; + + /** + * lcClient is the Leetcode client. + */ + lcClient: LCClient; + + /** + * questionsFetchAmount is the amount of questions to fetch from Leetcode. + * + * If not specified, it will be set to 10. + */ + questionsFetchAmount?: number; +} + +/** + * sync creates a season given a list of players, an LCCient, and a + * date range. + */ +export async function sync(options: SyncOptions): Promise { + // Fetch the daily questions of the season. + const seasonStartDate = new Date(options.season.start_date); + const seasonEndDate = new Date(seasonStartDate.getTime() + WEEK); + const recentDailyQuestions = await options.lcClient.listDailyQuestions( + options.questionsFetchAmount ?? 10, + seasonEndDate.getUTCFullYear(), + seasonEndDate.getUTCMonth() + 1, + ); + + // Fetch the submissions of the players. + for (const playerID in options.players) { + // Get the submissions of the player. + const player = options.players[playerID]; + const lcSubmissions = await options.lcClient + .getRecentAcceptedSubmissions(player.lc_username); + + // Store the submissions in the season. + for (const lcSubmission of lcSubmissions) { + const questionName = lcSubmission.name; + + // Skip if the submission is not the earliest submission. + const submissionDate = fromLCTimestamp(lcSubmission.timestamp); + const storedSubmission: api.Submission | undefined = options.season + .submissions[playerID]?.[questionName]; + if ( + storedSubmission && new Date(storedSubmission.date) < submissionDate + ) { + continue; + } + + // Skip if the submission is not in the season. + const isSubmissionInSeason = checkDateInWeek( + seasonStartDate.getTime(), + submissionDate.getTime(), + ); + if (!isSubmissionInSeason) { + continue; + } + + // Fetch the question if it is not in the season. + const storedQuestion: api.Question | undefined = + options.season.questions[questionName]; + if (!storedQuestion) { + // Skip if the question is not found. + const recentDailyQuestion = recentDailyQuestions + .find((q) => q.name === questionName); + if (!recentDailyQuestion) { + continue; + } + + // Skip if the question is not in the season. + const questionDate = new Date(recentDailyQuestion.date); + const isQuestionInSeason = checkDateInWeek( + seasonStartDate.getTime(), + questionDate.getTime(), + ); + if (!isQuestionInSeason) { + continue; + } + + // Store the question in the season. + options.season.questions[questionName] ??= recentDailyQuestion; + } + + // Store the earliest submission of the player. + options.season.submissions[playerID] ??= {}; + options.season.submissions[playerID][questionName] = { + id: lcSubmission.id, + date: submissionDate.toUTCString(), + }; + + // Store the player in the season if it is not in the season. + options.season.players[playerID] ??= player; + } + } + + // Calculate the scores of the players. + options.season.scores = calculateScores( + makeDefaultCalculateScoresOptions( + options.season.players, + options.season.questions, + options.season.submissions, + ), + ); + + return options.season; +} + +/** + * fromLCTimestamp converts a Leetcode timestamp to a Date. + */ +export function fromLCTimestamp(timestamp: string): Date { + const utcSeconds = parseInt(timestamp); + return new Date(utcSeconds * SECOND); +} + +/** + * checkDateInWeek checks if a date is in a week. + */ +export function checkDateInWeek(startOfWeek: number, date: number): boolean { + return date >= startOfWeek && date < startOfWeek + WEEK; +} diff --git a/main.ts b/main.ts index 6a1f1a2..28d879d 100644 --- a/main.ts +++ b/main.ts @@ -2,36 +2,45 @@ import { DenoKvLeaderboardClient } from "lc-dailies/lib/leaderboard/denokv/mod.t import { Router } from "lc-dailies/lib/router/mod.ts"; import * as lc from "lc-dailies/lib/lc/mod.ts"; import * as api from "lc-dailies/api/mod.ts"; -import * as env from "lc-dailies/env.ts"; +import { + DISCORD_APPLICATION_ID, + DISCORD_CHANNEL_ID, + DISCORD_PUBLIC_KEY, + DISCORD_TOKEN, + DISCORD_WEBHOOK_URL, + KV_URL, + PORT, + WEBHOOK_TOKEN, +} from "lc-dailies/env.ts"; if (import.meta.main) { await main(); } async function main() { - const kv = await Deno.openKv(env.KV_URL); + const kv = await Deno.openKv(KV_URL); const lcClient = new lc.LCClient(); const leaderboardClient = new DenoKvLeaderboardClient( kv, lcClient, ); const r = api.makeAPIRouter( - env.DISCORD_APPLICATION_ID, - env.DISCORD_PUBLIC_KEY, - env.DISCORD_CHANNEL_ID, - env.DISCORD_WEBHOOK_URL, - env.WEBHOOK_TOKEN, + DISCORD_APPLICATION_ID, + DISCORD_PUBLIC_KEY, + DISCORD_CHANNEL_ID, + DISCORD_WEBHOOK_URL, + WEBHOOK_TOKEN, lcClient, leaderboardClient, ); await Router.serve( { - port: env.PORT, + port: PORT, onListen: api.makeOnListen( - env.PORT, - env.DISCORD_APPLICATION_ID, - env.DISCORD_TOKEN, + PORT, + DISCORD_APPLICATION_ID, + DISCORD_TOKEN, ), }, r,