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

@@ -1,17 +1,51 @@
import { test, expect, beforeAll, afterAll, beforeEach, mock } from "bun:test";
import { Database } from "bun:sqlite";
import { getDb, initDb, closeDb } from "./database";
import { getDb, initDb, closeDb } from "./database.ts";
import path from "node:path";
import { rmSync, mkdirSync } from "node:fs";
import {
main,
processCategory,
insertCategoryRunSummary,
insertProductAnalysisResults,
} from "./bestsellers-by-category";
import * as keepaModule from "./keepa";
import * as spApiModule from "./sp-api";
import * as llmModule from "./llm";
const fetchSellabilityBatchMock = mock(async (asins: string[]) => {
return new Map(
asins.map((asin) => [
asin,
{
canSell: true,
sellabilityStatus: "available" as const,
sellabilityReason: "ok",
},
]),
);
});
const fetchSpApiPricingAndFeesMock = mock(async () => ({
fbaFee: 4,
fbmFee: 2,
referralFeePercent: 15,
estimatedSalePrice: 25,
canSell: true,
sellabilityStatus: "available" as const,
sellabilityReason: "ok",
}));
const analyzeProductsMock = mock(async (products: any[]) => {
return products.map((p, idx) => ({
asin: p.record.asin,
verdict: idx === 0 ? "FBA" : "FBM",
confidence: 90,
reasoning: "mocked",
}));
});
mock.module("./sp-api.ts", () => ({
fetchSellabilityBatch: fetchSellabilityBatchMock,
fetchSpApiPricingAndFees: fetchSpApiPricingAndFeesMock,
}));
mock.module("./llm.ts", () => ({
analyzeProducts: analyzeProductsMock,
}));
const modulePromise = import("./bestsellers-by-category.ts");
const DB_TEST_PATH = path.join(
process.cwd(),
@@ -20,24 +54,81 @@ const DB_TEST_PATH = path.join(
);
let db: Database;
let processCategory: (db: Database, runId: number, category: any, perCategoryTop: number) => Promise<any>;
let insertCategoryRunSummary: (db: Database, summary: any, runTimestamp: string) => Promise<number>;
let originalFetch: typeof globalThis.fetch;
beforeAll(async () => {
const mod = await modulePromise;
processCategory = mod.processCategory;
insertCategoryRunSummary = mod.insertCategoryRunSummary;
beforeAll(() => {
// Ensure the test output directory exists and is clean
rmSync(path.dirname(DB_TEST_PATH), { recursive: true, force: true });
mkdirSync(path.dirname(DB_TEST_PATH), { recursive: true });
initDb(DB_TEST_PATH);
db = getDb(DB_TEST_PATH);
originalFetch = globalThis.fetch;
});
afterAll(() => {
globalThis.fetch = originalFetch;
closeDb();
rmSync(path.dirname(DB_TEST_PATH), { recursive: true, force: true });
});
beforeEach(() => {
// Clear tables before each test if necessary, or use a fresh DB for each test
// For simplicity, we'll assume tables are clean after initDb in beforeAll
// and not clear for each test if data is not interdependent.
db.run("DELETE FROM product_analysis_results");
db.run("DELETE FROM category_analysis_runs");
globalThis.fetch = mock(async (input: string | URL | Request) => {
const rawUrl =
typeof input === "string"
? input
: input instanceof URL
? input.toString()
: input.url;
const url = new URL(rawUrl);
if (url.pathname === "/bestsellers") {
return new Response(
JSON.stringify({
bestSellersList: ["B000000001", "B000000002"],
tokensLeft: 10,
refillRate: 1,
}),
{ status: 200 },
);
}
if (url.pathname === "/product") {
return new Response(
JSON.stringify({
products: [
{
asin: "B000000001",
title: "Product One",
stats: { current: [null, null, null, 1000, null, null, null, null, null, null, null, 2, null, null, null, null, null, null, 2599], avg: [2400, null, null, 1200] },
csv: [[1, 2599]],
categoryTree: [{ name: "Category 1" }],
},
{
asin: "B000000002",
title: "Product Two",
stats: { current: [null, null, null, 2000, null, null, null, null, null, null, null, 3, null, null, null, null, null, null, 1999], avg: [1800, null, null, 2200] },
csv: [[1, 1999]],
categoryTree: [{ name: "Category 1" }],
},
],
tokensLeft: 10,
refillRate: 1,
}),
{ status: 200 },
);
}
return new Response("not found", { status: 404 });
}) as unknown as typeof globalThis.fetch;
});
test("processCategory function test", async () => {
@@ -48,31 +139,21 @@ test("processCategory function test", async () => {
childCount: 0,
};
const summary = await processCategory(db, mockCategory, 2);
const runId = await insertCategoryRunSummary(db, {
categoryId: mockCategory.id,
categoryLabel: mockCategory.label,
topAsinsChecked: 0,
availableAsins: 0,
fba: 0,
fbm: 0,
skip: 0,
status: "running",
error: "",
results: [],
}, new Date().toISOString());
const summary = await processCategory(db, runId, mockCategory, 2);
expect(summary.status).toBe("ok");
expect(summary.categoryId).toBe(mockCategory.id);
expect(summary.categoryLabel).toBe(mockCategory.label);
expect(summary.topAsinsChecked).toBe(2);
expect(summary.availableAsins).toBe(2);
expect(summary.fba).toBe(1);
expect(summary.fbm).toBe(1);
expect(summary.skip).toBe(0);
expect(summary.results?.length).toBe(2);
const runId = await insertCategoryRunSummary(
db,
summary,
new Date().toISOString(),
);
if (summary.results) {
await insertProductAnalysisResults(db, runId, summary.results);
}
// Verify category run summary insertion
const categoryRun = db
.query("SELECT * FROM category_analysis_runs")
.all() as any[];
const categoryRun = db.query("SELECT * FROM category_analysis_runs").all() as any[];
expect(categoryRun.length).toBe(1);
expect(categoryRun[0].category_label).toBe("Category 1");
expect(categoryRun[0].top_asins_checked).toBe(2);
@@ -81,10 +162,7 @@ test("processCategory function test", async () => {
expect(categoryRun[0].fbm_count).toBe(1);
expect(categoryRun[0].status).toBe("ok");
// Verify product analysis results insertion
const productResults = db
.query("SELECT * FROM product_analysis_results ORDER BY asin")
.all() as any[];
const productResults = db.query("SELECT * FROM product_analysis_results ORDER BY asin").all() as any[];
expect(productResults.length).toBe(2);
expect(productResults[0].asin).toBe("B000000001");