From 8e0252687a4e3d256808926c4cd09d4bda3242a6 Mon Sep 17 00:00:00 2001 From: Francespo <95753785+Francespo@users.noreply.github.com> Date: Wed, 11 Mar 2026 11:14:59 +0100 Subject: [PATCH 1/3] feat: implement Epic/Standard format toggle across dashboard and browser pages --- backend/analytics/core.py | 12 +++- backend/analytics/factions.py | 3 +- backend/analytics/lists.py | 25 +++++++- backend/analytics/ships.py | 18 ++++++ backend/api/cards.py | 11 +++- backend/api/lists.py | 2 + backend/api/pilot_detail.py | 6 +- backend/api/ship_detail.py | 3 +- backend/api/ships.py | 2 + backend/main.py | 7 ++- backend/tests/test_epic_filter.py | 59 ++++++++++++++++++ frontend/src/lib/api/ships.ts | 4 +- .../src/lib/components/AdvancedFilters.svelte | 27 +++++++- .../lib/components/ShipChassisFilter.svelte | 10 +-- frontend/src/routes/+page.svelte | 2 +- .../src/routes/api/meta-snapshot/+server.ts | 3 +- frontend/src/routes/cards/+page.svelte | 10 +++ frontend/src/routes/cards/+page.ts | 11 +++- frontend/src/routes/lists/+page.svelte | 14 ++++- frontend/src/routes/lists/+page.ts | 12 +++- frontend/src/routes/pilot/[id]/+page.ts | 9 +-- frontend/src/routes/ships/+page.svelte | 12 +++- frontend/src/routes/ships/+page.ts | 10 ++- issue_65.txt | Bin 0 -> 2386 bytes issue_65_utf8.txt | 22 +++++++ test_output.txt | Bin 0 -> 5522 bytes tmp/reproduce_issue.py | 27 ++++++++ 27 files changed, 284 insertions(+), 37 deletions(-) create mode 100644 backend/tests/test_epic_filter.py create mode 100644 issue_65.txt create mode 100644 issue_65_utf8.txt create mode 100644 test_output.txt create mode 100644 tmp/reproduce_issue.py diff --git a/backend/analytics/core.py b/backend/analytics/core.py index 481bb3a7..1a7f679a 100644 --- a/backend/analytics/core.py +++ b/backend/analytics/core.py @@ -43,6 +43,7 @@ def aggregate_card_stats( type_filter = filters.get("upgrade_type") # For upgrades text_filter = filters.get("search_text", "").lower() ship_filter = filters.get("ship") + include_epic = filters.get("include_epic", False) initiative_filter = filters.get("initiative") # New Context Filters @@ -154,9 +155,6 @@ def _int_or(val, fallback): if data_source == DataSource.XWA: if p_loadout < loadout_min or p_loadout > loadout_max: continue - - - include_epic_content = filters.get("include_epic", False) # Strict Format Visibility Filter is_legal = p_info.get("valid_in_standard", False) @@ -193,6 +191,10 @@ def _int_or(val, fallback): show_card = True elif data_source == DataSource.LEGACY: show_card = True + + # Epic Content Toggle: if true, show all epic pilots + if include_epic and is_epic: + show_card = True if not show_card: continue @@ -410,6 +412,10 @@ def _int_or(val, fallback): show_card = True elif data_source == DataSource.LEGACY: show_card = True + + # Epic Content Toggle + if include_epic and is_epic: + show_card = True if not show_card: continue diff --git a/backend/analytics/factions.py b/backend/analytics/factions.py index 5761bb5b..9a8422b5 100644 --- a/backend/analytics/factions.py +++ b/backend/analytics/factions.py @@ -110,7 +110,7 @@ def aggregate_faction_stats( results.sort(key=lambda x: x["popularity"], reverse=True) return results -def get_meta_snapshot(data_source: DataSource = DataSource.XWA, allowed_formats: list[str] | None = None) -> dict: +def get_meta_snapshot(data_source: DataSource = DataSource.XWA, allowed_formats: list[str] | None = None, include_epic: bool = False) -> dict: """ Get a high-level summary of the current meta (last 90 days). """ @@ -123,6 +123,7 @@ def get_meta_snapshot(data_source: DataSource = DataSource.XWA, allowed_formats: filters = { "date_start": date_str, + "include_epic": include_epic } if allowed_formats: filters["allowed_formats"] = get_active_formats(allowed_formats) diff --git a/backend/analytics/lists.py b/backend/analytics/lists.py index 9b38a041..d879cfa2 100644 --- a/backend/analytics/lists.py +++ b/backend/analytics/lists.py @@ -8,6 +8,7 @@ from ..data_structures.factions import Faction, get_faction_char from ..data_structures.data_source import DataSource from .filters import filter_query, get_active_formats, apply_tournament_filters +from ..utils.xwing_data.pilots import load_all_pilots import json def aggregate_list_stats( @@ -30,7 +31,11 @@ def aggregate_list_stats( # A more robust solution would use a canonical list hash. list_stats = {} - + + # Epic filter: exclude lists containing any epic-only pilot + include_epic = filters.get("include_epic", False) + all_pilots = load_all_pilots(data_source) + for result, tournament in rows: # Format filter optimization t_fmt_raw = tournament.format @@ -50,6 +55,24 @@ def aggregate_list_stats( pilots = xws.get("pilots", []) if not pilots: continue + + # Epic-only list exclusion: skip entire list if ANY pilot is epic-only + if not include_epic: + has_epic_only = False + for p in pilots: + pid = p.get("id") or p.get("name") + if not pid: + continue + p_info = all_pilots.get(pid, {}) + is_epic = p_info.get("epic", False) + is_legal = p_info.get("valid_in_standard", False) + is_wild = p_info.get("wildspace", False) + if is_epic and not is_legal and not is_wild: + has_epic_only = True + break + if has_epic_only: + continue + req_ships = filters.get("ships") if req_ships: diff --git a/backend/analytics/ships.py b/backend/analytics/ships.py index c8634591..ab5d4e07 100644 --- a/backend/analytics/ships.py +++ b/backend/analytics/ships.py @@ -53,6 +53,7 @@ def aggregate_ship_stats( allowed_formats = filters.get("allowed_formats") or None + include_epic = filters.get("include_epic", False) allowed_date_start = filters.get("date_start") or None allowed_date_end = filters.get("date_end") or None @@ -73,6 +74,15 @@ def aggregate_ship_stats( if not ship_xws or not faction: continue + + # Epic-only exclusion: skip pilots playable ONLY in Epic + is_epic = p_info.get("epic", False) + is_legal = p_info.get("valid_in_standard", False) + is_wild = p_info.get("wildspace", False) + + if not include_epic: + if is_epic and not is_legal and not is_wild: + continue # Normalize faction to xws format try: @@ -174,8 +184,16 @@ def aggregate_ship_stats( # Fallback visibility if no formats selected show_pilot = is_legal or is_wild + # Epic Content Toggle + if include_epic and is_epic: + show_pilot = True + if not show_pilot: continue + + # Epic-only exclusion: skip pilots playable ONLY in Epic + if not include_epic and is_epic and not is_legal and not is_wild: + continue key = (ship_xws, faction_xws) if key in ship_stats: diff --git a/backend/api/cards.py b/backend/api/cards.py index b9fc6fcf..97ddae49 100644 --- a/backend/api/cards.py +++ b/backend/api/cards.py @@ -40,6 +40,7 @@ def _build_filters( date_end: Optional[str] = None, player_count_min: Optional[int] = None, player_count_max: Optional[int] = None, + include_epic: bool = False, ) -> dict: # Base sizes mapping @@ -81,7 +82,7 @@ def _build_filters( "date_end": date_end, "player_count_min": player_count_min, "player_count_max": player_count_max, - "include_epic": False + "include_epic": include_epic } @@ -124,6 +125,7 @@ def get_pilots( date_end: Optional[str] = Query(None), player_count_min: Optional[int] = Query(None), player_count_max: Optional[int] = Query(None), + include_epic: bool = Query(False), ): try: ds_enum = DataSource(data_source) @@ -151,7 +153,8 @@ def get_pilots( is_unique=is_unique, is_limited=is_limited, is_not_limited=is_not_limited, base_sizes=base_sizes, platforms=platforms, continent=continent, country=country, city=city, date_start=date_start, date_end=date_end, - player_count_min=player_count_min, player_count_max=player_count_max + player_count_min=player_count_min, player_count_max=player_count_max, + include_epic=include_epic ) data = aggregate_card_stats(filters, criteria, s_dir, "pilots", ds_enum) @@ -183,6 +186,7 @@ def get_upgrades( date_end: Optional[str] = Query(None), player_count_min: Optional[int] = Query(None), player_count_max: Optional[int] = Query(None), + include_epic: bool = Query(False), ): try: ds_enum = DataSource(data_source) @@ -204,7 +208,8 @@ def get_upgrades( search_text=search_text, points_min=points_min, points_max=points_max, platforms=platforms, continent=continent, country=country, city=city, date_start=date_start, date_end=date_end, - player_count_min=player_count_min, player_count_max=player_count_max + player_count_min=player_count_min, player_count_max=player_count_max, + include_epic=include_epic ) data = aggregate_card_stats(filters, criteria, s_dir, "upgrades", ds_enum) diff --git a/backend/api/lists.py b/backend/api/lists.py index 1b1be729..2fb2c4e4 100644 --- a/backend/api/lists.py +++ b/backend/api/lists.py @@ -29,6 +29,7 @@ def get_lists( date_end: Optional[str] = Query(None), player_count_min: Optional[int] = Query(None), player_count_max: Optional[int] = Query(None), + include_epic: bool = Query(False), ): try: ds_enum = DataSource(data_source) @@ -44,6 +45,7 @@ def get_lists( "date_end": date_end, "player_count_min": player_count_min, "player_count_max": player_count_max, + "include_epic": include_epic, "ships": ships, } if formats: diff --git a/backend/api/pilot_detail.py b/backend/api/pilot_detail.py index a5c0287a..33111699 100644 --- a/backend/api/pilot_detail.py +++ b/backend/api/pilot_detail.py @@ -43,6 +43,7 @@ def get_pilot_upgrades( formats: list[str] | None = Query(None), search_text: str = Query(""), upgrade_types: list[str] | None = Query(None), + include_epic: bool = Query(False), ): """Return upgrade stats filtered to this pilot's lists.""" ds = DataSource(data_source) if data_source in ("xwa", "legacy") else DataSource.XWA @@ -54,7 +55,7 @@ def get_pilot_upgrades( "search_text": search_text, "upgrade_type": upgrade_types or [], "pilot_id": pilot_xws, - "include_epic": False, + "include_epic": include_epic, } data = aggregate_card_stats(filters, criteria, direction, "upgrades", ds) total = len(data) @@ -69,11 +70,12 @@ def get_pilot_chart( data_source: str = Query("xwa"), formats: list[str] | None = Query(None), comparison: list[str] | None = Query(None), + include_epic: bool = Query(False), ): """Return monthly usage history for the pilot and optional comparisons.""" filters = { "allowed_formats": formats, - "include_epic": False, + "include_epic": include_epic, } chart_data = get_card_usage_history( filters, diff --git a/backend/api/ship_detail.py b/backend/api/ship_detail.py index 35aebad9..4298c80c 100644 --- a/backend/api/ship_detail.py +++ b/backend/api/ship_detail.py @@ -45,6 +45,7 @@ def get_ship_pilots( data_source: str = Query("xwa"), sort_metric: str = Query("Popularity"), sort_direction: str = Query("desc"), + include_epic: bool = Query(False), ): """Return pilot stats filtered to this ship.""" ds = DataSource(data_source) if data_source in ("xwa", "legacy") else DataSource.XWA @@ -58,7 +59,7 @@ def get_ship_pilots( filters = { "ship": [ship_xws], - "include_epic": False, + "include_epic": include_epic, } data = aggregate_card_stats(filters, criteria, direction, "pilots", ds) return {"pilots": data} diff --git a/backend/api/ships.py b/backend/api/ships.py index 75555244..5fb8fada 100644 --- a/backend/api/ships.py +++ b/backend/api/ships.py @@ -50,6 +50,7 @@ def get_ships( date_end: Optional[str] = Query(None), player_count_min: Optional[int] = Query(None), player_count_max: Optional[int] = Query(None), + include_epic: bool = Query(False), ): try: ds_enum = DataSource(data_source) @@ -77,6 +78,7 @@ def get_ships( "date_end": date_end, "player_count_min": player_count_min, "player_count_max": player_count_max, + "include_epic": include_epic, } data = aggregate_ship_stats(filters, criteria, s_dir, ds_enum) diff --git a/backend/main.py b/backend/main.py index 76ff186c..8e74c81a 100644 --- a/backend/main.py +++ b/backend/main.py @@ -52,11 +52,14 @@ def read_root(): @app.get("/api/meta-snapshot", response_model=MetaSnapshotResponse) -def get_snapshot(data_source: str = Query("xwa", description="Data source: xwa or legacy")): +def get_snapshot( + data_source: str = Query("xwa", description="Data source: xwa or legacy"), + include_epic: bool = Query(False, description="Include epic content") +): ds_enum = DataSource.XWA if data_source == "xwa" else DataSource.LEGACY # We parse what HomeState loaded - snapshot = get_meta_snapshot(ds_enum, allowed_formats=None) + snapshot = get_meta_snapshot(ds_enum, allowed_formats=None, include_epic=include_epic) raw_lists = snapshot.get("lists", []) enriched_lists = [enrich_list_data(l) for l in raw_lists] diff --git a/backend/tests/test_epic_filter.py b/backend/tests/test_epic_filter.py new file mode 100644 index 00000000..a4487149 --- /dev/null +++ b/backend/tests/test_epic_filter.py @@ -0,0 +1,59 @@ +import pytest +from fastapi.testclient import TestClient +from backend.main import app +import os + +client = TestClient(app) + +def test_epic_filter_pilots(): + # 1. Default (epic should be excluded if include_epic=false) + response = client.get("/api/cards/pilots?include_epic=false&size=100") + assert response.status_code == 200 + data = response.json() + items = data.get("items", []) + + epic_xws = {'raiderclasscorvette', 'cr90corelliancorvette', 'gozanticlasscruiser', + 'gr75mediumtransport', 'cthc620cclasscorvette', 'croccruiser', 'tridentclassassaultship'} + + for item in items: + assert item['xws'] not in epic_xws, f"Epic pilot {item['xws']} found when filtered out" + +def test_epic_filter_upgrades(): + response = client.get("/api/cards/upgrades?include_epic=false&size=100") + assert response.status_code == 200 + data = response.json() + items = data.get("items", []) + + epic_types = {'huge ship turret', 'command', 'hardpoint', 'team', 'cargo'} + + for item in items: + assert item['type'].lower() not in epic_types, f"Epic upgrade type {item['type']} found when filtered out" + +def test_epic_filter_ships(): + response = client.get("/api/ships?include_epic=false&size=100") + assert response.status_code == 200 + data = response.json() + items = data.get("items", []) + + epic_ships = {'raider-class corvette', 'cr90 corellian corvette', 'gozanticlass cruiser', + 'gr-75 medium transport', 'cthc-620c-class corvette', 'c-roc cruiser', 'trident-class assault ship'} + + for item in items: + assert item['ship_name'].lower() not in epic_ships, f"Epic ship {item['ship_name']} found when filtered out" + +def test_meta_snapshot_epic(): + response = client.get("/api/meta-snapshot?include_epic=false") + assert response.status_code == 200 + data = response.json() + + # Check pilots in meta sample + # Check pilots in meta sample + epic_xws = {'raiderclasscorvette', 'cr90corelliancorvette', 'gozanticlasscruiser', + 'gr75mediumtransport', 'cthc620cclasscorvette', 'croccruiser', 'tridentclassassaultship'} + for p in data.get("pilots", []): + assert p['xws'] not in epic_xws, f"Epic pilot {p['xws']} found in meta snapshot" + + # Check upgrades in meta sample + epic_types = {'huge ship turret', 'command', 'hardpoint', 'team', 'cargo'} + for u in data.get("upgrades", []): + assert u['type'].lower() not in epic_types, f"Epic upgrade type {u['type']} found in meta snapshot" diff --git a/frontend/src/lib/api/ships.ts b/frontend/src/lib/api/ships.ts index 40ef77e4..ca8c63ce 100644 --- a/frontend/src/lib/api/ships.ts +++ b/frontend/src/lib/api/ships.ts @@ -6,9 +6,9 @@ export interface ShipChassis { factions: string[]; } -export async function fetchAllShips(dataSource: string): Promise { +export async function fetchAllShips(dataSource: string, includeEpic: boolean = false): Promise { try { - const response = await fetch(`${API_BASE}/ships/all?data_source=${dataSource}`); + const response = await fetch(`${API_BASE}/ships/all?data_source=${dataSource}&include_epic=${includeEpic}`); if (!response.ok) throw new Error('Failed to fetch ships'); return await response.json(); } catch (e) { diff --git a/frontend/src/lib/components/AdvancedFilters.svelte b/frontend/src/lib/components/AdvancedFilters.svelte index 9d025748..888b4f47 100644 --- a/frontend/src/lib/components/AdvancedFilters.svelte +++ b/frontend/src/lib/components/AdvancedFilters.svelte @@ -81,7 +81,7 @@ > Unique @@ -91,7 +91,7 @@ > Limited @@ -101,7 +101,7 @@ > Generic @@ -109,6 +109,27 @@ + +
+ Game Content +
+ +
+
+ {#if isPilotsTab}
diff --git a/frontend/src/lib/components/ShipChassisFilter.svelte b/frontend/src/lib/components/ShipChassisFilter.svelte index b6d37a27..cce26ba0 100644 --- a/frontend/src/lib/components/ShipChassisFilter.svelte +++ b/frontend/src/lib/components/ShipChassisFilter.svelte @@ -39,16 +39,18 @@ // Initial load onMount(async () => { isLoading = true; - ships = await fetchAllShips(filters.dataSource); + ships = await fetchAllShips(filters.dataSource, filters.includeEpic); isLoading = false; }); - // Re-fetch when data source changes + // Re-fetch when data source or includeEpic changes let currentDataSource = $state(filters.dataSource); + let currentIncludeEpic = $state(filters.includeEpic); $effect(() => { - if (currentDataSource !== filters.dataSource) { + if (currentDataSource !== filters.dataSource || currentIncludeEpic !== filters.includeEpic) { currentDataSource = filters.dataSource; - fetchAllShips(filters.dataSource).then((data) => (ships = data)); + currentIncludeEpic = filters.includeEpic; + fetchAllShips(filters.dataSource, filters.includeEpic).then((data) => (ships = data)); } }); diff --git a/frontend/src/routes/+page.svelte b/frontend/src/routes/+page.svelte index 81994791..f103aee9 100644 --- a/frontend/src/routes/+page.svelte +++ b/frontend/src/routes/+page.svelte @@ -17,7 +17,7 @@ error = false; errorMsg = ""; - const targetUrl = `/api/meta-snapshot?data_source=${source}`; + const targetUrl = `/api/meta-snapshot?data_source=${source}&include_epic=${filters.includeEpic}`; fetch(targetUrl) .then(async (res) => { if (!res.ok) { diff --git a/frontend/src/routes/api/meta-snapshot/+server.ts b/frontend/src/routes/api/meta-snapshot/+server.ts index e4c55a6e..8e66ecd9 100644 --- a/frontend/src/routes/api/meta-snapshot/+server.ts +++ b/frontend/src/routes/api/meta-snapshot/+server.ts @@ -3,8 +3,9 @@ import { API_BASE } from '$lib/api'; export async function GET({ url, fetch }) { const source = url.searchParams.get('data_source') || 'xwa'; + const includeEpic = url.searchParams.get('include_epic') || 'false'; try { - const res = await fetch(`${API_BASE}/meta-snapshot?data_source=${source}`); + const res = await fetch(`${API_BASE}/meta-snapshot?data_source=${source}&include_epic=${includeEpic}`); if (!res.ok) { throw new Error(`Backend error: ${res.status}`); } diff --git a/frontend/src/routes/cards/+page.svelte b/frontend/src/routes/cards/+page.svelte index 6383692a..53c849b6 100644 --- a/frontend/src/routes/cards/+page.svelte +++ b/frontend/src/routes/cards/+page.svelte @@ -21,8 +21,16 @@ let textSearch = $state(""); let selectedFactions = $state([]); let factionOpen = $state(false); + let isAdvanced = $state(false); const size = 20; + // Initialize store from URL if present + $effect.pre(() => { + if (data.includeEpic !== undefined) { + filters.includeEpic = data.includeEpic; + } + }); + let items = $derived(data.items ?? []); let total = $derived(data.total ?? 0); let isXwa = $derived(filters.dataSource === "xwa"); @@ -51,6 +59,8 @@ for (const c of filters.selectedCities) params.append("city", c); if (filters.dateStart) params.set("date_start", filters.dateStart); if (filters.dateEnd) params.set("date_end", filters.dateEnd); + params.set("include_epic", String(filters.includeEpic)); + // Advanced Filters if (filters.pointsMin) params.set("points_min", filters.pointsMin); diff --git a/frontend/src/routes/cards/+page.ts b/frontend/src/routes/cards/+page.ts index 5bb684bf..fd35cf4e 100644 --- a/frontend/src/routes/cards/+page.ts +++ b/frontend/src/routes/cards/+page.ts @@ -18,9 +18,16 @@ export const load: PageLoad = async ({ fetch, url }) => { const response = await fetch(apiUrl.toString()); if (!response.ok) throw new Error('Failed to fetch cards'); const data = await response.json(); - return { items: data.items, total: data.total, page: parseInt(data.page), size: parseInt(data.size), tab }; + return { + items: data.items, + total: data.total, + page: parseInt(data.page), + size: parseInt(data.size), + tab, + includeEpic: url.searchParams.get('include_epic') === 'true' + }; } catch (e) { console.error(e); - return { items: [], total: 0, page: 0, size: 20, tab }; + return { items: [], total: 0, page: 0, size: 20, tab, includeEpic: false }; } }; diff --git a/frontend/src/routes/lists/+page.svelte b/frontend/src/routes/lists/+page.svelte index 41edb266..e8b95d2a 100644 --- a/frontend/src/routes/lists/+page.svelte +++ b/frontend/src/routes/lists/+page.svelte @@ -15,13 +15,21 @@ let { data } = $props(); let page = $state(1); - let sortBy = $state("Games"); + const size = 20; + + // Initialize store from URL if present + $effect.pre(() => { + if (data.includeEpic !== undefined) { + filters.includeEpic = data.includeEpic; + } + }); + + let sortBy = $state(data.sort_metric || "Popularity"); let sortDirection = $state("desc"); let selectedFactions = $state([]); let factionOpen = $state(false); let minGames = $state(3); - const size = 20; let items = $derived(data.items ?? []); let total = $derived(data.total ?? 0); @@ -47,6 +55,8 @@ for (const c of filters.selectedCities) params.append("city", c); if (filters.dateStart) params.set("date_start", filters.dateStart); if (filters.dateEnd) params.set("date_end", filters.dateEnd); + params.set("include_epic", String(filters.includeEpic)); + goto(`?${params.toString()}`, { keepFocus: true, noScroll: true, diff --git a/frontend/src/routes/lists/+page.ts b/frontend/src/routes/lists/+page.ts index fb5b3b67..37f92145 100644 --- a/frontend/src/routes/lists/+page.ts +++ b/frontend/src/routes/lists/+page.ts @@ -18,9 +18,17 @@ export const load: PageLoad = async ({ fetch, url }) => { const response = await fetch(apiUrl.toString()); if (!response.ok) throw new Error('Failed to fetch lists'); const data = await response.json(); - return { items: data.items, total: data.total, page: parseInt(data.page), size: parseInt(data.size), sort_metric, sort_direction }; + return { + items: data.items, + total: data.total, + page: parseInt(data.page), + size: parseInt(data.size), + sort_metric, + sort_direction, + includeEpic: url.searchParams.get('include_epic') === 'true' + }; } catch (e) { console.error(e); - return { items: [], total: 0, page: 0, size: 20, sort_metric, sort_direction }; + return { items: [], total: 0, page: 0, size: 20, sort_metric, sort_direction, includeEpic: false }; } }; diff --git a/frontend/src/routes/pilot/[id]/+page.ts b/frontend/src/routes/pilot/[id]/+page.ts index 23eac507..cfd4404d 100644 --- a/frontend/src/routes/pilot/[id]/+page.ts +++ b/frontend/src/routes/pilot/[id]/+page.ts @@ -5,13 +5,14 @@ export const load: PageLoad = async ({ fetch, params, url }) => { url.search; // Force reactivity const pilotXws = params.id; const ds = url.searchParams.get('data_source') || 'xwa'; + const epic = url.searchParams.get('include_epic') || 'false'; // Fetch all 4 endpoints in parallel const [infoRes, upgradesRes, chartRes, configRes] = await Promise.allSettled([ - fetch(`${API_BASE}/pilot/${pilotXws}?data_source=${ds}`), - fetch(`${API_BASE}/pilot/${pilotXws}/upgrades?data_source=${ds}&size=50`), - fetch(`${API_BASE}/pilot/${pilotXws}/chart?data_source=${ds}`), - fetch(`${API_BASE}/pilot/${pilotXws}/configurations?data_source=${ds}&limit=10`), + fetch(`${API_BASE}/pilot/${pilotXws}?data_source=${ds}&include_epic=${epic}`), + fetch(`${API_BASE}/pilot/${pilotXws}/upgrades?data_source=${ds}&include_epic=${epic}&size=50`), + fetch(`${API_BASE}/pilot/${pilotXws}/chart?data_source=${ds}&include_epic=${epic}`), + fetch(`${API_BASE}/pilot/${pilotXws}/configurations?data_source=${ds}&include_epic=${epic}&limit=10`), ]); const info = infoRes.status === 'fulfilled' && infoRes.value.ok diff --git a/frontend/src/routes/ships/+page.svelte b/frontend/src/routes/ships/+page.svelte index 4af4c2de..5e8292d5 100644 --- a/frontend/src/routes/ships/+page.svelte +++ b/frontend/src/routes/ships/+page.svelte @@ -13,10 +13,18 @@ import { filters } from "$lib/stores/filters.svelte"; let { data } = $props(); + let page = $state(1); + const size = 50; + + // Initialize store from URL if present + $effect.pre(() => { + if (data.includeEpic !== undefined) { + filters.includeEpic = data.includeEpic; + } + }); let items = $derived(data.items ?? []); let total = $derived(data.total ?? 0); - let page = $state(1); let sortBy = $state("Popularity"); let sortDirection = $state("desc"); let selectedFactions = $state([]); @@ -43,6 +51,8 @@ for (const c of filters.selectedCities) params.append("city", c); if (filters.dateStart) params.set("date_start", filters.dateStart); if (filters.dateEnd) params.set("date_end", filters.dateEnd); + params.set("include_epic", String(filters.includeEpic)); + goto(`?${params.toString()}`, { keepFocus: true, diff --git a/frontend/src/routes/ships/+page.ts b/frontend/src/routes/ships/+page.ts index bf5304c9..55bb73df 100644 --- a/frontend/src/routes/ships/+page.ts +++ b/frontend/src/routes/ships/+page.ts @@ -15,9 +15,15 @@ export const load: PageLoad = async ({ fetch, url }) => { const response = await fetch(apiUrl.toString()); if (!response.ok) throw new Error('Failed to fetch ships'); const data = await response.json(); - return { items: data.items, total: data.total, page: parseInt(data.page), size: parseInt(data.size) }; + return { + items: data.items, + total: data.total, + page: parseInt(data.page), + size: parseInt(data.size), + includeEpic: url.searchParams.get('include_epic') === 'true' + }; } catch (e) { console.error(e); - return { items: [], total: 0, page: 0, size: 50 }; + return { items: [], total: 0, page: 0, size: 50, includeEpic: false }; } }; diff --git a/issue_65.txt b/issue_65.txt new file mode 100644 index 0000000000000000000000000000000000000000..d94dfe1c50a93d6663a3b28827c3c10074e4f8a2 GIT binary patch literal 2386 zcmb7`L2na54269KiT|(?5^52lNC*zS(LzzB7Al2`Ym?1331qinla_+`?ZEr&olFvP zL8~US_Kf}f{5lKp*^toTHV;t2Kqj*h>5AJ4W_Qxj{5pMX9^9c&-&aqaHbeQkGgz)DbaXbl4WkF;(_PUht3)40Dh3 zMYdAt!+vfry~?u7QK(9?rCo*X*-?iTPRF+Iv1;dy=u5{IypyF7H?`Lq+`P6|exfH% z@ITcp@|K@-FmQ>SU`YdN5NRc;Fqp zm7*5D!b%nJI+wjw6!u29M;?>CD1trouh7m=(88YUEh)Gsf76}cL|Nz*ioBJVXrO>C z*qiA=v0x8XH`+5$w#eIP4JLF$ZnTW&QXCcb-5G1N8o3`O!^23(U^FwHr|aHx)4{&t zQaudTiq7ty?5EQ8|8^nJZKFV=bnsfZseUPqGB;EK&!tvT>7MdWRKwi;+zDuU>2LI` z(D}JK$TSmiUl!(^TOf6Pt}_G0O)P{KQ&&RAQ)P~mFrIqHB$K7?;?>GVHwvE?aKaGr zD_OZKKwsItJV~W=CGLY-D=J*?992huneW6w?&Qq-$6eW|N|d$Pje*W*wu0?{>y7G$ z&fG*N`a&CuNjxY0?5;eJG5On?Wolq2GTb@ZX{R=rV1{8fD>7%;t&Lpa=aq`-D1F&A z)65|JkO}@klG#rfT6Kmf!DG(xM%{xi`WAks>00~JZ%mv(XHA!-RUhbKoZ7qtP*eEU z?}I#_x&L4;6^Rpv-qk{xnKLZMiV_oWr0*N?w@`hpSJ?MD=GysIHaZDfYQOMR+#>6~ zbl+|&6K>*HcJq#k%)tY>(e1Vlb46Lb!RmSyXTpP@SaGv_6OT+PaU0navZ**HX3msu z;)QLViK*kR57iMnSm*9wwY#o#j~sZ-`4-vcBxx;vBc?)%@>2Z=!n}g&hE^d@AIBB``4d`cGoMhg$-@4|3v?c<~FjarQvyDJw(V-3|6TiB^RqDT(zfie zW_I*@n9q&v+Aef0MQc~@zX>udZ(Jp0-|kpBsq6wUH-9tPivOJUOtbF@y<@5 zRh>o4iKq<3@6f)>M{QZ{2YYGH>{xebGJh8Qc4$wBKt-G?zcY8t@CtlKo@Fme-TE;vMgA*@iM2-;Z>UTw6CH;%m)= zHPkeuEh6Mll1H4}kVH-LfW^o` zP#5r6vCcTIJwp6h6=4*w^)TEdT!bujoNvyhi|EQcr->(IUmZN-w1v!s6-;{p@B8>;sM`|8f%ROb%;GJfB7Oy|z2APqjf2-!wwInl-YrFh7q)HewxGHAOOri7)+Z z;c*IrfyrgM-~cEEYJ=7w)44{V0SJ{;Xk3PO1FZsopojH9{-I_DvSXAd-s`fx%vX9R zkA9p1&J@AdqT*d_%qh^P8JVGa8hU$pAF0!OrZ)po=?AGIMm)cv&B@9lb7k?CIDpl3 zO$Vx-+o2A6B?Cd)nscNEk*OqYahNJ5skZ~saummk+mba}n-2Ry$r^Pll&$lPXv}oA zMYrlR>blDUD%0P_+RT1V1MfReqW_szfnoTlB^sPPuk=sv;3rm4kJ^pKom?8?pd5K~ zapQb>ptHuls@da}r|+L9)idyil==V6@{0N2$Slys&+_0H_%>F4w}o7eHegs&))jf& z)&8a8ewg>3%fUr|DJPa0ZX@qLsM12miSFIN9OSj z$iG9hZq&whz^#e)B2l%@t^0&U;OhN+tpB{NR$U?nJiTlEMZ)s=GpW_U`+t{+9sNI_ zf%z6#?^+Jzf3EednCQDz5~>6zi&#DP^bTb86Ova{zChmWX*~b@E7!i(uWMB#{2N}^ BeZBwy literal 0 HcmV?d00001 diff --git a/tmp/reproduce_issue.py b/tmp/reproduce_issue.py new file mode 100644 index 00000000..95545dd4 --- /dev/null +++ b/tmp/reproduce_issue.py @@ -0,0 +1,27 @@ + +from backend.analytics.core import aggregate_card_stats +from backend.data_structures.data_source import DataSource + +def test_epic_filter(): + filters = { + "allowed_formats": ["xwa"], + "include_epic": True + } + + # mode="pilots" + results = aggregate_card_stats(filters, mode="pilots", data_source=DataSource.XWA) + + # Look for "Alderaanian Guard" (alderaanianguard) + found = False + for r in results: + if r["xws"] == "alderaanianguard": + found = True + break + + if found: + print("SUCCESS: Alderaanian Guard found with include_epic=True") + else: + print("FAILURE: Alderaanian Guard NOT found with include_epic=True") + +if __name__ == "__main__": + test_epic_filter() From 20cfb5e162ffd66fa31ee206bc84990d6c5d6926 Mon Sep 17 00:00:00 2001 From: Francespo <95753785+Francespo@users.noreply.github.com> Date: Wed, 11 Mar 2026 11:16:29 +0100 Subject: [PATCH 2/3] cleanup: remove temporary verification and reproduction scripts --- backend/tests/test_epic_filter.py | 59 ------------------------------- tmp/reproduce_issue.py | 27 -------------- 2 files changed, 86 deletions(-) delete mode 100644 backend/tests/test_epic_filter.py delete mode 100644 tmp/reproduce_issue.py diff --git a/backend/tests/test_epic_filter.py b/backend/tests/test_epic_filter.py deleted file mode 100644 index a4487149..00000000 --- a/backend/tests/test_epic_filter.py +++ /dev/null @@ -1,59 +0,0 @@ -import pytest -from fastapi.testclient import TestClient -from backend.main import app -import os - -client = TestClient(app) - -def test_epic_filter_pilots(): - # 1. Default (epic should be excluded if include_epic=false) - response = client.get("/api/cards/pilots?include_epic=false&size=100") - assert response.status_code == 200 - data = response.json() - items = data.get("items", []) - - epic_xws = {'raiderclasscorvette', 'cr90corelliancorvette', 'gozanticlasscruiser', - 'gr75mediumtransport', 'cthc620cclasscorvette', 'croccruiser', 'tridentclassassaultship'} - - for item in items: - assert item['xws'] not in epic_xws, f"Epic pilot {item['xws']} found when filtered out" - -def test_epic_filter_upgrades(): - response = client.get("/api/cards/upgrades?include_epic=false&size=100") - assert response.status_code == 200 - data = response.json() - items = data.get("items", []) - - epic_types = {'huge ship turret', 'command', 'hardpoint', 'team', 'cargo'} - - for item in items: - assert item['type'].lower() not in epic_types, f"Epic upgrade type {item['type']} found when filtered out" - -def test_epic_filter_ships(): - response = client.get("/api/ships?include_epic=false&size=100") - assert response.status_code == 200 - data = response.json() - items = data.get("items", []) - - epic_ships = {'raider-class corvette', 'cr90 corellian corvette', 'gozanticlass cruiser', - 'gr-75 medium transport', 'cthc-620c-class corvette', 'c-roc cruiser', 'trident-class assault ship'} - - for item in items: - assert item['ship_name'].lower() not in epic_ships, f"Epic ship {item['ship_name']} found when filtered out" - -def test_meta_snapshot_epic(): - response = client.get("/api/meta-snapshot?include_epic=false") - assert response.status_code == 200 - data = response.json() - - # Check pilots in meta sample - # Check pilots in meta sample - epic_xws = {'raiderclasscorvette', 'cr90corelliancorvette', 'gozanticlasscruiser', - 'gr75mediumtransport', 'cthc620cclasscorvette', 'croccruiser', 'tridentclassassaultship'} - for p in data.get("pilots", []): - assert p['xws'] not in epic_xws, f"Epic pilot {p['xws']} found in meta snapshot" - - # Check upgrades in meta sample - epic_types = {'huge ship turret', 'command', 'hardpoint', 'team', 'cargo'} - for u in data.get("upgrades", []): - assert u['type'].lower() not in epic_types, f"Epic upgrade type {u['type']} found in meta snapshot" diff --git a/tmp/reproduce_issue.py b/tmp/reproduce_issue.py deleted file mode 100644 index 95545dd4..00000000 --- a/tmp/reproduce_issue.py +++ /dev/null @@ -1,27 +0,0 @@ - -from backend.analytics.core import aggregate_card_stats -from backend.data_structures.data_source import DataSource - -def test_epic_filter(): - filters = { - "allowed_formats": ["xwa"], - "include_epic": True - } - - # mode="pilots" - results = aggregate_card_stats(filters, mode="pilots", data_source=DataSource.XWA) - - # Look for "Alderaanian Guard" (alderaanianguard) - found = False - for r in results: - if r["xws"] == "alderaanianguard": - found = True - break - - if found: - print("SUCCESS: Alderaanian Guard found with include_epic=True") - else: - print("FAILURE: Alderaanian Guard NOT found with include_epic=True") - -if __name__ == "__main__": - test_epic_filter() From 2f85e2e94eb405aae46076bf432d0d5f01d8a629 Mon Sep 17 00:00:00 2001 From: Francespo <95753785+Francespo@users.noreply.github.com> Date: Sat, 14 Mar 2026 11:50:20 +0100 Subject: [PATCH 3/3] cleanup: remove temp files --- issue_65.txt | Bin 2386 -> 0 bytes issue_65_utf8.txt | 22 ---------------------- test_output.txt | Bin 5522 -> 0 bytes tmp.json | 0 4 files changed, 22 deletions(-) delete mode 100644 issue_65.txt delete mode 100644 issue_65_utf8.txt delete mode 100644 test_output.txt delete mode 100644 tmp.json diff --git a/issue_65.txt b/issue_65.txt deleted file mode 100644 index d94dfe1c50a93d6663a3b28827c3c10074e4f8a2..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 2386 zcmb7`L2na54269KiT|(?5^52lNC*zS(LzzB7Al2`Ym?1331qinla_+`?ZEr&olFvP zL8~US_Kf}f{5lKp*^toTHV;t2Kqj*h>5AJ4W_Qxj{5pMX9^9c&-&aqaHbeQkGgz)DbaXbl4WkF;(_PUht3)40Dh3 zMYdAt!+vfry~?u7QK(9?rCo*X*-?iTPRF+Iv1;dy=u5{IypyF7H?`Lq+`P6|exfH% z@ITcp@|K@-FmQ>SU`YdN5NRc;Fqp zm7*5D!b%nJI+wjw6!u29M;?>CD1trouh7m=(88YUEh)Gsf76}cL|Nz*ioBJVXrO>C z*qiA=v0x8XH`+5$w#eIP4JLF$ZnTW&QXCcb-5G1N8o3`O!^23(U^FwHr|aHx)4{&t zQaudTiq7ty?5EQ8|8^nJZKFV=bnsfZseUPqGB;EK&!tvT>7MdWRKwi;+zDuU>2LI` z(D}JK$TSmiUl!(^TOf6Pt}_G0O)P{KQ&&RAQ)P~mFrIqHB$K7?;?>GVHwvE?aKaGr zD_OZKKwsItJV~W=CGLY-D=J*?992huneW6w?&Qq-$6eW|N|d$Pje*W*wu0?{>y7G$ z&fG*N`a&CuNjxY0?5;eJG5On?Wolq2GTb@ZX{R=rV1{8fD>7%;t&Lpa=aq`-D1F&A z)65|JkO}@klG#rfT6Kmf!DG(xM%{xi`WAks>00~JZ%mv(XHA!-RUhbKoZ7qtP*eEU z?}I#_x&L4;6^Rpv-qk{xnKLZMiV_oWr0*N?w@`hpSJ?MD=GysIHaZDfYQOMR+#>6~ zbl+|&6K>*HcJq#k%)tY>(e1Vlb46Lb!RmSyXTpP@SaGv_6OT+PaU0navZ**HX3msu z;)QLViK*kR57iMnSm*9wwY#o#j~sZ-`4-vcBxx;vBc?)%@>2Z=!n}g&hE^d@AIBB``4d`cGoMhg$-@4|3v?c<~FjarQvyDJw(V-3|6TiB^RqDT(zfie zW_I*@n9q&v+Aef0MQc~@zX>udZ(Jp0-|kpBsq6wUH-9tPivOJUOtbF@y<@5 zRh>o4iKq<3@6f)>M{QZ{2YYGH>{xebGJh8Qc4$wBKt-G?zcY8t@CtlKo@Fme-TE;vMgA*@iM2-;Z>UTw6CH;%m)= zHPkeuEh6Mll1H4}kVH-LfW^o` zP#5r6vCcTIJwp6h6=4*w^)TEdT!bujoNvyhi|EQcr->(IUmZN-w1v!s6-;{p@B8>;sM`|8f%ROb%;GJfB7Oy|z2APqjf2-!wwInl-YrFh7q)HewxGHAOOri7)+Z z;c*IrfyrgM-~cEEYJ=7w)44{V0SJ{;Xk3PO1FZsopojH9{-I_DvSXAd-s`fx%vX9R zkA9p1&J@AdqT*d_%qh^P8JVGa8hU$pAF0!OrZ)po=?AGIMm)cv&B@9lb7k?CIDpl3 zO$Vx-+o2A6B?Cd)nscNEk*OqYahNJ5skZ~saummk+mba}n-2Ry$r^Pll&$lPXv}oA zMYrlR>blDUD%0P_+RT1V1MfReqW_szfnoTlB^sPPuk=sv;3rm4kJ^pKom?8?pd5K~ zapQb>ptHuls@da}r|+L9)idyil==V6@{0N2$Slys&+_0H_%>F4w}o7eHegs&))jf& z)&8a8ewg>3%fUr|DJPa0ZX@qLsM12miSFIN9OSj z$iG9hZq&whz^#e)B2l%@t^0&U;OhN+tpB{NR$U?nJiTlEMZ)s=GpW_U`+t{+9sNI_ zf%z6#?^+Jzf3EednCQDz5~>6zi&#DP^bTb86Ova{zChmWX*~b@E7!i(uWMB#{2N}^ BeZBwy diff --git a/tmp.json b/tmp.json deleted file mode 100644 index e69de29b..00000000