This commit is contained in:
yohlo
2025-10-01 13:26:42 -05:00
parent ce29c41bf3
commit 654041b6b6
18 changed files with 1381 additions and 7 deletions

View File

@@ -0,0 +1,106 @@
/// <reference path="../pb_data/types.d.ts" />
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);
})

View File

@@ -0,0 +1,28 @@
/// <reference path="../pb_data/types.d.ts" />
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)
})

View File

@@ -0,0 +1,28 @@
/// <reference path="../pb_data/types.d.ts" />
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)
})

View File

@@ -0,0 +1,159 @@
/// <reference path="../pb_data/types.d.ts" />
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);
})

View File

@@ -0,0 +1,133 @@
/// <reference path="../pb_data/types.d.ts" />
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);
})

View File

@@ -0,0 +1,104 @@
/// <reference path="../pb_data/types.d.ts" />
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);
})

View File

@@ -0,0 +1,27 @@
/// <reference path="../pb_data/types.d.ts" />
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)
})

View File

@@ -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 (
<>
<UnstyledButton w="100%" p="md" component={"button"} onClick={onClick}>
<UnstyledButton
w="100%"
p="md"
component={"button"}
onClick={onClick}
disabled={loading}
>
<Group>
<Icon weight="bold" size={20} />
<Text fw={500} size="md">
{label}
</Text>
{loading ? (
<Loader size="sm" style={{ marginLeft: "auto" }} />
) : (
<CaretRightIcon style={{ marginLeft: "auto" }} size={20} />
)}
</Group>
</UnstyledButton>
<Divider />

View File

@@ -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 (
<List p="0">
<ListLink
@@ -15,6 +28,12 @@ const AdminPage = () => {
Icon={TrophyIcon}
to="/admin/tournaments"
/>
<ListButton
label="Migrate Badge Progress"
Icon={MedalIcon}
onClick={handleMigrateBadges}
loading={isMigrating}
/>
<ListButton
label="Open Pocketbase"
Icon={DatabaseIcon}

View File

@@ -0,0 +1,47 @@
import { Box, Card, Skeleton, Text } from "@mantine/core";
const BadgeShowcaseSkeleton = () => {
return (
<Box mb="lg">
<Card
withBorder
radius="md"
p={0}
style={{
overflow: 'hidden',
}}
>
<Box
p="md"
style={{
background: 'light-dark(var(--mantine-color-gray-1), var(--mantine-color-dark-6))',
borderBottom: '1px solid light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-4))',
}}
>
<Text size="sm" fw={600} tt="uppercase" c="dimmed" style={{ letterSpacing: '0.5px' }}>
Badges
</Text>
</Box>
<Box
p="md"
style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(110px, 1fr))',
gap: 'var(--mantine-spacing-sm)',
}}
>
{[1, 2, 3, 4, 5, 6].map((i) => (
<Skeleton
key={i}
height={70}
radius="md"
/>
))}
</Box>
</Card>
</Box>
);
};
export default BadgeShowcaseSkeleton;

View File

@@ -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 (
<Box mb="lg">
<Card
withBorder
radius="md"
p={0}
>
<Box
p="md"
style={{
background: 'light-dark(var(--mantine-color-gray-1), var(--mantine-color-dark-6))',
borderBottom: '1px solid light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-4))'
}}
>
<Text size="sm" fw={600} tt="uppercase" c="dimmed" style={{ letterSpacing: '0.5px' }}>
Badges
</Text>
</Box>
<Box
p="md"
mah={120}
style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(110px, 1fr))',
gap: 'var(--mantine-spacing-sm)',
overflow: 'scroll',
}}
>
{badgesToDisplay.map((display) => (
<Tooltip
key={display.badge.id}
label={
<Box>
<Text size="xs" fw={600} mb={4}>
{display.badge.name}
</Text>
<Text size="xs" mb={4}>
{display.badge.description}
</Text>
{isCurrentUser && (
<Text size="xs" c="dimmed">
Progress: {display.progressText}
</Text>
)}
</Box>
}
multiline
w={220}
>
<Card
withBorder
padding="sm"
radius="md"
shadow={display.earned ? "xs" : undefined}
style={(theme) => ({
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,
},
})}
>
<Text
size="xs"
ta="center"
fw={display.earned ? 600 : 500}
c={display.earned ? undefined : "dimmed"}
style={{ lineHeight: 1.3 }}
>
{display.badge.name}
</Text>
</Card>
</Tooltip>
))}
</Box>
</Card>
</Box>
);
};
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;

View File

@@ -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());

View File

@@ -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())
);

View File

@@ -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<string, any>;
created: string;
updated: string;
}
export interface BadgeProgress {
id: string;
badge: BadgeInfo;
player: string;
progress: number;
earned: boolean;
created: string;
updated: string;
}

View File

@@ -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: <StatsOverview statsData={stats} isLoading={statsLoading} />,
content: (
<>
<Suspense fallback={<BadgeShowcaseSkeleton />}>
<BadgeShowcase playerId={id} />
</Suspense>
<StatsOverview statsData={stats} isLoading={statsLoading} />
</>
),
},
{
label: "Matches",

View File

@@ -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<typeof createTournamentsService>,
ReturnType<typeof createMatchesService>,
ReturnType<typeof createReactionsService>,
ReturnType<typeof createActivitiesService> {
ReturnType<typeof createActivitiesService>,
ReturnType<typeof createBadgesService> {
authPromise: Promise<void>;
}

View File

@@ -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<Badge> {
const result = await pb.collection("badges").getOne(id);
return transformBadge(result);
},
async listBadges(): Promise<Badge[]> {
const results = await pb.collection("badges").getFullList({
sort: 'name',
});
return results.map(transformBadge);
},
async getBadgeProgress(id: string): Promise<BadgeProgress> {
const result = await pb.collection("badge_progress").getOne(id, {
expand: 'badge,player',
});
return transformBadgeProgress(result);
},
async getPlayerBadgeProgress(playerId: string): Promise<BadgeProgress[]> {
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<BadgeProgress> {
return await pb.collection("badge_progress").create<BadgeProgress>(data);
},
async updateBadgeProgress(id: string, data: {
progress?: number;
earned?: boolean;
}): Promise<BadgeProgress> {
return await pb.collection("badge_progress").update<BadgeProgress>(id, data);
},
async deleteBadgeProgress(id: string): Promise<boolean> {
await pb.collection("badge_progress").delete(id);
return true;
},
async clearAllBadgeProgress(): Promise<number> {
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<number> {
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<number> {
const criteria = badge.criteria;
const stats = await pb.collection("player_stats").getFirstListItem<PlayerStats>(
`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<number> {
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<PlayerStats>();
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),
};
},
};
}

View File

@@ -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,
};
}