diff --git a/app/(website)/user/[id]/components/Tabs/UserTabGeneral.tsx b/app/(website)/user/[id]/components/Tabs/UserTabGeneral.tsx
index 5fc1cc0..7b472e1 100644
--- a/app/(website)/user/[id]/components/Tabs/UserTabGeneral.tsx
+++ b/app/(website)/user/[id]/components/Tabs/UserTabGeneral.tsx
@@ -1,8 +1,9 @@
-import { FolderKanbanIcon, Trophy, User2 } from "lucide-react";
+import { FolderKanbanIcon, HistoryIcon, Trophy, User2 } from "lucide-react";
import { useState } from "react";
import UserGrades from "@/app/(website)/user/[id]/components/UserGrades";
import { UserLevelProgress } from "@/app/(website)/user/[id]/components/UserLevelProgress";
+import UserPlayHistoryChart from "@/app/(website)/user/[id]/components/UserPlayHistoryChart";
import UserStatsChart from "@/app/(website)/user/[id]/components/UserStatsChart";
import BBCodeTextField from "@/components/BBCode/BBCodeTextField";
import PrettyHeader from "@/components/General/PrettyHeader";
@@ -11,6 +12,7 @@ import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
import { useUserGrades } from "@/lib/hooks/api/user/useUserGrades";
import { useUserGraph } from "@/lib/hooks/api/user/useUserGraph";
+import { useUserPlayHistoryGraph } from "@/lib/hooks/api/user/useUserPlayHistoryGraph";
import { useT } from "@/lib/i18n/utils";
import type { GameMode, UserResponse, UserStatsResponse } from "@/lib/types/api";
import NumberWith from "@/lib/utils/numberWith";
@@ -33,8 +35,11 @@ export default function UserTabGeneral({
const userGradesQuery = useUserGrades(user.user_id, gameMode);
const userGraphQuery = useUserGraph(user.user_id, gameMode);
+ const userPlayHistoryGraphQuery = useUserPlayHistoryGraph(user.user_id);
+
const userGrades = userGradesQuery.data;
const userGraph = userGraphQuery.data;
+ const userPlayHistoryGraph = userPlayHistoryGraphQuery.data;
return (
@@ -169,7 +174,7 @@ export default function UserTabGeneral({
{user.description && user.description.length > 0 && (
-
+
} />
@@ -178,6 +183,15 @@ export default function UserTabGeneral({
)}
+
+ {userPlayHistoryGraph && userPlayHistoryGraph.snapshots.length > 0 && (
+
+ )}
);
diff --git a/app/(website)/user/[id]/components/UserGeneralInformation.tsx b/app/(website)/user/[id]/components/UserGeneralInformation.tsx
index 726db29..128859e 100644
--- a/app/(website)/user/[id]/components/UserGeneralInformation.tsx
+++ b/app/(website)/user/[id]/components/UserGeneralInformation.tsx
@@ -35,7 +35,7 @@ export default function UserGeneralInformation({
const friendsData = friendsQuery.data;
const localizedPlaystyle = metadata
- ? metadata.playstyle.map(p => tPlaystyle(`options.${p}`)).join(", ")
+ ? metadata.playstyle.filter(p => p !== UserPlaystyle.NONE).map(p => tPlaystyle(`options.${p}`)).join(", ")
: null;
return (
diff --git a/app/(website)/user/[id]/components/UserPlayHistoryChart.tsx b/app/(website)/user/[id]/components/UserPlayHistoryChart.tsx
new file mode 100644
index 0000000..14a68a9
--- /dev/null
+++ b/app/(website)/user/[id]/components/UserPlayHistoryChart.tsx
@@ -0,0 +1,111 @@
+import Cookies from "js-cookie";
+import {
+ CartesianGrid,
+ Line,
+ LineChart,
+ ResponsiveContainer,
+ Tooltip,
+ XAxis,
+ YAxis,
+} from "recharts";
+
+import { useT } from "@/lib/i18n/utils";
+import type {
+ GetUserByUserIdPlayHistoryGraphResponse,
+} from "@/lib/types/api";
+
+interface Props {
+ data: GetUserByUserIdPlayHistoryGraphResponse;
+}
+
+export default function UserPlayHistoryChart({ data }: Props) {
+ const t = useT("pages.user.components.statsChart");
+
+ const locale = Cookies.get("locale") || "en";
+
+ if (data.snapshots.length === 0)
+ return null;
+
+ const firstDate = new Date(data.snapshots[0].saved_at);
+ const lastDate = new Date(data.snapshots.at(-1)!.saved_at);
+
+ let { snapshots } = data;
+
+ for (let date = firstDate; date <= lastDate; date.setMonth(date.getMonth() + 1)) {
+ const dateString = date.toLocaleString(locale, { year: "numeric", month: "short" });
+ if (!snapshots.some(s => new Date(s.saved_at).toLocaleString(locale, { year: "numeric", month: "short" }) === dateString)) {
+ snapshots.push({
+ saved_at: date.toISOString(),
+ play_count: 0,
+ });
+ }
+ }
+
+ snapshots = snapshots.sort((b, a) => new Date(b.saved_at).getTime() - new Date(a.saved_at).getTime());
+
+ const chartData = snapshots.map((s) => {
+ return {
+ date: new Date(s.saved_at).toLocaleString(locale, { year: "numeric", month: "short" }),
+ play_count: s.play_count,
+ };
+ });
+
+ const leewayForDomain = 10;
+
+ return (
+
+
+
+
+ {
+ return (Math.round(value / 10) * 10).toString();
+ }}
+ domain={[
+ (dataMin: number) => Math.floor(Math.max(0, dataMin - leewayForDomain) / 10) * 10,
+ (dataMax: number) => Math.ceil((dataMax + leewayForDomain) / 10) * 10,
+ ]}
+ stroke="#666"
+ tick={{ fontSize: 12 }}
+ />
+
+
+
+ [
+ t("tooltip", {
+ value: Math.round(value as number),
+ type: t("types.plays"),
+ }),
+ ]}
+ contentStyle={{
+ backgroundColor: "#fff",
+ border: "1px solid #ccc",
+ borderRadius: "4px",
+ color: "#333",
+ }}
+ labelStyle={{ color: "#666", fontWeight: "bold" }}
+ />
+
+
+ );
+}
diff --git a/components/Header/LanguageSelector.tsx b/components/Header/LanguageSelector.tsx
index d095143..90ddebb 100644
--- a/components/Header/LanguageSelector.tsx
+++ b/components/Header/LanguageSelector.tsx
@@ -58,6 +58,12 @@ export function LanguageSelector() {
}));
}, [getLanguageName]);
+ const isEnglishOnlyPossibleLanguage = languages.every(language => language.code === "en");
+
+ if (isEnglishOnlyPossibleLanguage) {
+ return null;
+ }
+
return (
diff --git a/lib/hooks/api/user/useUserPlayHistoryGraph.ts b/lib/hooks/api/user/useUserPlayHistoryGraph.ts
new file mode 100644
index 0000000..383ff13
--- /dev/null
+++ b/lib/hooks/api/user/useUserPlayHistoryGraph.ts
@@ -0,0 +1,9 @@
+"use client";
+
+import useSWR from "swr";
+
+import type { GetUserByUserIdPlayHistoryGraphResponse } from "@/lib/types/api";
+
+export function useUserPlayHistoryGraph(userId: number) {
+ return useSWR(`user/${userId}/play-history-graph`);
+}
diff --git a/lib/i18n/messages/de.json b/lib/i18n/messages/de.json
index 86dcfea..f7caefc 100644
--- a/lib/i18n/messages/de.json
+++ b/lib/i18n/messages/de.json
@@ -826,7 +826,8 @@
"performance": "Performance",
"showByRank": "Nach Rang anzeigen",
"showByPp": "Nach PP anzeigen",
- "aboutMe": "Über mich"
+ "aboutMe": "Über mich",
+ "playHistory": "Spielverlauf"
},
"scoresTab": {
"bestScores": "Beste Scores",
@@ -890,7 +891,8 @@
"date": "Datum",
"types": {
"pp": "pp",
- "rank": "rang"
+ "rank": "rang",
+ "plays": "Spiele"
},
"tooltip": "{value} {type}"
}
diff --git a/lib/i18n/messages/en-GB.json b/lib/i18n/messages/en-GB.json
index 044fa2a..ff683ee 100644
--- a/lib/i18n/messages/en-GB.json
+++ b/lib/i18n/messages/en-GB.json
@@ -830,7 +830,8 @@
"performance": "Pewfowmance",
"showByRank": "Show by rank",
"showByPp": "Show by pp",
- "aboutMe": "Abouwt me"
+ "aboutMe": "Abouwt me",
+ "playHistory": "Play histowy"
},
"scoresTab": {
"bestScores": "Best scowes",
@@ -894,7 +895,8 @@
"date": "Date",
"types": {
"pp": "pp",
- "rank": "rank"
+ "rank": "rank",
+ "plays": "pways"
},
"tooltip": "{value} {type}"
}
diff --git a/lib/i18n/messages/en.json b/lib/i18n/messages/en.json
index 4e83989..1271011 100644
--- a/lib/i18n/messages/en.json
+++ b/lib/i18n/messages/en.json
@@ -2174,6 +2174,10 @@
"aboutMe": {
"text": "About me",
"context": "Section header for user description/about section"
+ },
+ "playHistory": {
+ "text": "Play history",
+ "context": "Section header for user play history"
}
},
"scoresTab": {
@@ -2349,6 +2353,10 @@
"rank": {
"text": "rank",
"context": "Word for rank in chart tooltip"
+ },
+ "plays": {
+ "text": "plays",
+ "context": "Word for plays in chart tooltip"
}
},
"tooltip": {
diff --git a/lib/i18n/messages/es.json b/lib/i18n/messages/es.json
index 149e34f..6408b64 100644
--- a/lib/i18n/messages/es.json
+++ b/lib/i18n/messages/es.json
@@ -826,7 +826,8 @@
"performance": "Rendimiento",
"showByRank": "Mostrar por rango",
"showByPp": "Mostrar por pp",
- "aboutMe": "Sobre mí"
+ "aboutMe": "Sobre mí",
+ "playHistory": "Historial de juego"
},
"scoresTab": {
"bestScores": "Mejores puntuaciones",
@@ -890,7 +891,8 @@
"date": "Fecha",
"types": {
"pp": "pp",
- "rank": "rank"
+ "rank": "rank",
+ "plays": "jugadas"
},
"tooltip": "{value} {type}"
}
diff --git a/lib/i18n/messages/fr.json b/lib/i18n/messages/fr.json
index bc65c95..f597ab4 100644
--- a/lib/i18n/messages/fr.json
+++ b/lib/i18n/messages/fr.json
@@ -826,7 +826,8 @@
"performance": "Performance",
"showByRank": "Afficher par rang",
"showByPp": "Afficher par pp",
- "aboutMe": "À propos de moi"
+ "aboutMe": "À propos de moi",
+ "playHistory": "Historique de jeu"
},
"scoresTab": {
"bestScores": "Meilleures performances",
@@ -890,7 +891,8 @@
"date": "Date",
"types": {
"pp": "pp",
- "rank": "rank"
+ "rank": "rank",
+ "plays": "parties"
},
"tooltip": "{value} {type}"
}
diff --git a/lib/i18n/messages/ja.json b/lib/i18n/messages/ja.json
index e6ff9e2..c3ab4f9 100644
--- a/lib/i18n/messages/ja.json
+++ b/lib/i18n/messages/ja.json
@@ -826,7 +826,8 @@
"performance": "パフォーマンス",
"showByRank": "順位で表示",
"showByPp": "PPで表示",
- "aboutMe": "自己紹介"
+ "aboutMe": "自己紹介",
+ "playHistory": "プレイ履歴"
},
"scoresTab": {
"bestScores": "ベストパフォーマンス",
@@ -890,7 +891,8 @@
"date": "日付",
"types": {
"pp": "pp",
- "rank": "rank"
+ "rank": "rank",
+ "plays": "プレイ"
},
"tooltip": "{value} {type}"
}
diff --git a/lib/i18n/messages/ru.json b/lib/i18n/messages/ru.json
index b71094d..f04f4b7 100644
--- a/lib/i18n/messages/ru.json
+++ b/lib/i18n/messages/ru.json
@@ -823,7 +823,8 @@
"performance": "Производительность",
"showByRank": "Показать по рангу",
"showByPp": "Показать по пп",
- "aboutMe": "Обо мне"
+ "aboutMe": "Обо мне",
+ "playHistory": "История игры"
},
"scoresTab": {
"bestScores": "Лучшие результаты",
@@ -887,7 +888,8 @@
"date": "Дата",
"types": {
"pp": "пп",
- "rank": "ранг"
+ "rank": "ранг",
+ "plays": "игры"
},
"tooltip": "{value} {type}"
}
diff --git a/lib/i18n/messages/uk.json b/lib/i18n/messages/uk.json
index f9ae61f..5703834 100644
--- a/lib/i18n/messages/uk.json
+++ b/lib/i18n/messages/uk.json
@@ -826,7 +826,8 @@
"performance": "Продуктивність",
"showByRank": "Показати за рейтингом",
"showByPp": "Показати за pp",
- "aboutMe": "Про мене"
+ "aboutMe": "Про мене",
+ "playHistory": "Історія гри"
},
"scoresTab": {
"bestScores": "Кращі результати",
@@ -890,7 +891,8 @@
"date": "Дата",
"types": {
"pp": "pp",
- "rank": "рейтинг"
+ "rank": "рейтинг",
+ "plays": "ігри"
},
"tooltip": "{value} {type}"
}
diff --git a/lib/i18n/messages/zh-CN.json b/lib/i18n/messages/zh-CN.json
index 0419384..540b43d 100644
--- a/lib/i18n/messages/zh-CN.json
+++ b/lib/i18n/messages/zh-CN.json
@@ -826,7 +826,8 @@
"performance": "表现",
"showByRank": "按排名显示",
"showByPp": "按 PP 显示",
- "aboutMe": "关于我"
+ "aboutMe": "关于我",
+ "playHistory": "游玩历史"
},
"scoresTab": {
"bestScores": "最好成绩",
@@ -890,7 +891,8 @@
"date": "日期",
"types": {
"pp": "pp",
- "rank": "rank"
+ "rank": "rank",
+ "plays": "游玩次数"
},
"tooltip": "{value} {type}"
}
diff --git a/lib/types/api/types.gen.ts b/lib/types/api/types.gen.ts
index 1d52cb9..2f31901 100644
--- a/lib/types/api/types.gen.ts
+++ b/lib/types/api/types.gen.ts
@@ -615,6 +615,16 @@ export type PerformanceAttributes = {
state: ScoreState;
};
+export type PlayHistorySnapshotResponse = {
+ play_count: number;
+ saved_at: string;
+};
+
+export type PlayHistorySnapshotsResponse = {
+ total_count: number;
+ snapshots: PlayHistorySnapshotResponse[];
+};
+
export type PreviousUsernamesResponse = {
usernames: string[];
};
@@ -2021,6 +2031,37 @@ export type GetUserByUserIdGraphResponses = {
export type GetUserByUserIdGraphResponse = GetUserByUserIdGraphResponses[keyof GetUserByUserIdGraphResponses];
+export type GetUserByUserIdPlayHistoryGraphData = {
+ body?: never;
+ path: {
+ userId: number;
+ };
+ query?: never;
+ url: "/user/{userId}/play-history-graph";
+};
+
+export type GetUserByUserIdPlayHistoryGraphErrors = {
+ /**
+ * Bad Request
+ */
+ 400: ProblemDetailsResponseType;
+ /**
+ * Not Found
+ */
+ 404: ProblemDetailsResponseType;
+};
+
+export type GetUserByUserIdPlayHistoryGraphError = GetUserByUserIdPlayHistoryGraphErrors[keyof GetUserByUserIdPlayHistoryGraphErrors];
+
+export type GetUserByUserIdPlayHistoryGraphResponses = {
+ /**
+ * OK
+ */
+ 200: PlayHistorySnapshotsResponse;
+};
+
+export type GetUserByUserIdPlayHistoryGraphResponse = GetUserByUserIdPlayHistoryGraphResponses[keyof GetUserByUserIdPlayHistoryGraphResponses];
+
export type GetUserByIdScoresData = {
body?: never;
path: {
diff --git a/lib/types/api/zod.gen.ts b/lib/types/api/zod.gen.ts
index b65217c..17c7e90 100644
--- a/lib/types/api/zod.gen.ts
+++ b/lib/types/api/zod.gen.ts
@@ -957,6 +957,16 @@ export const zPerformanceAttributes = z.object({
state: zScoreState,
});
+export const zPlayHistorySnapshotResponse = z.object({
+ play_count: z.number().int(),
+ saved_at: z.string().datetime(),
+});
+
+export const zPlayHistorySnapshotsResponse = z.object({
+ total_count: z.number().int(),
+ snapshots: z.array(zPlayHistorySnapshotResponse),
+});
+
export const zPreviousUsernamesResponse = z.object({
usernames: z.array(z.string()),
});
@@ -1237,6 +1247,8 @@ export const zGetUserSelfByModeResponse = zUserWithStatsResponse;
export const zGetUserByUserIdGraphResponse = zStatsSnapshotsResponse;
+export const zGetUserByUserIdPlayHistoryGraphResponse = zPlayHistorySnapshotsResponse;
+
export const zGetUserByIdScoresResponse = zScoresResponse;
export const zGetUserByIdMostplayedResponse = zMostPlayedResponse;
diff --git a/openapi-ts.config.ts b/openapi-ts.config.ts
index 0754c90..9e9c7db 100644
--- a/openapi-ts.config.ts
+++ b/openapi-ts.config.ts
@@ -1,6 +1,9 @@
export default {
input: `https://api.${process.env.NEXT_PUBLIC_SERVER_DOMAIN}/openapi/v1.json`,
- output: "lib/types/api",
+ output: {
+ path: "lib/types/api",
+ lint: "eslint",
+ },
plugins: [
"zod",
{