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:
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user