diff --git a/pb_migrations/1759344931_deleted_player_badges_view.js b/pb_migrations/1759344931_deleted_player_badges_view.js deleted file mode 100644 index de1bd90..0000000 --- a/pb_migrations/1759344931_deleted_player_badges_view.js +++ /dev/null @@ -1,187 +0,0 @@ -/// -migrate((app) => { - const collection = app.findCollectionByNameOrId("pbc_5062686152"); - - return app.delete(collection); -}, (app) => { - const collection = new Collection({ - "createRule": null, - "deleteRule": null, - "fields": [ - { - "autogeneratePattern": "", - "hidden": false, - "id": "text3208210256", - "max": 0, - "min": 0, - "name": "id", - "pattern": "^[a-z0-9]+$", - "presentable": false, - "primaryKey": true, - "required": true, - "system": true, - "type": "text" - }, - { - "cascadeDelete": false, - "collectionId": "pbc_3072146508", - "hidden": false, - "id": "relation2582050271", - "maxSelect": 1, - "minSelect": 0, - "name": "player_id", - "presentable": false, - "required": false, - "system": false, - "type": "relation" - }, - { - "cascadeDelete": false, - "collectionId": "pbc_1340419796", - "hidden": false, - "id": "relation4154639100", - "maxSelect": 1, - "minSelect": 0, - "name": "badge_id", - "presentable": false, - "required": false, - "system": false, - "type": "relation" - }, - { - "autogeneratePattern": "", - "hidden": false, - "id": "_clone_GhrR", - "max": 0, - "min": 0, - "name": "badge_name", - "pattern": "", - "presentable": false, - "primaryKey": false, - "required": true, - "system": false, - "type": "text" - }, - { - "autogeneratePattern": "", - "hidden": false, - "id": "_clone_DEaW", - "max": 0, - "min": 0, - "name": "badge_description", - "pattern": "", - "presentable": false, - "primaryKey": false, - "required": true, - "system": false, - "type": "text" - }, - { - "hidden": false, - "id": "_clone_MHmw", - "maxSelect": 1, - "name": "badge_type", - "presentable": false, - "required": true, - "system": false, - "type": "select", - "values": [ - "tournament_participation", - "tournament_placement", - "performance", - "overtime", - "match_milestone" - ] - }, - { - "autogeneratePattern": "", - "hidden": false, - "id": "_clone_11YE", - "max": 50, - "min": 0, - "name": "badge_icon", - "pattern": "", - "presentable": false, - "primaryKey": false, - "required": false, - "system": false, - "type": "text" - }, - { - "autogeneratePattern": "", - "hidden": false, - "id": "_clone_qAJu", - "max": 50, - "min": 0, - "name": "badge_color", - "pattern": "", - "presentable": false, - "primaryKey": false, - "required": false, - "system": false, - "type": "text" - }, - { - "hidden": false, - "id": "_clone_giOf", - "name": "is_progressive", - "presentable": false, - "required": false, - "system": false, - "type": "bool" - }, - { - "hidden": false, - "id": "json3212413036", - "maxSize": 1, - "name": "current_progress", - "presentable": false, - "required": false, - "system": false, - "type": "json" - }, - { - "hidden": false, - "id": "json4171899439", - "maxSize": 1, - "name": "target_progress", - "presentable": false, - "required": false, - "system": false, - "type": "json" - }, - { - "hidden": false, - "id": "json3435813110", - "maxSize": 1, - "name": "is_earned", - "presentable": false, - "required": false, - "system": false, - "type": "json" - }, - { - "hidden": false, - "id": "_clone_Q7lC", - "max": "", - "min": "", - "name": "earned_at", - "presentable": false, - "required": false, - "system": false, - "type": "date" - } - ], - "id": "pbc_5062686152", - "indexes": [], - "listRule": null, - "name": "player_badges_view", - "system": false, - "type": "view", - "updateRule": null, - "viewQuery": "\n SELECT\n (p.id || '_' || b.id) as id,\n p.id as player_id,\n b.id as badge_id,\n b.name as badge_name,\n b.description as badge_description,\n b.type as badge_type,\n b.icon as badge_icon,\n b.color as badge_color,\n b.is_progressive,\n COALESCE(pbp.current_progress, 0) as current_progress,\n COALESCE(pbp.target_progress, b.progress_target, 1) as target_progress,\n COALESCE(pbp.is_earned, false) as is_earned,\n pbp.earned_at\n FROM players p\n CROSS JOIN badges b\n LEFT JOIN player_badge_progress pbp ON pbp.player_id = p.id AND pbp.badge_id = b.id\n ", - "viewRule": null - }); - - return app.save(collection); -}) diff --git a/src/app/routeTree.gen.ts b/src/app/routeTree.gen.ts index b8bfcc6..553c25a 100644 --- a/src/app/routeTree.gen.ts +++ b/src/app/routeTree.gen.ts @@ -16,6 +16,7 @@ import { Route as AuthedRouteImport } from './routes/_authed' import { Route as AuthedIndexRouteImport } from './routes/_authed/index' import { Route as AuthedStatsRouteImport } from './routes/_authed/stats' import { Route as AuthedSettingsRouteImport } from './routes/_authed/settings' +import { Route as AuthedBadgesRouteImport } from './routes/_authed/badges' import { Route as AuthedAdminRouteImport } from './routes/_authed/admin' import { Route as AuthedTournamentsIndexRouteImport } from './routes/_authed/tournaments/index' import { Route as AuthedAdminIndexRouteImport } from './routes/_authed/admin/index' @@ -76,6 +77,11 @@ const AuthedSettingsRoute = AuthedSettingsRouteImport.update({ path: '/settings', getParentRoute: () => AuthedRoute, } as any) +const AuthedBadgesRoute = AuthedBadgesRouteImport.update({ + id: '/badges', + path: '/badges', + getParentRoute: () => AuthedRoute, +} as any) const AuthedAdminRoute = AuthedAdminRouteImport.update({ id: '/admin', path: '/admin', @@ -215,6 +221,7 @@ export interface FileRoutesByFullPath { '/logout': typeof LogoutRoute '/refresh-session': typeof RefreshSessionRoute '/admin': typeof AuthedAdminRouteWithChildren + '/badges': typeof AuthedBadgesRoute '/settings': typeof AuthedSettingsRoute '/stats': typeof AuthedStatsRoute '/': typeof AuthedIndexRoute @@ -247,6 +254,7 @@ export interface FileRoutesByTo { '/login': typeof LoginRoute '/logout': typeof LogoutRoute '/refresh-session': typeof RefreshSessionRoute + '/badges': typeof AuthedBadgesRoute '/settings': typeof AuthedSettingsRoute '/stats': typeof AuthedStatsRoute '/': typeof AuthedIndexRoute @@ -282,6 +290,7 @@ export interface FileRoutesById { '/logout': typeof LogoutRoute '/refresh-session': typeof RefreshSessionRoute '/_authed/admin': typeof AuthedAdminRouteWithChildren + '/_authed/badges': typeof AuthedBadgesRoute '/_authed/settings': typeof AuthedSettingsRoute '/_authed/stats': typeof AuthedStatsRoute '/_authed/': typeof AuthedIndexRoute @@ -317,6 +326,7 @@ export interface FileRouteTypes { | '/logout' | '/refresh-session' | '/admin' + | '/badges' | '/settings' | '/stats' | '/' @@ -349,6 +359,7 @@ export interface FileRouteTypes { | '/login' | '/logout' | '/refresh-session' + | '/badges' | '/settings' | '/stats' | '/' @@ -383,6 +394,7 @@ export interface FileRouteTypes { | '/logout' | '/refresh-session' | '/_authed/admin' + | '/_authed/badges' | '/_authed/settings' | '/_authed/stats' | '/_authed/' @@ -481,6 +493,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof AuthedSettingsRouteImport parentRoute: typeof AuthedRoute } + '/_authed/badges': { + id: '/_authed/badges' + path: '/badges' + fullPath: '/badges' + preLoaderRoute: typeof AuthedBadgesRouteImport + parentRoute: typeof AuthedRoute + } '/_authed/admin': { id: '/_authed/admin' path: '/admin' @@ -687,6 +706,7 @@ const AuthedAdminRouteWithChildren = AuthedAdminRoute._addFileChildren( interface AuthedRouteChildren { AuthedAdminRoute: typeof AuthedAdminRouteWithChildren + AuthedBadgesRoute: typeof AuthedBadgesRoute AuthedSettingsRoute: typeof AuthedSettingsRoute AuthedStatsRoute: typeof AuthedStatsRoute AuthedIndexRoute: typeof AuthedIndexRoute @@ -699,6 +719,7 @@ interface AuthedRouteChildren { const AuthedRouteChildren: AuthedRouteChildren = { AuthedAdminRoute: AuthedAdminRouteWithChildren, + AuthedBadgesRoute: AuthedBadgesRoute, AuthedSettingsRoute: AuthedSettingsRoute, AuthedStatsRoute: AuthedStatsRoute, AuthedIndexRoute: AuthedIndexRoute, diff --git a/src/app/routes/_authed/badges.tsx b/src/app/routes/_authed/badges.tsx new file mode 100644 index 0000000..8582299 --- /dev/null +++ b/src/app/routes/_authed/badges.tsx @@ -0,0 +1,33 @@ +import BadgeStatsTable from '@/features/badges/components/badge-stats-table'; +import { badgeQueries, useAllBadges } from '@/features/badges/queries'; +import PlayerStatsTableSkeleton from '@/features/players/components/player-stats-table-skeleton'; +import { prefetchServerQuery } from '@/lib/tanstack-query/utils/prefetch'; +import { createFileRoute } from '@tanstack/react-router'; +import { Suspense } from 'react'; + +export const Route = createFileRoute('/_authed/badges')({ + component: Badges, + beforeLoad: ({ context }) => { + const queryClient = context.queryClient; + prefetchServerQuery(queryClient, badgeQueries.allBadges()); + }, + loader: () => ({ + withPadding: false, + fullWidth: true, + header: { + title: 'All Badges', + }, + refresh: [badgeQueries.allBadges().queryKey], + }), +}); + +function Badges() { + //TODO: CHANGE FALLBACK + return ( + }> +
+ +
+
+ ); +} diff --git a/src/features/badges/components/badge-showcase.tsx b/src/features/badges/components/badge-showcase.tsx index 074af79..41b2627 100644 --- a/src/features/badges/components/badge-showcase.tsx +++ b/src/features/badges/components/badge-showcase.tsx @@ -18,15 +18,15 @@ interface BadgeDisplay { interface BadgeIconProps { badge: Badge; - earned: boolean; + filled: boolean; } -const BadgeIcon = ({ badge, earned, size = 48 }: BadgeIconProps & { size?: number }) => { +export const BadgeIcon = ({ badge, filled, size = 48 }: BadgeIconProps & { size?: number }) => { const [imageError, setImageError] = useState(false); const imagePath = `/static/img/${badge.key}.png`; if (imageError) { - return earned ? ( + return filled ? ( setImageError(true)} style={{ objectFit: 'contain', - opacity: earned ? 1 : 0.4, + opacity: filled ? 1 : 0.4, }} /> ); @@ -218,7 +218,7 @@ const BadgeShowcase = ({ playerId }: BadgeShowcaseProps) => { zIndex: 1, }} > - + {showStack && ( { - + diff --git a/src/features/badges/components/badge-stats-table.tsx b/src/features/badges/components/badge-stats-table.tsx new file mode 100644 index 0000000..13cf1de --- /dev/null +++ b/src/features/badges/components/badge-stats-table.tsx @@ -0,0 +1,129 @@ +import { + Stack, + Container, + Title, + Box, + Divider, + Text, + Grid, + UnstyledButton, +} from '@mantine/core'; +import { useAllBadges, useAllEarnedBadges } from '../queries'; +import { BadgeIcon } from './badge-showcase'; +import { useMemo } from 'react'; +import { Badge, EarnedBadge } from '../types'; +import { useAllPlayers } from '@/features/teams/hooks/use-available-players'; +import { useSheet } from '@/hooks/use-sheet'; +import Sheet from '@/components/sheet/sheet'; + +const BadgeStatsTable = () => { + const { data: allBadges } = useAllBadges(); + const { data: allEarnedBadges } = useAllEarnedBadges(); + const { data: allPlayers } = useAllPlayers(); + const totalNumPlayers = allPlayers?.length || 0; + + const groupedEarnedBadges = useMemo(() => { + const returnDict = new Map<string, EarnedBadge[]>(); + allEarnedBadges?.forEach((earnedBadge) => { + if (!returnDict.has(earnedBadge.badge)) { + returnDict.set(earnedBadge.badge, []); + } + returnDict.get(earnedBadge.badge)!.push(earnedBadge); + }); + return returnDict; + }, [allEarnedBadges]); + + if (allBadges.length === 0) { + return ( + <Container px={0} size='md'> + <Stack align='center' gap='md' py='xl'> + <Title order={3} c='dimmed'> + No Badges Available + + + + ); + } + + return ( + + + + {allBadges.map((badge, index) => ( + = allBadges.length - 1} + /> + ))} + + + + ); +}; + +export default BadgeStatsTable; + +interface BadgeStatRowProps { + badge: Badge; + totalNumPlayers: number; + earnedBadges: EarnedBadge[]; + isLastRow: boolean; +} + +const BadgeStatRow: React.FC = ({ + badge, + totalNumPlayers, + earnedBadges, + isLastRow, +}) => { + const badgeSheet = useSheet(); + return ( + + + + + + + + {badge.name} + {badge.description} + + + + + {( + ((earnedBadges?.length ?? 0) / totalNumPlayers) * + 100 + ).toFixed(0)} + % + + of players + + + + + + + {earnedBadges?.map((earnedBadge) => ( + + {earnedBadge.player.first_name + + ' ' + + earnedBadge.player.last_name} + + ))} + + + {!isLastRow && } + + ); +}; diff --git a/src/features/badges/queries.ts b/src/features/badges/queries.ts index 6428f9d..80bc271 100644 --- a/src/features/badges/queries.ts +++ b/src/features/badges/queries.ts @@ -1,9 +1,10 @@ import { useServerSuspenseQuery } from "@/lib/tanstack-query/hooks"; -import { getPlayerBadges, getAllBadges } from "./server"; +import { getPlayerBadges, getAllBadges, getAllEarnedBadges } from "./server"; export const badgeKeys = { playerBadges: (playerId: string) => ['badges', 'player', playerId], allBadges: () => ['badges', 'all'], + allEarnedBadges: () => ['badges', 'earned'], }; export const badgeQueries = { @@ -15,6 +16,10 @@ export const badgeQueries = { queryKey: badgeKeys.allBadges(), queryFn: async () => await getAllBadges() }), + allEarnedBadges: () => ({ + queryKey: badgeKeys.allEarnedBadges(), + queryFn: async () => await getAllEarnedBadges(), + }), }; export const usePlayerBadges = (playerId: string) => @@ -22,3 +27,6 @@ export const usePlayerBadges = (playerId: string) => export const useAllBadges = () => useServerSuspenseQuery(badgeQueries.allBadges()); + +export const useAllEarnedBadges = () => + useServerSuspenseQuery(badgeQueries.allEarnedBadges()); diff --git a/src/features/badges/server.ts b/src/features/badges/server.ts index 8d34ef0..e2cab82 100644 --- a/src/features/badges/server.ts +++ b/src/features/badges/server.ts @@ -1,5 +1,8 @@ import { toServerResult } from "@/lib/tanstack-query/utils/to-server-result"; -import { superTokensAdminFunctionMiddleware, superTokensFunctionMiddleware } from "@/utils/supertokens"; +import { + superTokensAdminFunctionMiddleware, + superTokensFunctionMiddleware, +} from "@/utils/supertokens"; import { createServerFn } from "@tanstack/react-start"; import { pbAdmin } from "@/lib/pocketbase/client"; import { z } from "zod"; @@ -19,14 +22,17 @@ export const migrateBadgeProgress = createServerFn() export const getAllBadges = createServerFn() .middleware([superTokensFunctionMiddleware]) - .handler(async () => - toServerResult(() => pbAdmin.listBadges()) - ); + .handler(async () => + toServerResult(() => pbAdmin.listBadges())); + +export const getAllEarnedBadges = createServerFn() + .middleware([superTokensFunctionMiddleware]) + .handler(async () => toServerResult(() => pbAdmin.listEarnedBadges())); export const awardManualBadge = createServerFn() .inputValidator(z.object({ - playerId: z.string(), - badgeId: z.string(), + playerId: z.string(), + badgeId: z.string(), })) .middleware([superTokensAdminFunctionMiddleware]) .handler(async ({ data }) => diff --git a/src/features/badges/types.ts b/src/features/badges/types.ts index a71cf6c..3821521 100644 --- a/src/features/badges/types.ts +++ b/src/features/badges/types.ts @@ -1,3 +1,5 @@ +import { PlayerInfo } from '../players/types'; + export interface BadgeInfo { id: string; name: string; @@ -23,3 +25,13 @@ export interface BadgeProgress { created: string; updated: string; } + +export interface EarnedBadge { + id: string; + badge: string; + player: PlayerInfo; + progress: number; + earned: boolean; + created: string; + updated: string; +} diff --git a/src/features/core/hooks/use-links.ts b/src/features/core/hooks/use-links.ts index 4a2b357..7f23aca 100644 --- a/src/features/core/hooks/use-links.ts +++ b/src/features/core/hooks/use-links.ts @@ -1,4 +1,4 @@ -import { HouseIcon, RankingIcon, ShieldIcon, TrophyIcon, UserCircleIcon } from "@phosphor-icons/react"; +import { HouseIcon, RankingIcon, SealIcon, ShieldIcon, TrophyIcon, UserCircleIcon } from "@phosphor-icons/react"; import { useMemo } from "react"; export const useLinks = (userId: string | undefined, roles: string[]) => @@ -25,6 +25,11 @@ export const useLinks = (userId: string | undefined, roles: string[]) => href: `/profile/${userId}`, Icon: UserCircleIcon, include: ['/settings'] + }, + { + label: 'Badges', + href: '/badges', + Icon: SealIcon } ] diff --git a/src/features/teams/hooks/use-available-players.ts b/src/features/teams/hooks/use-available-players.ts index 352b301..95a71bf 100644 --- a/src/features/teams/hooks/use-available-players.ts +++ b/src/features/teams/hooks/use-available-players.ts @@ -16,22 +16,22 @@ export const playerQueries = { export const useAvailablePlayers = (excludedPlayerIds: string[] = []) => { const { data: allPlayers } = useServerSuspenseQuery(playerQueries.all()); - + const availablePlayers = useMemo(() => { if (!allPlayers) return []; - + return allPlayers.filter(player => - !excludedPlayerIds.includes(player.id) && - player.first_name && - player.last_name + !excludedPlayerIds.includes(player.id) && + player.first_name && + player.last_name ); }, [allPlayers, excludedPlayerIds]); const playerOptions = useMemo(() => availablePlayers.map(player => ({ - value: player.id, + value: player.id, label: `${player.first_name} ${player.last_name}`.trim() || 'Unnamed Player' - })), + })), [availablePlayers] ); @@ -40,4 +40,6 @@ export const useAvailablePlayers = (excludedPlayerIds: string[] = []) => { playerOptions, allPlayers }; -}; \ No newline at end of file +}; + +export const useAllPlayers = () => useServerSuspenseQuery(playerQueries.all()); diff --git a/src/lib/pocketbase/services/badges.ts b/src/lib/pocketbase/services/badges.ts index 08ae7b0..9597623 100644 --- a/src/lib/pocketbase/services/badges.ts +++ b/src/lib/pocketbase/services/badges.ts @@ -1,6 +1,6 @@ import PocketBase from "pocketbase"; -import { Badge, BadgeProgress } from "@/features/badges/types"; -import { transformBadge, transformBadgeProgress } from "@/lib/pocketbase/util/transform-types"; +import { Badge, BadgeProgress, EarnedBadge } from "@/features/badges/types"; +import { transformBadge, transformBadgeProgress, transformEarnedBadge } from "@/lib/pocketbase/util/transform-types"; export interface PlayerStats { player_id: string; @@ -41,6 +41,14 @@ export function createBadgesService(pb: PocketBase) { return results.map(transformBadgeProgress); }, + async listEarnedBadges(): Promise { + const results = await pb.collection("badge_progress").getFullList({ + filter: `earned = true`, + expand: 'player', + }); + return results.map(transformEarnedBadge); + }, + async createBadgeProgress(data: { badge: string; player: string; diff --git a/src/lib/pocketbase/util/transform-types.ts b/src/lib/pocketbase/util/transform-types.ts index b80a4e0..244fb72 100644 --- a/src/lib/pocketbase/util/transform-types.ts +++ b/src/lib/pocketbase/util/transform-types.ts @@ -3,7 +3,7 @@ import { Match } from "@/features/matches/types"; import { Player, PlayerInfo } from "@/features/players/types"; import { Team, TeamInfo } from "@/features/teams/types"; import { Tournament, TournamentInfo } from "@/features/tournaments/types"; -import { Badge, BadgeInfo, BadgeProgress } from "@/features/badges/types"; +import { Badge, BadgeInfo, BadgeProgress, EarnedBadge } from "@/features/badges/types"; import { Activity } from "../services/activities"; // pocketbase does this weird thing with relations where it puts them under a seperate "expand" field @@ -314,6 +314,18 @@ export function transformBadgeProgress(record: any): BadgeProgress { }; } +export function transformEarnedBadge(record: any): EarnedBadge { + return { + id: record.id, + badge: record.badge, + player: record.expand?.player ? transformPlayerInfo(record.expand.player) : record.player, + progress: record.progress, + earned: record.earned, + created: record.created, + updated: record.updated, + }; +} + export function transformActivity(record: any): Activity { return { id: record.id,