diff --git a/src/__mocks__/playerMock.ts b/src/__mocks__/playerMock.ts index 5d636a2..16a4b7d 100644 --- a/src/__mocks__/playerMock.ts +++ b/src/__mocks__/playerMock.ts @@ -9,6 +9,7 @@ export default function playerMock(extra: Partial = {}): Player { lastSeenAt: new Date().toISOString(), aliases: [], groups: [], + presence: null, ...extra } } diff --git a/src/api/usePlayerHeadlines.ts b/src/api/usePlayerHeadlines.ts new file mode 100644 index 0000000..4221004 --- /dev/null +++ b/src/api/usePlayerHeadlines.ts @@ -0,0 +1,36 @@ +import useSWR from 'swr' +import buildError from '../utils/buildError' +import { Game } from '../entities/game' +import makeValidatedGetRequest from './makeValidatedGetRequest' +import { z } from 'zod' +import { PlayerHeadlines } from '../entities/playerHeadline' + +const defaultHeadlines: PlayerHeadlines = { + total_players: { count: 0 }, + online_players: { count: 0 } +} + +export default function usePlayerHeadlines(activeGame: Game | null, includeDevData: boolean) { + const fetcher = async ([url]: [string]) => { + const headlines: (keyof PlayerHeadlines)[] = ['total_players', 'online_players'] + const res = await Promise.all(headlines.map((headline) => makeValidatedGetRequest(`${url}/${headline}`, z.object({ + count: z.number() + })))) + + return headlines.reduce((acc, curr, idx) => ({ + ...acc, + [curr]: res[idx] + }), defaultHeadlines) + } + + const { data, error } = useSWR( + activeGame ? [`/games/${activeGame.id}/headlines`, includeDevData] : null, + fetcher + ) + + return { + headlines: data ?? defaultHeadlines, + loading: !data && !error, + error: error && buildError(error) + } +} diff --git a/src/components/HeadlineStat.tsx b/src/components/HeadlineStat.tsx index e40b533..1d8ae4e 100644 --- a/src/components/HeadlineStat.tsx +++ b/src/components/HeadlineStat.tsx @@ -12,7 +12,7 @@ const HeadlineStat = (props: HeadlineStatProps) => {
-

{props.stat}

+

{props.stat.toLocaleString()}

) diff --git a/src/components/PlayerAliases.tsx b/src/components/PlayerAliases.tsx index a324102..d810b63 100644 --- a/src/components/PlayerAliases.tsx +++ b/src/components/PlayerAliases.tsx @@ -38,11 +38,17 @@ export default function PlayerAliases({ - {alias.service} + {alias.service}{alias.player.presence?.online && ' (Online)'}

} > - {getIcon(alias)} + + {getIcon(alias)} +
{alias.identifier} diff --git a/src/entities/player.ts b/src/entities/player.ts index be7c92a..9a697d8 100644 --- a/src/entities/player.ts +++ b/src/entities/player.ts @@ -1,6 +1,7 @@ import { z } from 'zod' import { propSchema } from './prop' import { playerAliasSchema, PlayerAlias } from './playerAlias' +import { playerPresenceSchema } from './playerPresence' export const basePlayerSchema = z.object({ id: z.string().uuid(), @@ -11,7 +12,8 @@ export const basePlayerSchema = z.object({ groups: z.array(z.object({ id: z.string(), name: z.string() - })) + })), + presence: playerPresenceSchema.nullable() }) export type Player = z.infer & { diff --git a/src/entities/playerHeadline.ts b/src/entities/playerHeadline.ts new file mode 100644 index 0000000..4e5421e --- /dev/null +++ b/src/entities/playerHeadline.ts @@ -0,0 +1,12 @@ +import { z } from 'zod' + +const countSchema = z.object({ + count: z.number() +}) + +export const playerHeadlinesSchema = z.object({ + total_players: countSchema, + online_players: countSchema +}) + +export type PlayerHeadlines = z.infer diff --git a/src/entities/playerPresence.ts b/src/entities/playerPresence.ts new file mode 100644 index 0000000..d847984 --- /dev/null +++ b/src/entities/playerPresence.ts @@ -0,0 +1,9 @@ +import { z } from 'zod' + +export const playerPresenceSchema = z.object({ + online: z.boolean(), + customStatus: z.string(), + updatedAt: z.string().datetime() +}) + +export type PlayerPresence = z.infer diff --git a/src/pages/Dashboard.tsx b/src/pages/Dashboard.tsx index fffc0c5..2a84234 100644 --- a/src/pages/Dashboard.tsx +++ b/src/pages/Dashboard.tsx @@ -16,6 +16,7 @@ import DevDataStatus from '../components/DevDataStatus' import SecondaryTitle from '../components/SecondaryTitle' import useIntendedRoute from '../utils/useIntendedRoute' import usePinnedGroups from '../api/usePinnedGroups' +import usePlayerHeadlines from '../api/usePlayerHeadlines' export const secondaryNavRoutes = [ { title: 'Dashboard', to: routes.dashboard }, @@ -46,7 +47,7 @@ export default function Dashboard() { const { headlines, loading: headlinesLoading, error: headlinesError } = useHeadlines(activeGame, startDate, endDate, includeDevData) const { stats, loading: statsLoading, error: statsError } = useStats(activeGame, includeDevData) const { groups: pinnedGroups, loading: pinnedGroupsLoading, error: pinnedGroupsError } = usePinnedGroups(activeGame, includeDevData) - + const { headlines: playerHeadlines, loading: playerHeadlinesLoading, error: playerHeadlinesError } = usePlayerHeadlines(activeGame, includeDevData) const intendedRouteChecked = useIntendedRoute() if (!intendedRouteChecked) return null @@ -91,6 +92,19 @@ export default function Dashboard() { } + Players + + {playerHeadlinesError && + + } + + {!playerHeadlinesLoading && !playerHeadlinesError && +
+ + +
+ } + {pinnedGroups.length > 0 && Pinned groups} {pinnedGroupsError && diff --git a/src/pages/PlayerProfile.tsx b/src/pages/PlayerProfile.tsx index 81a0f5b..a33581a 100644 --- a/src/pages/PlayerProfile.tsx +++ b/src/pages/PlayerProfile.tsx @@ -21,6 +21,7 @@ import activeGameState, { SelectedActiveGame } from '../state/activeGameState' import useDaySections from '../utils/useDaySections' import ActivityRenderer from '../components/ActivityRenderer' import userState, { AuthedUser } from '../state/userState' +import clsx from 'clsx' const links = [ { @@ -87,6 +88,9 @@ export default function PlayerProfile() {
+ + +
diff --git a/src/pages/__tests__/Dashboard.test.tsx b/src/pages/__tests__/Dashboard.test.tsx index d4a44c0..3841178 100644 --- a/src/pages/__tests__/Dashboard.test.tsx +++ b/src/pages/__tests__/Dashboard.test.tsx @@ -25,6 +25,12 @@ describe('', () => { axiosMock.onGet(/\/games\/\d\/headlines\/unique_event_submitters/).replyOnce(200, { count: 8 }) + axiosMock.onGet(/\/games\/\d\/headlines\/total_players/).replyOnce(200, { + count: 150030 + }) + axiosMock.onGet(/\/games\/\d\/headlines\/online_players/).replyOnce(200, { + count: 2094 + }) axiosMock.onGet(/\/games\/\d\/player-groups\/pinned/).replyOnce(200, { groups: [ @@ -79,12 +85,15 @@ describe('', () => { expect(await screen.findByText('Swerve City dashboard')).toBeInTheDocument() expect(await screen.findByText('New players')).toBeInTheDocument() - expect(screen.getByText('Returning players')).toBeInTheDocument() - expect(screen.getByText('New events')).toBeInTheDocument() - expect(screen.getByText('Unique event submitters')).toBeInTheDocument() + expect(await screen.findByText('Returning players')).toBeInTheDocument() + expect(await screen.findByText('New events')).toBeInTheDocument() + expect(await screen.findByText('Unique event submitters')).toBeInTheDocument() expect(await screen.findByText('Stat A')).toBeInTheDocument() - expect(screen.getByText('Stat B')).toBeInTheDocument() + expect(await screen.findByText('Stat B')).toBeInTheDocument() + + expect(await screen.findByText('150,030')).toBeInTheDocument() + expect(await screen.findByText('2,094')).toBeInTheDocument() }) it('should be able to change the time period for headlines', async () => { @@ -120,7 +129,7 @@ describe('', () => { await userEvent.click(screen.getByText('This year')) - expect(await screen.findByText('2103')).toBeInTheDocument() + expect(await screen.findByText('2,103')).toBeInTheDocument() }) it('should render headline, pinned group and stat errors', async () => {