;
- } => ({
- rank: async () =>
- rank({
- query: {
- ...baseQuery,
- friendsOnly: state.friendsOnly || undefined,
- },
- }),
- data: async () =>
- data({
- query: {
- ...baseQuery,
- page: state.page,
- pageSize: state.pageSize,
- friendsOnly: state.friendsOnly || undefined,
- },
- }),
- });
-
- let requests;
- if (state.type === "allTime") {
- requests = defineRequests(Ape.leaderboards.get, Ape.leaderboards.getRank, {
- language: "english",
- mode: "time",
- mode2: state.mode2,
- });
- } else if (state.type === "daily") {
- requests = defineRequests(
- Ape.leaderboards.getDaily,
- Ape.leaderboards.getDailyRank,
- {
- language: state.language,
- mode: state.mode,
- mode2: state.mode2,
- daysBefore: state.yesterday ? 1 : undefined,
- },
- );
- } else if (state.type === "weekly") {
- requests = defineRequests(
- Ape.leaderboards.getWeeklyXp,
- Ape.leaderboards.getWeeklyXpRank,
- {
- weeksBefore: state.lastWeek ? 1 : undefined,
- },
- );
- } else {
- throw new Error("unknown state type");
- }
-
- if (!isAuthenticated() || state.userData !== null) {
- requests.rank = undefined;
- }
-
- if (state.goToUserPage && requests.rank !== undefined) {
- state.goToUserPage = false;
- const rankResponse = await requests.rank();
- if (
- rankResponse !== undefined &&
- rankResponse.status === 200 &&
- rankResponse.body.data !== null
- ) {
- state.userData = rankResponse.body.data;
- state.page = Math.floor((state.userData.rank - 1) / state.pageSize);
- updateGetParameters();
- }
- requests.rank = undefined;
- }
-
- const [dataResponse, rankResponse] = await Promise.all([
- requests.data(),
- requests.rank?.(),
- ]);
-
- if (dataResponse.status === 200) {
- state.data = dataResponse.body.data.entries;
- state.count = dataResponse.body.data.count;
- state.pageSize = dataResponse.body.data.pageSize;
-
- if (state.type === "daily") {
- //@ts-expect-error not sure why this is causing errors when it's clearly defined in the schema
- // oxlint-disable-next-line no-unsafe-assignment
- state.minWpm = dataResponse.body.data.minWpm;
- }
- } else {
- state.data = null;
-
- if (dataResponse.status === 404) {
- state.error = "No leaderboard found";
- } else {
- state.error = "Something went wrong";
- Notifications.add(
- "Failed to get leaderboard: " + dataResponse.body.message,
- -1,
- );
- }
- }
-
- if (state.userData === null && rankResponse !== undefined) {
- if (rankResponse.status === 200) {
- if (rankResponse.body.data !== null) {
- state.userData = rankResponse.body.data;
- }
- } else {
- state.userData = null;
- state.error = "Something went wrong";
- Notifications.add("Failed to get rank: " + rankResponse.body.message, -1);
- }
- }
-
- state.loading = false;
- state.updating = false;
- updateContent();
- if (!update && isAuthenticated()) {
- fillUser();
- }
- return;
-}
-
-function updateJumpButtons(): void {
- const el = qsa(".page.pageLeaderboards .titleAndButtons .jumpButtons");
- el?.qsa("button")?.removeClass("active");
-
- const totalPages = Math.ceil(state.count / state.pageSize);
-
- if (totalPages <= 1) {
- el?.qsa("button")?.disable();
- } else {
- el?.qsa("button")?.enable();
- }
-
- if (state.page === 0) {
- el?.qs("button[data-action='previousPage']")?.disable();
- el?.qs("button[data-action='firstPage']")?.disable();
- } else {
- el?.qs("button[data-action='previousPage']")?.enable();
- el?.qs("button[data-action='firstPage']")?.enable();
- }
-
- if (isAuthenticated()) {
- const userButton = el?.qs("button[data-action='userPage']");
- if (!state.userData) {
- userButton?.disable();
- } else {
- const userPage = Math.floor((state.userData.rank - 1) / state.pageSize);
- if (state.page === userPage) {
- userButton?.disable();
- } else {
- userButton?.enable();
- }
- }
- }
-
- if (state.page >= totalPages - 1) {
- el?.qs("button[data-action='nextPage']")?.disable();
- } else {
- el?.qs("button[data-action='nextPage']")?.enable();
- }
-}
-
-function buildTableRow(entry: LeaderboardEntry, me = false): HTMLElement {
- const formatted = {
- wpm: Format.typingSpeed(entry.wpm, { showDecimalPlaces: true }),
- acc: Format.percentage(entry.acc, { showDecimalPlaces: true }),
- raw: Format.typingSpeed(entry.raw, { showDecimalPlaces: true }),
- con: Format.percentage(entry.consistency, { showDecimalPlaces: true }),
- };
-
- const element = document.createElement("tr");
- if (me) {
- element.classList.add("me");
- }
- element.dataset["uid"] = entry.uid;
- element.innerHTML = `
- | ${formatRank(entry.friendsRank)} |
- ${formatRank(entry.rank)} |
-
-
-
- ${entry.name}
-
- ${getHtmlByUserFlags({
- ...entry,
- isFriend: DB.isFriend(entry.uid),
- })}
- ${
- isSafeNumber(entry.badgeId) ? getBadgeHTMLbyId(entry.badgeId) : ""
- }
-
-
- |
-
- ${formatted.wpm}
- ${formatted.acc}
- |
-
-
- ${formatted.raw}
- ${formatted.con}
- |
- ${formatted.wpm} |
- ${formatted.acc} |
- ${formatted.raw} |
- ${formatted.con} |
- ${format(
- entry.timestamp,
- "dd MMM yyyy",
- )} ${format(entry.timestamp, "HH:mm")} |
-
- `;
- element
- .querySelector(".avatarPlaceholder")
- ?.replaceWith(getAvatarElement(entry));
- return element;
-}
-
-function buildWeeklyTableRow(
- entry: XpLeaderboardEntry,
- me = false,
-): HTMLElement {
- const activeDiff = formatDistanceToNow(entry.lastActivityTimestamp, {
- addSuffix: true,
- });
- const element = document.createElement("tr");
- if (me) {
- element.classList.add("me");
- }
- element.dataset["uid"] = entry.uid;
- element.innerHTML = `
- ${formatRank(entry.friendsRank)} |
- ${formatRank(entry.rank)} |
-
-
-
- ${entry.name}
-
- ${getHtmlByUserFlags({
- ...entry,
- isFriend: DB.isFriend(entry.uid),
- })}
- ${
- isSafeNumber(entry.badgeId) ? getBadgeHTMLbyId(entry.badgeId) : ""
- }
-
-
- |
- ${
- entry.totalXp < 1000 ? entry.totalXp : abbreviateNumber(entry.totalXp)
- } |
- ${DateTime.secondsToString(
- Math.round(entry.timeTypedSeconds),
- true,
- true,
- ":",
- )} |
-
- ${entry.totalXp < 1000 ? entry.totalXp : abbreviateNumber(entry.totalXp)}
- ${DateTime.secondsToString(
- Math.round(entry.timeTypedSeconds),
- true,
- true,
- ":",
- )} |
-
-
- ${format(entry.lastActivityTimestamp, "dd MMM yyyy")}
-
- ${format(entry.lastActivityTimestamp, "HH:mm")}
-
- |
-
- `;
- element
- .querySelector(".avatarPlaceholder")
- ?.replaceWith(getAvatarElement(entry));
- return element;
-}
-
-function fillTable(): void {
- const table = qs(".page.pageLeaderboards table tbody");
- table?.empty();
-
- if (state.friendsOnly) {
- table?.getParent()?.addClass("friendsOnly");
- } else {
- table?.getParent()?.removeClass("friendsOnly");
- }
-
- qsa(".page.pageLeaderboards table thead")?.hide();
- if (state.type === "allTime" || state.type === "daily") {
- qs(".page.pageLeaderboards table thead.allTimeAndDaily")?.show();
- } else if (state.type === "weekly") {
- qs(".page.pageLeaderboards table thead.weekly")?.show();
- }
-
- if (state.data === null || state.data.length === 0) {
- table?.appendHtml(`| No data |
`);
- qs(".page.pageLeaderboards table")?.show();
- return;
- }
-
- if (state.type === "allTime" || state.type === "daily") {
- for (const entry of state.data) {
- const me = getAuthenticatedUser()?.uid === entry.uid;
- table?.append(buildTableRow(entry, me));
- }
- } else if (state.type === "weekly") {
- for (const entry of state.data) {
- const me = getAuthenticatedUser()?.uid === entry.uid;
- table?.append(buildWeeklyTableRow(entry, me));
- }
- }
-
- qs(".page.pageLeaderboards table")?.show();
-}
-
-function getLbMemoryDifference(): number | null {
- if (state.type !== "allTime") return null;
- if (state.userData === null) return null;
-
- const memory =
- DB.getSnapshot()?.lbMemory?.["time"]?.[state.mode2]?.["english"] ?? 0;
-
- const rank = state.userData.rank;
- const diff = memory - rank;
-
- if (diff !== 0) {
- void DB.updateLbMemory("time", state.mode2, "english", rank, true);
- }
-
- return diff;
-}
-
-function fillUser(): void {
- if (isAuthenticated() && DB.getSnapshot()?.lbOptOut === true) {
- qs(".page.pageLeaderboards .bigUser")?.setHtml(
- 'You have opted out of the leaderboards.
',
- );
- return;
- }
-
- if (isAuthenticated() && DB.getSnapshot()?.banned === true) {
- qs(".page.pageLeaderboards .bigUser")?.setHtml(
- 'Your account is banned
',
- );
- return;
- }
-
- const minTimeTyping =
- ServerConfiguration.get()?.leaderboards.minTimeTyping ?? 7200;
-
- if (
- isAuthenticated() &&
- !isDevEnvironment() &&
- (DB.getSnapshot()?.typingStats?.timeTyping ?? 0) < minTimeTyping
- ) {
- qs(".page.pageLeaderboards .bigUser")?.setHtml(
- `Your account must have ${formatDuration(
- intervalToDuration({ start: 0, end: minTimeTyping * 1000 }),
- )} typed to be placed on the leaderboard.
`,
- );
- return;
- }
-
- if (isAuthenticated() && state.type === "daily" && state.userData === null) {
- let str = `Not qualified`;
-
- if (!state.yesterday) {
- str += ` (min speed required: ${Format.typingSpeed(state.minWpm, {
- showDecimalPlaces: true,
- suffix: ` ${Config.typingSpeedUnit}`,
- })})`;
- }
-
- qs(".page.pageLeaderboards .bigUser")?.setHtml(
- `${str}
`,
- );
- return;
- }
-
- if (isAuthenticated() && state.userData === null) {
- qs(".page.pageLeaderboards .bigUser")?.setHtml(
- `Not qualified
`,
- );
- return;
- }
-
- if (state.data === null) {
- return;
- }
-
- if (state.type === "allTime" || state.type === "daily") {
- if (!state.userData || !state.count) {
- qs(".page.pageLeaderboards .bigUser")?.hide();
- qs(".page.pageLeaderboards .tableAndUser > .divider")?.show();
- return;
- }
-
- const userData = state.userData;
- const rank = state.friendsOnly
- ? (userData.friendsRank as number)
- : userData.rank;
- const percentile = (rank / state.count) * 100;
-
- let percentileString = `Top ${percentile.toFixed(2)}%`;
- if (rank === 1) {
- percentileString = "GOAT";
- }
-
- const diff = getLbMemoryDifference();
- let diffText;
-
- if (diff === null) {
- diffText = "";
- } else if (diff === 0) {
- diffText = ` ( = since you last checked)`;
- } else if (diff > 0) {
- diffText = ` (${Math.abs(
- diff,
- )} since you last checked
- )`;
- } else {
- diffText = ` (${Math.abs(
- diff,
- )} since you last checked
- )`;
- }
-
- const formatted = {
- wpm: Format.typingSpeed(userData.wpm, { showDecimalPlaces: true }),
- acc: Format.percentage(userData.acc, { showDecimalPlaces: true }),
- raw: Format.typingSpeed(userData.raw, { showDecimalPlaces: true }),
- con: Format.percentage(userData.consistency, { showDecimalPlaces: true }),
- };
-
- const html = `
- ${formatRank(rank)}
-
-
You (${percentileString})
-
${diffText}
-
-
-
${Config.typingSpeedUnit}
-
${formatted.wpm}
-
-
-
accuracy
-
${formatted.acc}
-
-
-
raw
-
${formatted.raw}
-
-
-
consistency
-
${formatted.con}
-
-
-
date
-
${format(
- userData.timestamp,
- "dd MMM yyyy HH:mm",
- )}
-
-
-
-
-
${formatted.wpm}
-
${formatted.acc}
-
-
-
${formatted.raw}
-
${formatted.con}
-
-
-
${format(userData.timestamp, "dd MMM yyyy")}
-
${format(userData.timestamp, "HH:mm")}
-
- `;
-
- qs(".page.pageLeaderboards .bigUser")?.setHtml(html);
- } else if (state.type === "weekly") {
- if (!state.userData || !state.count) {
- qs(".page.pageLeaderboards .bigUser")?.hide();
- return;
- }
-
- const userData = state.userData;
- const rank = state.friendsOnly
- ? (userData.friendsRank as number)
- : userData.rank;
- const percentile = (rank / state.count) * 100;
-
- let percentileString = `Top ${percentile.toFixed(2)}%`;
- if (rank === 1) {
- percentileString = "GOAT";
- }
-
- const diff = getLbMemoryDifference();
- let diffText;
-
- if (diff === null) {
- diffText = "";
- } else if (diff === 0) {
- diffText = ` ( = since you last checked)`;
- } else if (diff > 0) {
- diffText = ` (${Math.abs(
- diff,
- )} since you last checked
- )`;
- } else {
- diffText = ` (${Math.abs(
- diff,
- )} since you last checked
- )`;
- }
-
- const formatted = {
- xp:
- userData.totalXp < 1000
- ? userData.totalXp
- : abbreviateNumber(userData.totalXp),
- time: DateTime.secondsToString(
- Math.round(userData.timeTypedSeconds),
- true,
- true,
- ":",
- ),
- };
-
- const html = `
- ${formatRank(rank)}
-
-
You (${percentileString})
-
${diffText}
-
-
-
xp gained
-
${formatted.xp}
-
-
-
time typed
-
${formatted.time}
-
-
-
${formatted.xp}
-
${formatted.time}
-
-
-
last activity
-
${format(
- userData.lastActivityTimestamp,
- "dd MMM yyyy HH:mm",
- )}
-
-
-
${format(userData.lastActivityTimestamp, "dd MMM yyyy")}
-
${format(
- userData.lastActivityTimestamp,
- "HH:mm",
- )}
-
- `;
-
- qs(".page.pageLeaderboards .bigUser")?.setHtml(html);
- }
- qs(".page.pageLeaderboards .bigUser")?.show();
- qs(".page.pageLeaderboards .tableAndUser > .divider")?.hide();
-}
-
-function updateContent(): void {
- qsa(".page.pageLeaderboards .loading").hide();
- qsa(".page.pageLeaderboards .updating").addClass("invisible");
- qs(".page.pageLeaderboards .error")?.hide();
-
- if (state.error !== undefined) {
- qs(".page.pageLeaderboards .error")?.show();
- qs(".page.pageLeaderboards .error p")?.setText(state.error);
- enableButtons();
- return;
- }
-
- if (state.updating) {
- disableButtons();
- qsa(".page.pageLeaderboards .updating").removeClass("invisible");
- return;
- } else if (state.loading) {
- disableButtons();
- qs(".page.pageLeaderboards .bigUser")?.hide();
- qsa(".page.pageLeaderboards .titleAndButtons")?.hide();
- qsa(".page.pageLeaderboards .loading").show();
- qs(".page.pageLeaderboards table")?.hide();
- return;
- } else {
- enableButtons();
- }
-
- if (isAuthenticated()) {
- qsa(".page.pageLeaderboards .needAuth")?.show();
- } else {
- qsa(".page.pageLeaderboards .needAuth")?.hide();
- }
-
- if (state.data === null) {
- Notifications.add("Data is null");
- return;
- }
-
- qsa(".page.pageLeaderboards .titleAndButtons")?.show();
- updateJumpButtons();
- updateTimerVisibility();
- fillTable();
-
- for (const element of document.querySelectorAll(
- ".page.pageLeaderboards .wide.speedUnit, .page.pageLeaderboards .narrow.speedUnit span",
- )) {
- element.innerHTML = Config.typingSpeedUnit;
- }
-
- if (state.scrollToUserAfterFill) {
- document.querySelector(".tableAndUser .me")?.scrollIntoView({
- block: "center",
- });
- state.scrollToUserAfterFill = false;
- }
-}
-
-function updateSideButtons(): void {
- updateTypeButtons();
- updateFriendsButtons();
- updateModeButtons();
- updateLanguageButtons();
-}
-
-function updateTypeButtons(): void {
- const el = qs(".page.pageLeaderboards .buttonGroup.typeButtons");
- el?.qsa("button")?.removeClass("active");
- el?.qs(`button[data-type=${state.type}]`)?.addClass("active");
-}
-
-function updateFriendsButtons(): void {
- const friendsOnlyGroup = qs(
- ".page.pageLeaderboards .buttonGroup.friendsOnlyButtons",
- );
- if (
- isAuthenticated() &&
- (ServerConfiguration.get()?.connections.enabled ?? false)
- ) {
- friendsOnlyGroup?.show();
- } else {
- friendsOnlyGroup?.hide();
- state.friendsOnly = false;
- return;
- }
-
- const everyoneButton = qs(
- ".page.pageLeaderboards .buttonGroup.friendsOnlyButtons .everyone",
- );
- const friendsOnlyButton = qs(
- ".page.pageLeaderboards .buttonGroup.friendsOnlyButtons .friendsOnly",
- );
- if (state.friendsOnly) {
- friendsOnlyButton?.addClass("active");
- everyoneButton?.removeClass("active");
- } else {
- friendsOnlyButton?.removeClass("active");
- everyoneButton?.addClass("active");
- }
-}
-
-function updateModeButtons(): void {
- if (state.type !== "allTime" && state.type !== "daily") {
- qs(".page.pageLeaderboards .buttonGroup.modeButtons")?.hide();
- return;
- }
- qs(".page.pageLeaderboards .buttonGroup.modeButtons")?.show();
-
- const el = qs(".page.pageLeaderboards .buttonGroup.modeButtons");
- el?.qsa("button")?.removeClass("active");
- el?.qs(
- `button[data-mode="${state.mode}"][data-mode2="${state.mode2}"]`,
- )?.addClass("active");
-
- //hide all mode buttons
- qsa(`.page.pageLeaderboards .buttonGroup.modeButtons button`)?.hide();
-
- //show all valid ones
- for (const mode of Object.keys(validLeaderboards[state.type]) as Mode[]) {
- for (const mode2 of Object.keys(
- // oxlint-disable-next-line no-non-null-assertion
- validLeaderboards[state.type][mode]!,
- )) {
- qs(
- `.page.pageLeaderboards .buttonGroup.modeButtons button[data-mode="${mode}"][data-mode2="${mode2}"]`,
- )?.show();
- }
- }
-}
-
-function updateLanguageButtons(): void {
- if (state.type !== "daily") {
- qs(".page.pageLeaderboards .buttonGroup.languageButtons")?.hide();
- return;
- }
- qs(".page.pageLeaderboards .buttonGroup.languageButtons")?.show();
-
- const el = qs(".page.pageLeaderboards .buttonGroup.languageButtons");
- el?.qsa("button")?.removeClass("active");
- el?.qs(`button[data-language=${state.language}]`)?.addClass("active");
-
- //hide all languages
- qsa(`.page.pageLeaderboards .buttonGroup.languageButtons button`)?.hide();
-
- //show all valid ones
- for (const lang of validLeaderboards[state.type][state.mode]?.[state.mode2] ??
- []) {
- qs(
- `.page.pageLeaderboards .buttonGroup.languageButtons button[data-language="${lang}"]`,
- )?.show();
- }
-}
-
-let updateTimer: number | undefined;
-
-function updateTimerElement(): void {
- if (state.type === "daily") {
- const diff = differenceInSeconds(new Date(), endOfDay(new UTCDateMini()));
-
- qs(".page.pageLeaderboards .titleAndButtons .timer")?.setText(
- "Next reset in: " + DateTime.secondsToString(diff, true),
- );
- } else if (state.type === "allTime") {
- const date = new Date();
- const minutesToNextUpdate = 14 - (date.getMinutes() % 15);
- const secondsToNextUpdate = 60 - date.getSeconds();
- const totalSeconds = minutesToNextUpdate * 60 + secondsToNextUpdate;
- qs(".page.pageLeaderboards .titleAndButtons .timer")?.setText(
- "Next update in: " + DateTime.secondsToString(totalSeconds, true),
- );
- } else if (state.type === "weekly") {
- const nextWeekTimestamp = endOfWeek(new UTCDateMini(), { weekStartsOn: 1 });
- const totalSeconds = differenceInSeconds(new Date(), nextWeekTimestamp);
- qs(".page.pageLeaderboards .titleAndButtons .timer")?.setText(
- "Next reset in: " +
- DateTime.secondsToString(totalSeconds, true, true, ":", true, true),
- );
- }
-}
-
-function updateTimerVisibility(): void {
- let visible = true;
-
- if (
- (state.type === "daily" && state.yesterday) ||
- (state.type === "weekly" && state.lastWeek)
- ) {
- visible = false;
- }
-
- if (visible) {
- qs(".page.pageLeaderboards .titleAndButtons .timer")?.removeClass(
- "invisible",
- );
- } else {
- qs(".page.pageLeaderboards .titleAndButtons .timer")?.addClass("invisible");
- }
-}
-
-function startTimer(): void {
- updateTimerElement();
- updateTimer = setInterval(() => {
- updateTimerElement();
- }, 1000) as unknown as number;
-}
-
-function stopTimer(): void {
- clearInterval(updateTimer);
- updateTimer = undefined;
- qs(".page.pageLeaderboards .titleAndButtons .timer")?.setText("-");
-}
-
-function convertRuleOption(rule: string): string[] {
- if (rule.startsWith("(")) {
- return rule.slice(1, -1).split("|");
- }
- return [rule];
-}
-
-async function updateValidDailyLeaderboards(): Promise {
- const dailyRulesConfig =
- ServerConfiguration.get()?.dailyLeaderboards.validModeRules;
-
- if (dailyRulesConfig === undefined) {
- throw new Error(
- "cannot load server configuration for dailyLeaderboards.validModeRules",
- );
- }
-
- //a rule can contain multiple values. create a flat list out of them
- const dailyRules = dailyRulesConfig.flatMap((rule) => {
- const languages = convertRuleOption(rule.language) as Language[];
- const mode2List = convertRuleOption(rule.mode2);
-
- return mode2List.map((mode2) => ({
- mode: rule.mode as Mode,
- mode2,
- languages,
- }));
- });
-
- validLeaderboards.daily = dailyRules.reduce<
- Partial>>
- >((acc, { mode, mode2, languages }) => {
- let modes = acc[mode];
- if (modes === undefined) {
- modes = {};
- acc[mode] = modes;
- }
-
- let modes2 = modes[mode2];
- if (modes2 === undefined) {
- modes2 = [];
- modes[mode2] = modes2;
- }
-
- modes2.push(...languages);
- return acc;
- }, {});
-}
-
-function checkIfLeaderboardIsValid(): void {
- if (state.type === "weekly") return;
-
- const validLeaderboard = validLeaderboards[state.type];
-
- let validModes2 = validLeaderboard[state.mode];
- if (validModes2 === undefined) {
- const firstMode = Object.keys(validLeaderboard).sort()[0] as Mode;
- if (firstMode === undefined) {
- throw new Error(`no valid leaderboard config for type ${state.type}`);
- }
- state.mode = firstMode;
- // oxlint-disable-next-line no-non-null-assertion
- validModes2 = validLeaderboard[state.mode]!;
- }
-
- let supportedLanguages = validModes2[state.mode2];
- if (supportedLanguages === undefined) {
- const firstMode2 = Object.keys(validModes2).sort(
- (a, b) => parseInt(a) - parseInt(b),
- )[0];
- if (firstMode2 === undefined) {
- throw new Error(
- `no valid leaderboard config for type ${state.type} and mode ${state.mode}`,
- );
- }
- state.mode2 = firstMode2;
- supportedLanguages = validModes2[state.mode2];
- }
-
- if (supportedLanguages === undefined || supportedLanguages.length < 1) {
- throw new Error(
- `Daily leaderboard config not valid for mode:${state.mode} mode2:${state.mode2}`,
- );
- }
-
- if (!supportedLanguages.includes(state.language)) {
- state.language = supportedLanguages.sort()[0] as Language;
- }
-}
-
-async function appendModeAndLanguageButtons(): Promise {
- const modes = Array.from(
- new Set(
- Object.values(validLeaderboards).flatMap(
- (rule) => Object.keys(rule) as Mode[],
- ),
- ),
- ).sort();
-
- const mode2Buttons = modes.flatMap((mode) => {
- const modes2 = Array.from(
- new Set(
- Object.values(validLeaderboards).flatMap((rule) =>
- Object.keys(rule[mode] ?? {}),
- ),
- ),
- ).sort((a, b) => parseInt(a) - parseInt(b));
-
- const icon = mode === "time" ? "fas fa-clock" : "fas fa-align-left";
-
- return modes2.map(
- (mode2) => ``,
- );
- });
- qs(".modeButtons")?.setHtml(
- `` + mode2Buttons.join("\n"),
- );
-
- const availableLanguages = Array.from(
- new Set(
- Object.values(validLeaderboards)
- .flatMap((rule) => Object.values(rule))
- .flatMap((mode) => Object.values(mode))
- .flatMap((it) => it),
- ),
- ).sort();
-
- const languageButtons = availableLanguages.map(
- (lang) =>
- ``,
- );
- qs(".languageButtons")?.setHtml(
- `` + languageButtons.join("\n"),
- );
-}
-
-function disableButtons(): void {
- qsa(".page.pageLeaderboards button")?.disable();
-}
-
-function enableButtons(): void {
- qsa(".page.pageLeaderboards button")?.enable();
-}
-
-export function goToPage(pageId: number): void {
- if (pageId < 0 || pageId === state.page) return;
- handleJumpButton("goToPage", pageId);
-}
-
-type Action =
- | "firstPage"
- | "previousPage"
- | "nextPage"
- | "goToPage"
- | "userPage";
-function handleJumpButton(action: Action, page?: number): void {
- if (action === "firstPage") {
- state.page = 0;
- } else if (action === "previousPage" && state.page > 0) {
- const totalPages = Math.ceil(state.count / state.pageSize);
- if (state.page > totalPages) {
- state.page = totalPages - 1;
- } else {
- state.page -= 1;
- }
- } else if (action === "nextPage") {
- state.page += 1;
- } else if (action === "goToPage" && page !== undefined) {
- state.page = page;
- } else if (action === "userPage") {
- if (isAuthenticated()) {
- const rank = state.userData?.rank;
- if (isSafeNumber(rank)) {
- // - 1 to make sure position 50 with page size 50 is on the first page (page 0)
- const page = Math.floor((rank - 1) / state.pageSize);
-
- if (state.page === page) {
- return;
- }
-
- state.page = page;
- state.scrollToUserAfterFill = true;
- }
- }
- } else {
- return;
- }
- updateGetParameters();
- void requestData(true);
- updateContent();
-}
-
-function handleYesterdayLastWeekButton(action: string): void {
- if (state.type === "daily" && action === "toggleYesterday") {
- state.yesterday = !state.yesterday;
- } else if (state.type === "weekly" && action === "toggleLastWeek") {
- state.lastWeek = !state.lastWeek;
- }
-
- updateGetParameters();
- void requestData();
- updateContent();
- updateTitle();
-}
-
-function updateGetParameters(): void {
- if (state.goToUserPage) {
- //parameters are updated in the requestData method
- return;
- }
-
- const params: UrlParameter = {};
-
- params.type = state.type;
- if (state.type === "allTime") {
- params.mode2 = state.mode2;
- } else if (state.type === "daily") {
- params.mode = state.mode;
- params.language = state.language;
- params.mode2 = state.mode2;
- if (state.yesterday) {
- params.yesterday = true;
- }
- } else if (state.type === "weekly") {
- if (state.lastWeek) {
- params.lastWeek = true;
- }
- }
-
- params.page = state.page + 1;
-
- if (state.friendsOnly) {
- params.friendsOnly = true;
- }
- page.setUrlParams(params);
-
- selectorLS.set(state);
-}
-
-function readGetParameters(params?: UrlParameter): void {
- if (params === undefined) {
- Object.assign(state, selectorLS.get());
- return;
- }
-
- if (params.type !== undefined) {
- state.type = params.type;
- }
-
- state.friendsOnly = params.friendsOnly ?? false;
-
- if (state.type === "allTime") {
- if (params.mode2 !== undefined) {
- state.mode2 = params.mode2 as AllTimeState["mode2"];
- }
- } else if (state.type === "daily") {
- if (params.language !== undefined) {
- state.language = params.language;
- }
- if (params.mode2 !== undefined) {
- state.mode2 = params.mode2;
- }
- if (params.mode !== undefined) {
- state.mode = params.mode;
- }
- if (params.yesterday !== undefined) {
- state.yesterday = params.yesterday;
- }
- } else if (state.type === "weekly") {
- if (params.lastWeek !== undefined) {
- state.lastWeek = params.lastWeek;
- }
- }
-
- if (params.page !== undefined) {
- state.page = params.page - 1;
-
- if (state.page < 0) {
- state.page = 0;
- }
- }
- if (params.goToUserPage === true) {
- state.goToUserPage = true;
- }
-}
-
-function utcToLocalDate(timestamp: UTCDateMini): Date {
- return subMinutes(timestamp, new Date().getTimezoneOffset());
-}
-
-function updateTimeText(
- dateString: string,
- localStart: Date,
- localEnd: Date,
-): void {
- const localDateString =
- "local time \n" +
- format(localStart, localDateFormat) +
- " - \n" +
- format(localEnd, localDateFormat);
-
- const text = qs(".page.pageLeaderboards .bigtitle .subtext > .text");
- text?.setText(`${dateString}`);
- text?.setAttribute("aria-label", localDateString);
-}
-
-function formatRank(rank: number | undefined): string {
- if (rank === undefined) return "";
- if (rank === 1) return '';
-
- return rank.toString();
-}
-
-qsa(".page.pageLeaderboards .jumpButtons button")?.on("click", function () {
- const action = this.getAttribute("data-action") as Action;
- if (action !== "goToPage") {
- handleJumpButton(action);
- }
-});
-
-qsa(".page.pageLeaderboards .bigtitle button")?.on("click", function () {
- const action = this.getAttribute("data-action") as string;
- handleYesterdayLastWeekButton(action);
-});
-
-qs(".page.pageLeaderboards .buttonGroup.typeButtons")?.onChild(
- "click",
- "button",
- function () {
- const type = this.getAttribute("data-type") as
- | "allTime"
- | "weekly"
- | "daily";
- if (state.type === type) return;
- state.type = type;
- if (state.type === "daily") {
- state.language = "english";
- state.yesterday = false;
- }
- if (state.type === "weekly") {
- state.lastWeek = false;
- }
- checkIfLeaderboardIsValid();
- state.data = null;
- state.page = 0;
- void requestData();
- updateTitle();
- updateSideButtons();
- updateContent();
- updateGetParameters();
- },
-);
-
-qs(".page.pageLeaderboards .buttonGroup.modeButtons")?.onChild(
- "click",
- "button",
- function () {
- const mode = this.getAttribute("data-mode") as Mode;
- const mode2 = this.getAttribute("data-mode2");
-
- if (
- mode !== undefined &&
- mode2 !== undefined &&
- mode2 !== null &&
- (state.type === "allTime" || state.type === "daily")
- ) {
- if (state.mode === mode && state.mode2 === mode2) return;
- state.mode = mode;
- state.mode2 = mode2;
- state.page = 0;
- } else {
- return;
- }
- checkIfLeaderboardIsValid();
- state.data = null;
- void requestData();
- updateSideButtons();
- updateTitle();
- updateContent();
- updateGetParameters();
- },
-);
-
-qs(".page.pageLeaderboards .buttonGroup.languageButtons")?.onChild(
- "click",
- "button",
- function () {
- const language = this.getAttribute("data-language") as Language;
-
- if (language !== undefined && state.type === "daily") {
- if (state.language === language) return;
- state.language = language;
- state.page = 0;
- } else {
- return;
- }
- checkIfLeaderboardIsValid();
- state.data = null;
- void requestData();
- updateSideButtons();
- updateTitle();
- updateContent();
- updateGetParameters();
- },
-);
-
-qs(".page.pageLeaderboards .buttonGroup.friendsOnlyButtons")?.onChild(
- "click",
- "button",
- () => {
- state.friendsOnly = !state.friendsOnly;
- state.page = 0;
- void requestData();
- updateTitle();
- updateSideButtons();
- updateContent();
- updateGetParameters();
- },
-);
-
-export const page = new PageWithUrlParams({
- id: "leaderboards",
- element: qsr(".page.pageLeaderboards"),
- path: "/leaderboards",
- urlParamsSchema: UrlParameterSchema,
- loadingOptions: {
- style: "spinner",
- loadingMode: () => "sync",
- loadingPromise: async () => {
- await ServerConfiguration.configurationPromise;
- },
- },
-
- afterHide: async (): Promise => {
- Skeleton.remove("pageLeaderboards");
- stopTimer();
- },
- beforeShow: async (options): Promise => {
- Skeleton.append("pageLeaderboards", "main");
- await updateValidDailyLeaderboards();
- await appendModeAndLanguageButtons();
- readGetParameters(options.urlParams);
- checkIfLeaderboardIsValid();
- startTimer();
- updateTitle();
- updateContent();
- updateSideButtons();
- updateGetParameters();
- void requestData(false);
- },
- afterShow: async (): Promise => {
- // updateSideButtons();
- },
-});
-
-onDOMReady(async () => {
- Skeleton.save("pageLeaderboards");
-});
-
-ConfigEvent.subscribe(({ key }) => {
- if (getActivePage() === "leaderboards" && key === "typingSpeedUnit") {
- updateContent();
- fillUser();
- }
-});
diff --git a/frontend/src/ts/pages/page.ts b/frontend/src/ts/pages/page.ts
index 44032b6cc3f6..49d6a314183a 100644
--- a/frontend/src/ts/pages/page.ts
+++ b/frontend/src/ts/pages/page.ts
@@ -115,7 +115,7 @@ type OptionsWithUrlParams = Options & {
urlParams?: z.infer;
};
-type UrlParamsSchema = z.ZodObject>;
+export type UrlParamsSchema = z.ZodObject>;
type PagePropertiesWithUrlParams = Omit<
PageProperties,
"beforeShow"
diff --git a/frontend/src/ts/queries/keys.ts b/frontend/src/ts/queries/keys.ts
new file mode 100644
index 000000000000..84cbbaf79e6f
--- /dev/null
+++ b/frontend/src/ts/queries/keys.ts
@@ -0,0 +1,6 @@
+export function baseKey(
+ key: string,
+ options?: { isUserSpecific?: true },
+): unknown[] {
+ return options?.isUserSpecific ? ["user", key] : [key];
+}
diff --git a/frontend/src/ts/queries/leaderboards.ts b/frontend/src/ts/queries/leaderboards.ts
new file mode 100644
index 000000000000..140209d985f7
--- /dev/null
+++ b/frontend/src/ts/queries/leaderboards.ts
@@ -0,0 +1,208 @@
+import {
+ GetLeaderboardQuery,
+ GetLeaderboardRankQuery,
+} from "@monkeytype/contracts/leaderboards";
+import { LanguageSchema } from "@monkeytype/schemas/languages";
+import { ModeSchema } from "@monkeytype/schemas/shared";
+import { QueryKey, queryOptions } from "@tanstack/solid-query";
+import { z } from "zod";
+import Ape from "../ape";
+
+export type LeaderboardType = Selection["type"];
+const XpSelection = z.object({
+ type: z.literal("weekly"),
+ friendsOnly: z.boolean(),
+ previous: z.boolean(),
+ language: z.never().optional(),
+ mode: z.never().optional(),
+ mode2: z.never().optional(),
+});
+const WpmSelection = z.object({
+ type: z.enum(["daily", "allTime"]),
+ friendsOnly: z.boolean(),
+ previous: z.boolean(),
+ mode: ModeSchema,
+ mode2: z.string(),
+ language: LanguageSchema,
+});
+
+export const SelectionSchema = WpmSelection.or(XpSelection);
+export type Selection = z.infer;
+
+const queryKeys = {
+ root: (options: Selection & { userSpecific?: true }) => [
+ options.userSpecific === true || options.friendsOnly
+ ? "user"
+ : "leaderboard",
+ "leaderboard",
+ options.type,
+ {
+ mode: options.mode,
+ mode2: options.mode2,
+ language: options.language,
+ friendsOnly: options.friendsOnly,
+ previous: options.previous,
+ },
+ ],
+ data: (options: Selection & { page: number }) => [
+ ...queryKeys.root(options),
+ { page: options.page },
+ ],
+ rank: (options: Selection) =>
+ queryKeys.root({ ...options, userSpecific: true }), //rank is always user specific
+};
+
+export const getLeaderboardQueryOptions = (
+ options: Selection & {
+ page: number;
+ }, // oxlint-disable-next-line typescript/explicit-function-return-type
+) =>
+ queryOptions({
+ queryKey: queryKeys.data(options),
+ queryFn: async (ctx) => {
+ const page = ctx.queryKey[4] as { page: number } | undefined;
+ if (page === undefined) throw new Error("page missing in query");
+
+ const selection = getSelectionFromQueryKey(ctx.queryKey);
+
+ let request;
+
+ if (selection.type === "weekly") {
+ request = Ape.leaderboards.getWeeklyXp({
+ query: {
+ friendsOnly: selection.friendsOnly ? true : undefined,
+ weeksBefore: selection.previous ? 1 : undefined,
+ pageSize: 50,
+ page: page.page,
+ },
+ });
+ } else {
+ const baseQuery: GetLeaderboardQuery = {
+ mode: selection.mode,
+ mode2: selection.mode2,
+ language: selection.language,
+ friendsOnly: selection.friendsOnly ? true : undefined,
+ pageSize: 50,
+ page: page.page,
+ };
+ if (selection.type === "allTime") {
+ request = Ape.leaderboards.get({ query: baseQuery });
+ } else {
+ request = Ape.leaderboards.getDaily({
+ query: {
+ ...baseQuery,
+ daysBefore: selection.previous ? 1 : undefined,
+ },
+ });
+ }
+ }
+
+ const response = await request;
+ if (response.status !== 200) {
+ throw new Error(
+ `Failed to get ${selection.type} leaderboard rank: ` +
+ response.body.message,
+ );
+ }
+ return response.body.data;
+ },
+ //5 minutes for alltime, one minute for others
+ staleTime: options.type === "allTime" ? 1000 * 60 * 60 : 1000 * 60,
+ placeholderData: (old) => {
+ if (
+ old === undefined ||
+ old["entries"] === undefined ||
+ old["entries"].length === 0 ||
+ old["entries"][0] === undefined
+ ) {
+ return undefined;
+ }
+ const last = old["entries"][0];
+ if (
+ (options.type === "weekly" && !("totalXp" in last)) ||
+ (options.type !== "weekly" && !("wpm" in last))
+ ) {
+ return undefined;
+ }
+
+ return old;
+ },
+ });
+
+// oxlint-disable-next-line typescript/explicit-function-return-type
+export const getRankQueryOptions = (options: Selection) =>
+ queryOptions({
+ queryKey: queryKeys.rank(options),
+ queryFn: async (ctx) => {
+ let request;
+ const selection = getSelectionFromQueryKey(ctx.queryKey);
+ if (selection.type === "weekly") {
+ request = Ape.leaderboards.getWeeklyXpRank({
+ query: {
+ friendsOnly: selection.friendsOnly ? true : undefined,
+ weeksBefore: selection.previous ? 1 : undefined,
+ },
+ });
+ } else {
+ const baseQuery: GetLeaderboardRankQuery = {
+ mode: selection.mode,
+ mode2: selection.mode2,
+ language: selection.language,
+ friendsOnly: selection.friendsOnly ? true : undefined,
+ };
+ if (selection.type === "allTime") {
+ request = Ape.leaderboards.getRank({ query: baseQuery });
+ } else {
+ request = Ape.leaderboards.getDailyRank({
+ query: {
+ ...baseQuery,
+ daysBefore: selection.previous ? 1 : undefined,
+ },
+ });
+ }
+ }
+
+ const response = await request;
+ if (response.status !== 200) {
+ throw new Error(
+ `Failed to get ${selection.type} leaderboard rank: ` +
+ response.body.message,
+ );
+ }
+ return response.body.data;
+ },
+ //5 minutes for alltime, one minute for others
+ staleTime: options.type === "allTime" ? 1000 * 60 * 60 : 1000 * 60,
+ });
+
+function getSelectionFromQueryKey(queryKey: QueryKey): Selection {
+ if (queryKey.length < 3) throw new Error("invalid query key");
+
+ const type = queryKey[2] as LeaderboardType | undefined;
+ const mode = queryKey[3] as
+ | Required & { previous: boolean }>
+ | undefined;
+
+ if (type === undefined) throw new Error("type missing in query");
+ if (mode === undefined) throw new Error("mode missing in query");
+
+ if (type === "weekly") {
+ return {
+ type: "weekly",
+ friendsOnly: mode.friendsOnly,
+ previous: mode.previous,
+ };
+ } else {
+ if (mode.language === undefined) {
+ throw new Error("language missing in query");
+ }
+ return {
+ type,
+ mode: mode.mode,
+ mode2: mode.mode2,
+ language: mode.language,
+ friendsOnly: mode.friendsOnly,
+ previous: mode.previous,
+ };
+ }
+}
diff --git a/frontend/src/ts/queries/server-configuration.ts b/frontend/src/ts/queries/server-configuration.ts
new file mode 100644
index 000000000000..c4cd1a9115b1
--- /dev/null
+++ b/frontend/src/ts/queries/server-configuration.ts
@@ -0,0 +1,27 @@
+import { queryOptions } from "@tanstack/solid-query";
+import { baseKey } from "./keys";
+import Ape from "../ape";
+
+const queryKeys = {
+ root: () => baseKey("serverConfiguration"),
+};
+
+const staleTime = 1000 * 60 * 60;
+
+// oxlint-disable-next-line typescript/explicit-function-return-type
+export const getServerConfigurationQueryOptions = () =>
+ queryOptions({
+ queryKey: queryKeys.root(),
+ queryFn: async () => {
+ const response = await Ape.configuration.get();
+
+ if (response.status !== 200) {
+ throw new Error(
+ `Could not fetch configuration: ${response.body.message}`,
+ );
+ }
+ return response.body.data;
+ },
+ staleTime,
+ gcTime: Infinity,
+ });
diff --git a/frontend/src/ts/signals/core.ts b/frontend/src/ts/signals/core.ts
index f3c6127b08e0..8614eda9e779 100644
--- a/frontend/src/ts/signals/core.ts
+++ b/frontend/src/ts/signals/core.ts
@@ -1,4 +1,4 @@
-import { createSignal } from "solid-js";
+import { createMemo, createSignal } from "solid-js";
import { PageName } from "../pages/page";
export const [getActivePage, setActivePage] = createSignal("loading");
@@ -29,3 +29,8 @@ export const [getCommandlineSubgroup, setCommandlineSubgroup] = createSignal<
export const [getFocus, setFocus] = createSignal(false);
export const [getGlobalOffsetTop, setGlobalOffsetTop] = createSignal(0);
export const [getIsScreenshotting, setIsScreenshotting] = createSignal(false);
+
+const [userId, setUserId] = createSignal(null);
+export { setUserId };
+export const getUserId = createMemo(() => userId());
+export const isLoggedIn = createMemo(() => getUserId() !== null);
diff --git a/frontend/src/ts/types/tanstack-table.d.ts b/frontend/src/ts/types/tanstack-table.d.ts
index d69863e3b835..b0719fca4858 100644
--- a/frontend/src/ts/types/tanstack-table.d.ts
+++ b/frontend/src/ts/types/tanstack-table.d.ts
@@ -12,6 +12,17 @@ declare module "@tanstack/solid-table" {
*/
breakpoint?: BreakpointKey;
+ /**
+ * define maximal breakpoint for the column to be visible.
+ * If not set, the column is always visible
+ */
+ maxBreakpoint?: BreakpointKey;
+
+ /**
+ * align header and cells, default: `left`
+ */
+ align?: "left" | "right" | "center";
+
/**
* additional attributes to be set on the table cell.
* Can be used to define mouse-overs with `aria-label` and `data-balloon-pos`
@@ -24,9 +35,9 @@ declare module "@tanstack/solid-table" {
}) => JSX.HTMLAttributes);
/**
- * additional attributes to be set on the header if it is sortable
+ * additional attributes to be set on the header
* Can be used to define mouse-overs with `aria-label` and `data-balloon-pos`
*/
- sortableHeaderMeta?: JSX.HTMLAttributes;
+ headerMeta?: JSX.HTMLAttributes;
}
}
diff --git a/frontend/src/ts/utils/format.ts b/frontend/src/ts/utils/format.ts
index c8ebab24b644..bf50d649b47a 100644
--- a/frontend/src/ts/utils/format.ts
+++ b/frontend/src/ts/utils/format.ts
@@ -1,6 +1,9 @@
import { get as getTypingSpeedUnit } from "../utils/typing-speed-units";
import * as Numbers from "@monkeytype/util/numbers";
-import { Config as ConfigType } from "@monkeytype/schemas/configs";
+import {
+ Config as ConfigType,
+ TypingSpeedUnit,
+} from "@monkeytype/schemas/configs";
import Config from "../config";
export type FormatOptions = {
@@ -20,9 +23,16 @@ export type FallbackOptions = {
fallback?: string;
};
+type FormatConfig = Pick<
+ ConfigType,
+ "typingSpeedUnit" | "alwaysShowDecimalPlaces"
+>;
+
export class Formatting {
- constructor(private config: ConfigType) {
- //
+ private config: FormatConfig;
+
+ constructor(config: FormatConfig) {
+ this.config = config;
}
typingSpeed(
@@ -65,6 +75,10 @@ export class Formatting {
return this.number(value, options);
}
+ get typingSpeedUnit(): TypingSpeedUnit {
+ return this.config.typingSpeedUnit;
+ }
+
private number(
value: number | null | undefined,
formatOptions: FormatOptions,