feat: add Stalker results page with filtering and pagination

- Introduced StalkerResultItem and StalkerResultsResponse types for handling API responses.
- Implemented StalkerExplorer component for displaying Stalker results with search and filter options.
- Added sorting functionality for Stalker results table.
- Enhanced Dashboard to include a button for navigating to Stalker results.
- Updated routing to support Stalker results page.
- Improved styles for section headers and inventory columns in the results table.
This commit is contained in:
Victor Noguera
2026-05-19 18:10:01 -04:00
parent 0f9b785cce
commit a7c0e44e3d
7 changed files with 2037 additions and 3 deletions

View File

@@ -53,6 +53,31 @@ type ProductListRecord = {
fetched_at: string;
};
type StalkerResultRecord = {
runId: number;
started_at: string;
status: string;
input_file: string;
source_asin: string;
title: string | null;
offer_count: number;
candidate_seller_count: number;
matched_seller_count: number;
scan_fetched_at: string;
seller_id: string;
seller_name: string | null;
rating: number | null;
rating_count: number | null;
storefront_asin_total: number | null;
persisted_inventory_sample_count: number | null;
offer_price: number | null;
condition: string | null;
is_fba: number | null;
stock: number | null;
persisted_inventory_asin_count: number;
inventory_sample_asins: string | null;
};
const DB_PATH = process.env.RESULTS_DB_PATH || path.join("db", "results.db");
const DEFAULT_PAGE_SIZE = 25;
const MAX_PAGE_SIZE = 200;
@@ -629,6 +654,170 @@ function getProductList(filters: URLSearchParams) {
};
}
function parseStalkerFilters(filters: URLSearchParams) {
const q = filters.get("q")?.trim() || "";
const sellerId = filters.get("sellerId")?.trim().toUpperCase() || "";
const runIdRaw = filters.get("runId")?.trim() || "";
const minRatingCountRaw = filters.get("minRatingCount")?.trim() || "";
const maxRatingCountRaw = filters.get("maxRatingCount")?.trim() || "";
const conditions: string[] = [];
const params: Array<string | number> = [];
if (runIdRaw) {
const runId = Number(runIdRaw);
if (Number.isInteger(runId) && runId > 0) {
conditions.push("r.id = ?");
params.push(runId);
}
}
if (sellerId) {
conditions.push("s.seller_id = ?");
params.push(sellerId);
}
if (minRatingCountRaw) {
conditions.push("s.rating_count >= ?");
params.push(Number(minRatingCountRaw));
}
if (maxRatingCountRaw) {
conditions.push("s.rating_count <= ?");
params.push(Number(maxRatingCountRaw));
}
if (q) {
const wildcard = `%${q}%`;
conditions.push(
`(sc.source_asin LIKE ? OR sc.title LIKE ? OR s.seller_id LIKE ? OR s.seller_name LIKE ? OR EXISTS (
SELECT 1 FROM stalker_seller_inventory inv_q
WHERE inv_q.run_id = r.id
AND inv_q.seller_id = s.seller_id
AND inv_q.asin LIKE ?
))`,
);
params.push(wildcard, wildcard, wildcard, wildcard, wildcard);
}
return {
where: conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "",
params,
};
}
function parseStalkerSort(sortParam: string | null): string {
const allowedSort = new Set([
"runId",
"started_at",
"source_asin",
"title",
"seller_id",
"seller_name",
"rating",
"rating_count",
"offer_price",
"stock",
"persisted_inventory_asin_count",
"storefront_asin_total",
"scan_fetched_at",
]);
const parsed = parseSort(
sortParam,
allowedSort,
"started_at DESC, runId DESC, source_asin ASC",
);
return parsed
.replaceAll("runId", "runId")
.replaceAll("rating_count", "rating_count")
.replaceAll("persisted_inventory_asin_count", "persisted_inventory_asin_count")
.replaceAll("storefront_asin_total", "storefront_asin_total");
}
function getStalkerResults(filters: URLSearchParams) {
const page = parseIntParam(filters.get("page"), 1);
const pageSize = Math.min(
parseIntParam(filters.get("pageSize"), DEFAULT_PAGE_SIZE),
MAX_PAGE_SIZE,
);
const offset = (page - 1) * pageSize;
const { where, params } = parseStalkerFilters(filters);
const orderBy = parseStalkerSort(filters.get("sort"));
const baseSelect = `
SELECT
r.id AS runId,
r.started_at,
r.status,
r.input_file,
sc.source_asin,
sc.title,
sc.offer_count,
sc.candidate_seller_count,
sc.matched_seller_count,
sc.fetched_at AS scan_fetched_at,
s.seller_id,
s.seller_name,
s.rating,
s.rating_count,
s.storefront_asin_total,
s.persisted_inventory_sample_count,
sas.offer_price,
sas.condition,
sas.is_fba,
sas.stock,
COUNT(DISTINCT inv.asin) AS persisted_inventory_asin_count,
GROUP_CONCAT(DISTINCT inv.asin) AS inventory_sample_asins
FROM stalker_asin_sellers sas
JOIN stalker_asin_scans sc ON sc.id = sas.scan_id
JOIN stalker_runs r ON r.id = sc.run_id
JOIN stalker_sellers s ON s.seller_id = sas.seller_id
LEFT JOIN stalker_seller_inventory inv
ON inv.run_id = r.id
AND inv.seller_id = s.seller_id
${where}
GROUP BY sas.id
`;
const totalRow = db
.query(`SELECT COUNT(*) AS total FROM (${baseSelect}) stalker_rows`)
.get(...params) as { total: number };
const summary = db
.query(
`SELECT
COUNT(DISTINCT runId) AS runs,
COUNT(DISTINCT source_asin) AS sourceAsins,
COUNT(DISTINCT seller_id) AS sellers,
COALESCE(SUM(persisted_inventory_asin_count), 0) AS persistedInventoryAsins
FROM (${baseSelect}) stalker_rows`,
)
.get(...params) as {
runs: number;
sourceAsins: number;
sellers: number;
persistedInventoryAsins: number;
};
const items = db
.query(
`SELECT * FROM (${baseSelect}) stalker_rows
ORDER BY ${orderBy}
LIMIT ? OFFSET ?`,
)
.all(...params, pageSize, offset) as StalkerResultRecord[];
return {
items,
summary,
page,
pageSize,
total: totalRow.total,
totalPages: Math.max(1, Math.ceil(totalRow.total / pageSize)),
};
}
function getRun(processType: ProcessType, runId: number) {
if (processType === "lead_analysis") {
const run = db
@@ -1259,6 +1448,7 @@ const server = Bun.serve({
routes: {
"/": index,
"/products": index,
"/stalker": index,
"/runs/:processType/:runId": index,
"/api/runs": (req) => {
const url = new URL(req.url);
@@ -1268,6 +1458,10 @@ const server = Bun.serve({
const url = new URL(req.url);
return json(getProductList(url.searchParams));
},
"/api/stalker/results": (req) => {
const url = new URL(req.url);
return json(getStalkerResults(url.searchParams));
},
"/api/upc/map": async (req) => {
let upcs: string[];
try {