From 2114b43664fb0a6ec28eba9a9ce681a00a883675 Mon Sep 17 00:00:00 2001 From: zkldi <20380519+zkldi@users.noreply.github.com> Date: Mon, 19 Dec 2022 16:42:25 +0000 Subject: [PATCH 1/3] feat: insights infrastructure + jubility as poc --- .../games/_game/_playtype/ScoresPage.tsx | 6 +- .../_game/_playtype/insights/InsightsPage.tsx | 80 ++++++++++++ .../games/_game/_playtype/tools/ToolsPage.tsx | 2 +- client/src/app/routes/UserRoutes.tsx | 4 + .../tools-insights/ToolsAndInsights.tsx | 67 ++++++++++ .../insights/JubilityBreakdownInsight.tsx | 123 ++++++++++++++++++ .../tools/BMSCustomTablesTool.tsx | 4 +- .../tools/IIDXEamusementExportTool.tsx | 4 +- .../tools/IIDXPlaylistsTool.tsx | 4 +- client/src/components/tools/Tools.tsx | 37 ------ client/src/components/user/UGPTHeader.tsx | 18 ++- client/src/types/ugpt.ts | 2 +- .../framework/user-game-stats/rating.ts | 18 ++- .../@gameSpecificRoutes/jubeat/router.ts | 35 +++++ .../games/@gameSpecificRoutes/router.ts | 2 + 15 files changed, 348 insertions(+), 58 deletions(-) create mode 100644 client/src/app/pages/dashboard/users/games/_game/_playtype/insights/InsightsPage.tsx create mode 100644 client/src/components/tools-insights/ToolsAndInsights.tsx create mode 100644 client/src/components/tools-insights/insights/JubilityBreakdownInsight.tsx rename client/src/components/{ => tools-insights}/tools/BMSCustomTablesTool.tsx (94%) rename client/src/components/{ => tools-insights}/tools/IIDXEamusementExportTool.tsx (94%) rename client/src/components/{ => tools-insights}/tools/IIDXPlaylistsTool.tsx (95%) delete mode 100644 client/src/components/tools/Tools.tsx create mode 100644 server/src/server/router/api/v1/users/_userID/games/@gameSpecificRoutes/jubeat/router.ts diff --git a/client/src/app/pages/dashboard/users/games/_game/_playtype/ScoresPage.tsx b/client/src/app/pages/dashboard/users/games/_game/_playtype/ScoresPage.tsx index 4ccd21aac..ce0c66ec5 100644 --- a/client/src/app/pages/dashboard/users/games/_game/_playtype/ScoresPage.tsx +++ b/client/src/app/pages/dashboard/users/games/_game/_playtype/ScoresPage.tsx @@ -144,9 +144,9 @@ function AlgSelector({ function useFetchPBs(url: string, reqUser: UserDocument) { const { data, error } = useQuery(url, async () => { const res = await APIFetchV1<{ - pbs: PBScoreDocument<"iidx:SP">[]; - charts: ChartDocument<"iidx:SP">[]; - songs: SongDocument<"iidx">[]; + pbs: PBScoreDocument[]; + charts: ChartDocument[]; + songs: SongDocument[]; }>(url); if (!res.success) { diff --git a/client/src/app/pages/dashboard/users/games/_game/_playtype/insights/InsightsPage.tsx b/client/src/app/pages/dashboard/users/games/_game/_playtype/insights/InsightsPage.tsx new file mode 100644 index 000000000..2d542ef16 --- /dev/null +++ b/client/src/app/pages/dashboard/users/games/_game/_playtype/insights/InsightsPage.tsx @@ -0,0 +1,80 @@ +import useSetSubheader from "components/layout/header/useSetSubheader"; +import Card from "components/layout/page/Card"; +import { GetGPTInsights } from "components/tools-insights/ToolsAndInsights"; +import Divider from "components/util/Divider"; +import LinkButton from "components/util/LinkButton"; +import React from "react"; +import { Col, Row } from "react-bootstrap"; +import { Link, Route, Switch } from "react-router-dom"; +import { FormatGame, GetGameConfig } from "tachi-common"; +import { UGPT } from "types/react"; + +export default function InsightsPage({ reqUser, game, playtype }: UGPT) { + const gameConfig = GetGameConfig(game); + useSetSubheader( + ["Users", reqUser.username, "Games", gameConfig.name, playtype, "Insights"], + [reqUser, game, playtype], + `${reqUser.username}'s ${FormatGame(game, playtype)} Insights` + ); + + const insights = GetGPTInsights(game, playtype); + + if (insights.length === 0) { + return ( +
+ {FormatGame(game, playtype)} has no insights. How'd you get here! +
+ ); + } + + return ( + + + {insights.map((tool) => ( + + + + < Back to all insights... + + } + > + {tool.description} + + + + {tool.component({ reqUser, game, playtype })} + + ))} + + + {insights.map((tool) => ( + + + + View + + + } + > + {tool.description} + + + ))} + + + + ); +} diff --git a/client/src/app/pages/dashboard/users/games/_game/_playtype/tools/ToolsPage.tsx b/client/src/app/pages/dashboard/users/games/_game/_playtype/tools/ToolsPage.tsx index db0a0ab17..db29333a6 100644 --- a/client/src/app/pages/dashboard/users/games/_game/_playtype/tools/ToolsPage.tsx +++ b/client/src/app/pages/dashboard/users/games/_game/_playtype/tools/ToolsPage.tsx @@ -1,6 +1,6 @@ import useSetSubheader from "components/layout/header/useSetSubheader"; import Card from "components/layout/page/Card"; -import { GetGPTTools } from "components/tools/Tools"; +import { GetGPTTools } from "components/tools-insights/ToolsAndInsights"; import Divider from "components/util/Divider"; import LinkButton from "components/util/LinkButton"; import React from "react"; diff --git a/client/src/app/routes/UserRoutes.tsx b/client/src/app/routes/UserRoutes.tsx index 6db0c6657..c3005c20d 100644 --- a/client/src/app/routes/UserRoutes.tsx +++ b/client/src/app/routes/UserRoutes.tsx @@ -32,6 +32,7 @@ import { UGPTStatsReturn } from "types/api-returns"; import UserImportsPage from "app/pages/dashboard/users/UserImportsPage"; import { TargetsContextProvider } from "context/TargetsContext"; import ToolsPage from "app/pages/dashboard/users/games/_game/_playtype/tools/ToolsPage"; +import InsightsPage from "app/pages/dashboard/users/games/_game/_playtype/insights/InsightsPage"; import ScoresPage from "../pages/dashboard/users/games/_game/_playtype/ScoresPage"; import UserPage from "../pages/dashboard/users/UserPage"; @@ -267,6 +268,9 @@ function UserGamePlaytypeRoutes({ reqUser, game }: { reqUser: UserDocument; game + + + diff --git a/client/src/components/tools-insights/ToolsAndInsights.tsx b/client/src/components/tools-insights/ToolsAndInsights.tsx new file mode 100644 index 000000000..9457e5a1d --- /dev/null +++ b/client/src/components/tools-insights/ToolsAndInsights.tsx @@ -0,0 +1,67 @@ +import { Game, GetIDString, IDStrings, Playtype } from "tachi-common"; +import { GPTToolOrInsight } from "types/ugpt"; +import { BMSCustomTablesTool } from "./tools/BMSCustomTablesTool"; +import { IIDXEamusementExportTool } from "./tools/IIDXEamusementExportTool"; +import { IIDXPlaylistsTool } from "./tools/IIDXPlaylistsTool"; +import { JubilityBreakdownInsight } from "./insights/JubilityBreakdownInsight"; + +// A tool is something that users can use, like exporting to a specific format +// or having things integrate with the game in a neat way. +// Tools are **NOT** displayed on someone elses profile. For that, you want +// Insights (Fancy score renderers, basically). + +// What tools does each game support? +const GPT_TOOLS: Record> = { + "bms:7K": [BMSCustomTablesTool], + "bms:14K": [BMSCustomTablesTool], + "chunithm:Single": [], + "gitadora:Dora": [], + "gitadora:Gita": [], + "iidx:DP": [IIDXEamusementExportTool, IIDXPlaylistsTool], + "iidx:SP": [IIDXEamusementExportTool, IIDXPlaylistsTool], + "itg:Stamina": [], + "jubeat:Single": [], + "museca:Single": [], + "pms:Controller": [], + "pms:Keyboard": [], + "popn:9B": [], + "sdvx:Single": [], + "usc:Controller": [], + "usc:Keyboard": [], + "wacca:Single": [], +}; + +// An insight is a page for a unique kind of data viz that tachi (generically) does not +// pick up. Good examples of this would be something like viewing what goes into your +// jubility. +const GPT_INSIGHTS: Record> = { + "bms:7K": [], + "bms:14K": [], + "chunithm:Single": [], + "gitadora:Dora": [], + "gitadora:Gita": [], + "iidx:DP": [], + "iidx:SP": [], + "itg:Stamina": [], + "jubeat:Single": [JubilityBreakdownInsight], + "museca:Single": [], + "pms:Controller": [], + "pms:Keyboard": [], + "popn:9B": [], + "sdvx:Single": [], + "usc:Controller": [], + "usc:Keyboard": [], + "wacca:Single": [], +}; + +export function GetGPTTools(game: Game, playtype: Playtype) { + const idString = GetIDString(game, playtype); + + return GPT_TOOLS[idString]; +} + +export function GetGPTInsights(game: Game, playtype: Playtype) { + const idString = GetIDString(game, playtype); + + return GPT_INSIGHTS[idString]; +} diff --git a/client/src/components/tools-insights/insights/JubilityBreakdownInsight.tsx b/client/src/components/tools-insights/insights/JubilityBreakdownInsight.tsx new file mode 100644 index 000000000..7642c27aa --- /dev/null +++ b/client/src/components/tools-insights/insights/JubilityBreakdownInsight.tsx @@ -0,0 +1,123 @@ +import { CreateChartMap } from "util/data"; +import { NumericSOV } from "util/sorts"; +import Card from "components/layout/page/Card"; +import PBTable from "components/tables/pbs/PBTable"; +import ApiError from "components/util/ApiError"; +import Divider from "components/util/Divider"; +import Loading from "components/util/Loading"; +import useApiQuery from "components/util/query/useApiQuery"; +import usePreferredRanking from "components/util/usePreferredRanking"; +import React from "react"; +import { Col, Row } from "react-bootstrap"; +import { ChartDocument, CreateSongMap, PBScoreDocument, SongDocument } from "tachi-common"; +import { UGPT } from "types/react"; +import { PBDataset } from "types/tables"; +import { GPTToolOrInsight } from "types/ugpt"; + +function Component({ game, playtype, reqUser }: UGPT) { + const { data, error } = useApiQuery<{ + songs: Array; + charts: Array; + pickUp: Array; + other: Array; + }>(`/users/${reqUser.id}/games/${game}/${playtype}/jubility`); + + const preferredRanking = usePreferredRanking(); + + if (error) { + return ; + } + + if (!data) { + return ; + } + + const songMap = CreateSongMap(data.songs); + const chartMap = CreateChartMap(data.charts); + + const pickUpDataset: PBDataset = []; + const normalDataset: PBDataset = []; + + const sortedJubilities = [...data.pickUp, ...data.other] + .map((e) => e.calculatedData.jubility) + .sort(NumericSOV((x) => x ?? -Infinity, true)); + + for (const pb of data.pickUp) { + const song = songMap.get(pb.songID); + const chart = chartMap.get(pb.chartID); + + if (!song) { + continue; + } + + if (!chart) { + continue; + } + + pickUpDataset.push({ + ...pb, + __related: { + chart, + song, + index: sortedJubilities.indexOf(pb.calculatedData.jubility), + }, + }); + } + + for (const pb of data.other) { + const song = songMap.get(pb.songID); + const chart = chartMap.get(pb.chartID); + + if (!song) { + continue; + } + + if (!chart) { + continue; + } + + normalDataset.push({ + ...pb, + __related: { + chart, + song, + index: sortedJubilities.indexOf(pb.calculatedData.jubility), + }, + }); + } + + return ( + + + + + + + + + + + + ); +} + +export const JubilityBreakdownInsight: GPTToolOrInsight = { + name: "Jubility Breakdown", + urlPath: "jubility", + description: `See what PBs are going into profile jubility!`, + component: Component, +}; diff --git a/client/src/components/tools/BMSCustomTablesTool.tsx b/client/src/components/tools-insights/tools/BMSCustomTablesTool.tsx similarity index 94% rename from client/src/components/tools/BMSCustomTablesTool.tsx rename to client/src/components/tools-insights/tools/BMSCustomTablesTool.tsx index ed03c899e..f9ae732ce 100644 --- a/client/src/components/tools/BMSCustomTablesTool.tsx +++ b/client/src/components/tools-insights/tools/BMSCustomTablesTool.tsx @@ -8,7 +8,7 @@ import { TachiConfig } from "lib/config"; import React from "react"; import { Col, Row } from "react-bootstrap"; import { UGPT } from "types/react"; -import { GPTTool } from "types/ugpt"; +import { GPTToolOrInsight } from "types/ugpt"; import Icon from "components/util/Icon"; import Divider from "components/util/Divider"; @@ -66,7 +66,7 @@ function Component({ game, playtype, reqUser }: UGPT) { ); } -export const BMSCustomTablesTool: GPTTool = { +export const BMSCustomTablesTool: GPTToolOrInsight = { name: `${TachiConfig.name} BMS Tables`, urlPath: "custom-tables", description: `${TachiConfig.name} has its own BMS tables that you can use in-game!`, diff --git a/client/src/components/tools/IIDXEamusementExportTool.tsx b/client/src/components/tools-insights/tools/IIDXEamusementExportTool.tsx similarity index 94% rename from client/src/components/tools/IIDXEamusementExportTool.tsx rename to client/src/components/tools-insights/tools/IIDXEamusementExportTool.tsx index 6f952d511..38361564e 100644 --- a/client/src/components/tools/IIDXEamusementExportTool.tsx +++ b/client/src/components/tools-insights/tools/IIDXEamusementExportTool.tsx @@ -8,7 +8,7 @@ import { TachiConfig } from "lib/config"; import React from "react"; import { Alert, Button, Col, Row } from "react-bootstrap"; import { UGPT } from "types/react"; -import { GPTTool } from "types/ugpt"; +import { GPTToolOrInsight } from "types/ugpt"; function Component({ game, playtype, reqUser }: UGPT) { const { data, error } = useApiQuery( @@ -60,7 +60,7 @@ function Component({ game, playtype, reqUser }: UGPT) { ); } -export const IIDXEamusementExportTool: GPTTool = { +export const IIDXEamusementExportTool: GPTToolOrInsight = { name: `e-amusement CSV Export`, urlPath: "eam-csv-export", description: `Export your ${TachiConfig.name} scores into e-amusement format.`, diff --git a/client/src/components/tools/IIDXPlaylistsTool.tsx b/client/src/components/tools-insights/tools/IIDXPlaylistsTool.tsx similarity index 95% rename from client/src/components/tools/IIDXPlaylistsTool.tsx rename to client/src/components/tools-insights/tools/IIDXPlaylistsTool.tsx index c386b965a..bf71786e1 100644 --- a/client/src/components/tools/IIDXPlaylistsTool.tsx +++ b/client/src/components/tools-insights/tools/IIDXPlaylistsTool.tsx @@ -8,7 +8,7 @@ import { TachiConfig } from "lib/config"; import React from "react"; import { Col, Row } from "react-bootstrap"; import { UGPT } from "types/react"; -import { GPTTool } from "types/ugpt"; +import { GPTToolOrInsight } from "types/ugpt"; import Icon from "components/util/Icon"; import Divider from "components/util/Divider"; @@ -76,7 +76,7 @@ function Component({ game, playtype, reqUser }: UGPT) { ); } -export const IIDXPlaylistsTool: GPTTool = { +export const IIDXPlaylistsTool: GPTToolOrInsight = { name: `${TachiConfig.name} IIDX Playlists`, urlPath: "custom-tables", description: `${TachiConfig.name} has its own IIDX playlists that you can use in-game via playlister!`, diff --git a/client/src/components/tools/Tools.tsx b/client/src/components/tools/Tools.tsx deleted file mode 100644 index 599c6b2e7..000000000 --- a/client/src/components/tools/Tools.tsx +++ /dev/null @@ -1,37 +0,0 @@ -import { Game, GetIDString, IDStrings, Playtype } from "tachi-common"; -import { GPTTool } from "types/ugpt"; -import { BMSCustomTablesTool } from "./BMSCustomTablesTool"; -import { IIDXEamusementExportTool } from "./IIDXEamusementExportTool"; -import { IIDXPlaylistsTool } from "./IIDXPlaylistsTool"; - -// A tool is something that users can use, like exporting to a specific format -// or having things integrate with the game in a neat way. -// Tools are **NOT** displayed on someone elses profile. For that, you want -// Insights (Fancy score renderers, basically). - -// What tools does each game support? -const GPT_TOOLS: Record> = { - "bms:7K": [BMSCustomTablesTool], - "bms:14K": [BMSCustomTablesTool], - "chunithm:Single": [], - "gitadora:Dora": [], - "gitadora:Gita": [], - "iidx:DP": [IIDXEamusementExportTool, IIDXPlaylistsTool], - "iidx:SP": [IIDXEamusementExportTool, IIDXPlaylistsTool], - "itg:Stamina": [], - "jubeat:Single": [], - "museca:Single": [], - "pms:Controller": [], - "pms:Keyboard": [], - "popn:9B": [], - "sdvx:Single": [], - "usc:Controller": [], - "usc:Keyboard": [], - "wacca:Single": [], -}; - -export function GetGPTTools(game: Game, playtype: Playtype) { - const idString = GetIDString(game, playtype); - - return GPT_TOOLS[idString]; -} diff --git a/client/src/components/user/UGPTHeader.tsx b/client/src/components/user/UGPTHeader.tsx index 255d1509c..658d6111a 100644 --- a/client/src/components/user/UGPTHeader.tsx +++ b/client/src/components/user/UGPTHeader.tsx @@ -10,11 +10,10 @@ import React, { useContext } from "react"; import { Button } from "react-bootstrap"; import { Game, Playtype, UserDocument } from "tachi-common"; import { UGPTStatsReturn } from "types/api-returns"; -import { UserContext } from "context/UserContext"; import useLUGPTSettings from "components/util/useLUGPTSettings"; import { UserSettingsContext } from "context/UserSettingsContext"; import FollowUserButton from "components/util/FollowUserButton"; -import { GetGPTTools } from "components/tools/Tools"; +import { GetGPTInsights, GetGPTTools } from "components/tools-insights/ToolsAndInsights"; import ProfileBadges from "./ProfileBadges"; import ProfilePicture from "./ProfilePicture"; import RankingData from "./UGPTRankingData"; @@ -201,10 +200,21 @@ export function UGPTBottomNav({ Sessions , + ]; + + if (GetGPTInsights(game, playtype).length > 0) { + navItems.push( + + Insights + + ); + } + + navItems.push( Leaderboard - , - ]; + + ); if (isRequestedUser) { navItems.push( diff --git a/client/src/types/ugpt.ts b/client/src/types/ugpt.ts index b41602d1e..85bbfc247 100644 --- a/client/src/types/ugpt.ts +++ b/client/src/types/ugpt.ts @@ -1,7 +1,7 @@ import React from "react"; import { UGPT } from "./react"; -export interface GPTTool { +export interface GPTToolOrInsight { urlPath: string; name: string; description: React.ReactChild; diff --git a/server/src/lib/score-import/framework/user-game-stats/rating.ts b/server/src/lib/score-import/framework/user-game-stats/rating.ts index b2f1b2b44..94491bac5 100644 --- a/server/src/lib/score-import/framework/user-game-stats/rating.ts +++ b/server/src/lib/score-import/framework/user-game-stats/rating.ts @@ -390,12 +390,7 @@ async function GetBestJubilityOnSongs( const CURRENT_JUBEAT_HOT_VERSION: GPTSupportedVersions["jubeat:Single"] = "festo"; -async function CalculateJubility( - game: Game, - playtype: Playtype, - userID: integer, - _logger: KtLogger -): Promise { +export async function GetPBsForJubility(userID: integer) { const hotSongs = await db.songs.jubeat.find( { "data.displayVersion": CURRENT_JUBEAT_HOT_VERSION }, { projection: { id: 1 } } @@ -415,6 +410,17 @@ async function CalculateJubility( GetBestJubilityOnSongs(coldSongIDs, userID, "jubeat", "Single", 30), ]); + return { bestHotScores, bestScores }; +} + +async function CalculateJubility( + game: Game, + playtype: Playtype, + userID: integer, + _logger: KtLogger +): Promise { + const { bestHotScores, bestScores } = await GetPBsForJubility(userID); + let jubility = 0; jubility = jubility + bestHotScores.reduce((a, e) => a + (e.calculatedData.jubility ?? 0), 0); diff --git a/server/src/server/router/api/v1/users/_userID/games/@gameSpecificRoutes/jubeat/router.ts b/server/src/server/router/api/v1/users/_userID/games/@gameSpecificRoutes/jubeat/router.ts new file mode 100644 index 000000000..97c2cd005 --- /dev/null +++ b/server/src/server/router/api/v1/users/_userID/games/@gameSpecificRoutes/jubeat/router.ts @@ -0,0 +1,35 @@ +import { Router } from "express"; +import { GetPBsForJubility } from "lib/score-import/framework/user-game-stats/rating"; +import { GetRelevantSongsAndCharts } from "utils/db"; +import { GetUser } from "utils/req-tachi-data"; + +const router: Router = Router({ mergeParams: true }); + +/** + * Retrieve the PBs that went into this users jubility ranking. + * + * @name GET /api/v1/users/:userID/games/jubeat/Single/jubility + */ +router.get("/Single/jubility", async (req, res) => { + const user = GetUser(req); + + const { bestHotScores, bestScores } = await GetPBsForJubility(user.id); + + const { songs, charts } = await GetRelevantSongsAndCharts( + [...bestHotScores, ...bestScores], + "jubeat" + ); + + return res.status(200).json({ + success: true, + description: `Retrieved scores that went into this users jubility.`, + body: { + songs, + charts, + pickUp: bestHotScores, + other: bestScores, + }, + }); +}); + +export default router; diff --git a/server/src/server/router/api/v1/users/_userID/games/@gameSpecificRoutes/router.ts b/server/src/server/router/api/v1/users/_userID/games/@gameSpecificRoutes/router.ts index d1081d5ed..4da9a2ced 100644 --- a/server/src/server/router/api/v1/users/_userID/games/@gameSpecificRoutes/router.ts +++ b/server/src/server/router/api/v1/users/_userID/games/@gameSpecificRoutes/router.ts @@ -4,11 +4,13 @@ import bmsRouter from "./bms/router"; import iidxRouter from "./iidx/router"; +import jubeatRouter from "./jubeat/router"; import { Router } from "express"; const router: Router = Router({ mergeParams: true }); router.use("/bms", bmsRouter); router.use("/iidx", iidxRouter); +router.use("/jubeat", jubeatRouter); export default router; From 7a4dc9f70664c05171506c55fd278d181606b691 Mon Sep 17 00:00:00 2001 From: zkldi <20380519+zkldi@users.noreply.github.com> Date: Tue, 20 Dec 2022 05:01:42 +0000 Subject: [PATCH 2/3] fix: invalid chart link and tidy up "most played" --- .../dashboard/games/_game/_playtype/GPTSongsPage.tsx | 3 ++- client/src/util/data.ts | 8 ++++---- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/client/src/app/pages/dashboard/games/_game/_playtype/GPTSongsPage.tsx b/client/src/app/pages/dashboard/games/_game/_playtype/GPTSongsPage.tsx index 8b48d2bf1..66c6550e9 100644 --- a/client/src/app/pages/dashboard/games/_game/_playtype/GPTSongsPage.tsx +++ b/client/src/app/pages/dashboard/games/_game/_playtype/GPTSongsPage.tsx @@ -82,7 +82,8 @@ function SearchSongsTable({ game, playtype, search }: { search: string } & GameP <> {search === "" && (
- Displaying the most played charts for {FormatGame(game, playtype)} +

Displaying the most played charts for {FormatGame(game, playtype)}

+
)} (scores: ScoreD export function CreateChartLink(chart: ChartDocument, game: Game) { if (chart.isPrimary) { - return `/dashboard/games/${game}/${chart.playtype}/songs/${ - chart.songID - }/${encodeURIComponent(chart.difficulty)}`; + return `/games/${game}/${chart.playtype}/songs/${chart.songID}/${encodeURIComponent( + chart.difficulty + )}`; } - return `/dashboard/games/${game}/${chart.playtype}/songs/${chart.songID}/${chart.chartID}`; + return `/games/${game}/${chart.playtype}/songs/${chart.songID}/${chart.chartID}`; } // stolen from server From 48c3a9d23bd75243a677dd0bf79d009e7b085128 Mon Sep 17 00:00:00 2001 From: zkldi <20380519+zkldi@users.noreply.github.com> Date: Tue, 20 Dec 2022 06:04:13 +0000 Subject: [PATCH 3/3] feat: squish tools and insights together --- .../_game/_playtype/insights/InsightsPage.tsx | 80 ------------------- .../ToolsPage.tsx => utils/UGPTUtilsPage.tsx} | 75 ++++++++--------- client/src/app/routes/UserRoutes.tsx | 34 ++++---- client/src/components/gpt-utils/GPTUtils.tsx | 62 ++++++++++++++ .../insights/JubilityBreakdownInsight.tsx | 4 +- .../tools/BMSCustomTablesTool.tsx | 5 +- .../tools/IIDXEamusementExportTool.tsx | 6 +- .../tools/IIDXPlaylistsTool.tsx | 10 +-- .../tools-insights/ToolsAndInsights.tsx | 67 ---------------- client/src/components/user/UGPTHeader.tsx | 40 ++++------ client/src/types/ugpt.ts | 3 +- 11 files changed, 146 insertions(+), 240 deletions(-) delete mode 100644 client/src/app/pages/dashboard/users/games/_game/_playtype/insights/InsightsPage.tsx rename client/src/app/pages/dashboard/users/games/_game/_playtype/{tools/ToolsPage.tsx => utils/UGPTUtilsPage.tsx} (56%) create mode 100644 client/src/components/gpt-utils/GPTUtils.tsx rename client/src/components/{tools-insights => gpt-utils}/insights/JubilityBreakdownInsight.tsx (96%) rename client/src/components/{tools-insights => gpt-utils}/tools/BMSCustomTablesTool.tsx (94%) rename client/src/components/{tools-insights => gpt-utils}/tools/IIDXEamusementExportTool.tsx (91%) rename client/src/components/{tools-insights => gpt-utils}/tools/IIDXPlaylistsTool.tsx (87%) delete mode 100644 client/src/components/tools-insights/ToolsAndInsights.tsx diff --git a/client/src/app/pages/dashboard/users/games/_game/_playtype/insights/InsightsPage.tsx b/client/src/app/pages/dashboard/users/games/_game/_playtype/insights/InsightsPage.tsx deleted file mode 100644 index 2d542ef16..000000000 --- a/client/src/app/pages/dashboard/users/games/_game/_playtype/insights/InsightsPage.tsx +++ /dev/null @@ -1,80 +0,0 @@ -import useSetSubheader from "components/layout/header/useSetSubheader"; -import Card from "components/layout/page/Card"; -import { GetGPTInsights } from "components/tools-insights/ToolsAndInsights"; -import Divider from "components/util/Divider"; -import LinkButton from "components/util/LinkButton"; -import React from "react"; -import { Col, Row } from "react-bootstrap"; -import { Link, Route, Switch } from "react-router-dom"; -import { FormatGame, GetGameConfig } from "tachi-common"; -import { UGPT } from "types/react"; - -export default function InsightsPage({ reqUser, game, playtype }: UGPT) { - const gameConfig = GetGameConfig(game); - useSetSubheader( - ["Users", reqUser.username, "Games", gameConfig.name, playtype, "Insights"], - [reqUser, game, playtype], - `${reqUser.username}'s ${FormatGame(game, playtype)} Insights` - ); - - const insights = GetGPTInsights(game, playtype); - - if (insights.length === 0) { - return ( -
- {FormatGame(game, playtype)} has no insights. How'd you get here! -
- ); - } - - return ( - - - {insights.map((tool) => ( - - - - < Back to all insights... - - } - > - {tool.description} - - - - {tool.component({ reqUser, game, playtype })} - - ))} - - - {insights.map((tool) => ( - - - - View - - - } - > - {tool.description} - - - ))} - - - - ); -} diff --git a/client/src/app/pages/dashboard/users/games/_game/_playtype/tools/ToolsPage.tsx b/client/src/app/pages/dashboard/users/games/_game/_playtype/utils/UGPTUtilsPage.tsx similarity index 56% rename from client/src/app/pages/dashboard/users/games/_game/_playtype/tools/ToolsPage.tsx rename to client/src/app/pages/dashboard/users/games/_game/_playtype/utils/UGPTUtilsPage.tsx index db29333a6..98f8d33c6 100644 --- a/client/src/app/pages/dashboard/users/games/_game/_playtype/tools/ToolsPage.tsx +++ b/client/src/app/pages/dashboard/users/games/_game/_playtype/utils/UGPTUtilsPage.tsx @@ -1,47 +1,67 @@ import useSetSubheader from "components/layout/header/useSetSubheader"; import Card from "components/layout/page/Card"; -import { GetGPTTools } from "components/tools-insights/ToolsAndInsights"; +import { GetGPTUtils, GetGPTUtilsName } from "components/gpt-utils/GPTUtils"; import Divider from "components/util/Divider"; import LinkButton from "components/util/LinkButton"; -import React from "react"; +import { UserContext } from "context/UserContext"; +import React, { useContext } from "react"; import { Col, Row } from "react-bootstrap"; import { Link, Route, Switch } from "react-router-dom"; import { FormatGame, GetGameConfig } from "tachi-common"; import { UGPT } from "types/react"; +import { GPTUtility } from "types/ugpt"; -export default function ToolsPage({ reqUser, game, playtype }: UGPT) { +export default function UGPTUtilsPage({ reqUser, game, playtype }: UGPT) { const gameConfig = GetGameConfig(game); + const { user } = useContext(UserContext); + + const isViewingOwnProfile = user?.id === reqUser.id; + + const utils = GetGPTUtils(game, playtype); + const pageName = GetGPTUtilsName(game, playtype, isViewingOwnProfile); + useSetSubheader( - ["Users", reqUser.username, "Games", gameConfig.name, playtype, "Tools"], + ["Users", reqUser.username, "Games", gameConfig.name, playtype, pageName ?? "Utils"], [reqUser, game, playtype], - `${reqUser.username}'s ${FormatGame(game, playtype)} Tools` + `${reqUser.username}'s ${FormatGame(game, playtype)} ${pageName ?? "Utils"}` ); - const tools = GetGPTTools(game, playtype); - - if (tools.length === 0) { - return ( -
- {FormatGame(game, playtype)} has no tools. How'd you get here! -
- ); - } - return ( - {tools.map((tool) => ( + + {utils.map((util) => ( + + + + View + + + } + > + {util.description} + + + ))} + + + {utils.map((tool) => ( < Back to all tools... @@ -55,25 +75,6 @@ export default function ToolsPage({ reqUser, game, playtype }: UGPT) { {tool.component({ reqUser, game, playtype })} ))} - - - {tools.map((tool) => ( - - - - Use Tool - - - } - > - {tool.description} - - - ))} - ); diff --git a/client/src/app/routes/UserRoutes.tsx b/client/src/app/routes/UserRoutes.tsx index c3005c20d..b6141db0f 100644 --- a/client/src/app/routes/UserRoutes.tsx +++ b/client/src/app/routes/UserRoutes.tsx @@ -1,19 +1,20 @@ import { APIFetchV1, APIFetchV1Return, ToAPIURL } from "util/api"; import { IsSupportedGame, IsSupportedPlaytype } from "util/asserts"; +import { ErrorPage } from "app/pages/ErrorPage"; import PlaytypeSelect from "app/pages/dashboard/games/_game/PlaytypeSelect"; -import FoldersMainPage from "app/pages/dashboard/users/games/_game/_playtype/folders/FoldersMainPage"; -import LeaderboardsPage from "app/pages/dashboard/users/games/_game/_playtype/LeaderboardsPage"; -import OverviewPage from "app/pages/dashboard/users/games/_game/_playtype/OverviewPage"; -import RivalsMainPage from "app/pages/dashboard/users/games/_game/_playtype/rivals/RivalsMainPage"; -import SessionsPage from "app/pages/dashboard/users/games/_game/_playtype/SessionsPage"; -import SpecificSessionPage from "app/pages/dashboard/users/games/_game/_playtype/SpecificSessionPage"; -import TargetsPage from "app/pages/dashboard/users/games/_game/_playtype/targets/TargetsPage"; -import UGPTSettingsPage from "app/pages/dashboard/users/games/_game/_playtype/UGPTSettingsPage"; import UserGamesPage from "app/pages/dashboard/users/UserGamesPage"; +import UserImportsPage from "app/pages/dashboard/users/UserImportsPage"; import UserIntegrationsPage from "app/pages/dashboard/users/UserIntegrationsPage"; import UserInvitesPage from "app/pages/dashboard/users/UserInvitesPage"; import UserSettingsPage from "app/pages/dashboard/users/UserSettingsPage"; -import { ErrorPage } from "app/pages/ErrorPage"; +import LeaderboardsPage from "app/pages/dashboard/users/games/_game/_playtype/LeaderboardsPage"; +import OverviewPage from "app/pages/dashboard/users/games/_game/_playtype/OverviewPage"; +import SessionsPage from "app/pages/dashboard/users/games/_game/_playtype/SessionsPage"; +import SpecificSessionPage from "app/pages/dashboard/users/games/_game/_playtype/SpecificSessionPage"; +import UGPTSettingsPage from "app/pages/dashboard/users/games/_game/_playtype/UGPTSettingsPage"; +import FoldersMainPage from "app/pages/dashboard/users/games/_game/_playtype/folders/FoldersMainPage"; +import RivalsMainPage from "app/pages/dashboard/users/games/_game/_playtype/rivals/RivalsMainPage"; +import TargetsPage from "app/pages/dashboard/users/games/_game/_playtype/targets/TargetsPage"; import RequireAuthAsUserParam from "components/auth/RequireAuthAsUserParam"; import LayoutHeaderContainer from "components/layout/LayoutHeaderContainer"; import { UGPTBottomNav, UGPTHeaderBody } from "components/user/UGPTHeader"; @@ -21,6 +22,7 @@ import { UserBottomNav, UserHeaderBody } from "components/user/UserHeader"; import Loading from "components/util/Loading"; import useApiQuery from "components/util/query/useApiQuery"; import { BackgroundContext } from "context/BackgroundContext"; +import { TargetsContextProvider } from "context/TargetsContext"; import { UGPTContextProvider } from "context/UGPTContext"; import { UserContext } from "context/UserContext"; import { UserSettingsContext } from "context/UserSettingsContext"; @@ -29,12 +31,9 @@ import { useQuery } from "react-query"; import { Redirect, Route, Switch, useHistory, useParams } from "react-router-dom"; import { FormatGame, Game, GetGameConfig, UserDocument, UserGameStats } from "tachi-common"; import { UGPTStatsReturn } from "types/api-returns"; -import UserImportsPage from "app/pages/dashboard/users/UserImportsPage"; -import { TargetsContextProvider } from "context/TargetsContext"; -import ToolsPage from "app/pages/dashboard/users/games/_game/_playtype/tools/ToolsPage"; -import InsightsPage from "app/pages/dashboard/users/games/_game/_playtype/insights/InsightsPage"; -import ScoresPage from "../pages/dashboard/users/games/_game/_playtype/ScoresPage"; +import UGPTUtilsPage from "app/pages/dashboard/users/games/_game/_playtype/utils/UGPTUtilsPage"; import UserPage from "../pages/dashboard/users/UserPage"; +import ScoresPage from "../pages/dashboard/users/games/_game/_playtype/ScoresPage"; export default function UserRoutes() { const params = useParams<{ userID: string }>(); @@ -265,11 +264,8 @@ function UserGamePlaytypeRoutes({ reqUser, game }: { reqUser: UserDocument; game - - - - - + + diff --git a/client/src/components/gpt-utils/GPTUtils.tsx b/client/src/components/gpt-utils/GPTUtils.tsx new file mode 100644 index 000000000..43d782a57 --- /dev/null +++ b/client/src/components/gpt-utils/GPTUtils.tsx @@ -0,0 +1,62 @@ +import { Game, IDStrings, Playtype } from "tachi-common"; +import { GPTUtility } from "types/ugpt"; +import { BMSCustomTablesTool } from "./tools/BMSCustomTablesTool"; +import { IIDXEamusementExportTool } from "./tools/IIDXEamusementExportTool"; +import { IIDXPlaylistsTool } from "./tools/IIDXPlaylistsTool"; +import { JubilityBreakdownInsight } from "./insights/JubilityBreakdownInsight"; + +// What utils does each game support? +const GPT_UTILS: Record> = { + "bms:7K": [BMSCustomTablesTool], + "bms:14K": [BMSCustomTablesTool], + "chunithm:Single": [], + "gitadora:Dora": [], + "gitadora:Gita": [], + "iidx:DP": [IIDXEamusementExportTool, IIDXPlaylistsTool], + "iidx:SP": [IIDXEamusementExportTool, IIDXPlaylistsTool], + "itg:Stamina": [], + "jubeat:Single": [JubilityBreakdownInsight], + "museca:Single": [], + "pms:Controller": [], + "pms:Keyboard": [], + "popn:9B": [], + "sdvx:Single": [], + "usc:Controller": [], + "usc:Keyboard": [], + "wacca:Single": [], +}; + +export function GetGPTUtils(game: Game, playtype: Playtype) { + const idString = `${game}:${playtype}` as IDStrings; + + return GPT_UTILS[idString]; +} + +export function GetGPTUtilsName(game: Game, playtype: Playtype, isViewingOwnProfile: boolean) { + const utils = GetGPTUtils(game, playtype); + + // things for personal use only are called "tools" + // things everyone might want to view about a person are called "insights". + // an example of an insight would be a jubility breakdown + // an example of a tool would be a eamusement csv export thing. + const tools = utils.filter((e) => e.personalUseOnly); + const insights = utils.filter((e) => e.personalUseOnly !== true); + + if (isViewingOwnProfile) { + if (tools.length > 0 && insights.length > 0) { + return "Tools & Insights"; + } else if (insights.length > 0) { + return "Insights"; + } else if (tools.length > 0) { + return "Tools"; + } + + return null; + } + + if (insights.length > 0) { + return "Insights"; + } + + return null; +} diff --git a/client/src/components/tools-insights/insights/JubilityBreakdownInsight.tsx b/client/src/components/gpt-utils/insights/JubilityBreakdownInsight.tsx similarity index 96% rename from client/src/components/tools-insights/insights/JubilityBreakdownInsight.tsx rename to client/src/components/gpt-utils/insights/JubilityBreakdownInsight.tsx index 7642c27aa..d3e31c826 100644 --- a/client/src/components/tools-insights/insights/JubilityBreakdownInsight.tsx +++ b/client/src/components/gpt-utils/insights/JubilityBreakdownInsight.tsx @@ -12,7 +12,7 @@ import { Col, Row } from "react-bootstrap"; import { ChartDocument, CreateSongMap, PBScoreDocument, SongDocument } from "tachi-common"; import { UGPT } from "types/react"; import { PBDataset } from "types/tables"; -import { GPTToolOrInsight } from "types/ugpt"; +import { GPTUtility } from "types/ugpt"; function Component({ game, playtype, reqUser }: UGPT) { const { data, error } = useApiQuery<{ @@ -115,7 +115,7 @@ function Component({ game, playtype, reqUser }: UGPT) { ); } -export const JubilityBreakdownInsight: GPTToolOrInsight = { +export const JubilityBreakdownInsight: GPTUtility = { name: "Jubility Breakdown", urlPath: "jubility", description: `See what PBs are going into profile jubility!`, diff --git a/client/src/components/tools-insights/tools/BMSCustomTablesTool.tsx b/client/src/components/gpt-utils/tools/BMSCustomTablesTool.tsx similarity index 94% rename from client/src/components/tools-insights/tools/BMSCustomTablesTool.tsx rename to client/src/components/gpt-utils/tools/BMSCustomTablesTool.tsx index f9ae732ce..43795f1a8 100644 --- a/client/src/components/tools-insights/tools/BMSCustomTablesTool.tsx +++ b/client/src/components/gpt-utils/tools/BMSCustomTablesTool.tsx @@ -8,7 +8,7 @@ import { TachiConfig } from "lib/config"; import React from "react"; import { Col, Row } from "react-bootstrap"; import { UGPT } from "types/react"; -import { GPTToolOrInsight } from "types/ugpt"; +import { GPTUtility } from "types/ugpt"; import Icon from "components/util/Icon"; import Divider from "components/util/Divider"; @@ -66,9 +66,10 @@ function Component({ game, playtype, reqUser }: UGPT) { ); } -export const BMSCustomTablesTool: GPTToolOrInsight = { +export const BMSCustomTablesTool: GPTUtility = { name: `${TachiConfig.name} BMS Tables`, urlPath: "custom-tables", description: `${TachiConfig.name} has its own BMS tables that you can use in-game!`, component: Component, + personalUseOnly: true, }; diff --git a/client/src/components/tools-insights/tools/IIDXEamusementExportTool.tsx b/client/src/components/gpt-utils/tools/IIDXEamusementExportTool.tsx similarity index 91% rename from client/src/components/tools-insights/tools/IIDXEamusementExportTool.tsx rename to client/src/components/gpt-utils/tools/IIDXEamusementExportTool.tsx index 38361564e..e8997615c 100644 --- a/client/src/components/tools-insights/tools/IIDXEamusementExportTool.tsx +++ b/client/src/components/gpt-utils/tools/IIDXEamusementExportTool.tsx @@ -1,6 +1,5 @@ import { CopyToClipboard } from "util/misc"; import ApiError from "components/util/ApiError"; -import DebugContent from "components/util/DebugContent"; import Divider from "components/util/Divider"; import Loading from "components/util/Loading"; import useApiQuery from "components/util/query/useApiQuery"; @@ -8,7 +7,7 @@ import { TachiConfig } from "lib/config"; import React from "react"; import { Alert, Button, Col, Row } from "react-bootstrap"; import { UGPT } from "types/react"; -import { GPTToolOrInsight } from "types/ugpt"; +import { GPTUtility } from "types/ugpt"; function Component({ game, playtype, reqUser }: UGPT) { const { data, error } = useApiQuery( @@ -60,9 +59,10 @@ function Component({ game, playtype, reqUser }: UGPT) { ); } -export const IIDXEamusementExportTool: GPTToolOrInsight = { +export const IIDXEamusementExportTool: GPTUtility = { name: `e-amusement CSV Export`, urlPath: "eam-csv-export", description: `Export your ${TachiConfig.name} scores into e-amusement format.`, component: Component, + personalUseOnly: true, }; diff --git a/client/src/components/tools-insights/tools/IIDXPlaylistsTool.tsx b/client/src/components/gpt-utils/tools/IIDXPlaylistsTool.tsx similarity index 87% rename from client/src/components/tools-insights/tools/IIDXPlaylistsTool.tsx rename to client/src/components/gpt-utils/tools/IIDXPlaylistsTool.tsx index bf71786e1..d0f3b9c59 100644 --- a/client/src/components/tools-insights/tools/IIDXPlaylistsTool.tsx +++ b/client/src/components/gpt-utils/tools/IIDXPlaylistsTool.tsx @@ -1,5 +1,4 @@ import { ToAPIURL } from "util/api"; -import { CopyToClipboard } from "util/misc"; import Card from "components/layout/page/Card"; import ApiError from "components/util/ApiError"; import Loading from "components/util/Loading"; @@ -8,9 +7,7 @@ import { TachiConfig } from "lib/config"; import React from "react"; import { Col, Row } from "react-bootstrap"; import { UGPT } from "types/react"; -import { GPTToolOrInsight } from "types/ugpt"; -import Icon from "components/util/Icon"; -import Divider from "components/util/Divider"; +import { GPTUtility } from "types/ugpt"; function Component({ game, playtype, reqUser }: UGPT) { const { data, error } = useApiQuery< @@ -76,9 +73,10 @@ function Component({ game, playtype, reqUser }: UGPT) { ); } -export const IIDXPlaylistsTool: GPTToolOrInsight = { +export const IIDXPlaylistsTool: GPTUtility = { name: `${TachiConfig.name} IIDX Playlists`, - urlPath: "custom-tables", + urlPath: "playlists", description: `${TachiConfig.name} has its own IIDX playlists that you can use in-game via playlister!`, component: Component, + personalUseOnly: true, }; diff --git a/client/src/components/tools-insights/ToolsAndInsights.tsx b/client/src/components/tools-insights/ToolsAndInsights.tsx deleted file mode 100644 index 9457e5a1d..000000000 --- a/client/src/components/tools-insights/ToolsAndInsights.tsx +++ /dev/null @@ -1,67 +0,0 @@ -import { Game, GetIDString, IDStrings, Playtype } from "tachi-common"; -import { GPTToolOrInsight } from "types/ugpt"; -import { BMSCustomTablesTool } from "./tools/BMSCustomTablesTool"; -import { IIDXEamusementExportTool } from "./tools/IIDXEamusementExportTool"; -import { IIDXPlaylistsTool } from "./tools/IIDXPlaylistsTool"; -import { JubilityBreakdownInsight } from "./insights/JubilityBreakdownInsight"; - -// A tool is something that users can use, like exporting to a specific format -// or having things integrate with the game in a neat way. -// Tools are **NOT** displayed on someone elses profile. For that, you want -// Insights (Fancy score renderers, basically). - -// What tools does each game support? -const GPT_TOOLS: Record> = { - "bms:7K": [BMSCustomTablesTool], - "bms:14K": [BMSCustomTablesTool], - "chunithm:Single": [], - "gitadora:Dora": [], - "gitadora:Gita": [], - "iidx:DP": [IIDXEamusementExportTool, IIDXPlaylistsTool], - "iidx:SP": [IIDXEamusementExportTool, IIDXPlaylistsTool], - "itg:Stamina": [], - "jubeat:Single": [], - "museca:Single": [], - "pms:Controller": [], - "pms:Keyboard": [], - "popn:9B": [], - "sdvx:Single": [], - "usc:Controller": [], - "usc:Keyboard": [], - "wacca:Single": [], -}; - -// An insight is a page for a unique kind of data viz that tachi (generically) does not -// pick up. Good examples of this would be something like viewing what goes into your -// jubility. -const GPT_INSIGHTS: Record> = { - "bms:7K": [], - "bms:14K": [], - "chunithm:Single": [], - "gitadora:Dora": [], - "gitadora:Gita": [], - "iidx:DP": [], - "iidx:SP": [], - "itg:Stamina": [], - "jubeat:Single": [JubilityBreakdownInsight], - "museca:Single": [], - "pms:Controller": [], - "pms:Keyboard": [], - "popn:9B": [], - "sdvx:Single": [], - "usc:Controller": [], - "usc:Keyboard": [], - "wacca:Single": [], -}; - -export function GetGPTTools(game: Game, playtype: Playtype) { - const idString = GetIDString(game, playtype); - - return GPT_TOOLS[idString]; -} - -export function GetGPTInsights(game: Game, playtype: Playtype) { - const idString = GetIDString(game, playtype); - - return GPT_INSIGHTS[idString]; -} diff --git a/client/src/components/user/UGPTHeader.tsx b/client/src/components/user/UGPTHeader.tsx index 658d6111a..4c3aa4697 100644 --- a/client/src/components/user/UGPTHeader.tsx +++ b/client/src/components/user/UGPTHeader.tsx @@ -13,7 +13,7 @@ import { UGPTStatsReturn } from "types/api-returns"; import useLUGPTSettings from "components/util/useLUGPTSettings"; import { UserSettingsContext } from "context/UserSettingsContext"; import FollowUserButton from "components/util/FollowUserButton"; -import { GetGPTInsights, GetGPTTools } from "components/tools-insights/ToolsAndInsights"; +import { GetGPTUtilsName } from "components/gpt-utils/GPTUtils"; import ProfileBadges from "./ProfileBadges"; import ProfilePicture from "./ProfilePicture"; import RankingData from "./UGPTRankingData"; @@ -202,10 +202,23 @@ export function UGPTBottomNav({ , ]; - if (GetGPTInsights(game, playtype).length > 0) { + const utilsName = GetGPTUtilsName(game, playtype, isRequestedUser); + + if (utilsName) { navItems.push( - - Insights + + {utilsName} + + ); + } + + if (isRequestedUser) { + navItems.push( + + Rivals + , + + Goals & Quests ); } @@ -217,25 +230,6 @@ export function UGPTBottomNav({ ); if (isRequestedUser) { - navItems.push( - - Rivals - , - - Goals & Quests - - ); - - const tools = GetGPTTools(game, playtype); - - if (tools.length > 0) { - navItems.push( - - Tools - - ); - } - navItems.push( Settings diff --git a/client/src/types/ugpt.ts b/client/src/types/ugpt.ts index 85bbfc247..4b676ce84 100644 --- a/client/src/types/ugpt.ts +++ b/client/src/types/ugpt.ts @@ -1,9 +1,10 @@ import React from "react"; import { UGPT } from "./react"; -export interface GPTToolOrInsight { +export interface GPTUtility { urlPath: string; name: string; description: React.ReactChild; component: (ugpt: UGPT) => JSX.Element; + personalUseOnly?: boolean; }