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,