feat(nexus): add useGateIndicator hook (phase 11)

Introduces a minimal pending-gate indicator hook for Phase 11. Reads
the existing approvals endpoint and exposes {hasPendingGates, count,
loading, error} for the IconRail Assistant dot overlay. The rename
from "Approvals" to "Gates" is display-only — approvalsApi and the
underlying /api/approvals endpoint are untouched.

Ships the hook + tests only; the IconRail wiring is deferred to the
controller post-Wave 2 (IconRail is a Phase 8 file).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Nexus Dev 2026-04-11 12:15:47 +00:00
parent d2dcb1c813
commit 6010a105fe
2 changed files with 125 additions and 0 deletions

View file

@ -0,0 +1,63 @@
// @vitest-environment jsdom
import { describe, it, expect } from "vitest";
import type { Approval } from "@paperclipai/shared";
import { countPendingGates } from "./useGateIndicator";
const NOW = new Date("2026-04-11T12:00:00Z");
function makeApproval(overrides: Partial<Approval> = {}): Approval {
return {
id: `gate-${Math.random().toString(36).slice(2, 8)}`,
companyId: "co-1",
type: "generic",
requestedByAgentId: null,
requestedByUserId: null,
status: "pending",
payload: {},
decisionNote: null,
decidedByUserId: null,
decidedAt: null,
createdAt: NOW,
updatedAt: NOW,
...overrides,
} as Approval;
}
describe("countPendingGates", () => {
it("returns 0 for undefined or empty lists", () => {
expect(countPendingGates(undefined)).toBe(0);
expect(countPendingGates([])).toBe(0);
});
it("counts approvals with status 'pending'", () => {
const approvals = [
makeApproval({ status: "pending" }),
makeApproval({ status: "pending" }),
makeApproval({ status: "approved" }),
];
expect(countPendingGates(approvals)).toBe(2);
});
it("counts approvals with status 'revision_requested' as pending", () => {
const approvals = [
makeApproval({ status: "revision_requested" }),
makeApproval({ status: "pending" }),
makeApproval({ status: "approved" }),
makeApproval({ status: "rejected" }),
];
expect(countPendingGates(approvals)).toBe(2);
});
it("does not count approved or rejected approvals", () => {
const approvals = [
makeApproval({ status: "approved" }),
makeApproval({ status: "rejected" }),
];
expect(countPendingGates(approvals)).toBe(0);
});
it("derives 'hasPendingGates' correctly — count > 0 → true, count === 0 → false", () => {
expect(countPendingGates([makeApproval({ status: "pending" })]) > 0).toBe(true);
expect(countPendingGates([makeApproval({ status: "approved" })]) > 0).toBe(false);
});
});

View file

@ -0,0 +1,62 @@
// [nexus] Phase 11 — pending-gate indicator hook.
//
// Reads the existing approvals API and exposes a minimal "are there any
// gates waiting on the user globally?" summary, intended for the IconRail
// Assistant destination dot overlay. The rename from "approvals" to
// "gates" is display-only — we reuse `approvalsApi` / `queryKeys.approvals`
// without touching the backend.
//
// The IconRail wiring itself lives in a Phase 8 file the controller owns;
// Phase 11 only ships the hook + tests. See the Phase 11 report for the
// exact diff the controller should apply post-Wave.
import { useMemo } from "react";
import { useQuery } from "@tanstack/react-query";
import type { Approval } from "@paperclipai/shared";
import { approvalsApi } from "../api/approvals";
import { queryKeys } from "../lib/queryKeys";
const PENDING_STATUSES: ReadonlySet<Approval["status"]> = new Set([
"pending",
"revision_requested",
]);
export interface GateIndicator {
hasPendingGates: boolean;
count: number;
loading: boolean;
error: Error | null;
}
/** Count how many approvals in the list are awaiting user decision. */
export function countPendingGates(approvals: readonly Approval[] | undefined): number {
if (!approvals || approvals.length === 0) return 0;
let count = 0;
for (const approval of approvals) {
if (PENDING_STATUSES.has(approval.status)) count += 1;
}
return count;
}
/**
* Returns `{ hasPendingGates, count, loading, error }` for the given company.
* When `companyId` is null/undefined the query is disabled and the hook
* returns zeroed values useful while bootstrapping IconRail before a
* company is selected.
*/
export function useGateIndicator(companyId: string | null | undefined): GateIndicator {
const query = useQuery({
queryKey: queryKeys.approvals.list(companyId ?? "__none__"),
queryFn: () => approvalsApi.list(companyId!),
enabled: Boolean(companyId),
});
return useMemo<GateIndicator>(() => {
const count = countPendingGates(query.data);
return {
hasPendingGates: count > 0,
count,
loading: query.isLoading,
error: (query.error as Error | null) ?? null,
};
}, [query.data, query.isLoading, query.error]);
}