From d4bd20fd788affdba0b54329318eb7c09821af7d Mon Sep 17 00:00:00 2001 From: Romonaga Date: Sat, 13 Jun 2026 12:57:04 -0400 Subject: [PATCH] Fix Project Finder candidate counts and paging --- backend/api/inventory.py | 9 ++- db/lab_inventory.py | 68 +++++++++++++++-- .../components/ProjectCandidateFilters.tsx | 2 +- .../src/components/ProjectCandidateList.tsx | 4 +- frontend/src/components/ProjectFinderView.tsx | 76 ++++++++++++------- frontend/src/hooks/useInventory.ts | 40 ++++++++-- frontend/src/libs/api/inventory.ts | 19 ++++- frontend/src/styles.css | 10 +++ frontend/src/types/inventory.ts | 9 +++ tests/test_lab_inventory.py | 46 +++++++++++ 10 files changed, 238 insertions(+), 45 deletions(-) diff --git a/backend/api/inventory.py b/backend/api/inventory.py index 17bbd28..9b456a1 100644 --- a/backend/api/inventory.py +++ b/backend/api/inventory.py @@ -158,10 +158,15 @@ async def inventory_part_delete(part_id: str, req: Request): return {"ok": True} @router.get("/api/inventory/project-candidates") - async def inventory_project_candidates(req: Request, limit: int = 24): + async def inventory_project_candidates(req: Request, limit: int = 32, offset: int = 0, filter: str = "all"): user, error = deps.require_authenticated_user(req) if error: return error - return project_finder_store.find(deps.user_id_for_user(user), limit=max(1, min(int(limit), 80))) + return project_finder_store.find( + deps.user_id_for_user(user), + limit=max(1, min(int(limit), 50)), + offset=max(0, int(offset)), + candidate_filter=filter, + ) return router diff --git a/db/lab_inventory.py b/db/lab_inventory.py index bef2c48..6694fc1 100644 --- a/db/lab_inventory.py +++ b/db/lab_inventory.py @@ -30,6 +30,8 @@ ) CODE_SAMPLE_TITLE_RE = re.compile(r"\bcode\s+sample\b", re.IGNORECASE) MIN_PROJECT_TEXT_CHARS = 90 +PROJECT_FINDER_CHUNK_SOURCE_LIMIT = 1200 +PROJECT_FINDER_INTELLIGENCE_SOURCE_LIMIT = 400 GENERIC_PART_PATTERNS: tuple[tuple[re.Pattern[str], str, str], ...] = ( (re.compile(r"\b(?:ne|lm)?555\b|\b555\s+timer\b", re.IGNORECASE), "NE555 timer", "timer"), @@ -249,21 +251,30 @@ def __init__(self, database: Database, inventory_store: LabInventoryStore, logge self.inventory_store = inventory_store self.logger = logger - def find(self, user_id: int, *, limit: int = 24) -> dict[str, Any]: + def find(self, user_id: int, *, limit: int = 24, offset: int = 0, candidate_filter: str = "all") -> dict[str, Any]: + limit = max(1, int(limit)) + offset = max(0, int(offset)) inventory = self.inventory_store.list_parts(user_id) term_rows = self.inventory_store.inventory_terms(user_id) terms = self._search_terms(term_rows) if not inventory or not terms: - return {"inventoryCount": len(inventory), "candidates": []} + return self._finder_response( + inventory_count=len(inventory), + term_count=len(terms), + ranked=[], + limit=limit, + offset=offset, + candidate_filter=candidate_filter, + ) with self.database.connection() as conn: chunk_rows = conn.execute( load_query("project_finder_chunk_candidates.sql"), - (terms, min(max(limit * 6, 20), 200)), + (terms, PROJECT_FINDER_CHUNK_SOURCE_LIMIT), ).fetchall() intelligence_rows = conn.execute( load_query("project_finder_intelligence_candidates.sql"), - (terms, min(max(limit, 10), 80)), + (terms, PROJECT_FINDER_INTELLIGENCE_SOURCE_LIMIT), ).fetchall() inventory_index = self._inventory_index(inventory, term_rows) @@ -277,15 +288,58 @@ def find(self, user_id: int, *, limit: int = 24) -> dict[str, Any]: key=lambda item: (item["buildable"], item["score"], item["matchedPartCount"]), reverse=True, ) + return self._finder_response( + inventory_count=len(inventory), + term_count=len(terms), + ranked=ranked, + limit=limit, + offset=offset, + candidate_filter=candidate_filter, + ) + + def _finder_response( + self, + *, + inventory_count: int, + term_count: int, + ranked: list[dict[str, Any]], + limit: int, + offset: int, + candidate_filter: str, + ) -> dict[str, Any]: + normalized_filter = self._normalize_candidate_filter(candidate_filter) + filtered = self._filter_candidates(ranked, normalized_filter) + page = filtered[offset:offset + limit] return { - "inventoryCount": len(inventory), - "termCount": len(terms), + "inventoryCount": inventory_count, + "termCount": term_count, + "candidateCount": len(ranked), "buildableCount": sum(1 for candidate in ranked if candidate["buildable"]), "needsPartsCount": sum(1 for candidate in ranked if not candidate["buildable"]), "missingPartSummary": self._missing_part_summary(ranked), - "candidates": ranked[:limit], + "filter": normalized_filter, + "filterCount": len(filtered), + "offset": offset, + "limit": limit, + "returnedCount": len(page), + "hasMore": offset + len(page) < len(filtered), + "candidates": page, } + @staticmethod + def _normalize_candidate_filter(candidate_filter: str) -> str: + if candidate_filter in {"buildable", "needs-parts"}: + return candidate_filter + return "all" + + @staticmethod + def _filter_candidates(ranked: list[dict[str, Any]], candidate_filter: str) -> list[dict[str, Any]]: + if candidate_filter == "buildable": + return [candidate for candidate in ranked if candidate["buildable"]] + if candidate_filter == "needs-parts": + return [candidate for candidate in ranked if not candidate["buildable"]] + return ranked + def _search_terms(self, rows: list[dict[str, Any]]) -> list[str]: terms = OrderedDict() for row in rows: diff --git a/frontend/src/components/ProjectCandidateFilters.tsx b/frontend/src/components/ProjectCandidateFilters.tsx index bacabc9..c0bb72d 100644 --- a/frontend/src/components/ProjectCandidateFilters.tsx +++ b/frontend/src/components/ProjectCandidateFilters.tsx @@ -1,4 +1,4 @@ -export type ProjectCandidateFilter = "all" | "buildable" | "needs-parts"; +import type { ProjectCandidateFilter } from "../types"; const filters: Array<{ id: ProjectCandidateFilter; label: string }> = [ { id: "all", label: "All" }, diff --git a/frontend/src/components/ProjectCandidateList.tsx b/frontend/src/components/ProjectCandidateList.tsx index 293f4cb..fbfb138 100644 --- a/frontend/src/components/ProjectCandidateList.tsx +++ b/frontend/src/components/ProjectCandidateList.tsx @@ -4,12 +4,14 @@ import { ProjectCandidateCard } from "./projectFinder/ProjectCandidateCard"; export function ProjectCandidateList({ candidates, finding, + emptyLabel = "Run the finder after adding parts.", buildingId, buildingElapsedSeconds, onBuild }: { candidates: ProjectCandidate[]; finding: boolean; + emptyLabel?: string; buildingId: string; buildingElapsedSeconds: number; onBuild: (candidate: ProjectCandidate) => void; @@ -19,7 +21,7 @@ export function ProjectCandidateList({ } if (!candidates.length) { - return
Run the finder after adding parts.
; + return
{emptyLabel}
; } return ( diff --git a/frontend/src/components/ProjectFinderView.tsx b/frontend/src/components/ProjectFinderView.tsx index ca44988..ff9d2b4 100644 --- a/frontend/src/components/ProjectFinderView.tsx +++ b/frontend/src/components/ProjectFinderView.tsx @@ -1,13 +1,13 @@ -import { useMemo, useState } from "react"; +import { useState } from "react"; import { buildAssemblyPlan } from "../libs/api"; import { rememberBenchPlanSelection } from "../libs/benchSelection"; import { errorMessage } from "../libs/errors"; import { formatNumber } from "../libs/format"; import { useElapsedSeconds } from "../hooks/useElapsedSeconds"; import { useInventory } from "../hooks/useInventory"; -import type { AppConfig, ProjectCandidate, View } from "../types"; +import type { AppConfig, ProjectCandidate, ProjectCandidateFilter, View } from "../types"; import { ErrorMessage } from "./ErrorMessage"; -import { ProjectCandidateFilter, ProjectCandidateFilters } from "./ProjectCandidateFilters"; +import { ProjectCandidateFilters } from "./ProjectCandidateFilters"; import { ProjectCandidateList } from "./ProjectCandidateList"; import { ProjectFinderSummary } from "./ProjectFinderSummary"; import { SectionHeader } from "./SectionHeader"; @@ -25,6 +25,9 @@ export function ProjectFinderView({ parts, candidates, inventoryCount, + candidateCount, + filterCount, + candidateHasMore, buildableCount, needsPartsCount, missingPartSummary, @@ -39,24 +42,24 @@ export function ProjectFinderView({ const [candidateFilter, setCandidateFilter] = useState("all"); const buildingElapsedSeconds = useElapsedSeconds(Boolean(buildingId)); - const filteredCandidates = useMemo(() => { - if (candidateFilter === "buildable") { - return candidates.filter((candidate) => candidate.buildable); - } - if (candidateFilter === "needs-parts") { - return candidates.filter((candidate) => !candidate.buildable); - } - return candidates; - }, [candidateFilter, candidates]); + const candidateCounts: Record = { + all: candidateCount, + buildable: buildableCount, + "needs-parts": needsPartsCount + }; + const activeFilterTotal = candidateCounts[candidateFilter] || filterCount || candidates.length; + const hasFinderRun = candidateCount > 0 || buildableCount > 0 || needsPartsCount > 0 || candidates.length > 0; - const candidateCounts = useMemo( - () => ({ - all: candidates.length, - buildable: candidates.filter((candidate) => candidate.buildable).length, - "needs-parts": candidates.filter((candidate) => !candidate.buildable).length - }), - [candidates] - ); + function runFinder(filter = candidateFilter, append = false) { + void findProjects(filter, { append }); + } + + function changeCandidateFilter(filter: ProjectCandidateFilter) { + setCandidateFilter(filter); + if (hasFinderRun) { + runFinder(filter); + } + } async function createBenchPlan(candidate: ProjectCandidate) { setBuildingId(candidate.id); @@ -91,11 +94,11 @@ export function ProjectFinderView({
- - + +
- +
+ ) : null} ); } +function filterDescription(filter: ProjectCandidateFilter) { + if (filter === "buildable") { + return "buildable"; + } + if (filter === "needs-parts") { + return "needs-parts"; + } + return "project"; +} + function FinderMetric({ label, value, suffix }: { label: string; value: number; suffix: string }) { return (
diff --git a/frontend/src/hooks/useInventory.ts b/frontend/src/hooks/useInventory.ts index da17eb2..13cd89c 100644 --- a/frontend/src/hooks/useInventory.ts +++ b/frontend/src/hooks/useInventory.ts @@ -1,9 +1,10 @@ import { useCallback, useEffect, useState } from "react"; import { deleteInventoryPart, getInventoryLocations, getInventoryParts, getProjectCandidates, saveInventoryPart } from "../libs/api"; import { errorMessage } from "../libs/errors"; -import type { InventoryLocation, InventoryPart, InventoryPartInput, ProjectCandidate, ProjectMissingPartSummary } from "../types"; +import type { InventoryLocation, InventoryPart, InventoryPartInput, ProjectCandidate, ProjectCandidateFilter, ProjectMissingPartSummary } from "../types"; const emptyCandidates: ProjectCandidate[] = []; +const projectFinderPageSize = 32; export function useInventory(isActive: boolean) { const [parts, setParts] = useState([]); @@ -12,6 +13,12 @@ export function useInventory(isActive: boolean) { const [loading, setLoading] = useState(false); const [finding, setFinding] = useState(false); const [inventoryCount, setInventoryCount] = useState(0); + const [candidateCount, setCandidateCount] = useState(0); + const [filterCount, setFilterCount] = useState(0); + const [candidateOffset, setCandidateOffset] = useState(0); + const [candidateLimit, setCandidateLimit] = useState(projectFinderPageSize); + const [candidateHasMore, setCandidateHasMore] = useState(false); + const [candidateFilter, setCandidateFilter] = useState("all"); const [buildableCount, setBuildableCount] = useState(0); const [needsPartsCount, setNeedsPartsCount] = useState(0); const [missingPartSummary, setMissingPartSummary] = useState([]); @@ -43,22 +50,34 @@ export function useInventory(isActive: boolean) { await Promise.all([loadParts(), loadLocations()]); }, [loadLocations, loadParts]); - const findProjects = useCallback(async () => { + const findProjects = useCallback(async (filter: ProjectCandidateFilter = "all", options: { append?: boolean } = {}) => { setFinding(true); setError(""); + const append = Boolean(options.append); + const nextOffset = append ? candidateOffset + candidateLimit : 0; try { - const response = await getProjectCandidates(32); + const response = await getProjectCandidates({ + limit: projectFinderPageSize, + offset: nextOffset, + filter + }); setInventoryCount(response.inventoryCount); + setCandidateCount(response.candidateCount || 0); + setFilterCount(response.filterCount ?? response.candidates.length); + setCandidateOffset(response.offset || 0); + setCandidateLimit(response.limit || projectFinderPageSize); + setCandidateHasMore(Boolean(response.hasMore)); + setCandidateFilter(response.filter || filter); setBuildableCount(response.buildableCount || 0); setNeedsPartsCount(response.needsPartsCount || 0); setMissingPartSummary(response.missingPartSummary || []); - setCandidates(response.candidates); + setCandidates((current) => append ? [...current, ...response.candidates] : response.candidates); } catch (err) { setError(errorMessage(err, "Could not find project candidates")); } finally { setFinding(false); } - }, []); + }, [candidateLimit, candidateOffset]); const savePart = useCallback( async (part: InventoryPartInput) => { @@ -85,6 +104,11 @@ export function useInventory(isActive: boolean) { await deleteInventoryPart(partId); setParts((current) => current.filter((part) => part.id !== partId)); setCandidates(emptyCandidates); + setCandidateCount(0); + setFilterCount(0); + setCandidateOffset(0); + setCandidateHasMore(false); + setCandidateFilter("all"); setBuildableCount(0); setNeedsPartsCount(0); setMissingPartSummary([]); @@ -126,6 +150,12 @@ export function useInventory(isActive: boolean) { locations, candidates, inventoryCount, + candidateCount, + filterCount, + candidateOffset, + candidateLimit, + candidateHasMore, + candidateFilter, buildableCount, needsPartsCount, missingPartSummary, diff --git a/frontend/src/libs/api/inventory.ts b/frontend/src/libs/api/inventory.ts index 462c4bc..de8ab67 100644 --- a/frontend/src/libs/api/inventory.ts +++ b/frontend/src/libs/api/inventory.ts @@ -1,4 +1,4 @@ -import type { InventoryImportItem, InventoryImportPreview, InventoryLocation, InventoryPart, InventoryPartInput, ProjectFinderResponse } from "../../types"; +import type { InventoryImportItem, InventoryImportPreview, InventoryLocation, InventoryPart, InventoryPartInput, ProjectCandidateFilter, ProjectFinderResponse } from "../../types"; import { requestJson } from "./core"; export function getInventoryParts(): Promise<{ parts: InventoryPart[] }> { @@ -53,6 +53,19 @@ export function previewInventoryPhotoImport(file: File, note = ""): Promise { - return requestJson(`/api/inventory/project-candidates?limit=${encodeURIComponent(String(limit))}`); +export function getProjectCandidates({ + limit = 32, + offset = 0, + filter = "all" +}: { + limit?: number; + offset?: number; + filter?: ProjectCandidateFilter; +} = {}): Promise { + const params = new URLSearchParams({ + limit: String(limit), + offset: String(offset), + filter + }); + return requestJson(`/api/inventory/project-candidates?${params.toString()}`); } diff --git a/frontend/src/styles.css b/frontend/src/styles.css index e6e822f..6d9d067 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -2099,6 +2099,16 @@ nav { font-size: 0.82rem; } +.project-candidate-pagination { + display: flex; + justify-content: center; + padding: 2px 0 4px; +} + +.project-candidate-pagination .ghost-button { + min-width: 220px; +} + .project-candidate { display: grid; gap: 12px; diff --git a/frontend/src/types/inventory.ts b/frontend/src/types/inventory.ts index c280b49..ab92b7f 100644 --- a/frontend/src/types/inventory.ts +++ b/frontend/src/types/inventory.ts @@ -101,11 +101,20 @@ export interface ProjectMissingPartSummary { exampleTitles: string[]; } +export type ProjectCandidateFilter = "all" | "buildable" | "needs-parts"; + export interface ProjectFinderResponse { inventoryCount: number; termCount?: number; + candidateCount?: number; buildableCount?: number; needsPartsCount?: number; missingPartSummary?: ProjectMissingPartSummary[]; + filter?: ProjectCandidateFilter; + filterCount?: number; + offset?: number; + limit?: number; + returnedCount?: number; + hasMore?: boolean; candidates: ProjectCandidate[]; } diff --git a/tests/test_lab_inventory.py b/tests/test_lab_inventory.py index 2b2c86d..6f391b8 100644 --- a/tests/test_lab_inventory.py +++ b/tests/test_lab_inventory.py @@ -186,6 +186,52 @@ def test_missing_part_summary_ranks_repeated_gaps(self): self.assertEqual(summary[0]["count"], 2) self.assertEqual(summary[0]["exampleTitles"], ["LED flasher", "Timer alarm"]) + def test_finder_response_pages_selected_filter_with_global_counts(self): + store = ProjectFinderStore(None, None) + ranked = [ + {"id": "buildable-1", "buildable": True, "missingParts": []}, + {"id": "buildable-2", "buildable": True, "missingParts": []}, + {"id": "needs-1", "buildable": False, "missingParts": [{"name": "Breadboard", "type": "tooling"}], "title": "Needs one"}, + {"id": "needs-2", "buildable": False, "missingParts": [{"name": "LED", "type": "indicator"}], "title": "Needs two"}, + ] + + response = store._finder_response( + inventory_count=3, + term_count=5, + ranked=ranked, + limit=1, + offset=0, + candidate_filter="needs-parts", + ) + + self.assertEqual(response["candidateCount"], 4) + self.assertEqual(response["buildableCount"], 2) + self.assertEqual(response["needsPartsCount"], 2) + self.assertEqual(response["filter"], "needs-parts") + self.assertEqual(response["filterCount"], 2) + self.assertEqual(response["candidates"], [ranked[2]]) + self.assertTrue(response["hasMore"]) + + def test_finder_response_offsets_selected_filter(self): + store = ProjectFinderStore(None, None) + ranked = [ + {"id": "buildable-1", "buildable": True, "missingParts": []}, + {"id": "needs-1", "buildable": False, "missingParts": [{"name": "Breadboard", "type": "tooling"}], "title": "Needs one"}, + {"id": "needs-2", "buildable": False, "missingParts": [{"name": "LED", "type": "indicator"}], "title": "Needs two"}, + ] + + response = store._finder_response( + inventory_count=3, + term_count=5, + ranked=ranked, + limit=1, + offset=1, + candidate_filter="needs-parts", + ) + + self.assertEqual(response["candidates"], [ranked[2]]) + self.assertFalse(response["hasMore"]) + if __name__ == "__main__": unittest.main()