From 386e159d2bbf255b29bf3868d62f1bb4bffce892 Mon Sep 17 00:00:00 2001 From: reyanthony062001-ops Date: Fri, 10 Jul 2026 08:58:25 +0000 Subject: [PATCH] feat(miner-ui): portfolio-queue summary cards over the local queue store --- .../src/lib/portfolio-queue.ts | 88 ++++++++ .../src/portfolio-queue.test.tsx | 198 ++++++++++++++++++ apps/gittensory-miner-ui/src/routeTree.gen.ts | 24 ++- .../gittensory-miner-ui/src/routes/__root.tsx | 3 + .../src/routes/portfolio.tsx | 122 +++++++++++ .../vite-portfolio-queue-api.ts | 85 ++++++++ apps/gittensory-miner-ui/vite.config.ts | 2 + 7 files changed, 519 insertions(+), 3 deletions(-) create mode 100644 apps/gittensory-miner-ui/src/lib/portfolio-queue.ts create mode 100644 apps/gittensory-miner-ui/src/portfolio-queue.test.tsx create mode 100644 apps/gittensory-miner-ui/src/routes/portfolio.tsx create mode 100644 apps/gittensory-miner-ui/vite-portfolio-queue-api.ts diff --git a/apps/gittensory-miner-ui/src/lib/portfolio-queue.ts b/apps/gittensory-miner-ui/src/lib/portfolio-queue.ts new file mode 100644 index 0000000000..c3dd430965 --- /dev/null +++ b/apps/gittensory-miner-ui/src/lib/portfolio-queue.ts @@ -0,0 +1,88 @@ +// Read-only client + pure aggregation for the local portfolio-queue API (#4306). The view is summary CARDS, +// so the aggregation lives here (client-side over `listQueue()`'s rows, per the issue's guidance) as pure, +// unit-testable functions — the middleware serves raw rows and duplicates no aggregation. + +export const PORTFOLIO_QUEUE_API_PATH = "/api/portfolio-queue"; + +export const QUEUE_STATUSES = ["queued", "in_progress", "done"] as const; + +export type QueueStatus = (typeof QUEUE_STATUSES)[number]; + +/** One `miner_portfolio_queue` row as served by the local API — mirrors `portfolio-queue.js`'s `rowToEntry`. */ +export type PortfolioQueueRow = { + repoFullName: string; + identifier: string; + priority: number; + status: QueueStatus; + enqueuedAt: string; +}; + +export type QueueStatusCounts = Record; + +export type RepoQueueSummary = { repoFullName: string; counts: QueueStatusCounts; total: number }; + +export type PortfolioQueueSummary = { + total: number; + counts: QueueStatusCounts; + byRepo: RepoQueueSummary[]; +}; + +export type PortfolioQueueResult = { ok: true; rows: PortfolioQueueRow[] } | { ok: false; error: string }; + +function isQueueStatus(value: unknown): value is QueueStatus { + return value === "queued" || value === "in_progress" || value === "done"; +} + +function isPortfolioQueueRow(value: unknown): value is PortfolioQueueRow { + if (typeof value !== "object" || value === null) return false; + const row = value as Record; + return ( + typeof row.repoFullName === "string" && + typeof row.identifier === "string" && + typeof row.priority === "number" && + typeof row.enqueuedAt === "string" && + isQueueStatus(row.status) + ); +} + +const emptyCounts = (): QueueStatusCounts => ({ queued: 0, in_progress: 0, done: 0 }); + +/** Pure aggregation: overall counts by status plus a per-repo breakdown (sorted by repo name for stable cards). + * The cross-repo section is the schema's own multi-repo shape surfaced as data — the view decides rendering. */ +export function summarizePortfolioQueue(rows: PortfolioQueueRow[]): PortfolioQueueSummary { + const counts = emptyCounts(); + const perRepo = new Map(); + for (const row of rows) { + counts[row.status] += 1; + const repoCounts = perRepo.get(row.repoFullName) ?? emptyCounts(); + repoCounts[row.status] += 1; + perRepo.set(row.repoFullName, repoCounts); + } + const byRepo = [...perRepo.entries()] + .map(([repoFullName, repoCounts]) => ({ + repoFullName, + counts: repoCounts, + total: repoCounts.queued + repoCounts.in_progress + repoCounts.done, + })) + .sort((a, b) => a.repoFullName.localeCompare(b.repoFullName)); + return { total: rows.length, counts, byRepo }; +} + +/** Fetch the local queue rows; failures surface as a typed error result the view renders, never a crash. */ +export async function fetchPortfolioQueue(fetchImpl: typeof fetch = fetch): Promise { + try { + const response = await fetchImpl(PORTFOLIO_QUEUE_API_PATH); + if (!response.ok) return { ok: false, error: `local portfolio-queue API responded ${response.status}` }; + const payload: unknown = await response.json(); + const rows = (payload as { rows?: unknown }).rows; + if (!Array.isArray(rows) || !rows.every(isPortfolioQueueRow)) { + return { ok: false, error: "local portfolio-queue API returned an unexpected payload shape" }; + } + return { ok: true, rows }; + } catch (error) { + return { + ok: false, + error: error instanceof Error ? error.message : "failed to reach the local portfolio-queue API", + }; + } +} diff --git a/apps/gittensory-miner-ui/src/portfolio-queue.test.tsx b/apps/gittensory-miner-ui/src/portfolio-queue.test.tsx new file mode 100644 index 0000000000..8c5be04123 --- /dev/null +++ b/apps/gittensory-miner-ui/src/portfolio-queue.test.tsx @@ -0,0 +1,198 @@ +import { render, screen, waitFor } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; + +import { + fetchPortfolioQueue, + PORTFOLIO_QUEUE_API_PATH, + summarizePortfolioQueue, + type PortfolioQueueResult, + type PortfolioQueueRow, +} from "./lib/portfolio-queue"; +import { PortfolioPage, PortfolioQueueView } from "./routes/portfolio"; +import { handlePortfolioQueueRequest, type PortfolioQueueApiDeps } from "../vite-portfolio-queue-api"; + +const fixtureRows: PortfolioQueueRow[] = [ + { + repoFullName: "acme/widgets", + identifier: "issue:12", + priority: 5, + status: "queued", + enqueuedAt: "2026-07-10T06:00:00.000Z", + }, + { + repoFullName: "acme/widgets", + identifier: "issue:13", + priority: 3, + status: "in_progress", + enqueuedAt: "2026-07-10T06:05:00.000Z", + }, + { + repoFullName: "acme/gadgets", + identifier: "issue:7", + priority: 8, + status: "done", + enqueuedAt: "2026-07-10T05:00:00.000Z", + }, + { + repoFullName: "acme/gadgets", + identifier: "issue:8", + priority: 1, + status: "queued", + enqueuedAt: "2026-07-10T05:30:00.000Z", + }, +]; + +describe("summarizePortfolioQueue (#4306)", () => { + it("counts rows by status and per repo, sorted by repo name", () => { + const summary = summarizePortfolioQueue(fixtureRows); + expect(summary.total).toBe(4); + expect(summary.counts).toEqual({ queued: 2, in_progress: 1, done: 1 }); + expect(summary.byRepo).toEqual([ + { repoFullName: "acme/gadgets", counts: { queued: 1, in_progress: 0, done: 1 }, total: 2 }, + { repoFullName: "acme/widgets", counts: { queued: 1, in_progress: 1, done: 0 }, total: 2 }, + ]); + }); + + it("summarizes an empty queue to zeros with no repo rows", () => { + expect(summarizePortfolioQueue([])).toEqual({ + total: 0, + counts: { queued: 0, in_progress: 0, done: 0 }, + byRepo: [], + }); + }); +}); + +describe("PortfolioQueueView (#4306)", () => { + it("renders one card per status with the aggregated counts", () => { + render(); + // The status words also appear as per-repo column headers, so target the card
elements (first match). + expect(screen.getAllByText("Queued")[0]!.nextSibling?.textContent).toBe("2"); + expect(screen.getAllByText("In progress")[0]!.nextSibling?.textContent).toBe("1"); + expect(screen.getAllByText("Done")[0]!.nextSibling?.textContent).toBe("1"); + }); + + it("renders the per-repo breakdown when the queue spans multiple repos", () => { + render(); + expect(screen.getByRole("columnheader", { name: "Repository" })).toBeTruthy(); + expect(screen.getByText("acme/widgets")).toBeTruthy(); + expect(screen.getByText("acme/gadgets")).toBeTruthy(); + }); + + it("omits the per-repo table for a single-repo queue (cards only)", () => { + render(); + expect(screen.queryByRole("table")).toBeNull(); + expect(screen.getByText("Queued").nextSibling?.textContent).toBe("1"); + }); + + it("renders the fresh-install empty state without erroring", () => { + render(); + expect(screen.getByText(/No queued work yet/i)).toBeTruthy(); + }); + + it("renders an error message when the local API is unreachable", () => { + render(); + expect(screen.getByRole("alert").textContent).toContain("connection refused"); + }); + + it("renders the loading state before the first result arrives", () => { + render(); + expect(screen.getByText(/Loading local portfolio queue/i)).toBeTruthy(); + }); +}); + +describe("PortfolioPage (#4306)", () => { + it("loads rows through the injected loader and renders the cards", async () => { + const loadPortfolioQueue = async (): Promise => ({ ok: true, rows: fixtureRows }); + render(); + expect(screen.getByRole("heading", { name: "Portfolio queue" })).toBeTruthy(); + await waitFor(() => expect(screen.getByText("acme/widgets")).toBeTruthy()); + }); +}); + +describe("fetchPortfolioQueue (#4306)", () => { + const jsonResponse = (status: number, payload: unknown) => + ({ ok: status >= 200 && status < 300, status, json: async () => payload }) as unknown as Response; + + it("returns typed rows from a well-formed payload, requesting the local API path", async () => { + let requested: string | undefined; + const result = await fetchPortfolioQueue(async (input) => { + requested = String(input); + return jsonResponse(200, { rows: fixtureRows }); + }); + expect(requested).toBe(PORTFOLIO_QUEUE_API_PATH); + expect(result).toEqual({ ok: true, rows: fixtureRows }); + }); + + it("surfaces non-2xx, malformed payloads, and thrown fetches as typed errors", async () => { + expect(await fetchPortfolioQueue(async () => jsonResponse(500, {}))).toEqual({ + ok: false, + error: "local portfolio-queue API responded 500", + }); + expect(await fetchPortfolioQueue(async () => jsonResponse(200, { rows: "nope" }))).toMatchObject({ ok: false }); + expect( + await fetchPortfolioQueue(async () => jsonResponse(200, { rows: [{ ...fixtureRows[0], status: "warp" }] })), + ).toMatchObject({ ok: false }); + expect( + await fetchPortfolioQueue(async () => { + throw new Error("connection refused"); + }), + ).toEqual({ ok: false, error: "connection refused" }); + }); +}); + +describe("handlePortfolioQueueRequest (#4306)", () => { + const rows = fixtureRows; + function deps(overrides: Partial = {}): PortfolioQueueApiDeps { + return { + loadPortfolioQueueModule: async () => ({ + resolvePortfolioQueueDbPath: () => "/home/miner/.config/gittensory-miner/portfolio-queue.sqlite3", + listQueue: () => rows, + }), + fileExists: () => true, + ...overrides, + }; + } + + it("serves the queue rows via the existing portfolio-queue.js exports", async () => { + const handled = await handlePortfolioQueueRequest("GET", "/api/portfolio-queue", deps()); + expect(handled).toEqual({ status: 200, body: JSON.stringify({ rows }) }); + }); + + it("serves [] on a fresh install WITHOUT initializing the store", async () => { + let listed = false; + const handled = await handlePortfolioQueueRequest( + "GET", + "/api/portfolio-queue", + deps({ + loadPortfolioQueueModule: async () => ({ + resolvePortfolioQueueDbPath: () => "/nowhere/portfolio-queue.sqlite3", + listQueue: () => { + listed = true; + return rows; + }, + }), + fileExists: () => false, + }), + ); + expect(handled).toEqual({ status: 200, body: JSON.stringify({ rows: [] }) }); + expect(listed).toBe(false); + }); + + it("falls through (null) for other paths and non-GET methods", async () => { + expect(await handlePortfolioQueueRequest("GET", "/api/run-state", deps())).toBeNull(); + expect(await handlePortfolioQueueRequest("POST", "/api/portfolio-queue", deps())).toBeNull(); + }); + + it("surfaces a store read failure as a 500 with a safe message", async () => { + const handled = await handlePortfolioQueueRequest( + "GET", + "/api/portfolio-queue", + deps({ + loadPortfolioQueueModule: async () => { + throw new Error("sqlite locked"); + }, + }), + ); + expect(handled).toEqual({ status: 500, body: JSON.stringify({ error: "sqlite locked" }) }); + }); +}); diff --git a/apps/gittensory-miner-ui/src/routeTree.gen.ts b/apps/gittensory-miner-ui/src/routeTree.gen.ts index 133bd284a8..3afffb3aec 100644 --- a/apps/gittensory-miner-ui/src/routeTree.gen.ts +++ b/apps/gittensory-miner-ui/src/routeTree.gen.ts @@ -10,6 +10,7 @@ import { Route as rootRouteImport } from './routes/__root' import { Route as RunHistoryRouteImport } from './routes/run-history' +import { Route as PortfolioRouteImport } from './routes/portfolio' import { Route as IndexRouteImport } from './routes/index' const RunHistoryRoute = RunHistoryRouteImport.update({ @@ -17,6 +18,11 @@ const RunHistoryRoute = RunHistoryRouteImport.update({ path: '/run-history', getParentRoute: () => rootRouteImport, } as any) +const PortfolioRoute = PortfolioRouteImport.update({ + id: '/portfolio', + path: '/portfolio', + getParentRoute: () => rootRouteImport, +} as any) const IndexRoute = IndexRouteImport.update({ id: '/', path: '/', @@ -25,27 +31,31 @@ const IndexRoute = IndexRouteImport.update({ export interface FileRoutesByFullPath { '/': typeof IndexRoute + '/portfolio': typeof PortfolioRoute '/run-history': typeof RunHistoryRoute } export interface FileRoutesByTo { '/': typeof IndexRoute + '/portfolio': typeof PortfolioRoute '/run-history': typeof RunHistoryRoute } export interface FileRoutesById { __root__: typeof rootRouteImport '/': typeof IndexRoute + '/portfolio': typeof PortfolioRoute '/run-history': typeof RunHistoryRoute } export interface FileRouteTypes { fileRoutesByFullPath: FileRoutesByFullPath - fullPaths: '/' | '/run-history' + fullPaths: '/' | '/portfolio' | '/run-history' fileRoutesByTo: FileRoutesByTo - to: '/' | '/run-history' - id: '__root__' | '/' | '/run-history' + to: '/' | '/portfolio' | '/run-history' + id: '__root__' | '/' | '/portfolio' | '/run-history' fileRoutesById: FileRoutesById } export interface RootRouteChildren { IndexRoute: typeof IndexRoute + PortfolioRoute: typeof PortfolioRoute RunHistoryRoute: typeof RunHistoryRoute } @@ -58,6 +68,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof RunHistoryRouteImport parentRoute: typeof rootRouteImport } + '/portfolio': { + id: '/portfolio' + path: '/portfolio' + fullPath: '/portfolio' + preLoaderRoute: typeof PortfolioRouteImport + parentRoute: typeof rootRouteImport + } '/': { id: '/' path: '/' @@ -70,6 +87,7 @@ declare module '@tanstack/react-router' { const rootRouteChildren: RootRouteChildren = { IndexRoute: IndexRoute, + PortfolioRoute: PortfolioRoute, RunHistoryRoute: RunHistoryRoute, } export const routeTree = rootRouteImport diff --git a/apps/gittensory-miner-ui/src/routes/__root.tsx b/apps/gittensory-miner-ui/src/routes/__root.tsx index 8b59bfbe61..b7bfcfc925 100644 --- a/apps/gittensory-miner-ui/src/routes/__root.tsx +++ b/apps/gittensory-miner-ui/src/routes/__root.tsx @@ -20,6 +20,9 @@ function RootLayout() { Run history + + Portfolio + diff --git a/apps/gittensory-miner-ui/src/routes/portfolio.tsx b/apps/gittensory-miner-ui/src/routes/portfolio.tsx new file mode 100644 index 0000000000..da1b6d0610 --- /dev/null +++ b/apps/gittensory-miner-ui/src/routes/portfolio.tsx @@ -0,0 +1,122 @@ +import { createFileRoute } from "@tanstack/react-router"; +import { useEffect, useState } from "react"; + +import { + fetchPortfolioQueue, + summarizePortfolioQueue, + type PortfolioQueueResult, + type QueueStatus, +} from "../lib/portfolio-queue"; + +export const Route = createFileRoute("/portfolio")({ + component: PortfolioPage, +}); + +// Portfolio/queue summary cards (#4306): read-only counts by status over the local `miner_portfolio_queue` +// store, with a per-repo breakdown when the local queue spans repos. Same 4-state pattern as the run-history +// view (loading / error / fresh-install empty / populated). + +const STATUS_LABELS: Record = { + queued: "Queued", + in_progress: "In progress", + done: "Done", +}; + +const STATUS_CARD_CLASSES: Record = { + queued: "border-sky-400/30 bg-sky-500/10 text-sky-100", + in_progress: "border-amber-400/30 bg-amber-500/10 text-amber-100", + done: "border-emerald-400/30 bg-emerald-500/10 text-emerald-100", +}; + +export function PortfolioQueueView({ result }: { result: PortfolioQueueResult | null }) { + if (result === null) { + return

Loading local portfolio queue…

; + } + if (!result.ok) { + return ( +

+ Could not read the local portfolio queue: {result.error} +

+ ); + } + const summary = summarizePortfolioQueue(result.rows); + if (summary.total === 0) { + return ( +

+ No queued work yet — the cards fill in once the miner enqueues its first portfolio item. +

+ ); + } + return ( +
+
+ {(Object.keys(STATUS_LABELS) as QueueStatus[]).map((status) => ( +
+
{STATUS_LABELS[status]}
+
{summary.counts[status]}
+
+ ))} +
+ {summary.byRepo.length > 1 && ( + + + + + + + + + + + {summary.byRepo.map((repo) => ( + + + + + + + ))} + +
+ Repository + + Queued + + In progress + + Done +
{repo.repoFullName}{repo.counts.queued}{repo.counts.in_progress}{repo.counts.done}
+ )} +
+ ); +} + +export function PortfolioPage({ + loadPortfolioQueue = fetchPortfolioQueue, +}: { + loadPortfolioQueue?: () => Promise; +}) { + const [result, setResult] = useState(null); + + useEffect(() => { + let cancelled = false; + void loadPortfolioQueue().then((loaded) => { + if (!cancelled) setResult(loaded); + }); + return () => { + cancelled = true; + }; + }, [loadPortfolioQueue]); + + return ( +
+

Portfolio queue

+

+ Local, read-only summary of the miner's portfolio queue (`miner_portfolio_queue`). +

+
+ +
+
+ ); +} diff --git a/apps/gittensory-miner-ui/vite-portfolio-queue-api.ts b/apps/gittensory-miner-ui/vite-portfolio-queue-api.ts new file mode 100644 index 0000000000..82700e99d5 --- /dev/null +++ b/apps/gittensory-miner-ui/vite-portfolio-queue-api.ts @@ -0,0 +1,85 @@ +import { existsSync } from "node:fs"; +import type { Plugin } from "vite"; + +// Local read-only portfolio-queue API (#4306) — the sibling of `vite-run-state-api.ts` (#4305), same shape for +// the same reason: the dashboard is a browser app while the queue store is a `node:sqlite` file on disk, so the +// dev server bridges the two by calling into `packages/gittensory-miner/lib/portfolio-queue.js`'s EXISTING +// exports (`resolvePortfolioQueueDbPath`/`listQueue`) — no SQL and no aggregation duplicated at this layer. +// +// Same read-only fresh-install rule as the run-state endpoint: `listQueue()` lazily initializes the default +// store, which would CREATE the SQLite file — so the handler probes the resolved DB path first and serves +// `{ rows: [] }` without ever touching the store when no DB exists yet. + +type PortfolioQueueModule = { + resolvePortfolioQueueDbPath: () => string; + listQueue: () => Array<{ + repoFullName: string; + identifier: string; + priority: number; + status: string; + enqueuedAt: string; + }>; +}; + +export type PortfolioQueueApiDeps = { + /** Import of `packages/gittensory-miner/lib/portfolio-queue.js` — injectable so tests never touch a real store. */ + loadPortfolioQueueModule: () => Promise; + /** File-existence probe for the fresh-install fast path. */ + fileExists: (path: string) => boolean; +}; + +const defaultDeps: PortfolioQueueApiDeps = { + loadPortfolioQueueModule: () => + import("../../packages/gittensory-miner/lib/portfolio-queue.js") as Promise, + fileExists: existsSync, +}; + +/** Request handler factored out of the Vite plugin shape so tests drive it directly (mirrors the run-state API). */ +export async function handlePortfolioQueueRequest( + method: string | undefined, + url: string | undefined, + deps: PortfolioQueueApiDeps = defaultDeps, +): Promise<{ status: number; body: string } | null> { + if (url !== "/api/portfolio-queue" || (method !== undefined && method !== "GET")) return null; + try { + const queue = await deps.loadPortfolioQueueModule(); + if (!deps.fileExists(queue.resolvePortfolioQueueDbPath())) { + return { status: 200, body: JSON.stringify({ rows: [] }) }; + } + return { status: 200, body: JSON.stringify({ rows: queue.listQueue() }) }; + } catch (error) { + const message = error instanceof Error ? error.message : "failed to read local portfolio queue"; + return { status: 500, body: JSON.stringify({ error: message }) }; + } +} + +/** Vite dev/preview middleware serving the local read-only portfolio-queue endpoint. */ +export function portfolioQueueApiPlugin(deps: PortfolioQueueApiDeps = defaultDeps): Plugin { + const attach = (middlewares: { + use: ( + fn: ( + req: { method?: string; url?: string }, + res: { statusCode: number; setHeader: (k: string, v: string) => void; end: (body: string) => void }, + next: () => void, + ) => void, + ) => void; + }) => { + middlewares.use((req, res, next) => { + void handlePortfolioQueueRequest(req.method, req.url, deps).then((handled) => { + if (!handled) return next(); + res.statusCode = handled.status; + res.setHeader("Content-Type", "application/json"); + res.end(handled.body); + }); + }); + }; + return { + name: "gittensory-miner-ui:portfolio-queue-api", + configureServer(server) { + attach(server.middlewares); + }, + configurePreviewServer(server) { + attach(server.middlewares); + }, + }; +} diff --git a/apps/gittensory-miner-ui/vite.config.ts b/apps/gittensory-miner-ui/vite.config.ts index 0b5bc6c053..50a5f69fa6 100644 --- a/apps/gittensory-miner-ui/vite.config.ts +++ b/apps/gittensory-miner-ui/vite.config.ts @@ -4,6 +4,7 @@ import react from "@vitejs/plugin-react"; import { defineConfig } from "vite"; import tsconfigPaths from "vite-tsconfig-paths"; +import { portfolioQueueApiPlugin } from "./vite-portfolio-queue-api"; import { runStateApiPlugin } from "./vite-run-state-api"; export default defineConfig({ @@ -13,6 +14,7 @@ export default defineConfig({ tailwindcss(), tsconfigPaths(), runStateApiPlugin(), + portfolioQueueApiPlugin(), ], server: { // Offset from gittensory-ui (5173) so both apps can run side-by-side locally.