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({
-
-
+
+
-