Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
698df5e
Merge pull request #2 from coleestrin/mercs
coleestrin Jan 10, 2026
77a276d
fix: aura names
coleestrin Jan 10, 2026
a463e73
fix: selected color
coleestrin Jan 10, 2026
76642a3
perf: virtual filters
coleestrin Jan 10, 2026
7fb9a2f
feat: item tooltips and images
coleestrin Jan 11, 2026
8354111
feat: account tracking
coleestrin Jan 11, 2026
1c3befe
fix: tests
coleestrin Jan 11, 2026
7ec5dac
Merge pull request #3 from coleestrin/accounts
coleestrin Jan 11, 2026
15d0929
fix: route
coleestrin Jan 11, 2026
16c527d
feat: snapshots
coleestrin Jan 11, 2026
8cdd22d
chore: prettier
coleestrin Jan 11, 2026
793c6d7
fix: tests
coleestrin Jan 11, 2026
504b5c9
fix: date
coleestrin Jan 11, 2026
4ac4f16
fix: styles
coleestrin Jan 11, 2026
57a5133
fix: tests
coleestrin Jan 11, 2026
3348545
Merge pull request #4 from coleestrin/snapshots
coleestrin Jan 11, 2026
1aaf3df
feat: account queue
coleestrin Jan 11, 2026
3f6a4d3
show user message if already in queue
coleestrin Jan 11, 2026
909ec1e
show user message if already in queue
coleestrin Jan 11, 2026
2efa63d
fix: cache
coleestrin Jan 12, 2026
5d23a6e
fix: tests
coleestrin Jan 12, 2026
b218159
Merge pull request #5 from coleestrin/refresh
coleestrin Jan 12, 2026
f275135
feat: leaderboards
coleestrin Jan 12, 2026
a667537
fix: rate limit
coleestrin Jan 12, 2026
f8079e1
Merge pull request #6 from coleestrin/leaderboard
coleestrin Jan 12, 2026
8cb653c
clarify mirror copies
coleestrin Jan 12, 2026
719054e
update default leaderboard tab to mirrored
coleestrin Jan 12, 2026
f516af8
Merge branch 'main' of github.com:tsalopek/pd2-tools
tsalopek Jan 20, 2026
7790e4d
Adding copilot instructions. Adding the capability to perform charact…
tsalopek Feb 1, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions .github/copilot-instructions.md

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could you remove this from the PR?

Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
# pd2-tools Copilot Instructions

## Big picture
- Monorepo with two apps: **api/** (Node/Express + Postgres/Redis) and **web/** (Vite + React + Mantine). API serves data consumed by the web client at /api/v1.
- API startup: api/src/index.ts wires Redis + DB shutdown; api/src/server.ts builds the Express app and mounts routes at /api/${API_VERSION}.
- Background work is intentionally separate from the HTTP process: api/src/jobs.ts starts cron jobs (character scraper, online player tracker, leaderboard updater). Production runs multiple API instances, so avoid mixing jobs into the server entry.

## Data flow & storage
- Primary data lives in Postgres; schemas are auto-created in code on startup:
- Character/leaderboard schema: api/src/database/postgres/index.ts
- Economy schema: api/src/database/postgres/economy.ts
- Redis is optional and used as a read-through cache. If Redis is unavailable, calls fall back to DB (see api/src/utils/cache.ts).
- GET routes commonly use auto-caching with deterministic cache keys; the `skills` query param is URL-encoded JSON and normalized for cache keys (api/src/middleware/auto-cache.ts).

## API conventions
- Routes are grouped in api/src/routes and assembled in api/src/routes/index.ts.
- Query validation is explicit; `validateSeason` enforces positive integers and attaches `seasonNumber` to the request (api/src/middleware/validation.ts).
- Responses use `{ error: { message } }` on failures; not-found handled by middleware (api/src/middleware/error-handler.ts).

## Frontend conventions
- API calls are centralized in web/src/api with a shared fetch wrapper (web/src/api/client.ts) and endpoint constants (web/src/config/api.ts).
- React Query is the default data-fetching layer with a 5-minute staleTime (web/src/App.tsx).
- Mantine is the UI system; global theme and routing live in web/src/App.tsx.

## External integrations
- PD2 public API is polled by background jobs (api/src/jobs/*) and by character scraper logic with rate limiting and profanity filtering.
- Economy/leaderboard data depends on scheduled jobs; avoid changing cron timing without understanding load constraints in api/src/jobs/character-scraper.ts.

## Dev workflows
- API: npm run dev (ts-node), npm run build, npm start, npm run jobs, npm test.
- Web: npm run dev, npm run build, npm run preview.
- Both apps rely on .env files (see README.md and .env.example in each app).
4 changes: 4 additions & 0 deletions api/src/routes/characters.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ router.get(
skills,
mercTypes,
mercItems,
query,
season,
} = req.query;

Expand Down Expand Up @@ -87,6 +88,9 @@ router.get(
if (mercItems) {
filter.requiredMercItems = (mercItems as string).split(",");
}
if (query && (query as string).trim().length >= 3) {
filter.searchQuery = (query as string).trim();

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Were there database changes you forgot to commit? I don't see any mention of searchQuery in api/src/database/postgres/index.ts

}
if (season) {
filter.season = parseInt(season as string, 10);
} else {
Expand Down
1 change: 1 addition & 0 deletions web/src/api/characters.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ export const charactersAPI = {
mercItems: filter.requiredMercItems?.join(","),
minLevel: filter.levelRange?.min,
maxLevel: filter.levelRange?.max,
query: filter.query,
season: filter.season,
});
},
Expand Down
82 changes: 50 additions & 32 deletions web/src/components/builds/CharacterTable/index.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import React, { useEffect, useMemo, useState } from "react";
import React, { useEffect, useMemo, useRef, useState } from "react";
import {
MantineReactTable,
MRT_ColumnDef,
Expand All @@ -21,7 +21,7 @@ interface TransformedCharacterRow {
dead?: boolean;
life: number;
mana: number;
realSks: Array<{ skill: string; level: number; [key: string]: unknown }>;
realSks: Array<{ skill: string; level: number;[key: string]: unknown }>;
highestSkLevel?: number;
rank?: number;
}
Expand All @@ -48,9 +48,12 @@ export default function PlayerTable({
pageIndex: 0,
pageSize: 40,
});
const requestIdRef = useRef(0);

// Effect to fetch data when pagination or filters change
useEffect(() => {
const fetchCharacters = async () => {
const requestId = ++requestIdRef.current;
// If we have initial characters and this is not a pagination request,
// use those instead of making an API call
if (initialCharacters && pagination.pageIndex === 0) {
Expand Down Expand Up @@ -93,10 +96,6 @@ export default function PlayerTable({
if (filters.mercItemFilter.length) {
queryParams.append("mercItems", filters.mercItemFilter.join(","));
}
if (filters.searchQuery) {
// Assuming 'query' is the param name for search
queryParams.append("query", filters.searchQuery);
}

const levelRangeCookie = Cookies.get("levelRange");
const levelRange = levelRangeCookie
Expand All @@ -116,24 +115,31 @@ export default function PlayerTable({
requiredMercItems: filters.mercItemFilter,
levelRange: { min: levelRange.min, max: levelRange.max },
season: filters.season,
query: filters.searchQuery || undefined,
},
pagination.pageIndex + 1,
pagination.pageSize
); // page is 1-indexed in API

setCharacterData(jsonResponse.characters || []);
// Make sure to set the total count from the API response
setTotalRowCount(jsonResponse.total || 1000); // Use actual total from API
if (requestId === requestIdRef.current) {
setCharacterData(jsonResponse.characters || []);
// Make sure to set the total count from the API response
setTotalRowCount(jsonResponse.total || 1000); // Use actual total from API
}
} catch (error) {
console.error("Failed to fetch characters for table:", error);
setIsError(true);
setCharacterData([]); // Clear data on error
setTotalRowCount(0);
if (requestId === requestIdRef.current) {
setIsError(true);
setCharacterData([]); // Clear data on error
setTotalRowCount(0);
}
}
}

setIsLoading(false);
setIsRefetching(false);
if (requestId === requestIdRef.current) {
setIsLoading(false);
setIsRefetching(false);
}
};

fetchCharacters();
Expand All @@ -149,21 +155,22 @@ export default function PlayerTable({
// Transform fetched data for table display
const tableDisplayData = useMemo<TransformedCharacterRow[]>(() => {
if (!characterData) return [];
return characterData.map((charResponse) => ({
name: charResponse.character?.name || "",
level: charResponse.character?.level || 0,
class: charResponse.character?.class?.name || "",
dead: (charResponse as any).lbInfo?.dead, // lbInfo is in the extended response
life: charResponse.character?.life || 0,
mana: charResponse.character?.mana || 0,
realSks: (charResponse.realSkills || []) as Array<{
skill: string;
level: number;
[key: string]: unknown;
}>,
highestSkLevel: (charResponse.realSkills?.[0] as any)?.level,
rank: (charResponse as any).lbInfo?.rank, // If using rank
}));
return characterData
.map((charResponse) => ({
name: charResponse.character?.name || "",
level: charResponse.character?.level || 0,
class: charResponse.character?.class?.name || "",
dead: (charResponse as any).lbInfo?.dead, // lbInfo is in the extended response
life: charResponse.character?.life || 0,
mana: charResponse.character?.mana || 0,
realSks: (charResponse.realSkills || []) as Array<{
skill: string;
level: number;
[key: string]: unknown;
}>,
highestSkLevel: (charResponse.realSkills?.[0] as any)?.level,
rank: (charResponse as any).lbInfo?.rank, // If using rank
}));
}, [characterData]);

const columns = useMemo<MRT_ColumnDef<TransformedCharacterRow>[]>(
Expand Down Expand Up @@ -277,10 +284,21 @@ export default function PlayerTable({
},
mantinePaperProps: {
style: {
position: "relative",
boxShadow:
"0 4px 16px rgba(0, 0, 0, 0.35), 0 2px 8px rgba(0, 0, 0, 0.25)",
},
},
mantineTopToolbarProps: {
style: {
position: "absolute",
top: 0,
right: 0,
padding: "6px 8px",
minHeight: "unset",
background: "transparent",
},
},
mantineTableProps: {
highlightOnHover: true,
striped: "odd",
Expand Down Expand Up @@ -366,9 +384,9 @@ export default function PlayerTable({
},
muiToolbarAlertBannerProps: isError
? {
color: "error",
children: "Error loading character data. Please try again.",
}
color: "error",
children: "Error loading character data. Please try again.",
}
: undefined,
});

Expand Down
63 changes: 52 additions & 11 deletions web/src/components/builds/ClassBar/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -237,7 +237,10 @@ export default function ClassBar({
updateFilters,
}: BuildsComponentProps) {
const isMobile = useMediaQuery("(max-width: 767px)");
const [searchInput, setSearchInput] = useState(filters.searchQuery);
const [searchInput, setSearchInput] = useState(filters.filterSearchQuery);
const [characterSearchInput, setCharacterSearchInput] = useState(
filters.searchQuery
);
const [settingsOpened, setSettingsOpened] = useState(false);
const [accountQueueOpened, setAccountQueueOpened] = useState(false);
const searchTimeout = useRef<NodeJS.Timeout>();
Expand All @@ -263,7 +266,7 @@ export default function ClassBar({
}

searchTimeout.current = setTimeout(() => {
updateFilters({ searchQuery: value });
updateFilters({ filterSearchQuery: value });
}, 300);
};

Expand All @@ -272,11 +275,29 @@ export default function ClassBar({
if (searchTimeout.current) {
clearTimeout(searchTimeout.current);
}
updateFilters({ filterSearchQuery: "" });
};

const handleCharacterSearchChange = (
e: React.ChangeEvent<HTMLInputElement>
) => {
const value = e.target.value;
setCharacterSearchInput(value);
if (value.length >= 3 || value.length === 0) {
updateFilters({ searchQuery: value });
} else {
updateFilters({ searchQuery: "" });
}
};

const handleCharacterSearchClear = () => {
setCharacterSearchInput("");
updateFilters({ searchQuery: "" });
};

const handleResetFilters = () => {
setSearchInput("");
setCharacterSearchInput("");
if (searchTimeout.current) {
clearTimeout(searchTimeout.current);
}
Expand All @@ -287,6 +308,7 @@ export default function ClassBar({
mercTypeFilter: [],
mercItemFilter: [],
searchQuery: "",
filterSearchQuery: "",
});
};

Expand Down Expand Up @@ -398,15 +420,34 @@ export default function ClassBar({
}}
>
<Flex align="center" justify="space-between" h="100%">
{!isMobile && (
<Text>
Found{" "}
<Text span fw={700}>
{data.breakdown.total?.toLocaleString()}
</Text>{" "}
characters
</Text>
)}
<Flex align="center" gap="md">

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it would look nicer if the search bar was floated to the far right, so that its directly to the left of the settings button

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We'd also want to debounce this to avoid api requests as they're actively typing characters

<TextInput
placeholder="Search characters..."
rightSection={
characterSearchInput ? (
<ActionIcon
variant="transparent"
color="gray"
onClick={handleCharacterSearchClear}
>
<IconX size={16} />
</ActionIcon>
) : null
}
value={characterSearchInput}
onChange={handleCharacterSearchChange}
style={{ width: isMobile ? "100%" : "260px" }}
/>
{!isMobile && (
<Text>
Found{" "}
<Text span fw={700}>
{data.breakdown.total?.toLocaleString()}
</Text>{" "}
characters
</Text>
)}
</Flex>
<Flex gap="md" style={{ marginLeft: isMobile ? "0" : "auto" }}>
<Button
variant="outline"
Expand Down
6 changes: 3 additions & 3 deletions web/src/components/builds/ClassCard/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ type Breakdown = {

interface Props {
breakdown: Record<string, number>;
filters: Pick<CharacterFilters, "classFilter" | "searchQuery">;
filters: Pick<CharacterFilters, "classFilter" | "filterSearchQuery">;
updateFilters: (filters: Partial<{ classFilter: string[] }>) => void;
}

Expand All @@ -51,7 +51,7 @@ export default function ClassCard({
};

const filteredClasses = useMemo(() => {
const searchQuery = filters.searchQuery?.toLowerCase() || "";
const searchQuery = filters.filterSearchQuery?.toLowerCase() || "";
const total = breakdown.total || 0;

// Single pass for filtering, mapping, and sorting
Expand Down Expand Up @@ -81,7 +81,7 @@ export default function ClassCard({
}>
)
.sort((a, b) => b.percentage - a.percentage);
}, [breakdown, filters.searchQuery, selectedClassesSet]);
}, [breakdown, filters.filterSearchQuery, selectedClassesSet]);

const hasClasses = filteredClasses.length > 0;

Expand Down
6 changes: 3 additions & 3 deletions web/src/components/builds/MercItemCard/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ interface Props {
data: {
mercItemUsage: ItemUsageStats[];
};
filters: Pick<CharacterFilters, "mercItemFilter" | "searchQuery">;
filters: Pick<CharacterFilters, "mercItemFilter" | "filterSearchQuery">;
updateFilters: (filters: Partial<{ mercItemFilter: string[] }>) => void;
}

Expand Down Expand Up @@ -53,7 +53,7 @@ export default function MercItemCard({ data, filters, updateFilters }: Props) {

const itemPercentages = useMemo(() => {
if (!data.mercItemUsage) return [];
const searchQuery = filters.searchQuery?.toLowerCase() || "";
const searchQuery = filters.filterSearchQuery?.toLowerCase() || "";

return data.mercItemUsage
.reduce(
Expand Down Expand Up @@ -81,7 +81,7 @@ export default function MercItemCard({ data, filters, updateFilters }: Props) {
b.percentage - a.percentage ||
Number(b.isSelected) - Number(a.isSelected)
);
}, [data.mercItemUsage, filters.searchQuery, selectedItemsSet]);
}, [data.mercItemUsage, filters.filterSearchQuery, selectedItemsSet]);

const getItemTypeColor = (type: string) => {
switch (type) {
Expand Down
6 changes: 3 additions & 3 deletions web/src/components/builds/MercTypeCard/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ interface Props {
data: {
mercTypeUsage: MercTypeStats[];
};
filters: Pick<CharacterFilters, "mercTypeFilter" | "searchQuery">;
filters: Pick<CharacterFilters, "mercTypeFilter" | "filterSearchQuery">;
updateFilters: (filters: Partial<{ mercTypeFilter: string[] }>) => void;
}

Expand All @@ -63,7 +63,7 @@ export default function MercTypeCard({ data, filters, updateFilters }: Props) {
};

const filteredMercTypes = useMemo(() => {
const searchQuery = filters.searchQuery?.toLowerCase() || "";
const searchQuery = filters.filterSearchQuery?.toLowerCase() || "";

return data.mercTypeUsage
.filter((mercType) => {
Expand All @@ -88,7 +88,7 @@ export default function MercTypeCard({ data, filters, updateFilters }: Props) {
isSelected: selectedMercTypesSet.has(mercType.mercType),
}))
.sort((a, b) => b.percentage - a.percentage);
}, [data.mercTypeUsage, filters.searchQuery, selectedMercTypesSet]);
}, [data.mercTypeUsage, filters.filterSearchQuery, selectedMercTypesSet]);

const hasMercTypes = filteredMercTypes.length > 0;

Expand Down
Loading
Loading