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", {