diff --git a/frontend/src/html/pages/test-result.html b/frontend/src/html/pages/test-result.html index 469f6617104c..00fbcbb45530 100644 --- a/frontend/src/html/pages/test-result.html +++ b/frontend/src/html/pages/test-result.html @@ -95,7 +95,8 @@ data-balloon-pos="up" class="bottom" > - - +
-
+
diff --git a/frontend/src/styles/test.scss b/frontend/src/styles/test.scss index 2727a0f96fbb..7430620e900f 100644 --- a/frontend/src/styles/test.scss +++ b/frontend/src/styles/test.scss @@ -975,6 +975,12 @@ &.dailyLeaderboard { max-width: 13rem; white-space: nowrap; + + .score { + color: var(--sub-color); + font-size: 0.75rem; + line-height: 0.75rem; + } } &.source { diff --git a/frontend/src/ts/commandline/commandline-metadata.ts b/frontend/src/ts/commandline/commandline-metadata.ts index 999614654a29..4a6534f25000 100644 --- a/frontend/src/ts/commandline/commandline-metadata.ts +++ b/frontend/src/ts/commandline/commandline-metadata.ts @@ -738,6 +738,12 @@ export const commandlineConfigMetadata: CommandlineConfigMetadataObject = { }, alias: "pb", }, + showDailyLbStanding: { + subgroup: { + options: "fromSchema", + }, + alias: "daily standing", + }, monkeyPowerLevel: { alias: "powermode", isVisible: false, diff --git a/frontend/src/ts/commandline/lists.ts b/frontend/src/ts/commandline/lists.ts index 326f9cfdfb06..313b6171facc 100644 --- a/frontend/src/ts/commandline/lists.ts +++ b/frontend/src/ts/commandline/lists.ts @@ -201,6 +201,7 @@ export const commands: CommandsSubgroup = { "capsLockWarning", "showAverage", "showPb", + "showDailyLbStanding", "monkeyPowerLevel", "monkey", ), diff --git a/frontend/src/ts/components/pages/settings/SettingsPage.tsx b/frontend/src/ts/components/pages/settings/SettingsPage.tsx index 6722313494fa..45de5ffdbd36 100644 --- a/frontend/src/ts/components/pages/settings/SettingsPage.tsx +++ b/frontend/src/ts/components/pages/settings/SettingsPage.tsx @@ -189,6 +189,7 @@ export function SettingsPage(): JSXElement { +
diff --git a/frontend/src/ts/config/metadata.tsx b/frontend/src/ts/config/metadata.tsx index 056471a3d42f..d7e04da2b6e6 100644 --- a/frontend/src/ts/config/metadata.tsx +++ b/frontend/src/ts/config/metadata.tsx @@ -1226,6 +1226,15 @@ export const configMetadata: ConfigMetadataObject = { changeRequiresRestart: false, group: "hideElements", }, + showDailyLbStanding: { + key: "showDailyLbStanding", + fa: { icon: "fa-list-ol" }, + displayString: "show daily leaderboard standing", + changeRequiresRestart: false, + group: "hideElements", + description: + "On the result page, show your current daily leaderboard standing even when the completed test did not improve your daily best. Only applies to modes with a daily leaderboard.", + }, // other (hidden) accountChart: { diff --git a/frontend/src/ts/constants/default-config.ts b/frontend/src/ts/constants/default-config.ts index 3625eb63dd87..ef455bacf77e 100644 --- a/frontend/src/ts/constants/default-config.ts +++ b/frontend/src/ts/constants/default-config.ts @@ -101,6 +101,7 @@ const obj: Config = { lazyMode: false, showAverage: "off", showPb: false, + showDailyLbStanding: false, tapeMode: "off", tapeMargin: 50, maxLineWidth: 0, diff --git a/frontend/src/ts/test/daily-lb-standing.ts b/frontend/src/ts/test/daily-lb-standing.ts new file mode 100644 index 000000000000..4ae842014dc1 --- /dev/null +++ b/frontend/src/ts/test/daily-lb-standing.ts @@ -0,0 +1,44 @@ +import { LeaderboardEntry } from "@monkeytype/schemas/leaderboards"; +import { Language } from "@monkeytype/schemas/languages"; +import { Mode, Mode2 } from "@monkeytype/schemas/shared"; + +import Ape from "../ape"; +import { tryCatch } from "@monkeytype/util/trycatch"; + +// Daily leaderboards only exist for these modes. This is a client-side +// pre-filter to avoid pointless requests - the server still validates against +// its own configuration and responds with an error for unsupported modes. +export function canHaveDailyLeaderboard( + mode: Mode, + mode2: Mode2, +): boolean { + return mode === "time" && (mode2 === "15" || mode2 === "60"); +} + +/** + * Get the current user's standing on today's daily leaderboard, regardless of + * whether the last completed test improved it. + * @returns the leaderboard entry, or null if the user has no entry today, the + * mode has no daily leaderboard or the request failed. + */ +export async function getCurrentStanding( + mode: Mode, + mode2: Mode2, + language: Language, +): Promise { + if (!canHaveDailyLeaderboard(mode, mode2)) { + return null; + } + + const { data: response, error } = await tryCatch( + Ape.leaderboards.getDailyRank({ + query: { mode, mode2, language }, + }), + ); + + if (error !== null || response.status !== 200) { + return null; + } + + return response.body.data; +} diff --git a/frontend/src/ts/test/test-logic.ts b/frontend/src/ts/test/test-logic.ts index 553f403a7dd6..517ae3caf083 100644 --- a/frontend/src/ts/test/test-logic.ts +++ b/frontend/src/ts/test/test-logic.ts @@ -72,7 +72,9 @@ import { getAuthenticatedUser } from "../firebase"; import { highlight } from "../events/keymap"; import * as LazyModeState from "../legacy-states/remember-lazy-mode"; import Format from "../singletons/format"; -import { Mode } from "@monkeytype/schemas/shared"; +import { Mode, Mode2 } from "@monkeytype/schemas/shared"; +import { Language } from "@monkeytype/schemas/languages"; +import * as DailyLbStanding from "./daily-lb-standing"; import { CompletedEvent, CompletedEventCustomText, @@ -1203,18 +1205,29 @@ async function saveResult( if (data.dailyLeaderboardRank === undefined) { dailyLeaderboardEl.classList.add("hidden"); + if (Config.showDailyLbStanding) { + void showCurrentDailyStanding( + dailyLeaderboardEl, + result.mode, + result.mode2, + result.language, + ); + } } else { dailyLeaderboardEl.classList.remove("hidden"); dailyLeaderboardEl.style.maxWidth = "13rem"; + // undo the dimming/label a previous "current standing" render may have set + const valueEl = qs("#result .stats .dailyLeaderboard .bottom"); + valueEl?.native.style.removeProperty("opacity"); + valueEl?.setAttribute("aria-label", "Show daily leaderboard"); + animate(dailyLeaderboardEl, { opacity: [0, 1], duration: Misc.applyReducedMotion(250), }); - qs("#result .stats .dailyLeaderboard .bottom")?.setHtml( - Format.rank(data.dailyLeaderboardRank, { fallback: "" }), - ); + setDailyLeaderboardValue(data.dailyLeaderboardRank, result.wpm, result.acc); } qs("#retrySavingResultButton")?.hide(); @@ -1225,6 +1238,53 @@ async function saveResult( return response; } +// Shown when the completed test did not improve the user's daily best (so the +// server returned no rank) but the user still has an entry on today's +// leaderboard. Dimmed to distinguish it from a placement made by this test. +async function showCurrentDailyStanding( + dailyLeaderboardEl: HTMLElement, + mode: Mode, + mode2: Mode2, + language: Language, +): Promise { + const standing = await DailyLbStanding.getCurrentStanding( + mode, + mode2, + language, + ); + if (standing === null || !getResultVisible()) return; + + const valueEl = qs("#result .stats .dailyLeaderboard .bottom"); + dailyLeaderboardEl.classList.remove("hidden"); + dailyLeaderboardEl.style.maxWidth = "13rem"; + valueEl?.native.style.setProperty("opacity", "0.5"); + valueEl?.setAttribute("aria-label", "current standing"); + + animate(dailyLeaderboardEl, { + opacity: [0, 1], + duration: Misc.applyReducedMotion(250), + }); + + setDailyLeaderboardValue(standing.rank, standing.wpm, standing.acc); +} + +// The rank sits in .text, with the score that holds it below in .score, so the +// user can see what they need to beat without opening the leaderboard. +function setDailyLeaderboardValue( + rank: number, + wpm: number, + acc: number, +): void { + qs("#result .stats .dailyLeaderboard .bottom .text")?.setHtml( + Format.rank(rank, { fallback: "" }), + ); + qs("#result .stats .dailyLeaderboard .bottom .score")?.setText( + `${Format.typingSpeed(wpm, { + suffix: ` ${Config.typingSpeedUnit}`, + })} ${Format.accuracy(acc)}`, + ); +} + export function fail(reason: string): void { failReason = reason; void finish(true); diff --git a/packages/schemas/src/configs.ts b/packages/schemas/src/configs.ts index 36c979dafcde..bf3072f457cb 100644 --- a/packages/schemas/src/configs.ts +++ b/packages/schemas/src/configs.ts @@ -260,6 +260,9 @@ export type ShowAverage = z.infer; export const ShowPbSchema = z.boolean(); export type ShowPb = z.infer; +export const ShowDailyLbStandingSchema = z.boolean(); +export type ShowDailyLbStanding = z.infer; + export const ColorHexValueSchema = z.string().regex(/^#([\da-f]{3}){1,2}$/i); export type ColorHexValue = z.infer; @@ -498,6 +501,7 @@ export const ConfigSchema = z capsLockWarning: z.boolean(), showAverage: ShowAverageSchema, showPb: ShowPbSchema, + showDailyLbStanding: ShowDailyLbStandingSchema, // other (hidden) accountChart: AccountChartSchema,