feat: add frontend dashboard for run results viewer

- Implemented main dashboard with run metrics and filtering options.
- Created detailed view for individual runs with results and anomalies.
- Added product listing page with filtering and pagination.
- Introduced utility functions for formatting dates and numbers.
- Styled components with CSS for a clean and responsive layout.
- Set up HTML entry point and linked to the main JavaScript file.
- Updated TypeScript configuration to include DOM types.
This commit is contained in:
Victor Noguera
2026-04-13 02:36:35 -04:00
parent a906f5ede3
commit 281bc7dcc9
14 changed files with 2484 additions and 567 deletions

View File

@@ -13,6 +13,7 @@ import type {
SellabilityInfo,
SpApiData,
} from "./types.ts";
type CategoryInfo = {
id: number;
@@ -36,7 +37,7 @@ type CategoryRunSummary = {
fba: number;
fbm: number;
skip: number;
status: "ok" | "empty" | "failed";
status: "running" | "ok" | "empty" | "failed";
error: string;
runId?: number;
results?: AnalysisResult[];
@@ -158,6 +159,37 @@ export async function insertCategoryRunSummary(
return Number(result.lastInsertRowid);
}
export async function updateCategoryRunSummary(
db: Database,
runId: number,
summary: Pick<CategoryRunSummary, "topAsinsChecked" | "availableAsins" | "fba" | "fbm" | "skip" | "status" | "error">,
): Promise<void> {
db.run(
`
UPDATE category_analysis_runs
SET
top_asins_checked = ?,
available_asins = ?,
fba_count = ?,
fbm_count = ?,
skip_count = ?,
status = ?,
error_message = ?
WHERE id = ?
`,
[
summary.topAsinsChecked,
summary.availableAsins,
summary.fba,
summary.fbm,
summary.skip,
summary.status,
summary.error,
runId,
],
);
}
export async function insertProductAnalysisResults(
db: Database,
runId: number,
@@ -817,16 +849,96 @@ function buildEnrichedProducts(
});
}
async function runLlmInBatches(
products: EnrichedProduct[],
): Promise<LlmVerdict[]> {
const verdicts: LlmVerdict[] = [];
export async function processCategory(
db: Database,
runId: number,
category: CategoryInfo,
perCategoryTop: number,
): Promise<CategoryRunSummary> {
log("info", `\nCategory ${category.label} (${category.id})`);
for (let i = 0; i < products.length; i += LLM_BATCH_SIZE) {
const batch = products.slice(i, i + LLM_BATCH_SIZE);
const topAsins = await fetchCategoryBestSellerAsins(category, perCategoryTop);
if (topAsins.length === 0) {
log("info", " Keepa returned no ASINs for this category.");
await updateCategoryRunSummary(db, runId, {
topAsinsChecked: 0,
availableAsins: 0,
fba: 0,
fbm: 0,
skip: 0,
status: "empty",
error: "No ASINs returned by Keepa",
});
return {
categoryId: category.id,
categoryLabel: category.label,
topAsinsChecked: 0,
availableAsins: 0,
fba: 0,
fbm: 0,
skip: 0,
status: "empty",
error: "No ASINs returned by Keepa",
results: [],
};
}
const uniqueTopAsins = Array.from(new Set(topAsins));
if (uniqueTopAsins.length !== topAsins.length) {
log("warn", ` Removed ${topAsins.length - uniqueTopAsins.length} duplicate ASINs before analysis.`);
}
log("info", ` Top ASINs fetched: ${uniqueTopAsins.length}`);
const sellabilityMap = await fetchSellabilityMap(uniqueTopAsins);
const availableAsins = uniqueTopAsins.filter((asin) => {
const info = sellabilityMap.get(asin);
return info?.canSell === true && info.sellabilityStatus === "available";
});
log("info", ` Sellable ASINs: ${availableAsins.length}/${uniqueTopAsins.length}`);
if (availableAsins.length === 0) {
await updateCategoryRunSummary(db, runId, {
topAsinsChecked: uniqueTopAsins.length,
availableAsins: 0,
fba: 0,
fbm: 0,
skip: 0,
status: "empty",
error: "No sellable ASINs",
});
return {
categoryId: category.id,
categoryLabel: category.label,
topAsinsChecked: uniqueTopAsins.length,
availableAsins: 0,
fba: 0,
fbm: 0,
skip: 0,
status: "empty",
error: "No sellable ASINs",
results: [],
};
}
const keepaEnrichment = await fetchKeepaEnrichmentMap(availableAsins);
const spApiMap = await fetchSpApiMap(availableAsins, sellabilityMap);
const enrichedProducts = buildEnrichedProducts(
availableAsins,
sellabilityMap,
spApiMap,
keepaEnrichment,
);
const results: AnalysisResult[] = [];
let fba = 0;
let fbm = 0;
let skip = 0;
const totalBatches = Math.ceil(enrichedProducts.length / LLM_BATCH_SIZE);
for (let i = 0; i < enrichedProducts.length; i += LLM_BATCH_SIZE) {
const batch = enrichedProducts.slice(i, i + LLM_BATCH_SIZE);
const batchNum = Math.floor(i / LLM_BATCH_SIZE) + 1;
const totalBatches = Math.ceil(products.length / LLM_BATCH_SIZE);
log("info", ` LLM batch ${batchNum}/${totalBatches}...`);
let batchVerdicts: LlmVerdict[];
@@ -843,116 +955,64 @@ async function runLlmInBatches(
}));
}
verdicts.push(...batchVerdicts);
const verdictByAsin = new Map(batchVerdicts.map((v) => [v.asin, v]));
const batchResults: AnalysisResult[] = batch.map((product) => ({
product,
verdict: verdictByAsin.get(product.record.asin) ?? {
asin: product.record.asin,
verdict: "SKIP",
confidence: 0,
reasoning: "LLM returned no verdict",
},
}));
if (i + LLM_BATCH_SIZE < products.length) {
await insertProductAnalysisResults(db, runId, batchResults);
for (const result of batchResults) {
results.push(result);
if (result.verdict.verdict === "FBA") {
fba++;
} else if (result.verdict.verdict === "FBM") {
fbm++;
} else {
skip++;
}
}
await updateCategoryRunSummary(db, runId, {
topAsinsChecked: uniqueTopAsins.length,
availableAsins: availableAsins.length,
fba,
fbm,
skip,
status: "running",
error: "",
});
log(
"info",
` Persisted batch ${batchNum}/${totalBatches} (${batchResults.length} rows, totals FBA/FBM/SKIP=${fba}/${fbm}/${skip})`,
);
if (i + LLM_BATCH_SIZE < enrichedProducts.length) {
await sleep(1500);
}
}
return verdicts;
}
export async function processCategory(
db: Database,
category: CategoryInfo,
perCategoryTop: number,
): Promise<CategoryRunSummary> {
log("info", `\nCategory ${category.label} (${category.id})`);
const topAsins = await fetchCategoryBestSellerAsins(category, perCategoryTop);
if (topAsins.length === 0) {
log("info", " Keepa returned no ASINs for this category.");
return {
categoryId: category.id,
categoryLabel: category.label,
topAsinsChecked: 0,
availableAsins: 0,
fba: 0,
fbm: 0,
skip: 0,
status: "empty",
error: "No ASINs returned by Keepa",
results: [],
};
}
log("info", ` Top ASINs fetched: ${topAsins.length}`);
const sellabilityMap = await fetchSellabilityMap(topAsins);
const availableAsins = topAsins.filter((asin) => {
const info = sellabilityMap.get(asin);
return info?.canSell === true && info.sellabilityStatus === "available";
await updateCategoryRunSummary(db, runId, {
topAsinsChecked: uniqueTopAsins.length,
availableAsins: availableAsins.length,
fba,
fbm,
skip,
status: "ok",
error: "",
});
log("info", ` Sellable ASINs: ${availableAsins.length}/${topAsins.length}`);
if (availableAsins.length === 0) {
return {
categoryId: category.id,
categoryLabel: category.label,
topAsinsChecked: topAsins.length,
availableAsins: 0,
fba: 0,
fbm: 0,
skip: 0,
status: "empty",
error: "No sellable ASINs",
results: [],
};
}
const keepaEnrichment = await fetchKeepaEnrichmentMap(availableAsins);
const spApiMap = await fetchSpApiMap(availableAsins, sellabilityMap);
const titleByAsin = new Map<string, string>();
const keepaMap = new Map<string, KeepaData>();
for (const asin of availableAsins) {
const enriched = keepaEnrichment.get(asin);
if (enriched?.title) {
titleByAsin.set(asin, enriched.title);
}
if (enriched?.keepa) {
keepaMap.set(asin, enriched.keepa);
}
}
const enrichedProducts = buildEnrichedProducts(
availableAsins,
sellabilityMap,
spApiMap,
keepaEnrichment,
);
const verdicts = await runLlmInBatches(enrichedProducts);
const verdictByAsin = new Map(verdicts.map((v) => [v.asin, v]));
const results: AnalysisResult[] = enrichedProducts.map((product) => ({
product,
verdict: verdictByAsin.get(product.record.asin) ?? {
asin: product.record.asin,
verdict: "SKIP",
confidence: 0,
reasoning: "LLM returned no verdict",
},
}));
// No longer writing to XLSX, directly insert into DB
// const outputName = `${sanitizeFileSegment(category.label)}_${category.id}.xlsx`;
// const outputPath = path.join(outputDir, outputName);
// writeCategoryResultsWorkbook(results, outputPath);
// The categoryRunId will be provided by the main function after inserting the summary
// We need to pass it here or get it after inserting the summary in main.
// For now, let's assume it's handled in main.
const fba = results.filter((r) => r.verdict.verdict === "FBA").length;
const fbm = results.filter((r) => r.verdict.verdict === "FBM").length;
const skip = results.filter((r) => r.verdict.verdict === "SKIP").length;
return {
categoryId: category.id,
categoryLabel: category.label,
topAsinsChecked: topAsins.length,
topAsinsChecked: uniqueTopAsins.length,
availableAsins: availableAsins.length,
fba,
fbm,
@@ -968,7 +1028,7 @@ export async function main(): Promise<void> {
assertSpApiPrerequisites();
mkdirSync(args.outputDir, { recursive: true });
const DB_PATH = path.join(args.outputDir, "analysis.sqlite");
const DB_PATH = process.env.RESULTS_DB_PATH || path.join(process.cwd(), "results.db");
initDb(DB_PATH);
const db = getDb(DB_PATH);
@@ -1001,23 +1061,33 @@ export async function main(): Promise<void> {
for (const category of allowedCategories) {
let categorySummary: CategoryRunSummary;
let runId: number | undefined;
try {
runId = await insertCategoryRunSummary(
db,
{
categoryId: category.id,
categoryLabel: category.label,
topAsinsChecked: 0,
availableAsins: 0,
fba: 0,
fbm: 0,
skip: 0,
status: "running",
error: "",
results: [],
},
runTimestamp,
);
categorySummary = await processCategory(
db,
runId,
category,
args.perCategoryTop,
);
const runId = await insertCategoryRunSummary(
db,
categorySummary,
runTimestamp,
);
if (categorySummary.results) {
await insertProductAnalysisResults(db, runId, categorySummary.results);
totalInsertedAsins += categorySummary.results.length;
}
totalInsertedAsins += categorySummary.results?.length ?? 0;
processedCategories++;
allCategorySummaries.push({ ...categorySummary, runId });
@@ -1039,8 +1109,19 @@ export async function main(): Promise<void> {
error: message,
results: [],
};
if (runId) {
await updateCategoryRunSummary(db, runId, {
topAsinsChecked: 0,
availableAsins: 0,
fba: 0,
fbm: 0,
skip: 0,
status: "failed",
error: message,
});
}
processedCategories++;
allCategorySummaries.push(categorySummary);
allCategorySummaries.push({ ...categorySummary, runId });
}
}