diff --git a/pb_migrations/1760127117_updated_players.js b/pb_migrations/1760127117_updated_players.js new file mode 100644 index 0000000..f748717 --- /dev/null +++ b/pb_migrations/1760127117_updated_players.js @@ -0,0 +1,26 @@ +/// +migrate((app) => { + const collection = app.findCollectionByNameOrId("pbc_3072146508") + + // add field + collection.fields.addAt(5, new Field({ + "hidden": false, + "id": "date3558165700", + "max": "", + "min": "", + "name": "last_activity", + "presentable": false, + "required": false, + "system": false, + "type": "date" + })) + + return app.save(collection) +}, (app) => { + const collection = app.findCollectionByNameOrId("pbc_3072146508") + + // remove field + collection.fields.removeById("date3558165700") + + return app.save(collection) +}) diff --git a/src/app/routes/_authed/admin/activities.tsx b/src/app/routes/_authed/admin/activities.tsx index e9141d1..e1ee2ba 100644 --- a/src/app/routes/_authed/admin/activities.tsx +++ b/src/app/routes/_authed/admin/activities.tsx @@ -1,12 +1,16 @@ import { createFileRoute } from "@tanstack/react-router"; import { prefetchServerQuery } from "@/lib/tanstack-query/utils/prefetch"; import { ActivitiesTable, activityQueries } from "@/features/activities"; +import { PlayersActivityTable, playerQueries } from "@/features/players"; +import { Tabs } from "@mantine/core"; +import { useState } from "react"; export const Route = createFileRoute("/_authed/admin/activities")({ component: Stats, beforeLoad: ({ context }) => { const queryClient = context.queryClient; prefetchServerQuery(queryClient, activityQueries.search()); + prefetchServerQuery(queryClient, playerQueries.activity()); }, loader: () => ({ withPadding: false, @@ -15,10 +19,27 @@ export const Route = createFileRoute("/_authed/admin/activities")({ title: "Activities", withBackButton: true, }, - refresh: [activityQueries.search().queryKey], + refresh: [activityQueries.search().queryKey, playerQueries.activity().queryKey], }), }); function Stats() { - return ; + const [activeTab, setActiveTab] = useState("server-functions"); + + return ( + + + Server Functions + Player Activity + + + + + + + + + + + ); } diff --git a/src/components/button.tsx b/src/components/button.tsx index 68b6dab..9224a52 100644 --- a/src/components/button.tsx +++ b/src/components/button.tsx @@ -10,5 +10,4 @@ const Button = forwardRef((props, ref) => { return ; }); -Button.displayName = "Button"; export default Button; diff --git a/src/features/activities/components/activities-table.tsx b/src/features/activities/components/activities-table.tsx index 2b07a88..dbbb381 100644 --- a/src/features/activities/components/activities-table.tsx +++ b/src/features/activities/components/activities-table.tsx @@ -92,8 +92,6 @@ const ActivityListItem = memo(({ activity, onClick }: ActivityListItemProps) => ); }); -ActivityListItem.displayName = "ActivityListItem"; - interface ActivityDetailsSheetProps { activity: Activity | null; isOpen: boolean; @@ -205,8 +203,6 @@ const ActivityDetailsSheet = memo(({ activity, isOpen, onClose }: ActivityDetail ); }); -ActivityDetailsSheet.displayName = "ActivityDetailsSheet"; - const ActivitiesResults = ({ searchParams, page, setPage, onActivityClick }: any) => { const { data: result } = useActivities(searchParams); return ( diff --git a/src/features/players/components/player-stats-table.tsx b/src/features/players/components/player-stats-table.tsx index f16d95b..8195f36 100644 --- a/src/features/players/components/player-stats-table.tsx +++ b/src/features/players/components/player-stats-table.tsx @@ -141,8 +141,6 @@ const PlayerListItem = memo(({ stat, onPlayerClick, mmr }: PlayerListItemProps) ); }); -PlayerListItem.displayName = 'PlayerListItem'; - const PlayerStatsTable = () => { const { data: playerStats } = useAllPlayerStats(); const navigate = useNavigate(); diff --git a/src/features/players/components/players-activity-table.tsx b/src/features/players/components/players-activity-table.tsx new file mode 100644 index 0000000..ba64f8f --- /dev/null +++ b/src/features/players/components/players-activity-table.tsx @@ -0,0 +1,118 @@ +import { memo } from "react"; +import { + Text, + Stack, + Group, + Box, + Container, + Divider, + UnstyledButton, +} from "@mantine/core"; +import { Player } from "../types"; +import { usePlayersActivity } from "../queries"; + +interface PlayerActivityItemProps { + player: Player; +} + +const PlayerActivityItem = memo(({ player }: PlayerActivityItemProps) => { + const playerName = player.first_name && player.last_name + ? `${player.first_name} ${player.last_name}` + : player.first_name || player.last_name || "Unknown Player"; + + const formatDate = (dateStr?: string) => { + if (!dateStr) return "Never"; + const date = new Date(dateStr); + return date.toLocaleString(); + }; + + const getTimeSince = (dateStr?: string) => { + if (!dateStr) return "Never active"; + const date = new Date(dateStr); + const now = new Date(); + const diffMs = now.getTime() - date.getTime(); + const diffMins = Math.floor(diffMs / 60000); + const diffHours = Math.floor(diffMins / 60); + const diffDays = Math.floor(diffHours / 24); + + if (diffMins < 1) return "Just now"; + if (diffMins < 60) return `${diffMins}m ago`; + if (diffHours < 24) return `${diffHours}h ago`; + if (diffDays < 30) return `${diffDays}d ago`; + return formatDate(dateStr); + }; + + const isActive = player.last_activity && + (new Date().getTime() - new Date(player.last_activity).getTime()) < 5 * 60 * 1000; + + return ( + + + + + + {playerName} + + {isActive && ( + + )} + + + + {getTimeSince(player.last_activity)} + + {player.last_activity && ( + + {formatDate(player.last_activity)} + + )} + + + + + ); +}); + +export const PlayersActivityTable = () => { + const { data: players } = usePlayersActivity(); + + return ( + + + + + {players.length} players + + + + + {players.map((player: Player, index: number) => ( + + + {index < players.length - 1 && } + + ))} + + + {players.length === 0 && ( + + No player activity found + + )} + + + ); +}; diff --git a/src/features/players/index.ts b/src/features/players/index.ts index d847586..b551ff7 100644 --- a/src/features/players/index.ts +++ b/src/features/players/index.ts @@ -1,3 +1,5 @@ import { Logger } from "@/lib/logger"; -export const logger = new Logger('Players'); \ No newline at end of file +export const logger = new Logger('Players'); +export * from "./queries"; +export { PlayersActivityTable } from "./components/players-activity-table"; \ No newline at end of file diff --git a/src/features/players/queries.ts b/src/features/players/queries.ts index ee4170f..3a871b4 100644 --- a/src/features/players/queries.ts +++ b/src/features/players/queries.ts @@ -1,5 +1,5 @@ import { useServerSuspenseQuery } from "@/lib/tanstack-query/hooks"; -import { listPlayers, getPlayer, getUnassociatedPlayers, fetchMe, getPlayerStats, getAllPlayerStats, getPlayerMatches, getUnenrolledPlayers } from "./server"; +import { listPlayers, getPlayer, getUnassociatedPlayers, fetchMe, getPlayerStats, getAllPlayerStats, getPlayerMatches, getUnenrolledPlayers, getPlayersActivity } from "./server"; export const playerKeys = { auth: ['auth'], @@ -10,6 +10,7 @@ export const playerKeys = { stats: (id: string) => ['players', 'stats', id], allStats: ['players', 'stats', 'all'], matches: (id: string) => ['players', 'matches', id], + activity: ['players', 'activity'], }; export const playerQueries = { @@ -45,6 +46,10 @@ export const playerQueries = { queryKey: playerKeys.matches(id), queryFn: async () => await getPlayerMatches({ data: id }) }), + activity: () => ({ + queryKey: playerKeys.activity, + queryFn: async () => await getPlayersActivity() + }), }; export const useMe = () => { @@ -89,4 +94,7 @@ export const usePlayerMatches = (id: string) => useServerSuspenseQuery(playerQueries.matches(id)); export const useUnenrolledPlayers = (tournamentId: string) => - useServerSuspenseQuery(playerQueries.unenrolled(tournamentId)); \ No newline at end of file + useServerSuspenseQuery(playerQueries.unenrolled(tournamentId)); + +export const usePlayersActivity = () => + useServerSuspenseQuery(playerQueries.activity()); \ No newline at end of file diff --git a/src/features/players/server.ts b/src/features/players/server.ts index d316b0d..399dba3 100644 --- a/src/features/players/server.ts +++ b/src/features/players/server.ts @@ -161,3 +161,9 @@ export const getUnenrolledPlayers = createServerFn() .handler(async ({ data: tournamentId }) => toServerResult(async () => await pbAdmin.getUnenrolledPlayers(tournamentId)) ); + +export const getPlayersActivity = createServerFn() + .middleware([superTokensFunctionMiddleware]) + .handler(async () => + toServerResult(async () => await pbAdmin.getPlayersActivity()) + ); diff --git a/src/features/players/types.ts b/src/features/players/types.ts index f850715..bf7bba0 100644 --- a/src/features/players/types.ts +++ b/src/features/players/types.ts @@ -14,6 +14,7 @@ export interface Player { last_name?: string; created?: string; updated?: string; + last_activity?: string; teams?: TeamInfo[]; } @@ -23,7 +24,9 @@ export const playerInputSchema = z.object({ last_name: z.string().min(2).max(20).regex(/^[a-zA-Z0-9\s]+$/, "Last name must be 2-20 characters long and contain only letters and spaces"), }); -export const playerUpdateSchema = playerInputSchema.partial(); +export const playerUpdateSchema = playerInputSchema.extend({ + last_activity: z.string().optional(), +}).partial(); export type PlayerInput = z.infer; export type PlayerUpdateInput = z.infer; diff --git a/src/features/tournaments/components/upcoming-tournament/enroll-team/team-selection-view.tsx b/src/features/tournaments/components/upcoming-tournament/enroll-team/team-selection-view.tsx index 95a3bb7..d4e3956 100644 --- a/src/features/tournaments/components/upcoming-tournament/enroll-team/team-selection-view.tsx +++ b/src/features/tournaments/components/upcoming-tournament/enroll-team/team-selection-view.tsx @@ -79,6 +79,4 @@ const TeamSelectionView: React.FC = React.memo(({ ); }); -TeamSelectionView.displayName = 'TeamSelectionView'; - export default TeamSelectionView; \ No newline at end of file diff --git a/src/lib/pocketbase/services/players.ts b/src/lib/pocketbase/services/players.ts index cdd2783..89a720d 100644 --- a/src/lib/pocketbase/services/players.ts +++ b/src/lib/pocketbase/services/players.ts @@ -166,5 +166,13 @@ export function createPlayersService(pb: PocketBase) { return allPlayers.map(transformPlayer); } }, + + async getPlayersActivity(): Promise { + const result = await pb.collection("players").getFullList({ + sort: "-last_activity", + fields: "id,first_name,last_name,auth_id,last_activity", + }); + return result.map(transformPlayer); + }, }; } diff --git a/src/lib/pocketbase/util/transform-types.ts b/src/lib/pocketbase/util/transform-types.ts index b80a4e0..03c583d 100644 --- a/src/lib/pocketbase/util/transform-types.ts +++ b/src/lib/pocketbase/util/transform-types.ts @@ -128,6 +128,7 @@ export function transformPlayer(record: any): Player { auth_id: record.auth_id, created: record.created, updated: record.updated, + last_activity: record.last_activity, teams, }; } diff --git a/src/utils/supertokens.ts b/src/utils/supertokens.ts index 8d7efc1..faeba78 100644 --- a/src/utils/supertokens.ts +++ b/src/utils/supertokens.ts @@ -11,6 +11,7 @@ import { getSessionForStart } from "@/lib/supertokens/recipes/start-session"; import { Logger } from "@/lib/logger"; import z from "zod"; import { serverFnLoggingMiddleware } from "./activities"; +import { pbAdmin } from "@/lib/pocketbase/client"; const logger = new Logger("Middleware"); const verifySuperTokensSession = async ( @@ -75,6 +76,17 @@ export const getSessionContext = createServerOnlyFn(async (request: Request, opt phone: session.context.phone }; + try { + const player = await pbAdmin.getPlayerByAuthId(session.context.userAuthId); + if (player) { + await pbAdmin.updatePlayer(player.id, { + last_activity: new Date().toISOString(), + }); + } + } catch (error) { + logger.error("Failed to update player last_activity", error); + } + return context; });