Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
88 changes: 88 additions & 0 deletions apps/gittensory-miner-ui/src/lib/portfolio-queue.ts
Original file line number Diff line number Diff line change
@@ -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<QueueStatus, number>;

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<string, unknown>;
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<string, QueueStatusCounts>();
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<PortfolioQueueResult> {
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",
};
}
}
198 changes: 198 additions & 0 deletions apps/gittensory-miner-ui/src/portfolio-queue.test.tsx
Original file line number Diff line number Diff line change
@@ -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(<PortfolioQueueView result={{ ok: true, rows: fixtureRows }} />);
// The status words also appear as per-repo column headers, so target the card <dt> 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(<PortfolioQueueView result={{ ok: true, rows: fixtureRows }} />);
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(<PortfolioQueueView result={{ ok: true, rows: fixtureRows.slice(0, 2) }} />);
expect(screen.queryByRole("table")).toBeNull();
expect(screen.getByText("Queued").nextSibling?.textContent).toBe("1");
});

it("renders the fresh-install empty state without erroring", () => {
render(<PortfolioQueueView result={{ ok: true, rows: [] }} />);
expect(screen.getByText(/No queued work yet/i)).toBeTruthy();
});

it("renders an error message when the local API is unreachable", () => {
render(<PortfolioQueueView result={{ ok: false, error: "connection refused" }} />);
expect(screen.getByRole("alert").textContent).toContain("connection refused");
});

it("renders the loading state before the first result arrives", () => {
render(<PortfolioQueueView result={null} />);
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<PortfolioQueueResult> => ({ ok: true, rows: fixtureRows });
render(<PortfolioPage loadPortfolioQueue={loadPortfolioQueue} />);
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> = {}): 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" }) });
});
});
24 changes: 21 additions & 3 deletions apps/gittensory-miner-ui/src/routeTree.gen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,19 @@

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({
id: '/run-history',
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: '/',
Expand All @@ -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
}

Expand All @@ -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: '/'
Expand All @@ -70,6 +87,7 @@ declare module '@tanstack/react-router' {

const rootRouteChildren: RootRouteChildren = {
IndexRoute: IndexRoute,
PortfolioRoute: PortfolioRoute,
RunHistoryRoute: RunHistoryRoute,
}
export const routeTree = rootRouteImport
Expand Down
3 changes: 3 additions & 0 deletions apps/gittensory-miner-ui/src/routes/__root.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,9 @@ function RootLayout() {
<Link to="/run-history" className="hover:text-white">
Run history
</Link>
<Link to="/portfolio" className="hover:text-white">
Portfolio
</Link>
</nav>
</div>
</header>
Expand Down
Loading
Loading