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 () => {