From 51012cb1e43a5149219b1c4994cfe406cba5e280 Mon Sep 17 00:00:00 2001 From: Zoo Date: Sun, 5 Jul 2026 21:53:41 +0200 Subject: [PATCH 1/2] feat: enhanced player profiles Add per-session tracking plus an admin-facing activity visualisation and session history to the webpanel player profile. --- apps/core/src/modules/game/manager.test.ts | 131 +++++++++ apps/core/src/modules/game/manager.ts | 8 + apps/core/src/modules/session/manager.test.ts | 18 ++ apps/core/src/modules/session/manager.ts | 1 + .../api/players.activity.integration.test.ts | 155 +++++++++++ apps/core/src/routes/api/players.ts | 53 +++- .../players/components/activity-heatmap.tsx | 254 ++++++++++++++++++ .../pages/players/components/tab-elements.tsx | 96 ++++++- .../webpanel/src/pages/players/playerview.tsx | 18 +- packages/database/src/index.ts | 2 + .../migrations/0007_player_sessions.ts | 20 ++ .../src/migrations/migrations/index.ts | 2 + .../src/repositories/player-sessions.test.ts | 112 ++++++++ .../src/repositories/player-sessions.ts | 201 ++++++++++++++ packages/database/src/schema.ts | 36 +++ packages/shared/src/types/players.ts | 28 ++ 16 files changed, 1130 insertions(+), 5 deletions(-) create mode 100644 apps/core/src/routes/api/players.activity.integration.test.ts create mode 100644 apps/webpanel/src/pages/players/components/activity-heatmap.tsx create mode 100644 packages/database/src/migrations/migrations/0007_player_sessions.ts create mode 100644 packages/database/src/repositories/player-sessions.test.ts create mode 100644 packages/database/src/repositories/player-sessions.ts diff --git a/apps/core/src/modules/game/manager.test.ts b/apps/core/src/modules/game/manager.test.ts index 154e56b3..6cf6bce6 100644 --- a/apps/core/src/modules/game/manager.test.ts +++ b/apps/core/src/modules/game/manager.test.ts @@ -20,6 +20,60 @@ const mockIsAnyIdentifierWhitelisted = mock(async () => false); const mockUpsertPlayer = mock(async () => ({})); const mockUpdatePlaytime = mock(async () => {}); +// Stateful in-memory fake for the player_sessions repo so the join/drop wiring +// can be asserted through real open/close/listSessions behaviour. +let sessionRows: any[] = []; +let nextSessionId = 1; +const mockPlayerSessions = { + open: mock( + ( + playerId: number, + serverSessionId: number | null, + connectedAt: Date = new Date(), + ) => { + const row = { + id: nextSessionId++, + playerId, + serverSessionId, + connectedAt: connectedAt.getTime(), + disconnectedAt: null as number | null, + durationMs: null as number | null, + endReason: null as string | null, + }; + sessionRows.push(row); + return row; + }, + ), + close: mock( + ( + playerId: number, + endReason: string | null = null, + disconnectedAt: Date = new Date(), + ) => { + const open = [...sessionRows] + .reverse() + .find((r) => r.playerId === playerId && r.disconnectedAt === null); + if (!open) return null; + open.disconnectedAt = disconnectedAt.getTime(); + open.durationMs = Math.max(0, open.disconnectedAt - open.connectedAt); + open.endReason = endReason; + return open; + }, + ), + closeDangling: mock(() => {}), + listSessions: (playerId: number, page = 1, pageSize = 25) => { + const all = sessionRows + .filter((r) => r.playerId === playerId) + .sort((a, b) => b.connectedAt - a.connectedAt); + return { + items: all.slice((page - 1) * pageSize, (page - 1) * pageSize + pageSize), + total: all.length, + page, + pageSize, + }; + }, +}; + mock.module('@fxmanager/database', () => ({ repo: { players: { @@ -40,6 +94,7 @@ mock.module('@fxmanager/database', () => ({ closeDangling: () => {}, prune: () => {}, }, + playerSessions: mockPlayerSessions, }, })); @@ -79,6 +134,13 @@ describe('GameManager', () => { mockUpsertPlayer.mockReset(); mockUpdatePlaytime.mockReset(); + // Reset the stateful player_sessions fake + sessionRows = []; + nextSessionId = 1; + mockPlayerSessions.open.mockClear(); + mockPlayerSessions.close.mockClear(); + mockPlayerSessions.closeDangling.mockClear(); + // Spying on core system managers cleanly isolates state mutations to this file execution context wsSpy = spyOn(wsManager, 'broadcast').mockImplementation(() => {}); @@ -387,6 +449,31 @@ describe('GameManager', () => { expect(gameManager.getPlayerList()).toHaveLength(1); expect(setPlayerCountSpy).toHaveBeenLastCalledWith(1); }); + + it('opens an in-progress player_session on join', async () => { + const dbPlayerPayload = { + id: 77, + name: 'Rex', + playtime: 0, + identifiers: sampleIdentifiers, + isStaff: false, + firstSeen: new Date(), + lastSeen: new Date(), + }; + mockUpsertPlayer.mockResolvedValueOnce(dbPlayerPayload); + + await gameManager.playerJoin({ + name: 'Rex', + identifiers: sampleIdentifiers, + serverId: 3, + }); + + expect(mockPlayerSessions.open).toHaveBeenCalledWith(77, null); + const { items } = mockPlayerSessions.listSessions(77, 1, 10); + expect(items).toHaveLength(1); + expect(items[0].disconnectedAt).toBeNull(); + expect(items[0].durationMs).toBeNull(); + }); }); describe('resetPlayerlist()', () => { @@ -478,6 +565,50 @@ describe('GameManager', () => { expect(recordSpy).not.toHaveBeenCalled(); recordSpy.mockRestore(); }); + + it('closes the open player_session with the drop reason', async () => { + mockPlayerSessions.open(12, null, new Date(Date.now() - 5_000)); + (gameManager as any).playerlist.push({ + serverId: 9, + id: 12, + name: 'Vader', + playtime: 1000, + identifiers: sampleIdentifiers, + isStaff: false, + firstSeen: new Date(), + lastSeen: new Date(Date.now() - 5_000), + health: 100, + ping: 25, + }); + + await gameManager.playerDrop(9, { reason: 'Quit', category: 0 }); + + expect(mockPlayerSessions.close).toHaveBeenCalledWith(12, 'Quit'); + const { items } = mockPlayerSessions.listSessions(12, 1, 10); + expect(items[0].disconnectedAt).not.toBeNull(); + expect(items[0].durationMs).toBeGreaterThanOrEqual(0); + expect(items[0].endReason).toBe('Quit'); + }); + + it('closes the player_session with a null reason when drop carries no reason', async () => { + mockPlayerSessions.open(12, null, new Date(Date.now() - 5_000)); + (gameManager as any).playerlist.push({ + serverId: 9, + id: 12, + name: 'Vader', + playtime: 1000, + identifiers: sampleIdentifiers, + isStaff: false, + firstSeen: new Date(), + lastSeen: new Date(Date.now() - 5_000), + health: 100, + ping: 25, + }); + + await gameManager.playerDrop(9); + + expect(mockPlayerSessions.close).toHaveBeenCalledWith(12, null); + }); }); describe('playerUpdates()', () => { diff --git a/apps/core/src/modules/game/manager.ts b/apps/core/src/modules/game/manager.ts index a2a6ee6f..acb1f1aa 100644 --- a/apps/core/src/modules/game/manager.ts +++ b/apps/core/src/modules/game/manager.ts @@ -165,6 +165,8 @@ export class GameManager { if (player.isStaff) aceSync.refresh(); + repo.playerSessions.open(player.id, sessionManager.getCurrentId()); + const playerPayload = { serverId, health: -1, @@ -216,6 +218,12 @@ export class GameManager { const newPlaytime = player.playtime + sessionDuration; repo.players.updatePlaytime(player.id, newPlaytime); + + const endReason = + typeof drop?.reason === 'string' && drop.reason.length > 0 + ? drop.reason + : null; + repo.playerSessions.close(player.id, endReason); wsManager.broadcast<{ serverId: number }>({ channel: 'playerlist', event: 'player_left', diff --git a/apps/core/src/modules/session/manager.test.ts b/apps/core/src/modules/session/manager.test.ts index c7e997fe..449e8170 100644 --- a/apps/core/src/modules/session/manager.test.ts +++ b/apps/core/src/modules/session/manager.test.ts @@ -28,6 +28,7 @@ const mockClose = mock(() => closedStub()); const mockCloseDangling = mock(() => {}); const mockPrune = mock(() => {}); const mockListRecent = mock(() => [closedStub()]); +const mockPlayerCloseDangling = mock(() => {}); mock.module('@fxmanager/database', () => ({ repo: { @@ -38,6 +39,9 @@ mock.module('@fxmanager/database', () => ({ prune: mockPrune, listRecent: mockListRecent, }, + playerSessions: { + closeDangling: mockPlayerCloseDangling, + }, }, })); @@ -55,6 +59,7 @@ describe('sessionManager', () => { mockCloseDangling.mockReset(); mockPrune.mockReset(); mockListRecent.mockReset().mockReturnValue([closedStub()]); + mockPlayerCloseDangling.mockReset(); wsSpy.mockClear(); }); @@ -67,6 +72,19 @@ describe('sessionManager', () => { expect(mockCloseDangling).toHaveBeenCalledTimes(1); }); + it('init reconciles dangling player sessions after server sessions', () => { + const order: string[] = []; + mockCloseDangling.mockImplementation(() => { + order.push('server'); + }); + mockPlayerCloseDangling.mockImplementation(() => { + order.push('player'); + }); + sessionManager.init(); + expect(mockPlayerCloseDangling).toHaveBeenCalledTimes(1); + expect(order).toEqual(['server', 'player']); + }); + it('openSession opens, caches, and does not double-open', () => { const s = sessionManager.openSession(); expect(s.id).toBe(7); diff --git a/apps/core/src/modules/session/manager.ts b/apps/core/src/modules/session/manager.ts index 5f11669c..b62b7acb 100644 --- a/apps/core/src/modules/session/manager.ts +++ b/apps/core/src/modules/session/manager.ts @@ -8,6 +8,7 @@ class SessionManager { init(): void { repo.serverSessions.closeDangling(); + repo.playerSessions.closeDangling(); } openSession(): ServerSession { diff --git a/apps/core/src/routes/api/players.activity.integration.test.ts b/apps/core/src/routes/api/players.activity.integration.test.ts new file mode 100644 index 00000000..c8a91434 --- /dev/null +++ b/apps/core/src/routes/api/players.activity.integration.test.ts @@ -0,0 +1,155 @@ +/** biome-ignore-all lint/suspicious/noExplicitAny: fakes for gm/repo/admin are cast to satisfy handler options */ +import { beforeAll, beforeEach, describe, expect, it, mock } from 'bun:test'; +import Fastify, { type FastifyInstance } from 'fastify'; + +let currentAdmin = { id: 1, username: 'Tester', permissions: 0 }; +let authed = true; + +const activityPayload = { + from: '2026-06-06', + to: '2026-07-05', + days: [ + { date: '2026-07-01', playtimeMs: 3_600_000, sessionCount: 2 }, + { date: '2026-07-03', playtimeMs: 1_800_000, sessionCount: 1 }, + ], + summary: { + daysActive: 2, + totalPlaytimeMs: 5_400_000, + longestSessionMs: 3_600_000, + avgSessionMs: 1_800_000, + }, +}; + +const sessionsPage = { + items: [ + { + id: 3, + connectedAt: 3000, + disconnectedAt: 4000, + durationMs: 1000, + endReason: 'quit', + }, + { + id: 2, + connectedAt: 2000, + disconnectedAt: 2500, + durationMs: 500, + endReason: 'crash', + }, + ], + total: 3, + page: 1, + pageSize: 2, +}; + +const mockGetRangeActivity = mock( + (_playerId: number, _from: Date, _to: Date) => activityPayload, +); +const mockListSessions = mock( + (_playerId: number, _page: number, _pageSize: number) => sessionsPage, +); + +const fakeRepo = { + playerSessions: { + getRangeActivity: mockGetRangeActivity, + listSessions: mockListSessions, + }, +}; + +mock.module('@fxmanager/database', () => ({ repo: fakeRepo })); +mock.module('../../middleware/session', () => ({ + sessionAuth: async (req: any, reply: any) => { + if (!authed) { + return reply.code(401).send({ success: false, error: 'Unauthorized' }); + } + req.admin = currentAdmin; + }, +})); + +const { default: PlayersModule } = await import('./players'); + +const fakeGm = { getPlayer: () => undefined } as any; + +const DAY_MS = 86_400_000; + +describe('players activity + sessions endpoints (HTTP)', () => { + let app: FastifyInstance; + + const get = (url: string) => app.inject({ method: 'GET', url }); + + beforeAll(async () => { + app = Fastify(); + await app.register(PlayersModule.handler, { + prefix: '/players', + gm: fakeGm, + } as any); + await app.ready(); + }); + + beforeEach(() => { + currentAdmin = { id: 1, username: 'Tester', permissions: 0 }; + authed = true; + mockGetRangeActivity.mockClear(); + mockListSessions.mockClear(); + }); + + it('GET /:id/activity returns days + summary over a default ~30-day window', async () => { + const res = await get('/players/10/activity'); + + expect(res.statusCode).toBe(200); + expect(res.json()).toMatchObject({ success: true, data: activityPayload }); + + expect(mockGetRangeActivity).toHaveBeenCalledTimes(1); + const call = mockGetRangeActivity.mock.calls[0]; + expect(call?.[0]).toBe(10); + const from = call?.[1] as Date; + const to = call?.[2] as Date; + expect(from).toBeInstanceOf(Date); + expect(to).toBeInstanceOf(Date); + // default window spans 30 calendar days (29 full days + end-of-today) + expect(Math.round((to.getTime() - from.getTime()) / DAY_MS)).toBe(30); + }); + + it('GET /:id/activity?from&to honours the explicit range', async () => { + const res = await get('/players/10/activity?from=2026-06-01&to=2026-06-30'); + + expect(res.statusCode).toBe(200); + const call = mockGetRangeActivity.mock.calls[0]; + expect(call?.[0]).toBe(10); + const from = call?.[1] as Date; + const to = call?.[2] as Date; + expect(from.getTime()).toBe(new Date('2026-06-01T00:00:00.000').getTime()); + expect(to.getTime()).toBe(new Date('2026-06-30T23:59:59.999').getTime()); + }); + + it('GET /:id/sessions paginates with explicit page/pageSize', async () => { + const res = await get('/players/10/sessions?page=1&pageSize=2'); + + expect(res.statusCode).toBe(200); + expect(res.json()).toMatchObject(sessionsPage); + expect(mockListSessions).toHaveBeenCalledWith(10, 1, 2); + }); + + it('GET /:id/sessions defaults to page 1, pageSize 25', async () => { + const res = await get('/players/10/sessions'); + + expect(res.statusCode).toBe(200); + expect(mockListSessions).toHaveBeenCalledWith(10, 1, 25); + }); + + it('requires auth for activity (401 without a session)', async () => { + authed = false; + const res = await get('/players/10/activity'); + + expect(res.statusCode).toBe(401); + expect(mockGetRangeActivity).not.toHaveBeenCalled(); + }); + + it('requires auth for sessions (401 without a session)', async () => { + authed = false; + const res = await get('/players/10/sessions'); + + expect(res.statusCode).toBe(401); + expect(mockListSessions).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/core/src/routes/api/players.ts b/apps/core/src/routes/api/players.ts index 0d520653..756249c7 100644 --- a/apps/core/src/routes/api/players.ts +++ b/apps/core/src/routes/api/players.ts @@ -7,7 +7,11 @@ import { sessionAuth } from '../../middleware/session'; import { PermissionManager } from '@fxmanager/shared/utils'; import { UserPermissions } from '@fxmanager/shared/constants'; import { repo } from '@fxmanager/database'; -import type { PaginatedResponse, Player } from '@fxmanager/shared/types'; +import type { + PaginatedResponse, + Player, + PlayerSession, +} from '@fxmanager/shared/types'; import { txAdminCompat } from '../../modules/txadmin/compat'; import { buildBannedPayload, @@ -15,6 +19,15 @@ import { } from '../../modules/txadmin/payloads'; import { emitActionRevoked } from '../../modules/txadmin/revoke'; +const startOfDay = (d: Date) => + new Date(d.getFullYear(), d.getMonth(), d.getDate()); +const addDays = (d: Date, n: number) => + new Date(d.getFullYear(), d.getMonth(), d.getDate() + n); +const endOfToday = () => { + const d = new Date(); + return new Date(d.getFullYear(), d.getMonth(), d.getDate(), 23, 59, 59, 999); +}; + const PlayerEndpoints: RouteModule['handler'] = async (fastify, options) => { const { gm } = options; @@ -54,6 +67,44 @@ const PlayerEndpoints: RouteModule['handler'] = async (fastify, options) => { return { success: true, data: profile }; }); + fastify.get('/:playerId/activity', (request) => { + const { playerId: raw } = request.params as { playerId: string }; + const playerId = parseInt(raw, 10); + const { from, to } = (request.query ?? {}) as { + from?: string; + to?: string; + }; + + const toDate = to ? new Date(`${to}T23:59:59.999`) : endOfToday(); + const fromDate = from + ? new Date(`${from}T00:00:00.000`) + : startOfDay(addDays(toDate, -29)); + + const data = repo.playerSessions.getRangeActivity( + playerId, + fromDate, + toDate, + ); + return { success: true, data }; + }); + + fastify.get( + '/:playerId/sessions', + (request): PaginatedResponse => { + const { playerId: raw } = request.params as { playerId: string }; + const playerId = parseInt(raw, 10); + const { page, pageSize } = (request.query ?? {}) as { + page?: string; + pageSize?: string; + }; + return repo.playerSessions.listSessions( + playerId, + Number(page ?? 1), + Number(pageSize ?? 25), + ); + }, + ); + fastify.post('/:playerId/notes', async (request, reply) => { const { playerId: playerIdRaw } = request.params as { playerId: string }; const { content } = request.body as { content: string }; diff --git a/apps/webpanel/src/pages/players/components/activity-heatmap.tsx b/apps/webpanel/src/pages/players/components/activity-heatmap.tsx new file mode 100644 index 00000000..8f63771a --- /dev/null +++ b/apps/webpanel/src/pages/players/components/activity-heatmap.tsx @@ -0,0 +1,254 @@ +import { useEffect, useMemo, useState } from 'react'; +import { + Activity, + CalendarDays, + ChevronLeft, + ChevronRight, + Clock, + Hourglass, + Timer, +} from 'lucide-react'; +import { + Card, + CardContent, + CardHeader, + CardTitle, +} from '@fxmanager/ui/components/card'; +import { Button } from '@fxmanager/ui/components/button'; +import type { ApiResponse, PlayerActivity } from '@fxmanager/shared/types'; +import { QueryService } from '@/lib/query'; +import { formatDuration } from '@/lib/utils'; +import { StatCard } from '@/components/stat-card'; + +const WINDOW = 30; +const MONTHS = [ + 'Jan', + 'Feb', + 'Mar', + 'Apr', + 'May', + 'Jun', + 'Jul', + 'Aug', + 'Sep', + 'Oct', + 'Nov', + 'Dec', +]; +const pad = (n: number) => String(n).padStart(2, '0'); +const keyOf = (d: Date) => + `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`; +const startOfDay = (d: Date) => + new Date(d.getFullYear(), d.getMonth(), d.getDate()); +const addDays = (d: Date, n: number) => + new Date(d.getFullYear(), d.getMonth(), d.getDate() + n); +const shortLabel = (key: string) => { + const [, m, dd] = key.split('-'); + return `${Number(dd)} ${MONTHS[Number(m) - 1]}`; +}; + +// Sequential single-hue (amber, theme --primary) magnitude ramp. Light + dark +// steps are validated with dataviz `validate_palette --ordinal`: monotone +// lightness, ΔL ≥ 0.06, one hue, and the palest step clears 2:1 on its surface. +const LEVEL_CLASS = [ + 'bg-muted', + 'bg-[#dda544] dark:bg-[#805529]', + 'bg-[#c9862b] dark:bg-[#a06a30]', + 'bg-[#ac6c1a] dark:bg-[#c48843]', + 'bg-[#8a520e] dark:bg-[#e6ab5e]', +]; +function level(ms: number): number { + if (ms <= 0) return 0; + if (ms < 3_600_000) return 1; + if (ms < 10_800_000) return 2; + if (ms < 21_600_000) return 3; + return 4; +} + +type Day = { date: string; playtimeMs: number; sessionCount: number }; + +export function ActivityHeatmap({ playerId }: { playerId: number }) { + const [offset, setOffset] = useState(0); // windows back from today + const [data, setData] = useState(null); + const [loading, setLoading] = useState(true); + const [hovered, setHovered] = useState(null); + + const { from, to } = useMemo(() => { + const toDate = startOfDay(addDays(new Date(), -offset * WINDOW)); + return { from: addDays(toDate, -(WINDOW - 1)), to: toDate }; + }, [offset]); + + useEffect(() => { + let cancelled = false; + setLoading(true); + const params = new URLSearchParams({ from: keyOf(from), to: keyOf(to) }); + QueryService>({ + endpoint: `/players/${playerId}/activity?${params}`, + method: 'GET', + }) + .then((res) => { + if (!cancelled && res.success) setData(res.data); + }) + .catch((err) => console.error('Loading activity failed', err)) + .finally(() => !cancelled && setLoading(false)); + return () => { + cancelled = true; + }; + }, [playerId, from, to]); + + const days = useMemo(() => { + const map = new Map(data?.days.map((d) => [d.date, d]) ?? []); + const out: Day[] = []; + for (let d = new Date(from); d <= to; d = addDays(d, 1)) { + const a = map.get(keyOf(d)); + out.push({ + date: keyOf(d), + playtimeMs: a?.playtimeMs ?? 0, + sessionCount: a?.sessionCount ?? 0, + }); + } + return out; + }, [data, from, to]); + + const maxMs = useMemo( + () => Math.max(1, ...days.map((d) => d.playtimeMs)), + [days], + ); + + const s = data?.summary; + const isEmpty = !loading && s && s.daysActive === 0; + const active = hovered != null ? days[hovered] : null; + + return ( + + + + + Activity — last {WINDOW} days + +
+ + {active + ? `${shortLabel(active.date)} · ${ + active.playtimeMs > 0 + ? `${formatDuration(active.playtimeMs)} · ${active.sessionCount} session${active.sessionCount > 1 ? 's' : ''}` + : 'no activity' + }` + : offset === 0 + ? '' + : `${keyOf(from)} → ${keyOf(to)}`} + +
+ + +
+
+
+ +
+ + + + +
+ + {isEmpty ? ( +
+ +

+ No activity in this window — history collects going forward. +

+
+ ) : ( +
+ {/* biome-ignore lint/a11y/noStaticElementInteractions: chart hover, not an interactive control */} +
setHovered(null)} + > + {days.map((d, i) => { + const pct = + d.playtimeMs > 0 + ? Math.max(6, (d.playtimeMs / maxMs) * 100) + : 0; + const dimmed = hovered != null && hovered !== i; + return ( + // biome-ignore lint/a11y/noStaticElementInteractions: chart column hover +
setHovered(i)} + title={`${d.date}: ${ + d.playtimeMs > 0 + ? `${formatDuration(d.playtimeMs)} · ${d.sessionCount} session(s)` + : 'no activity' + }`} + > +
0 ? `${pct}%` : '3px' }} + /> +
+ ); + })} +
+
+ {days.map((d, i) => ( +
+ {i % 5 === 0 ? shortLabel(d.date) : ''} +
+ ))} +
+
+ )} + +
+ Less + {LEVEL_CLASS.map((c) => ( + + ))} + More +
+ + + ); +} diff --git a/apps/webpanel/src/pages/players/components/tab-elements.tsx b/apps/webpanel/src/pages/players/components/tab-elements.tsx index 741ea3d1..ce250e6a 100644 --- a/apps/webpanel/src/pages/players/components/tab-elements.tsx +++ b/apps/webpanel/src/pages/players/components/tab-elements.tsx @@ -9,6 +9,7 @@ import { import { AlertTriangle, Ban, + Clock, FileText, Flag, Hammer, @@ -16,9 +17,11 @@ import { StickyNote, Undo2, } from 'lucide-react'; -import { useState } from 'react'; -import { formatDate } from '@/lib/utils'; +import { useEffect, useState } from 'react'; +import { formatDate, formatDuration } from '@/lib/utils'; import { QueryService } from '@/lib/query'; +import PageSelector from '@/components/page-selector'; +import PageSizeSelector from '@/components/page-size-selector'; import { Badge } from '@fxmanager/ui/components/badge'; import { Card, @@ -38,7 +41,12 @@ import { AlertDialogTrigger, } from '@fxmanager/ui/components/alert-dialog'; import type { PlayerProfile } from '@fxmanager/database/types'; -import type { ApiResponse, RevokeActionType } from '@fxmanager/shared/types'; +import type { + ApiResponse, + PaginatedResponse, + PlayerSession, + RevokeActionType, +} from '@fxmanager/shared/types'; import { useAuth } from '@/hooks/use-auth'; import { PermissionManager } from '@fxmanager/shared/utils'; import { UserPermissions } from '@fxmanager/shared/constants'; @@ -394,6 +402,88 @@ export function NotesTab({ notes }: { notes: PlayerProfile['notes'] }) { ); } +export function SessionsTab({ playerId }: { playerId: number }) { + const [page, setPage] = useState(1); + const [pageSize, setPageSize] = useState(20); + const [items, setItems] = useState([]); + const [total, setTotal] = useState(0); + const [loading, setLoading] = useState(true); + + useEffect(() => { + let cancelled = false; + setLoading(true); + const params = new URLSearchParams({ + page: String(page), + pageSize: String(pageSize), + }); + QueryService>({ + endpoint: `/players/${playerId}/sessions?${params}`, + method: 'GET', + }) + .then((res) => { + if (cancelled) return; + setItems(res.items); + setTotal(res.total); + }) + .catch((err) => console.error('Loading sessions failed', err)) + .finally(() => !cancelled && setLoading(false)); + return () => { + cancelled = true; + }; + }, [playerId, page, pageSize]); + + if (!loading && !items.length) + return ; + + return ( + <> + + + + Connected + Duration + Ended + + + + {items.map((s) => ( + + {formatDate(new Date(s.connectedAt))} + + {s.durationMs == null ? ( + In progress + ) : ( + formatDuration(s.durationMs) + )} + + + {s.endReason ?? '—'} + + + ))} + +
+
+ { + setPageSize(n); + setPage(1); + }} + label="Sessions per page" + /> + +
+ + ); +} + function EmptyState({ icon: Icon, message, diff --git a/apps/webpanel/src/pages/players/playerview.tsx b/apps/webpanel/src/pages/players/playerview.tsx index ddb698d5..3990aca6 100644 --- a/apps/webpanel/src/pages/players/playerview.tsx +++ b/apps/webpanel/src/pages/players/playerview.tsx @@ -43,8 +43,10 @@ import { KicksTab, NotesTab, ReportsTab, + SessionsTab, WarnsTab, } from './components/tab-elements'; +import { ActivityHeatmap } from './components/activity-heatmap'; import { usePlayerAction } from '@/hooks/use-player-actions'; import { PlayerActionDialog, @@ -125,7 +127,7 @@ export default function PlayerView() { }, [params.playerId]); function handleTabChange(tab: string) { - if (tab === 'report') return; + if (tab === 'report' || tab === 'session') return; setActionTab(tab as ActionTab); } @@ -228,6 +230,8 @@ export default function PlayerView() { />
+ + @@ -317,6 +321,10 @@ export default function PlayerView() { )} + + + Sessions + @@ -370,6 +378,14 @@ export default function PlayerView() {
+ + + + + + + + diff --git a/packages/database/src/index.ts b/packages/database/src/index.ts index 7d27697a..b80f2da6 100644 --- a/packages/database/src/index.ts +++ b/packages/database/src/index.ts @@ -18,6 +18,7 @@ import { createMigrateRepository } from './repositories/migrate'; import { createDisconnectsRepository } from './repositories/disconnects'; import { createServerSessionsRepository } from './repositories/server-sessions'; import { createPerfSnapshotsRepository } from './repositories/perf-snapshots'; +import { createPlayerSessionsRepository } from './repositories/player-sessions'; export type { Migration } from './migrations/types'; export type { ImportSummary } from './import/txadmin.importer'; @@ -88,4 +89,5 @@ export const repo = { serverSessions: createServerSessionsRepository(db), disconnects: createDisconnectsRepository(db), perfSnapshots: createPerfSnapshotsRepository(db), + playerSessions: createPlayerSessionsRepository(db), }; diff --git a/packages/database/src/migrations/migrations/0007_player_sessions.ts b/packages/database/src/migrations/migrations/0007_player_sessions.ts new file mode 100644 index 00000000..ff7e9525 --- /dev/null +++ b/packages/database/src/migrations/migrations/0007_player_sessions.ts @@ -0,0 +1,20 @@ +import type { Migration } from '../types'; + +export const m0007_player_sessions: Migration = { + version: 7, + description: 'Add player_sessions for per-session activity + heat map', + up: [ + `CREATE TABLE \`player_sessions\` ( + \`id\` integer PRIMARY KEY AUTOINCREMENT NOT NULL, + \`player_id\` integer NOT NULL, + \`server_session_id\` integer, + \`connected_at\` integer NOT NULL, + \`disconnected_at\` integer, + \`duration_ms\` integer, + \`end_reason\` text, + FOREIGN KEY (\`player_id\`) REFERENCES \`players\`(\`id\`) ON UPDATE no action ON DELETE cascade, + FOREIGN KEY (\`server_session_id\`) REFERENCES \`server_sessions\`(\`id\`) ON UPDATE no action ON DELETE set null +)`, + `CREATE INDEX \`player_session_player_connected_idx\` ON \`player_sessions\` (\`player_id\`, \`connected_at\`)`, + ], +}; diff --git a/packages/database/src/migrations/migrations/index.ts b/packages/database/src/migrations/migrations/index.ts index bf2a3337..1f0c45a0 100644 --- a/packages/database/src/migrations/migrations/index.ts +++ b/packages/database/src/migrations/migrations/index.ts @@ -1,3 +1,4 @@ +import { m0007_player_sessions } from './0007_player_sessions'; import { m0006_admin_groups } from './0006_admin_groups'; import { m0005_disconnect_events } from './0005_disconnect_events'; import { m0004_disconnect_sessions } from './0004_disconnect_sessions'; @@ -31,4 +32,5 @@ export const migrations: Migration[] = [ m0004_disconnect_sessions, m0005_disconnect_events, m0006_admin_groups, + m0007_player_sessions, ]; diff --git a/packages/database/src/repositories/player-sessions.test.ts b/packages/database/src/repositories/player-sessions.test.ts new file mode 100644 index 00000000..8f7672cc --- /dev/null +++ b/packages/database/src/repositories/player-sessions.test.ts @@ -0,0 +1,112 @@ +/** biome-ignore-all lint/suspicious/noExplicitAny: clearing private singletons */ +import { afterAll, beforeEach, describe, expect, it, spyOn } from 'bun:test'; +import { Database } from 'bun:sqlite'; +import { drizzle } from 'drizzle-orm/bun-sqlite'; +import * as schema from '../schema'; +import { players, serverSessions } from '../schema'; +import { migrations, runMigrations } from '../migrations'; +import { createPlayerSessionsRepository } from './player-sessions'; + +describe('PlayerSessionsRepository', () => { + const logSpy = spyOn(console, 'log').mockImplementation(() => {}); + let sqlite: Database; + let db: ReturnType>; + let repo: ReturnType; + + const seedPlayer = (name = 'Alice') => + db.insert(players).values({ name }).returning().get().id; + + beforeEach(() => { + logSpy.mockClear(); + const zero = createPlayerSessionsRepository({} as any); + (zero.constructor as any).instance = undefined; + + sqlite = new Database(':memory:'); + sqlite.run('PRAGMA foreign_keys = ON;'); + runMigrations(sqlite, migrations); + db = drizzle(sqlite, { schema }); + repo = createPlayerSessionsRepository(db); + }); + + afterAll(() => logSpy.mockRestore()); + + it('open() creates an in-progress row (null disconnect/duration)', () => { + const pid = seedPlayer(); + const s = repo.open(pid, null, new Date(1_000_000)); + expect(s.connectedAt).toBe(1_000_000); + expect(s.disconnectedAt).toBeNull(); + expect(s.durationMs).toBeNull(); + }); + + it('close() sets disconnect + duration on the latest open row', () => { + const pid = seedPlayer(); + repo.open(pid, null, new Date(1_000_000)); + const closed = repo.close(pid, 'quit', new Date(1_060_000))!; + expect(closed.disconnectedAt).toBe(1_060_000); + expect(closed.durationMs).toBe(60_000); + expect(closed.endReason).toBe('quit'); + }); + + it('close() returns null when the player has no open row', () => { + const pid = seedPlayer(); + expect(repo.close(pid, 'quit')).toBeNull(); + }); + + it('closeDangling() closes orphans using their server_session end time', () => { + const pid = seedPlayer(); + const ss = db + .insert(serverSessions) + .values({ startedAt: new Date(1_000_000), endedAt: new Date(1_500_000) }) + .returning() + .get(); + repo.open(pid, ss.id, new Date(1_000_000)); + repo.closeDangling(new Date(9_000_000)); + const [row] = repo.listSessions(pid, 1, 10).items; + expect(row.disconnectedAt).toBe(1_500_000); + expect(row.durationMs).toBe(500_000); + expect(row.endReason).toBe('reconciled'); + }); + + it('closeDangling() falls back to the provided time when no server session', () => { + const pid = seedPlayer(); + repo.open(pid, null, new Date(1_000_000)); + repo.closeDangling(new Date(2_000_000)); + expect(repo.listSessions(pid, 1, 10).items[0].durationMs).toBe(1_000_000); + }); + + it('getRangeActivity() buckets playtime by start-day and summarises', () => { + const pid = seedPlayer(); + // Two sessions same day, one another day, one outside the range. + const day1a = new Date(2026, 6, 1, 10, 0); + const day1b = new Date(2026, 6, 1, 20, 0); + const day2 = new Date(2026, 6, 3, 12, 0); + const outside = new Date(2026, 5, 1, 12, 0); + for (const c of [day1a, day1b, day2, outside]) { + repo.open(pid, null, c); + repo.close(pid, 'quit', new Date(c.getTime() + 3_600_000)); // 1h each + } + const act = repo.getRangeActivity( + pid, + new Date(2026, 6, 1, 0, 0), + new Date(2026, 6, 31, 23, 59), + ); + expect(act.summary.daysActive).toBe(2); + expect(act.summary.totalPlaytimeMs).toBe(3 * 3_600_000); + expect(act.summary.longestSessionMs).toBe(3_600_000); + const d1 = act.days.find((d) => d.date === '2026-07-01')!; + expect(d1.playtimeMs).toBe(2 * 3_600_000); + expect(d1.sessionCount).toBe(2); + }); + + it('listSessions() paginates newest-first with total', () => { + const pid = seedPlayer(); + for (let i = 1; i <= 3; i++) { + repo.open(pid, null, new Date(i * 1_000_000)); + repo.close(pid, 'quit', new Date(i * 1_000_000 + 1000)); + } + const page = repo.listSessions(pid, 1, 2); + expect(page.total).toBe(3); + expect(page.items).toHaveLength(2); + expect(page.items[0].connectedAt).toBe(3_000_000); // newest first + }); +}); diff --git a/packages/database/src/repositories/player-sessions.ts b/packages/database/src/repositories/player-sessions.ts new file mode 100644 index 00000000..b8371a5f --- /dev/null +++ b/packages/database/src/repositories/player-sessions.ts @@ -0,0 +1,201 @@ +import { + and, + count, + desc, + eq, + gte, + isNotNull, + isNull, + lte, +} from 'drizzle-orm'; +import type { BunSQLiteDatabase } from 'drizzle-orm/bun-sqlite'; +import type { + PaginatedResponse, + PlayerActivity, + PlayerSession, +} from '@fxmanager/shared/types'; +import { playerSessions, serverSessions } from '../schema'; +import type * as schema from '../schema'; + +type DB = BunSQLiteDatabase; +type Row = typeof playerSessions.$inferSelect; + +const pad = (n: number) => String(n).padStart(2, '0'); +const localDateKey = (d: Date) => + `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`; + +const toApi = (row: Row): PlayerSession => ({ + id: row.id, + connectedAt: row.connectedAt.getTime(), + disconnectedAt: row.disconnectedAt ? row.disconnectedAt.getTime() : null, + durationMs: row.durationMs, + endReason: row.endReason, +}); + +class PlayerSessionsRepository { + private static instance: PlayerSessionsRepository; + private constructor(private readonly db: DB) {} + + static getInstance(db: DB): PlayerSessionsRepository { + if (!PlayerSessionsRepository.instance) { + PlayerSessionsRepository.instance = new PlayerSessionsRepository(db); + } + return PlayerSessionsRepository.instance; + } + + open( + playerId: number, + serverSessionId: number | null, + connectedAt = new Date(), + ): PlayerSession { + const row = this.db + .insert(playerSessions) + .values({ playerId, serverSessionId, connectedAt }) + .returning() + .get(); + return toApi(row); + } + + close( + playerId: number, + endReason: string | null = null, + disconnectedAt = new Date(), + ): PlayerSession | null { + const open = this.db + .select() + .from(playerSessions) + .where( + and( + eq(playerSessions.playerId, playerId), + isNull(playerSessions.disconnectedAt), + ), + ) + .orderBy(desc(playerSessions.connectedAt), desc(playerSessions.id)) + .limit(1) + .get(); + if (!open) return null; + + const durationMs = Math.max( + 0, + disconnectedAt.getTime() - open.connectedAt.getTime(), + ); + this.db + .update(playerSessions) + .set({ disconnectedAt, durationMs, endReason }) + .where(eq(playerSessions.id, open.id)) + .run(); + return this.get(open.id); + } + + /** Close crash/restart orphans (endedAt null) using their server session's end. */ + closeDangling(fallbackEndedAt = new Date()): void { + const dangling = this.db + .select() + .from(playerSessions) + .where(isNull(playerSessions.disconnectedAt)) + .all(); + + for (const row of dangling) { + const ss = row.serverSessionId + ? this.db + .select() + .from(serverSessions) + .where(eq(serverSessions.id, row.serverSessionId)) + .get() + : null; + const endedAt = ss?.endedAt ?? fallbackEndedAt; + const durationMs = Math.max( + 0, + endedAt.getTime() - row.connectedAt.getTime(), + ); + this.db + .update(playerSessions) + .set({ disconnectedAt: endedAt, durationMs, endReason: 'reconciled' }) + .where(eq(playerSessions.id, row.id)) + .run(); + } + } + + get(id: number): PlayerSession | null { + const row = this.db + .select() + .from(playerSessions) + .where(eq(playerSessions.id, id)) + .get(); + return row ? toApi(row) : null; + } + + /** Day-bucketed playtime + summary over [from, to], attributed to start-day. */ + getRangeActivity(playerId: number, from: Date, to: Date): PlayerActivity { + const rows = this.db + .select() + .from(playerSessions) + .where( + and( + eq(playerSessions.playerId, playerId), + gte(playerSessions.connectedAt, from), + lte(playerSessions.connectedAt, to), + isNotNull(playerSessions.durationMs), + ), + ) + .all(); + + const byDay = new Map(); + let total = 0; + let longest = 0; + for (const r of rows) { + const key = localDateKey(r.connectedAt); + const b = byDay.get(key) ?? { playtimeMs: 0, sessionCount: 0 }; + const d = r.durationMs ?? 0; + b.playtimeMs += d; + b.sessionCount += 1; + byDay.set(key, b); + total += d; + if (d > longest) longest = d; + } + + const days = [...byDay.entries()] + .map(([date, v]) => ({ date, ...v })) + .sort((a, b) => a.date.localeCompare(b.date)); + + return { + from: localDateKey(from), + to: localDateKey(to), + days, + summary: { + daysActive: byDay.size, + totalPlaytimeMs: total, + longestSessionMs: longest, + avgSessionMs: rows.length ? Math.round(total / rows.length) : 0, + }, + }; + } + + listSessions( + playerId: number, + page = 1, + pageSize = 25, + ): PaginatedResponse { + const offset = (page - 1) * pageSize; + const items = this.db + .select() + .from(playerSessions) + .where(eq(playerSessions.playerId, playerId)) + .orderBy(desc(playerSessions.connectedAt), desc(playerSessions.id)) + .limit(pageSize) + .offset(offset) + .all() + .map(toApi); + const total = + this.db + .select({ c: count() }) + .from(playerSessions) + .where(eq(playerSessions.playerId, playerId)) + .get()?.c ?? 0; + return { items, total, page, pageSize }; + } +} + +export function createPlayerSessionsRepository(db: DB) { + return PlayerSessionsRepository.getInstance(db); +} diff --git a/packages/database/src/schema.ts b/packages/database/src/schema.ts index 31381688..8fb154fe 100644 --- a/packages/database/src/schema.ts +++ b/packages/database/src/schema.ts @@ -284,6 +284,30 @@ export const disconnectEvents = sqliteTable( (t) => [index('disconnect_event_session_ts_idx').on(t.sessionId, t.ts)], ); +export const playerSessions = sqliteTable( + 'player_sessions', + { + id: integer('id').primaryKey({ autoIncrement: true }), + playerId: integer('player_id') + .notNull() + .references(() => players.id, { onDelete: 'cascade' }), + serverSessionId: integer('server_session_id').references( + () => serverSessions.id, + { onDelete: 'set null' }, + ), + connectedAt: integer('connected_at', { mode: 'timestamp' }).notNull(), + disconnectedAt: integer('disconnected_at', { mode: 'timestamp' }), + durationMs: integer('duration_ms'), + endReason: text('end_reason'), + }, + (t) => [ + index('player_session_player_connected_idx').on( + t.playerId, + t.connectedAt, + ), + ], +); + // region Relations export const playersRelations = relations(players, ({ many, one }) => ({ @@ -293,6 +317,7 @@ export const playersRelations = relations(players, ({ many, one }) => ({ kicks: many(kicks), notes: many(playerNotes), reports: many(reports), + sessions: many(playerSessions), adminProfile: one(adminUsers, { fields: [players.id], references: [adminUsers.playerId], @@ -393,3 +418,14 @@ export const reportsRelations = relations(reports, ({ one }) => ({ references: [players.id], }), })); + +export const playerSessionsRelations = relations(playerSessions, ({ one }) => ({ + player: one(players, { + fields: [playerSessions.playerId], + references: [players.id], + }), + serverSession: one(serverSessions, { + fields: [playerSessions.serverSessionId], + references: [serverSessions.id], + }), +})); diff --git a/packages/shared/src/types/players.ts b/packages/shared/src/types/players.ts index 1eca7528..57a11ce7 100644 --- a/packages/shared/src/types/players.ts +++ b/packages/shared/src/types/players.ts @@ -37,3 +37,31 @@ export interface NoteForm { } export type RevokeActionType = 'ban' | 'kick' | 'warn'; + +export interface PlayerSession { + id: number; + connectedAt: number; + disconnectedAt: number | null; + durationMs: number | null; + endReason: string | null; +} + +export interface PlayerActivityDay { + date: string; // YYYY-MM-DD, server-local + playtimeMs: number; + sessionCount: number; +} + +export interface PlayerActivitySummary { + daysActive: number; + totalPlaytimeMs: number; + longestSessionMs: number; + avgSessionMs: number; +} + +export interface PlayerActivity { + from: string; // YYYY-MM-DD + to: string; // YYYY-MM-DD + days: PlayerActivityDay[]; + summary: PlayerActivitySummary; +} From 1b96584f548fd8ce14e2e627f40490a685b8c743 Mon Sep 17 00:00:00 2001 From: Maximus7474 Date: Sun, 5 Jul 2026 22:52:20 +0200 Subject: [PATCH 2/2] tweak(webpanel/pages/players): layout of activity timeframe for better ux --- .../players/components/activity-heatmap.tsx | 31 +++++++++++-------- 1 file changed, 18 insertions(+), 13 deletions(-) diff --git a/apps/webpanel/src/pages/players/components/activity-heatmap.tsx b/apps/webpanel/src/pages/players/components/activity-heatmap.tsx index 8f63771a..dfc66b87 100644 --- a/apps/webpanel/src/pages/players/components/activity-heatmap.tsx +++ b/apps/webpanel/src/pages/players/components/activity-heatmap.tsx @@ -1,6 +1,7 @@ import { useEffect, useMemo, useState } from 'react'; import { Activity, + ArrowRight, CalendarDays, ChevronLeft, ChevronRight, @@ -121,23 +122,12 @@ export function ActivityHeatmap({ playerId }: { playerId: number }) { return ( - + - Activity — last {WINDOW} days + Activity - last {WINDOW} days
- - {active - ? `${shortLabel(active.date)} · ${ - active.playtimeMs > 0 - ? `${formatDuration(active.playtimeMs)} · ${active.sessionCount} session${active.sessionCount > 1 ? 's' : ''}` - : 'no activity' - }` - : offset === 0 - ? '' - : `${keyOf(from)} → ${keyOf(to)}`} -
+ + {active ? ( + `${shortLabel(active.date)} · ${ + active.playtimeMs > 0 + ? `${formatDuration(active.playtimeMs)} · ${active.sessionCount} session${active.sessionCount > 1 ? 's' : ''}` + : 'no activity' + }` + ) : ( + <> + {keyOf(from)} + + {keyOf(to)} + + )} +