diff --git a/pb_migrations/1759285520_deleted_player_badge_progress.js b/pb_migrations/1759285520_deleted_player_badge_progress.js new file mode 100644 index 0000000..f469e81 --- /dev/null +++ b/pb_migrations/1759285520_deleted_player_badge_progress.js @@ -0,0 +1,106 @@ +/// +migrate((app) => { + const collection = app.findCollectionByNameOrId("pbc_badge_progress"); + + return app.delete(collection); +}, (app) => { + const collection = new Collection({ + "createRule": null, + "deleteRule": null, + "fields": [ + { + "autogeneratePattern": "[a-z0-9]{15}", + "hidden": false, + "id": "text3208210256", + "max": 15, + "min": 15, + "name": "id", + "pattern": "^[a-z0-9]+$", + "presentable": false, + "primaryKey": true, + "required": true, + "system": true, + "type": "text" + }, + { + "cascadeDelete": true, + "collectionId": "pbc_3072146508", + "hidden": false, + "id": "relation_player", + "maxSelect": 1, + "minSelect": 1, + "name": "player_id", + "presentable": false, + "required": true, + "system": false, + "type": "relation" + }, + { + "cascadeDelete": true, + "collectionId": "pbc_1340419796", + "hidden": false, + "id": "relation_badge", + "maxSelect": 1, + "minSelect": 1, + "name": "badge_id", + "presentable": false, + "required": true, + "system": false, + "type": "relation" + }, + { + "hidden": false, + "id": "number_current", + "max": null, + "min": 0, + "name": "current_progress", + "onlyInt": false, + "presentable": false, + "required": false, + "system": false, + "type": "number" + }, + { + "hidden": false, + "id": "bool_earned", + "name": "is_earned", + "presentable": false, + "required": false, + "system": false, + "type": "bool" + }, + { + "hidden": false, + "id": "autodate2990389176", + "name": "created", + "onCreate": true, + "onUpdate": false, + "presentable": false, + "system": false, + "type": "autodate" + }, + { + "hidden": false, + "id": "autodate3332085495", + "name": "updated", + "onCreate": true, + "onUpdate": true, + "presentable": false, + "system": false, + "type": "autodate" + } + ], + "id": "pbc_badge_progress", + "indexes": [ + "CREATE UNIQUE INDEX `idx_player_badge` ON `player_badge_progress` (`player_id`, `badge_id`)" + ], + "listRule": null, + "name": "player_badge_progress", + "system": false, + "type": "base", + "updateRule": null, + "viewRule": null + }); + + return app.save(collection); +}) diff --git a/pb_migrations/1759285544_updated_players.js b/pb_migrations/1759285544_updated_players.js new file mode 100644 index 0000000..29769c3 --- /dev/null +++ b/pb_migrations/1759285544_updated_players.js @@ -0,0 +1,28 @@ +/// +migrate((app) => { + const collection = app.findCollectionByNameOrId("pbc_3072146508") + + // remove field + collection.fields.removeById("relation2029409178") + + return app.save(collection) +}, (app) => { + const collection = app.findCollectionByNameOrId("pbc_3072146508") + + // add field + collection.fields.addAt(5, new Field({ + "cascadeDelete": false, + "collectionId": "pbc_1340419796", + "hidden": false, + "id": "relation2029409178", + "maxSelect": 999, + "minSelect": 0, + "name": "badges", + "presentable": false, + "required": false, + "system": false, + "type": "relation" + })) + + return app.save(collection) +}) diff --git a/pb_migrations/1759285564_updated_players.js b/pb_migrations/1759285564_updated_players.js new file mode 100644 index 0000000..52ac9ba --- /dev/null +++ b/pb_migrations/1759285564_updated_players.js @@ -0,0 +1,28 @@ +/// +migrate((app) => { + const collection = app.findCollectionByNameOrId("pbc_3072146508") + + // remove field + collection.fields.removeById("relation2813965191") + + return app.save(collection) +}, (app) => { + const collection = app.findCollectionByNameOrId("pbc_3072146508") + + // add field + collection.fields.addAt(5, new Field({ + "cascadeDelete": false, + "collectionId": "pbc_1340419796", + "hidden": false, + "id": "relation2813965191", + "maxSelect": 1, + "minSelect": 0, + "name": "featured_badge", + "presentable": false, + "required": false, + "system": false, + "type": "relation" + })) + + return app.save(collection) +}) diff --git a/pb_migrations/1759285574_deleted_badges.js b/pb_migrations/1759285574_deleted_badges.js new file mode 100644 index 0000000..ba2b038 --- /dev/null +++ b/pb_migrations/1759285574_deleted_badges.js @@ -0,0 +1,159 @@ +/// +migrate((app) => { + const collection = app.findCollectionByNameOrId("pbc_1340419796"); + + return app.delete(collection); +}, (app) => { + const collection = new Collection({ + "createRule": null, + "deleteRule": null, + "fields": [ + { + "autogeneratePattern": "[a-z0-9]{15}", + "hidden": false, + "id": "text3208210256", + "max": 15, + "min": 15, + "name": "id", + "pattern": "^[a-z0-9]+$", + "presentable": false, + "primaryKey": true, + "required": true, + "system": true, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text1579384326", + "max": 0, + "min": 0, + "name": "name", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": true, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text1843675174", + "max": 0, + "min": 0, + "name": "description", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": true, + "system": false, + "type": "text" + }, + { + "hidden": false, + "id": "select4029814376", + "maxSelect": 1, + "name": "type", + "presentable": false, + "required": true, + "system": false, + "type": "select", + "values": [ + "tournament_participation", + "tournament_placement", + "performance", + "overtime", + "match_milestone" + ] + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text_icon_key", + "max": 100, + "min": 0, + "name": "icon_key", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "hidden": false, + "id": "json1578432567", + "maxSize": 2000000, + "name": "criteria", + "presentable": false, + "required": true, + "system": false, + "type": "json" + }, + { + "hidden": false, + "id": "number_sort", + "max": null, + "min": 0, + "name": "sort_order", + "onlyInt": true, + "presentable": false, + "required": false, + "system": false, + "type": "number" + }, + { + "hidden": false, + "id": "bool2847519203", + "name": "is_progressive", + "presentable": false, + "required": false, + "system": false, + "type": "bool" + }, + { + "hidden": false, + "id": "number2948571038", + "max": null, + "min": null, + "name": "progress_target", + "onlyInt": false, + "presentable": false, + "required": false, + "system": false, + "type": "number" + }, + { + "hidden": false, + "id": "autodate2990389176", + "name": "created", + "onCreate": true, + "onUpdate": false, + "presentable": false, + "system": false, + "type": "autodate" + }, + { + "hidden": false, + "id": "autodate3332085495", + "name": "updated", + "onCreate": true, + "onUpdate": true, + "presentable": false, + "system": false, + "type": "autodate" + } + ], + "id": "pbc_1340419796", + "indexes": [], + "listRule": null, + "name": "badges", + "system": false, + "type": "base", + "updateRule": null, + "viewRule": null + }); + + return app.save(collection); +}) diff --git a/pb_migrations/1759285803_created_badges.js b/pb_migrations/1759285803_created_badges.js new file mode 100644 index 0000000..6578255 --- /dev/null +++ b/pb_migrations/1759285803_created_badges.js @@ -0,0 +1,133 @@ +/// +migrate((app) => { + const collection = new Collection({ + "createRule": null, + "deleteRule": null, + "fields": [ + { + "autogeneratePattern": "[a-z0-9]{15}", + "hidden": false, + "id": "text3208210256", + "max": 15, + "min": 15, + "name": "id", + "pattern": "^[a-z0-9]+$", + "presentable": false, + "primaryKey": true, + "required": true, + "system": true, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text1579384326", + "max": 0, + "min": 0, + "name": "name", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text2324736937", + "max": 0, + "min": 0, + "name": "key", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text1843675174", + "max": 0, + "min": 0, + "name": "description", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "hidden": false, + "id": "json3055524737", + "maxSize": 0, + "name": "criteria", + "presentable": false, + "required": false, + "system": false, + "type": "json" + }, + { + "hidden": false, + "id": "select2363381545", + "maxSelect": 1, + "name": "type", + "presentable": false, + "required": false, + "system": false, + "type": "select", + "values": [ + "manual", + "match", + "tournament" + ] + }, + { + "hidden": false, + "id": "bool3646955747", + "name": "progressive", + "presentable": false, + "required": false, + "system": false, + "type": "bool" + }, + { + "hidden": false, + "id": "autodate2990389176", + "name": "created", + "onCreate": true, + "onUpdate": false, + "presentable": false, + "system": false, + "type": "autodate" + }, + { + "hidden": false, + "id": "autodate3332085495", + "name": "updated", + "onCreate": true, + "onUpdate": true, + "presentable": false, + "system": false, + "type": "autodate" + } + ], + "id": "pbc_1340419796", + "indexes": [], + "listRule": null, + "name": "badges", + "system": false, + "type": "base", + "updateRule": null, + "viewRule": null + }); + + return app.save(collection); +}, (app) => { + const collection = app.findCollectionByNameOrId("pbc_1340419796"); + + return app.delete(collection); +}) diff --git a/pb_migrations/1759285923_created_badge_progress.js b/pb_migrations/1759285923_created_badge_progress.js new file mode 100644 index 0000000..d645713 --- /dev/null +++ b/pb_migrations/1759285923_created_badge_progress.js @@ -0,0 +1,104 @@ +/// +migrate((app) => { + const collection = new Collection({ + "createRule": null, + "deleteRule": null, + "fields": [ + { + "autogeneratePattern": "[a-z0-9]{15}", + "hidden": false, + "id": "text3208210256", + "max": 15, + "min": 15, + "name": "id", + "pattern": "^[a-z0-9]+$", + "presentable": false, + "primaryKey": true, + "required": true, + "system": true, + "type": "text" + }, + { + "cascadeDelete": false, + "collectionId": "pbc_1340419796", + "hidden": false, + "id": "relation4277159965", + "maxSelect": 1, + "minSelect": 0, + "name": "badge", + "presentable": false, + "required": false, + "system": false, + "type": "relation" + }, + { + "cascadeDelete": false, + "collectionId": "pbc_3072146508", + "hidden": false, + "id": "relation2551806565", + "maxSelect": 1, + "minSelect": 0, + "name": "player", + "presentable": false, + "required": false, + "system": false, + "type": "relation" + }, + { + "hidden": false, + "id": "number570552902", + "max": null, + "min": null, + "name": "progress", + "onlyInt": false, + "presentable": false, + "required": false, + "system": false, + "type": "number" + }, + { + "hidden": false, + "id": "bool2625885481", + "name": "earned", + "presentable": false, + "required": false, + "system": false, + "type": "bool" + }, + { + "hidden": false, + "id": "autodate2990389176", + "name": "created", + "onCreate": true, + "onUpdate": false, + "presentable": false, + "system": false, + "type": "autodate" + }, + { + "hidden": false, + "id": "autodate3332085495", + "name": "updated", + "onCreate": true, + "onUpdate": true, + "presentable": false, + "system": false, + "type": "autodate" + } + ], + "id": "pbc_3342597247", + "indexes": [], + "listRule": null, + "name": "badge_progress", + "system": false, + "type": "base", + "updateRule": null, + "viewRule": null + }); + + return app.save(collection); +}, (app) => { + const collection = app.findCollectionByNameOrId("pbc_3342597247"); + + return app.delete(collection); +}) diff --git a/pb_migrations/1759340868_updated_badges.js b/pb_migrations/1759340868_updated_badges.js new file mode 100644 index 0000000..db4a6ca --- /dev/null +++ b/pb_migrations/1759340868_updated_badges.js @@ -0,0 +1,27 @@ +/// +migrate((app) => { + const collection = app.findCollectionByNameOrId("pbc_1340419796") + + // add field + collection.fields.addAt(7, new Field({ + "hidden": false, + "id": "number4113142680", + "max": null, + "min": null, + "name": "order", + "onlyInt": false, + "presentable": false, + "required": false, + "system": false, + "type": "number" + })) + + return app.save(collection) +}, (app) => { + const collection = app.findCollectionByNameOrId("pbc_1340419796") + + // remove field + collection.fields.removeById("number4113142680") + + return app.save(collection) +}) diff --git a/src/components/list-button.tsx b/src/components/list-button.tsx index 02f5ac2..07cafe5 100644 --- a/src/components/list-button.tsx +++ b/src/components/list-button.tsx @@ -1,22 +1,33 @@ -import { Divider, Group, Text, UnstyledButton } from "@mantine/core"; +import { Divider, Group, Loader, Text, UnstyledButton } from "@mantine/core"; import { CaretRightIcon, Icon } from "@phosphor-icons/react"; interface ListButtonProps { label: string; Icon: Icon; onClick: () => void; + loading?: boolean; } -const ListButton = ({ label, onClick, Icon }: ListButtonProps) => { +const ListButton = ({ label, onClick, Icon, loading }: ListButtonProps) => { return ( <> - + {label} - + {loading ? ( + + ) : ( + + )} diff --git a/src/features/admin/components/admin-page.tsx b/src/features/admin/components/admin-page.tsx index c4f4b82..d8ad538 100644 --- a/src/features/admin/components/admin-page.tsx +++ b/src/features/admin/components/admin-page.tsx @@ -4,10 +4,23 @@ import { DatabaseIcon, TreeStructureIcon, TrophyIcon, + MedalIcon, } from "@phosphor-icons/react"; import ListButton from "@/components/list-button"; +import { migrateBadgeProgress } from "@/features/badges/server"; +import { useState } from "react"; const AdminPage = () => { + const [isMigrating, setIsMigrating] = useState(false); + + const handleMigrateBadges = async () => { + if (isMigrating) return; + + setIsMigrating(true); + await migrateBadgeProgress(); + setIsMigrating(false); + }; + return ( { Icon={TrophyIcon} to="/admin/tournaments" /> + { + return ( + + + + + Badges + + + + + {[1, 2, 3, 4, 5, 6].map((i) => ( + + ))} + + + + ); +}; + +export default BadgeShowcaseSkeleton; diff --git a/src/features/badges/components/badge-showcase.tsx b/src/features/badges/components/badge-showcase.tsx new file mode 100644 index 0000000..3a9bde9 --- /dev/null +++ b/src/features/badges/components/badge-showcase.tsx @@ -0,0 +1,189 @@ +import { Box, Text, Tooltip, Card } from "@mantine/core"; +import { usePlayerBadges, useAllBadges } from "../queries"; +import { useAuth } from "@/contexts/auth-context"; +import { Badge, BadgeProgress } from "../types"; +import { useMemo } from "react"; + +interface BadgeShowcaseProps { + playerId: string; +} + +interface BadgeDisplay { + badge: Badge; + progress?: BadgeProgress; + earned: boolean; + progressText: string; +} + +const BadgeShowcase = ({ playerId }: BadgeShowcaseProps) => { + const { user } = useAuth(); + const { data: badgeProgress } = usePlayerBadges(playerId); + const { data: allBadges } = useAllBadges(); + + const isCurrentUser = user?.id === playerId; + + const badgesToDisplay = useMemo(() => { + const displays: BadgeDisplay[] = []; + + if (isCurrentUser) { + for (const badge of allBadges) { + const progress = badgeProgress.find(bp => bp.badge.id === badge.id); + const earned = progress?.earned || false; + + if (badge.type === 'manual' && !earned) { + continue; + } + + let progressText = ""; + if (progress) { + const target = getTargetProgress(badge); + progressText = `${progress.progress} / ${target}`; + } else { + const target = getTargetProgress(badge); + progressText = `0 / ${target}`; + } + + displays.push({ + badge, + progress, + earned, + progressText, + }); + } + + displays.sort((a, b) => { + if (a.earned && !b.earned) return -1; + if (!a.earned && b.earned) return 1; + return a.badge.order - b.badge.order; + }); + } else { + const earnedProgress = badgeProgress.filter(bp => bp.earned); + for (const progress of earnedProgress) { + const badge: Badge = { + ...progress.badge, + criteria: {}, + created: progress.created, + updated: progress.updated, + }; + + const target = getTargetProgress(badge); + displays.push({ + badge, + progress, + earned: true, + progressText: `${progress.progress} / ${target}`, + }); + } + + displays.sort((a, b) => a.badge.order - b.badge.order); + } + + return displays; + }, [allBadges, badgeProgress, isCurrentUser]); + + if (badgesToDisplay.length === 0) { + return null; + } + + return ( + + + + + Badges + + + + + {badgesToDisplay.map((display) => ( + + + {display.badge.name} + + + {display.badge.description} + + {isCurrentUser && ( + + Progress: {display.progressText} + + )} + + } + multiline + w={220} + > + ({ + opacity: display.earned ? 1 : 0.35, + cursor: "pointer", + transition: 'all 0.2s ease', + minHeight: 70, + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + borderStyle: display.earned ? 'solid' : 'dashed', + ':hover': { + transform: display.earned ? 'translateY(-2px)' : 'none', + boxShadow: display.earned ? theme.shadows.sm : undefined, + }, + })} + > + + {display.badge.name} + + + + ))} + + + + ); +}; + +function getTargetProgress(badge: Badge): number { + const criteria = badge.criteria; + return ( + criteria.matches_played || + criteria.tournament_wins || + criteria.tournaments_attended || + criteria.overtime_matches || + criteria.overtime_wins || + criteria.consecutive_wins || + 1 + ); +} + +export default BadgeShowcase; diff --git a/src/features/badges/queries.ts b/src/features/badges/queries.ts new file mode 100644 index 0000000..6428f9d --- /dev/null +++ b/src/features/badges/queries.ts @@ -0,0 +1,24 @@ +import { useServerSuspenseQuery } from "@/lib/tanstack-query/hooks"; +import { getPlayerBadges, getAllBadges } from "./server"; + +export const badgeKeys = { + playerBadges: (playerId: string) => ['badges', 'player', playerId], + allBadges: () => ['badges', 'all'], +}; + +export const badgeQueries = { + playerBadges: (playerId: string) => ({ + queryKey: badgeKeys.playerBadges(playerId), + queryFn: async () => await getPlayerBadges({ data: playerId }) + }), + allBadges: () => ({ + queryKey: badgeKeys.allBadges(), + queryFn: async () => await getAllBadges() + }), +}; + +export const usePlayerBadges = (playerId: string) => + useServerSuspenseQuery(badgeQueries.playerBadges(playerId)); + +export const useAllBadges = () => + useServerSuspenseQuery(badgeQueries.allBadges()); diff --git a/src/features/badges/server.ts b/src/features/badges/server.ts index 9306d85..973c5f2 100644 --- a/src/features/badges/server.ts +++ b/src/features/badges/server.ts @@ -1,4 +1,24 @@ import { toServerResult } from "@/lib/tanstack-query/utils/to-server-result"; -import { superTokensAdminFunctionMiddleware } 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"; +export const getPlayerBadges = createServerFn() + .inputValidator(z.string()) + .middleware([superTokensFunctionMiddleware]) + .handler(async ({ data: playerId }) => + toServerResult(() => pbAdmin.getPlayerBadgeProgress(playerId)) + ); + +export const migrateBadgeProgress = createServerFn() + .middleware([superTokensAdminFunctionMiddleware]) + .handler(async () => + toServerResult(() => pbAdmin.migrateBadgeProgress()) + ); + +export const getAllBadges = createServerFn() + .middleware([superTokensFunctionMiddleware]) + .handler(async () => + toServerResult(() => pbAdmin.listBadges()) + ); diff --git a/src/features/badges/types.ts b/src/features/badges/types.ts new file mode 100644 index 0000000..a71cf6c --- /dev/null +++ b/src/features/badges/types.ts @@ -0,0 +1,25 @@ +export interface BadgeInfo { + id: string; + name: string; + key: string; + description: string; + type: "manual" | "match" | "tournament"; + progressive: boolean; + order: number; +} + +export interface Badge extends BadgeInfo { + criteria: Record; + created: string; + updated: string; +} + +export interface BadgeProgress { + id: string; + badge: BadgeInfo; + player: string; + progress: number; + earned: boolean; + created: string; + updated: string; +} diff --git a/src/features/players/components/profile/index.tsx b/src/features/players/components/profile/index.tsx index 15acb78..9ae7a9b 100644 --- a/src/features/players/components/profile/index.tsx +++ b/src/features/players/components/profile/index.tsx @@ -1,10 +1,13 @@ import { Box } from "@mantine/core"; +import { Suspense } from "react"; import Header from "./header"; import SwipeableTabs from "@/components/swipeable-tabs"; import { usePlayer, usePlayerMatches, usePlayerStats } from "../../queries"; import TeamList from "@/features/teams/components/team-list"; import StatsOverview from "@/components/stats-overview"; import MatchList from "@/features/matches/components/match-list"; +import BadgeShowcase from "@/features/badges/components/badge-showcase"; +import BadgeShowcaseSkeleton from "@/features/badges/components/badge-showcase-skeleton"; interface ProfileProps { id: string; @@ -18,7 +21,14 @@ const Profile = ({ id }: ProfileProps) => { const tabs = [ { label: "Overview", - content: , + content: ( + <> + }> + + + + + ), }, { label: "Matches", diff --git a/src/lib/pocketbase/client.ts b/src/lib/pocketbase/client.ts index 9ebee61..b11ebd9 100644 --- a/src/lib/pocketbase/client.ts +++ b/src/lib/pocketbase/client.ts @@ -5,6 +5,7 @@ import { createTeamsService } from "./services/teams"; import { createMatchesService } from "./services/matches"; import { createReactionsService } from "./services/reactions"; import { createActivitiesService } from "./services/activities"; +import { createBadgesService } from "./services/badges"; import dotenv from 'dotenv'; dotenv.config(); @@ -37,6 +38,7 @@ class PocketBaseAdminClient { Object.assign(this, createMatchesService(this.pb)); Object.assign(this, createReactionsService(this.pb)); Object.assign(this, createActivitiesService(this.pb)); + Object.assign(this, createBadgesService(this.pb)); }); } @@ -57,7 +59,8 @@ interface AdminClient ReturnType, ReturnType, ReturnType, - ReturnType { + ReturnType, + ReturnType { authPromise: Promise; } diff --git a/src/lib/pocketbase/services/badges.ts b/src/lib/pocketbase/services/badges.ts new file mode 100644 index 0000000..9edd3b7 --- /dev/null +++ b/src/lib/pocketbase/services/badges.ts @@ -0,0 +1,407 @@ +import PocketBase from "pocketbase"; +import { Badge, BadgeProgress } from "@/features/badges/types"; +import { transformBadge, transformBadgeProgress } from "@/lib/pocketbase/util/transform-types"; + +export interface PlayerStats { + player_id: string; + matches: number; + wins: number; + losses: number; + total_cups_made: number; + total_cups_against: number; + margin_of_victory: number; +} + +export function createBadgesService(pb: PocketBase) { + return { + async getBadge(id: string): Promise { + const result = await pb.collection("badges").getOne(id); + return transformBadge(result); + }, + + async listBadges(): Promise { + const results = await pb.collection("badges").getFullList({ + sort: 'name', + }); + return results.map(transformBadge); + }, + + async getBadgeProgress(id: string): Promise { + const result = await pb.collection("badge_progress").getOne(id, { + expand: 'badge,player', + }); + return transformBadgeProgress(result); + }, + + async getPlayerBadgeProgress(playerId: string): Promise { + const results = await pb.collection("badge_progress").getFullList({ + filter: `player = "${playerId}"`, + expand: 'badge', + }); + return results.map(transformBadgeProgress); + }, + + async createBadgeProgress(data: { + badge: string; + player: string; + progress: number; + earned: boolean; + }): Promise { + return await pb.collection("badge_progress").create(data); + }, + + async updateBadgeProgress(id: string, data: { + progress?: number; + earned?: boolean; + }): Promise { + return await pb.collection("badge_progress").update(id, data); + }, + + async deleteBadgeProgress(id: string): Promise { + await pb.collection("badge_progress").delete(id); + return true; + }, + + async clearAllBadgeProgress(): Promise { + const existingProgress = await pb.collection("badge_progress").getFullList(); + for (const progress of existingProgress) { + await pb.collection("badge_progress").delete(progress.id); + } + return existingProgress.length; + }, + + async calculateBadgeProgress(playerId: string, badge: Badge): Promise { + if (badge.type === "manual") { + return 0; + } + + if (badge.type === "match") { + return await this.calculateMatchBadgeProgress(playerId, badge); + } + + if (badge.type === "tournament") { + return await this.calculateTournamentBadgeProgress(playerId, badge); + } + + return 0; + }, + + async calculateMatchBadgeProgress(playerId: string, badge: Badge): Promise { + const criteria = badge.criteria; + + const stats = await pb.collection("player_stats").getFirstListItem( + `player_id = "${playerId}"` + ).catch(() => null); + + if (!stats) return 0; + + if (criteria.matches_played !== undefined) { + return stats.matches; + } + + if (criteria.overtime_matches !== undefined || criteria.overtime_wins !== undefined) { + const matches = await pb.collection("matches").getFullList({ + filter: `(home.players.id ?~ "${playerId}" || away.players.id ?~ "${playerId}") && status = "ended" && ot_count > 0`, + expand: 'home,away,home.players,away.players', + }); + + if (criteria.overtime_matches !== undefined) { + return matches.length; + } + + if (criteria.overtime_wins !== undefined) { + const overtimeWins = matches.filter(m => { + const isHome = m.expand?.home?.expand?.players?.some((p: any) => p.id === playerId) || + m.expand?.home?.players?.includes(playerId); + const isAway = m.expand?.away?.expand?.players?.some((p: any) => p.id === playerId) || + m.expand?.away?.players?.includes(playerId); + + if (isHome) { + return m.home_cups > m.away_cups; + } else if (isAway) { + return m.away_cups > m.home_cups; + } + return false; + }); + return overtimeWins.length; + } + } + + if (criteria.margin_of_victory !== undefined) { + const matches = await pb.collection("matches").getFullList({ + filter: `(home.players.id ?~ "${playerId}" || away.players.id ?~ "${playerId}") && status = "ended"`, + expand: 'home,away,home.players,away.players', + }); + + const bigWins = matches.filter(m => { + const isHome = m.expand?.home?.expand?.players?.some((p: any) => p.id === playerId) || + m.expand?.home?.players?.includes(playerId); + const isAway = m.expand?.away?.expand?.players?.some((p: any) => p.id === playerId) || + m.expand?.away?.players?.includes(playerId); + + if (isHome && m.home_cups > m.away_cups) { + return (m.home_cups - m.away_cups) >= criteria.margin_of_victory; + } else if (isAway && m.away_cups > m.home_cups) { + return (m.away_cups - m.home_cups) >= criteria.margin_of_victory; + } + return false; + }); + + return bigWins.length > 0 ? 1 : 0; + } + + return 0; + }, + + async calculateTournamentBadgeProgress(playerId: string, badge: Badge): Promise { + const criteria = badge.criteria; + + const matches = await pb.collection("matches").getFullList({ + filter: `(home.players.id ?~ "${playerId}" || away.players.id ?~ "${playerId}") && status = "ended"`, + expand: 'tournament,home,away,home.players,away.players', + }); + + const tournamentIds = new Set(matches.map(m => m.tournament)); + const tournamentsAttended = tournamentIds.size; + + if (criteria.tournaments_attended !== undefined) { + return tournamentsAttended; + } + + if (criteria.tournament_wins !== undefined) { + if (tournamentIds.size === 0) return 0; + + let tournamentWins = 0; + + for (const tournamentId of tournamentIds) { + const tournamentMatches = await pb.collection("matches").getFullList({ + filter: `tournament = "${tournamentId}" && status = "ended"`, + expand: 'home,away,home.players,away.players', + }); + + const winnersMatches = tournamentMatches.filter(m => !m.is_losers_bracket); + const finalsMatch = winnersMatches.reduce((highest: any, current: any) => + (!highest || current.lid > highest.lid) ? current : highest, null); + + if (finalsMatch && finalsMatch.status === 'ended') { + const finalsWinnerId = (finalsMatch.home_cups > finalsMatch.away_cups) ? finalsMatch.home : finalsMatch.away; + + const winningTeam = finalsMatch.expand?.[finalsWinnerId === finalsMatch.home ? 'home' : 'away']; + const winningPlayers = winningTeam?.expand?.players || winningTeam?.players || []; + + const playerWon = winningPlayers.some((p: any) => + (typeof p === 'string' ? p : p.id) === playerId + ); + + if (playerWon) { + tournamentWins++; + } + } + } + + return tournamentWins; + } + + if (criteria.placement !== undefined && typeof criteria.placement === 'number') { + let placementCount = 0; + + for (const tournamentId of tournamentIds) { + const tournamentMatches = await pb.collection("matches").getFullList({ + filter: `tournament = "${tournamentId}" && status = "ended"`, + expand: 'home,away,home.players,away.players', + }); + + if (criteria.placement === 2) { + const winnersMatches = tournamentMatches.filter(m => !m.is_losers_bracket); + const finalsMatch = winnersMatches.reduce((highest: any, current: any) => + (!highest || current.lid > highest.lid) ? current : highest, null); + + if (finalsMatch && finalsMatch.status === 'ended') { + const finalsLoserId = (finalsMatch.home_cups > finalsMatch.away_cups) ? finalsMatch.away : finalsMatch.home; + + const losingTeam = finalsMatch.expand?.[finalsLoserId === finalsMatch.home ? 'home' : 'away']; + const losingPlayers = losingTeam?.expand?.players || losingTeam?.players || []; + + const playerLost = losingPlayers.some((p: any) => + (typeof p === 'string' ? p : p.id) === playerId + ); + + if (playerLost) { + placementCount++; + } + } + } + + if (criteria.placement === 3) { + const losersMatches = tournamentMatches.filter(m => m.is_losers_bracket); + const losersFinale = losersMatches.reduce((highest: any, current: any) => + (!highest || current.lid > highest.lid) ? current : highest, null); + + if (losersFinale && losersFinale.status === 'ended') { + const losersFinaleLoserId = (losersFinale.home_cups > losersFinale.away_cups) ? losersFinale.away : losersFinale.home; + + const losingTeam = losersFinale.expand?.[losersFinaleLoserId === losersFinale.home ? 'home' : 'away']; + const losingPlayers = losingTeam?.expand?.players || losingTeam?.players || []; + + const playerLost = losingPlayers.some((p: any) => + (typeof p === 'string' ? p : p.id) === playerId + ); + + if (playerLost) { + placementCount++; + } + } + } + } + + return placementCount; + } + + if (criteria.tournament_record !== undefined) { + const tournaments = await pb.collection("tournaments").getFullList({ + sort: 'start_time', + }); + + let timesWent02 = 0; + + for (const tournamentId of tournamentIds) { + const tournament = tournaments.find(t => t.id === tournamentId); + if (!tournament) continue; + + const tournamentMatches = matches.filter(m => m.tournament === tournamentId); + + let wins = 0; + let losses = 0; + + for (const match of tournamentMatches) { + const isHome = match.expand?.home?.expand?.players?.some((p: any) => p.id === playerId) || + match.expand?.home?.players?.includes(playerId); + const isAway = match.expand?.away?.expand?.players?.some((p: any) => p.id === playerId) || + match.expand?.away?.players?.includes(playerId); + + if (isHome && match.home_cups > match.away_cups) { + wins++; + } else if (isAway && match.away_cups > match.home_cups) { + wins++; + } else { + losses++; + } + } + + const record = `${wins}-${losses}`; + + if (record === criteria.tournament_record) { + if (criteria.won_previous !== undefined && criteria.won_previous === true) { + const currentIndex = tournaments.findIndex(t => t.id === tournamentId); + if (currentIndex > 0) { + const previousTournament = tournaments[currentIndex - 1]; + if (previousTournament.winner_id === playerId) { + timesWent02++; + } + } + } else { + timesWent02++; + } + } + } + + return timesWent02 > 0 ? 1 : 0; + } + + if (criteria.consecutive_wins !== undefined) { + const tournaments = await pb.collection("tournaments").getFullList({ + sort: 'start_time', + }); + + let consecutiveWins = 0; + let maxConsecutiveWins = 0; + + for (const tournament of tournaments) { + if (!tournamentIds.has(tournament.id)) continue; + + if (tournament.winner_id === playerId) { + consecutiveWins++; + maxConsecutiveWins = Math.max(maxConsecutiveWins, consecutiveWins); + } else { + consecutiveWins = 0; + } + } + + return maxConsecutiveWins >= criteria.consecutive_wins ? 1 : 0; + } + + return 0; + }, + + getTargetProgress(badge: Badge): number { + if (badge.type === "manual") { + return 1; + } + + const criteria = badge.criteria; + + return ( + criteria.matches_played || + criteria.tournament_wins || + criteria.tournaments_attended || + criteria.overtime_matches || + criteria.overtime_wins || + criteria.consecutive_wins || + 1 + ); + }, + + async migrateBadgeProgress(): Promise<{ + success: boolean; + playersProcessed: number; + progressRecordsCreated: number; + totalBadgesEarned: number; + averageBadgesPerPlayer: string; + }> { + await this.clearAllBadgeProgress(); + + const badges = await this.listBadges(); + + const playerStats = await pb.collection("player_stats").getFullList(); + const uniquePlayers = new Set(playerStats.map(s => s.player_id)); + + let totalProgressRecords = 0; + let totalBadgesEarned = 0; + + for (const playerId of uniquePlayers) { + for (const badge of badges) { + try { + const progress = await this.calculateBadgeProgress(playerId, badge); + const target = this.getTargetProgress(badge); + const earned = progress >= target; + + if (progress > 0 || earned) { + await this.createBadgeProgress({ + badge: badge.id, + player: playerId, + progress: progress, + earned: earned, + }); + + totalProgressRecords++; + + if (earned) { + totalBadgesEarned++; + } + } + } catch (error: any) { + console.error(`Error processing badge "${badge.name}" for player ${playerId}:`, error.message); + } + } + } + + return { + success: true, + playersProcessed: uniquePlayers.size, + progressRecordsCreated: totalProgressRecords, + totalBadgesEarned: totalBadgesEarned, + averageBadgesPerPlayer: (totalBadgesEarned / uniquePlayers.size).toFixed(2), + }; + }, + }; +} diff --git a/src/lib/pocketbase/util/transform-types.ts b/src/lib/pocketbase/util/transform-types.ts index 5193495..93ac5e9 100644 --- a/src/lib/pocketbase/util/transform-types.ts +++ b/src/lib/pocketbase/util/transform-types.ts @@ -3,6 +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"; // pocketbase does this weird thing with relations where it puts them under a seperate "expand" field // this file transforms raw pocketbase results to our types @@ -278,3 +279,36 @@ export function transformReaction(record: any) { match: record.match }; } + +export function transformBadgeInfo(record: any): BadgeInfo { + return { + id: record.id, + name: record.name, + key: record.key, + description: record.description, + type: record.type, + progressive: record.progressive, + order: record.order ?? 999, + }; +} + +export function transformBadge(record: any): Badge { + return { + ...transformBadgeInfo(record), + criteria: record.criteria, + created: record.created, + updated: record.updated, + }; +} + +export function transformBadgeProgress(record: any): BadgeProgress { + return { + id: record.id, + badge: record.expand?.badge ? transformBadgeInfo(record.expand.badge) : record.badge, + player: record.player, + progress: record.progress, + earned: record.earned, + created: record.created, + updated: record.updated, + }; +}