diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 0000000..f533f12 --- /dev/null +++ b/.github/copilot-instructions.md @@ -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). diff --git a/api/src/routes/characters.ts b/api/src/routes/characters.ts index 2613bf9..4f01698 100644 --- a/api/src/routes/characters.ts +++ b/api/src/routes/characters.ts @@ -43,6 +43,7 @@ router.get( skills, mercTypes, mercItems, + query, season, } = req.query; @@ -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(); + } if (season) { filter.season = parseInt(season as string, 10); } else { diff --git a/web/src/api/characters.ts b/web/src/api/characters.ts index 63cd34f..d9117e5 100644 --- a/web/src/api/characters.ts +++ b/web/src/api/characters.ts @@ -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, }); }, diff --git a/web/src/components/builds/CharacterTable/index.tsx b/web/src/components/builds/CharacterTable/index.tsx index a5eeb74..760c9ae 100644 --- a/web/src/components/builds/CharacterTable/index.tsx +++ b/web/src/components/builds/CharacterTable/index.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useMemo, useState } from "react"; +import React, { useEffect, useMemo, useRef, useState } from "react"; import { MantineReactTable, MRT_ColumnDef, @@ -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; } @@ -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) { @@ -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 @@ -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(); @@ -149,21 +155,22 @@ export default function PlayerTable({ // Transform fetched data for table display const tableDisplayData = useMemo(() => { 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[]>( @@ -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", @@ -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, }); diff --git a/web/src/components/builds/ClassBar/index.tsx b/web/src/components/builds/ClassBar/index.tsx index a141a95..bb196c4 100644 --- a/web/src/components/builds/ClassBar/index.tsx +++ b/web/src/components/builds/ClassBar/index.tsx @@ -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(); @@ -263,7 +266,7 @@ export default function ClassBar({ } searchTimeout.current = setTimeout(() => { - updateFilters({ searchQuery: value }); + updateFilters({ filterSearchQuery: value }); }, 300); }; @@ -272,11 +275,29 @@ export default function ClassBar({ if (searchTimeout.current) { clearTimeout(searchTimeout.current); } + updateFilters({ filterSearchQuery: "" }); + }; + + const handleCharacterSearchChange = ( + e: React.ChangeEvent + ) => { + 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); } @@ -287,6 +308,7 @@ export default function ClassBar({ mercTypeFilter: [], mercItemFilter: [], searchQuery: "", + filterSearchQuery: "", }); }; @@ -398,15 +420,34 @@ export default function ClassBar({ }} > - {!isMobile && ( - - Found{" "} - - {data.breakdown.total?.toLocaleString()} - {" "} - characters - - )} + + + + + ) : null + } + value={characterSearchInput} + onChange={handleCharacterSearchChange} + style={{ width: isMobile ? "100%" : "260px" }} + /> + {!isMobile && ( + + Found{" "} + + {data.breakdown.total?.toLocaleString()} + {" "} + characters + + )} +