).span;
+ const spanValue = typeof span === "function" ? span(cell) : span;
+ if (spanValue && spanValue > 1) {
+ i += spanValue - 1; // skip next `spanValue - 1` cell
+ }
+ cell.span = spanValue;
+ renderCells.push(cell);
+ }
+
+ return (
+ <>
+ {renderCells.map((cell) => {
+ return (
+ |
+ {flexRender(cell.column.columnDef.cell, cell.getContext())}
+ |
+ );
+ })}
+ >
+ );
+};
+
const FilterModal = ({
label,
onChange,
diff --git a/website/src/components/LeaderboardTable/LeaderboardTable.tsx b/website/src/components/LeaderboardTable/LeaderboardTable.tsx
index 6314080c..b775be8d 100644
--- a/website/src/components/LeaderboardTable/LeaderboardTable.tsx
+++ b/website/src/components/LeaderboardTable/LeaderboardTable.tsx
@@ -1,5 +1,6 @@
-import { CircularProgress, useColorModeValue, useToken } from "@chakra-ui/react";
+import { Box, CircularProgress, Flex, useColorModeValue, useToken } from "@chakra-ui/react";
import { createColumnHelper } from "@tanstack/react-table";
+import { MoreHorizontal } from "lucide-react";
import { useTranslation } from "next-i18next";
import React, { useCallback, useMemo, useState } from "react";
import { get } from "src/lib/api";
@@ -7,9 +8,11 @@ import { colors } from "src/styles/Theme/colors";
import { LeaderboardEntity, LeaderboardReply, LeaderboardTimeFrame } from "src/types/Leaderboard";
import useSWRImmutable from "swr/immutable";
-import { DataTable, DataTableRowPropsCallback } from "../DataTable";
+import { DataTable, DataTableColumnDef, DataTableRowPropsCallback } from "../DataTable";
-const columnHelper = createColumnHelper();
+type WindowLeaderboardEntity = LeaderboardEntity & { isSpaceRow?: boolean };
+
+const columnHelper = createColumnHelper();
/**
* Presents a grid of leaderboard entries with more detailed information.
@@ -18,10 +21,12 @@ export const LeaderboardTable = ({
timeFrame,
limit: limit,
rowPerPage,
+ hideCurrentUserRanking,
}: {
timeFrame: LeaderboardTimeFrame;
limit: number;
rowPerPage: number;
+ hideCurrentUserRanking?: boolean;
}) => {
const { t } = useTranslation("leaderboard");
@@ -29,15 +34,19 @@ export const LeaderboardTable = ({
data: reply,
isLoading,
error,
- } = useSWRImmutable(`/api/leaderboard?time_frame=${timeFrame}&limit=${limit}`, get, {
- revalidateOnMount: true,
- });
-
- const columns = useMemo(
+ } = useSWRImmutable(
+ `/api/leaderboard?time_frame=${timeFrame}&limit=${limit}&includeUserStats=${!hideCurrentUserRanking}`,
+ get
+ );
+ const columns: DataTableColumnDef[] = useMemo(
() => [
- columnHelper.accessor("rank", {
- header: t("rank"),
- }),
+ {
+ ...columnHelper.accessor("rank", {
+ header: t("rank"),
+ cell: ({ row, getValue }) => (row.original.isSpaceRow ? : getValue()),
+ }),
+ span: (cell) => (cell.row.original.isSpaceRow ? 6 : undefined),
+ },
columnHelper.accessor("display_name", {
header: t("user"),
}),
@@ -63,15 +72,72 @@ export const LeaderboardTable = ({
}, [t, reply?.last_updated]);
const [page, setPage] = useState(1);
- const data = useMemo(() => {
+ const data: WindowLeaderboardEntity[] = useMemo(() => {
+ if (!reply) {
+ return [];
+ }
const start = (page - 1) * rowPerPage;
- return reply?.leaderboard.slice(start, start + rowPerPage) || [];
- }, [rowPerPage, page, reply?.leaderboard]);
+ const end = start + rowPerPage;
+ const leaderBoardEntities = reply.leaderboard.slice(start, end);
+ if (hideCurrentUserRanking) {
+ return leaderBoardEntities;
+ }
+ const userStatsWindow: WindowLeaderboardEntity[] = reply.user_stats_window;
+ const userStats = userStatsWindow.find((stats) => stats.highlighted);
+ if (userStats.rank > end) {
+ leaderBoardEntities.push(
+ { isSpaceRow: true } as WindowLeaderboardEntity,
+ ...reply.user_stats_window.filter(
+ (stats) =>
+ leaderBoardEntities.findIndex((leaderBoardEntity) => leaderBoardEntity.user_id === stats.user_id) === -1
+ ) // filter to avoid duplicated row
+ );
+ }
+ return leaderBoardEntities;
+ }, [page, rowPerPage, reply, hideCurrentUserRanking]);
+ const rowProps = useLeaderboardRowProps();
+
+ if (isLoading) {
+ return ;
+ }
+
+ if (error) {
+ return Unable to load leaderboard;
+ }
+
+ const maxPage = Math.ceil(reply.leaderboard.length / rowPerPage);
+
+ return (
+ = maxPage}
+ disablePrevious={page === 1}
+ onNextClick={() => setPage((p) => p + 1)}
+ onPreviousClick={() => setPage((p) => p - 1)}
+ rowProps={rowProps}
+ >
+ );
+};
+
+const SpaceRow = () => {
+ const color = useColorModeValue("gray.600", "gray.400");
+ return (
+
+
+
+ );
+};
+
+const useLeaderboardRowProps = () => {
const borderColor = useToken("colors", useColorModeValue(colors.light.active, colors.dark.active));
- const rowProps = useCallback>(
+ return useCallback>(
(row) => {
- return row.original.highlighted
+ const rowData = row.original;
+ return rowData.highlighted
? {
sx: {
// https://stackoverflow.com/questions/37963524/how-to-apply-border-radius-to-tr-in-bootstrap
@@ -93,28 +159,4 @@ export const LeaderboardTable = ({
},
[borderColor]
);
-
- if (isLoading) {
- return ;
- }
-
- if (error) {
- return Unable to load leaderboard;
- }
-
- const maxPage = Math.ceil(reply.leaderboard.length / rowPerPage);
-
- return (
- setPage((p) => p + 1)}
- onPreviousClick={() => setPage((p) => p - 1)}
- rowProps={rowProps}
- >
- );
};
diff --git a/website/src/lib/api.ts b/website/src/lib/api.ts
index 27b1b811..c35ea87f 100644
--- a/website/src/lib/api.ts
+++ b/website/src/lib/api.ts
@@ -25,7 +25,13 @@ api.interceptors.response.use(
(response) => response,
(error) => {
const err = error?.response?.data;
- throw new OasstError(err?.message ?? error, err?.errorCode, error?.response?.httpStatusCode || -1);
+ throw new OasstError({
+ message: err?.message ?? error,
+ errorCode: err?.errorCode,
+ httpStatusCode: error?.response?.httpStatusCode || -1,
+ method: err?.config?.method,
+ path: err?.config?.url,
+ });
}
);
diff --git a/website/src/lib/oasst_api_client.ts b/website/src/lib/oasst_api_client.ts
index 2aafab37..abd1173f 100644
--- a/website/src/lib/oasst_api_client.ts
+++ b/website/src/lib/oasst_api_client.ts
@@ -7,11 +7,27 @@ export class OasstError {
message: string;
errorCode: number;
httpStatusCode: number;
+ path: string;
+ method: string;
- constructor(message: string, errorCode: number, httpStatusCode: number) {
+ constructor({
+ errorCode,
+ httpStatusCode,
+ message,
+ path,
+ method,
+ }: {
+ message: string;
+ errorCode: number;
+ httpStatusCode: number;
+ path: string;
+ method: string;
+ }) {
this.message = message;
this.errorCode = errorCode;
this.httpStatusCode = httpStatusCode;
+ this.path = path;
+ this.method = method;
}
toString() {
@@ -60,9 +76,21 @@ export class OasstApiClient {
try {
error = JSON.parse(errorText);
} catch (e) {
- throw new OasstError(errorText, 0, resp.status);
+ throw new OasstError({
+ message: errorText,
+ errorCode: 0,
+ httpStatusCode: resp.status,
+ path,
+ method,
+ });
}
- throw new OasstError(error.message ?? error, error.error_code, resp.status);
+ throw new OasstError({
+ message: error.message ?? error,
+ errorCode: error.error_code,
+ httpStatusCode: resp.status,
+ path,
+ method,
+ });
}
return resp.json();
@@ -297,9 +325,9 @@ export class OasstApiClient {
return this.get(`/api/v1/messages/${messageId}/conversation`);
}
- async fetch_tos_acceptance(user: BackendUserCore): Promise {
- const backendUser = await this.get(`/api/v1/frontend_users/${user.auth_method}/${user.id}`);
- return backendUser.tos_acceptance_date;
+ async fetch_tos_acceptance(backendUserCore: BackendUserCore): Promise {
+ const user = await this.fetch_frontend_user(backendUserCore);
+ return user.tos_acceptance_date;
}
async set_tos_acceptance(user: BackendUserCore) {
@@ -312,4 +340,14 @@ export class OasstApiClient {
const backendUser = await this.get(`/api/v1/frontend_users/${user.auth_method}/${user.id}`);
return this.get(`/api/v1/users/${backendUser.user_id}/stats`);
}
+
+ fetch_user_stats_window(user_id: string, time_frame: LeaderboardTimeFrame, window_size?: number) {
+ return this.get(`/api/v1/users/${user_id}/stats/${time_frame}/window`, {
+ window_size,
+ });
+ }
+
+ fetch_frontend_user(user: BackendUserCore) {
+ return this.get(`/api/v1/frontend_users/${user.auth_method}/${user.id}`);
+ }
}
diff --git a/website/src/pages/api/leaderboard.ts b/website/src/pages/api/leaderboard.ts
index fad1d8a6..42f74c3d 100644
--- a/website/src/pages/api/leaderboard.ts
+++ b/website/src/pages/api/leaderboard.ts
@@ -1,5 +1,6 @@
import { withoutRole } from "src/lib/auth";
import { createApiClient } from "src/lib/oasst_client_factory";
+import { getBackendUserCore } from "src/lib/users";
import { LeaderboardTimeFrame } from "src/types/Leaderboard";
/**
@@ -7,9 +8,29 @@ import { LeaderboardTimeFrame } from "src/types/Leaderboard";
*/
const handler = withoutRole("banned", async (req, res, token) => {
const oasstApiClient = await createApiClient(token);
+ const backendUser = await getBackendUserCore(token.sub);
const time_frame = (req.query.time_frame as LeaderboardTimeFrame) ?? LeaderboardTimeFrame.day;
- const info = await oasstApiClient.fetch_leaderboard(time_frame, { limit: req.query.limit as unknown as number });
- res.status(200).json(info);
+ const includeUserStats = req.query.includeUserStats;
+
+ if (includeUserStats !== "true") {
+ const leaderboard = await oasstApiClient.fetch_leaderboard(time_frame, {
+ limit: req.query.limit as unknown as number,
+ });
+ return res.status(200).json(leaderboard);
+ }
+ const user = await oasstApiClient.fetch_frontend_user(backendUser);
+
+ const [leaderboard, user_stats] = await Promise.all([
+ oasstApiClient.fetch_leaderboard(time_frame, {
+ limit: req.query.limit as unknown as number,
+ }),
+ oasstApiClient.fetch_user_stats_window(user.user_id, time_frame, 3),
+ ]);
+
+ res.status(200).json({
+ ...leaderboard,
+ user_stats_window: user_stats.leaderboard.map((stats) => ({ ...stats, is_window: true })),
+ });
});
export default handler;