diff --git a/src/app/(frontend)/operations/[operationId]/page.tsx b/src/app/(frontend)/operations/[operationId]/page.tsx new file mode 100644 index 0000000..fd0727f --- /dev/null +++ b/src/app/(frontend)/operations/[operationId]/page.tsx @@ -0,0 +1,79 @@ +import config from "@payload-config"; +import { getPayload } from "payload"; +import { headers as nextHeaders } from "next/headers"; +import Link from "next/link"; +import { isPayloadUser } from "@/utils/access-control/isPayloadUser"; +import { hasPermission } from "@/utils/access-control/hasPermission"; +import { getOperationAar } from "@/lib/operations/surfaceQuery"; +import { + AarView, + NoticeState, + OperationsRealtimeRefresher, + OperationsStrip, + PageShell, + ReconcilePanel, +} from "@/components/frontend/operations"; +import type { ReconcilableEvent } from "@/components/frontend/operations"; + +export const metadata = { + title: "Operation AAR: Polaris Task Force", +}; + +export default async function OperationAarPage({ + params, +}: { + params: Promise<{ operationId: string }>; +}) { + const { operationId } = await params; + const payload = await getPayload({ config }); + const { user: authUser } = await payload.auth({ + headers: await nextHeaders(), + canSetHeaders: false, + }); + const user = isPayloadUser(authUser) ? authUser : null; + const surfaceUser = user ? { id: user.id } : null; + + const aar = await getOperationAar(payload, surfaceUser, operationId); + const canReconcile = user ? await hasPermission(payload, user, "operation-events:update") : false; + const reconcilable: ReconcilableEvent[] = + aar && canReconcile + ? aar.events + .filter((ev) => ev.status !== "validated") + .map((ev) => ({ + id: ev.id, + eventId: ev.eventId, + status: ev.status, + errorCode: ev.errorCode, + errorMessage: ev.errorMessage, + })) + : []; + + return ( +
+ + + {aar === null ? ( + + Open operations ledger + + } + /> + ) : ( + <> + + + + )} + + +
+ ); +} diff --git a/src/app/(frontend)/operations/actions.ts b/src/app/(frontend)/operations/actions.ts new file mode 100644 index 0000000..8151961 --- /dev/null +++ b/src/app/(frontend)/operations/actions.ts @@ -0,0 +1,48 @@ +"use server"; + +import config from "@payload-config"; +import { getPayload } from "payload"; +import { headers as nextHeaders } from "next/headers"; +import { isPayloadUser } from "@/utils/access-control/isPayloadUser"; +import { replayOperationEvent } from "@/lib/operations/reconcile"; +import { backfillOperationEvents } from "@/lib/operations/rollout"; + +export interface ActionResult { + success: boolean; + error?: string; + data?: T; +} + +async function authenticate() { + const { headers } = await import("next/headers"); + const payload = await getPayload({ config }); + const { user: authUser } = await payload.auth({ + headers: await headers(), + canSetHeaders: false, + }); + return { payload, user: isPayloadUser(authUser) ? authUser : null }; +} + +export async function reconcileOperationEvent(operationEventId: number): Promise> { + try { + const { payload, user } = await authenticate(); + if (!user) return { success: false, error: "Authentication required." }; + const result = await replayOperationEvent(payload, user, operationEventId); + if (!result.ok) return { success: false, error: result.error ?? "Replay failed." }; + return { success: true, data: { status: result.status, effectsCreated: result.effectsCreated } }; + } catch (e) { + return { success: false, error: e instanceof Error ? e.message : "Unknown error" }; + } +} + +export async function reconcileBackfill(): Promise> { + try { + const { payload, user } = await authenticate(); + if (!user) return { success: false, error: "Authentication required." }; + const result = await backfillOperationEvents(payload, user); + if (result.forbidden) return { success: false, error: "forbidden" }; + return { success: true, data: { processed: result.processed, skipped: result.skipped } }; + } catch (e) { + return { success: false, error: e instanceof Error ? e.message : "Unknown error" }; + } +} diff --git a/src/app/(frontend)/operations/ledger/page.tsx b/src/app/(frontend)/operations/ledger/page.tsx new file mode 100644 index 0000000..a5dc184 --- /dev/null +++ b/src/app/(frontend)/operations/ledger/page.tsx @@ -0,0 +1,65 @@ +import config from "@payload-config"; +import { getPayload } from "payload"; +import { headers as nextHeaders } from "next/headers"; +import { isPayloadUser } from "@/utils/access-control/isPayloadUser"; +import { getOperationLedger } from "@/lib/operations/surfaceQuery"; +import { + LedgerTable, + OperationsRealtimeRefresher, + OperationsStrip, + PageShell, +} from "@/components/frontend/operations"; +import type { LedgerTableFilters } from "@/components/frontend/operations"; + +export const metadata = { + title: "Operations Ledger: Polaris Task Force", +}; + +interface LedgerSearchParams { + operationId?: string; + status?: string; + type?: string; + page?: string; +} + +export default async function OperationsLedgerPage({ + searchParams, +}: { + searchParams: Promise; +}) { + const params = await searchParams; + const payload = await getPayload({ config }); + const { user: authUser } = await payload.auth({ + headers: await nextHeaders(), + canSetHeaders: false, + }); + const user = isPayloadUser(authUser) ? authUser : null; + const surfaceUser = user ? { id: user.id } : null; + + const page = Number.parseInt(params.page ?? "1", 10); + const filters: LedgerTableFilters = { + operationId: params.operationId || undefined, + status: params.status || undefined, + type: params.type || undefined, + }; + const result = await getOperationLedger( + payload, + surfaceUser, + filters, + Number.isNaN(page) ? 1 : page, + ); + + return ( +
+ + +

+ Every validated, rejected, and dead-lettered operation event with its derived effects. + Rejected and dead-lettered entries are retained as evidence and never applied. +

+
+ + +
+ ); +} diff --git a/src/app/(frontend)/operations/page.tsx b/src/app/(frontend)/operations/page.tsx new file mode 100644 index 0000000..beb39cd --- /dev/null +++ b/src/app/(frontend)/operations/page.tsx @@ -0,0 +1,59 @@ +import config from "@payload-config"; +import { getPayload } from "payload"; +import { headers as nextHeaders } from "next/headers"; +import { isPayloadUser } from "@/utils/access-control/isPayloadUser"; +import { getOperationOverview } from "@/lib/operations/surface"; +import { + MyExtractionsCard, + OperationsRealtimeRefresher, + OperationsStrip, + PageShell, + PressureCard, + ReadinessCard, + RecentOperationsCard, + ReservationsCard, +} from "@/components/frontend/operations"; + +export const metadata = { + title: "Operations: Polaris Task Force", +}; + +export default async function OperationsPage() { + const payload = await getPayload({ config }); + const { user: authUser } = await payload.auth({ + headers: await nextHeaders(), + canSetHeaders: false, + }); + const user = isPayloadUser(authUser) ? authUser : null; + const surfaceUser = user ? { id: user.id } : null; + + const overview = await getOperationOverview(payload, surfaceUser); + + return ( +
+ + +

+ Live and historical operation activity: extraction returns, settlements, readiness + aggregates, and zone pressure. Every effect here is provenance-linked to a validated + source event from the Arma bridge. +

+
+ +
+ +
+ + + + +
+
+ + +
+ ); +} diff --git a/src/components/frontend/blocks/AppSidebar.tsx b/src/components/frontend/blocks/AppSidebar.tsx index 5a510cb..f9b70b9 100644 --- a/src/components/frontend/blocks/AppSidebar.tsx +++ b/src/components/frontend/blocks/AppSidebar.tsx @@ -153,6 +153,7 @@ export function AppSidebar({ {hasIntelligence && ( )} + {hasLogistics && ( )} diff --git a/src/components/frontend/blocks/sidebarData.ts b/src/components/frontend/blocks/sidebarData.ts index 6a3b435..0c9443f 100644 --- a/src/components/frontend/blocks/sidebarData.ts +++ b/src/components/frontend/blocks/sidebarData.ts @@ -12,6 +12,7 @@ import { Kanban, LifeBuoy, Package, + Radar, ScrollText, Search, ShoppingBag, @@ -66,6 +67,18 @@ export const sidebarData = { icon: BookOpen, }, ], + navOperations: [ + { + title: "Ops Overview", + url: "/operations", + icon: Radar, + }, + { + title: "Ops Ledger", + url: "/operations/ledger", + icon: ScrollText, + }, + ], navLogistics: [ { title: "Assets", diff --git a/src/components/frontend/operations/AarView.tsx b/src/components/frontend/operations/AarView.tsx new file mode 100644 index 0000000..6dab612 --- /dev/null +++ b/src/components/frontend/operations/AarView.tsx @@ -0,0 +1,158 @@ +import Link from "next/link"; +import { LedgerRow, type ReadoutCell } from "./LedgerRow"; +import { NoticeState } from "./NoticeState"; +import { ProvenanceChip } from "./ProvenanceChip"; +import { STATUS_TONE_CLASSES, statusToneFor } from "./tokens"; +import type { AarResult } from "@/lib/operations/surfaceQuery"; + +const headCell = "px-3 py-2 text-left font-mono text-[9px] uppercase tracking-widest text-white/50"; +const cell = "px-3 py-2 align-middle text-xs"; + +function Section({ title, children }: { title: string; children: React.ReactNode }) { + return ( +
+

{title}

+ {children} +
+ ); +} + +function countsRow(aar: AarResult): ReadoutCell[] { + return [ + { label: "Events", value: String(aar.counts.events), mono: true }, + { label: "Validated", value: String(aar.counts.validated), mono: true }, + { label: "Rejected", value: String(aar.counts.rejected), mono: true }, + { label: "Applied", value: String(aar.counts.applied), mono: true }, + { label: "Failed", value: String(aar.counts.failed), mono: true }, + { label: "Pending", value: String(aar.counts.pending), mono: true }, + ]; +} + +export function AarView({ aar }: { aar: AarResult }) { + const failedEffects = aar.effects.filter((e) => e.status === "failed" || e.status === "reversed"); + const appliedEffects = aar.effects.filter((e) => e.status === "applied"); + + return ( +
+ + +
+ {aar.events.length === 0 ? ( + + ) : ( + + + + + + + + + + + {aar.events.map((ev) => ( + + + + + + + ))} + +
EventTypeStatusOccurred
{ev.eventId}{ev.type} + + {ev.status} + + {ev.errorCode && ( + + {ev.errorCode}: {ev.errorMessage ?? "see notes"} + + )} + {ev.occurredAt}
+ )} +
+ +
+ {appliedEffects.length === 0 ? ( + + ) : ( +
    + {appliedEffects.map((eff) => ( +
  • + {eff.effectKey} + + + {eff.effectType} + + + +
  • + ))} +
+ )} +
+ +
+ {failedEffects.length === 0 ? ( + + ) : ( +
    + {failedEffects.map((eff) => ( +
  • +

    {eff.effectKey}

    +

    + {eff.notes ?? "Effect did not apply; see the operations ledger for reconciliation state."} +

    +
  • + ))} +
+ )} +
+ +
+ {aar.pressure.length === 0 ? ( + + ) : ( +
    + {aar.pressure.map((p, i) => ( +
  • + Zone {p.zoneId} + = 0 ? "text-emerald-400" : "text-rose-400"}`} + > + {p.delta >= 0 ? `+${p.delta}` : p.delta} + +
  • + ))} +
+ )} +
+ +
+ {aar.reservations.length === 0 ? ( + + ) : ( +
    + {aar.reservations.map((r) => ( +
  • + {r.reservationKey} + {r.status} +
  • + ))} +
+ )} +
+ +

+ + Open the full operations ledger + +

+
+ ); +} diff --git a/src/components/frontend/operations/ClassificationStrip.tsx b/src/components/frontend/operations/ClassificationStrip.tsx new file mode 100644 index 0000000..31cb37b --- /dev/null +++ b/src/components/frontend/operations/ClassificationStrip.tsx @@ -0,0 +1,14 @@ +import { DOSSIER } from "./tokens"; + +/** + * Full-width classification strip: a label at both ends in mono uppercase. + * Carries real information (origin + reference), so it is not aria-hidden. + */ +export function ClassificationStrip({ left, right }: { left: string; right: string }) { + return ( +
+ {left} + {right} +
+ ); +} diff --git a/src/components/frontend/operations/CornerBrackets.tsx b/src/components/frontend/operations/CornerBrackets.tsx new file mode 100644 index 0000000..f8af952 --- /dev/null +++ b/src/components/frontend/operations/CornerBrackets.tsx @@ -0,0 +1,15 @@ +/** + * Four corner L-marks, inset from the edges. Decorative only. + * Brightens on the PARENT's group-hover; all motion is transition-only and + * disabled under prefers-reduced-motion. + */ +export function CornerBrackets() { + return ( + + ); +} diff --git a/src/components/frontend/operations/LedgerRow.tsx b/src/components/frontend/operations/LedgerRow.tsx new file mode 100644 index 0000000..0831b25 --- /dev/null +++ b/src/components/frontend/operations/LedgerRow.tsx @@ -0,0 +1,32 @@ +import { DOSSIER } from "./tokens"; + +export interface ReadoutCell { + label: string; + value: string; + mono?: boolean; +} + +/** + * Permanent single-line readout footer: cells separated by hairlines, each a + * mono tabular value plus a small uppercase label. Presentational only. + */ +export function LedgerRow({ cells }: { cells: readonly ReadoutCell[] }) { + return ( +
+ {cells.map((cell, index) => ( +
+ + {cell.value} + + {cell.label} +
+ ))} +
+ ); +} diff --git a/src/components/frontend/operations/LedgerTable.tsx b/src/components/frontend/operations/LedgerTable.tsx new file mode 100644 index 0000000..50c20cf --- /dev/null +++ b/src/components/frontend/operations/LedgerTable.tsx @@ -0,0 +1,137 @@ +import Link from "next/link"; +import { LedgerRow, type ReadoutCell } from "./LedgerRow"; +import { NoticeState } from "./NoticeState"; +import { STATUS_TONE_CLASSES, statusToneFor } from "./tokens"; +import type { LedgerResult } from "@/lib/operations/surfaceQuery"; + +const headCell = "px-3 py-2 text-left font-mono text-[9px] uppercase tracking-widest text-white/50"; +const cell = "px-3 py-2 align-middle text-xs"; + +export interface LedgerTableFilters { + operationId?: string; + status?: string; + type?: string; +} + +function filterHref(filters: LedgerTableFilters, page: number): string { + const params = new URLSearchParams(); + if (filters.operationId) params.set("operationId", filters.operationId); + if (filters.status) params.set("status", filters.status); + if (filters.type) params.set("type", filters.type); + if (page > 1) params.set("page", String(page)); + const qs = params.toString(); + return qs ? `/operations/ledger?${qs}` : "/operations/ledger"; +} + +export function LedgerTable({ + result, + filters, +}: { + result: LedgerResult; + filters: LedgerTableFilters; +}) { + const countCells: ReadoutCell[] = Object.entries(result.effectCounts).map(([status, count]) => ({ + label: status, + value: String(count), + mono: true, + })); + + return ( +
+ {countCells.length > 0 && } +
+ {(["", "validated", "rejected", "dead-letter"] as const).map((status) => ( + + {status || "all statuses"} + + ))} +
+ {result.events.length === 0 ? ( + + ) : ( + + + + + + + + + + + + + {result.events.map((ev) => ( + + + + + + + + + ))} + +
EventOperationTypeStatusOccurredDetail
{ev.eventId} + + {ev.operationId} + + {ev.type} + + {ev.status} + + {ev.occurredAt} + {ev.errorCode || ev.errorMessage ? ( + + {ev.errorCode ?? "error"}: {ev.errorMessage ?? "see reconciliation notes"} + + ) : ev.notes ? ( + {ev.notes} + ) : ( + n/a + )} +
+ )} +
+ + Page {result.page} of {Math.max(1, result.totalPages)} ({result.totalDocs} events) + + + {result.page > 1 && ( + + Prev + + )} + {result.page < result.totalPages && ( + + Next + + )} + +
+
+ ); +} diff --git a/src/components/frontend/operations/NoticeState.tsx b/src/components/frontend/operations/NoticeState.tsx new file mode 100644 index 0000000..fcbb5a5 --- /dev/null +++ b/src/components/frontend/operations/NoticeState.tsx @@ -0,0 +1,37 @@ +import type { ReactNode } from "react"; + +/** + * Empty/error/loading notice states for operations surfaces. + * `error` renders with role="alert" (assertive); empty/loading render with + * role="status" (polite). Text is the message; optional action passes through. + */ +export function NoticeState({ + variant, + message, + hint, + action, +}: { + variant: "empty" | "error" | "loading"; + message: string; + hint?: string; + action?: ReactNode; +}) { + const role = variant === "error" ? "alert" : "status"; + const tone = + variant === "error" + ? "border-rose-500/30 bg-rose-500/5 text-rose-300" + : "border-border/60 bg-card/50 text-muted-foreground"; + return ( +
+

{message}

+ {hint ?

{hint}

: null} + {action ?
{action}
: null} +
+ ); +} diff --git a/src/components/frontend/operations/OperationSections.tsx b/src/components/frontend/operations/OperationSections.tsx new file mode 100644 index 0000000..5b0b628 --- /dev/null +++ b/src/components/frontend/operations/OperationSections.tsx @@ -0,0 +1,237 @@ +import Link from "next/link"; +import { CornerBrackets } from "./CornerBrackets"; +import { ClassificationStrip } from "./ClassificationStrip"; +import { LedgerRow, type ReadoutCell } from "./LedgerRow"; +import { NoticeState } from "./NoticeState"; +import { ProvenanceChip } from "./ProvenanceChip"; +import { DOSSIER } from "./tokens"; +import type { SurfaceOverview } from "@/lib/operations/surface"; + +const cell = "px-3 py-2 align-middle text-xs"; +const headCell = "px-3 py-2 text-left font-mono text-[9px] uppercase tracking-widest text-white/50"; + +function CardShell({ + title, + children, +}: { + title: string; + children: React.ReactNode; +}) { + return ( +
+
+ ); +} + +export function RecentOperationsCard({ operations }: { operations: SurfaceOverview["recentOperations"] }) { + return ( + + {operations.length === 0 ? ( + + ) : ( + + + + + + + + + + + + + {operations.map((op) => ( + + + + + + + + + ))} + +
OperationEventsAppliedFailedRejectedLast activity
+ + {op.operationId} + + {op.eventCount}{op.appliedCount}{op.failedCount}{op.rejectedCount}{op.lastOccurredAt ?? "n/a"}
+ )} +
+ ); +} + +export function MyExtractionsCard({ + extractions, + signedIn, +}: { + extractions: SurfaceOverview["myExtractions"]; + signedIn: boolean; +}) { + if (!signedIn) { + return ( + + + + ); + } + return ( + + {extractions.length === 0 ? ( + + ) : ( +
    + {extractions.map((ex) => ( +
  • + {ex.eventLabel} + + + {ex.operationId} + + + +
  • + ))} +
+ )} +
+ ); +} + +export function ReservationsCard({ + reservations, + canSee, +}: { + reservations: SurfaceOverview["activeReservations"]; + canSee: boolean; +}) { + if (!canSee) { + return ( + + + + ); + } + return ( + + {reservations.length === 0 ? ( + + ) : ( +
    + {reservations.map((r) => { + const cells: ReadoutCell[] = [ + { label: "Key", value: r.reservationKey, mono: true }, + { label: "Expires", value: r.expiresAt ?? "n/a" }, + ]; + return ; + })} +
+ )} +
+ ); +} + +export function ReadinessCard({ + readiness, + canSee, +}: { + readiness: SurfaceOverview["ownReadiness"]; + canSee: boolean; +}) { + if (!canSee) { + return ( + + + + ); + } + return ( + + {readiness === null ? ( + + ) : ( +
+

{readiness.score ?? "n/a"}

+ {readiness.explanation &&

{readiness.explanation}

} + {readiness.windowLabel && ( +

+ Window {readiness.windowLabel} +

+ )} +
+ )} +
+ ); +} + +export function PressureCard({ + pressure, + canSee, +}: { + pressure: SurfaceOverview["pressure"]; + canSee: boolean; +}) { + if (!canSee) { + return ( + + + + ); + } + return ( + + {pressure.length === 0 ? ( + + ) : ( +
    + {pressure.map((p) => ( +
  • + Zone {p.zoneId} + + {p.state} ({p.pressure}) + +
  • + ))} +
+ )} +
+ ); +} + +export function OperationsStrip({ left }: { left: string }) { + return ; +} diff --git a/src/components/frontend/operations/OperationsRealtimeRefresher.tsx b/src/components/frontend/operations/OperationsRealtimeRefresher.tsx new file mode 100644 index 0000000..0110f93 --- /dev/null +++ b/src/components/frontend/operations/OperationsRealtimeRefresher.tsx @@ -0,0 +1,17 @@ +"use client"; + +import { useRouter } from "next/navigation"; +import { useGameTick } from "@/hooks/useGameTick"; + +/** + * Enhancement-only realtime refresh for /operations surfaces. The server + * re-fetch on navigation is the correctness source; this only triggers an + * early refresh when a game tick lands while the page is open. + */ +export function OperationsRealtimeRefresher() { + const router = useRouter(); + useGameTick(() => { + router.refresh(); + }); + return null; +} diff --git a/src/components/frontend/operations/PageShell.tsx b/src/components/frontend/operations/PageShell.tsx new file mode 100644 index 0000000..68cf4cb --- /dev/null +++ b/src/components/frontend/operations/PageShell.tsx @@ -0,0 +1,27 @@ +import type { ReactNode } from "react"; +import { ClassificationStrip } from "./ClassificationStrip"; + +/** + * Responsive page shell for operations surfaces: header with title + optional + * classification-style subtitle line, then the content area. Server-renderable + * (no hooks); pairs with the strategic primitives on task-9 role surfaces. + */ +export function PageShell({ + title, + meta, + children, +}: { + title: string; + meta?: string; + children: ReactNode; +}) { + return ( +
+
+

{title}

+ {meta ? : null} +
+
{children}
+
+ ); +} diff --git a/src/components/frontend/operations/ProvenanceChip.tsx b/src/components/frontend/operations/ProvenanceChip.tsx new file mode 100644 index 0000000..934f6b5 --- /dev/null +++ b/src/components/frontend/operations/ProvenanceChip.tsx @@ -0,0 +1,22 @@ +import { DOSSIER } from "./tokens"; + +/** + * Small mono chip attributing a fact to its provenance (operation id, effect + * key, source). With `href` it renders as a focusable link; otherwise a plain + * marker. + */ +export function ProvenanceChip({ label, href }: { label: string; href?: string }) { + const className = `inline-block rounded-sm ${DOSSIER.chip} ${href ? DOSSIER.focusRing : ""}`; + if (href) { + return ( + + {label} + + ); + } + return ( + + {label} + + ); +} diff --git a/src/components/frontend/operations/ReconcilePanel.tsx b/src/components/frontend/operations/ReconcilePanel.tsx new file mode 100644 index 0000000..e68a6cb --- /dev/null +++ b/src/components/frontend/operations/ReconcilePanel.tsx @@ -0,0 +1,75 @@ +"use client"; + +import { useState, useTransition } from "react"; +import { reconcileOperationEvent } from "@/app/(frontend)/operations/actions"; + +export interface ReconcilableEvent { + id: number; + eventId: string; + status: string; + errorCode: string | null; + errorMessage: string | null; +} + +/** + * Authorized dead-letter/rejected replay controls for the AAR page. Rendered + * only when the viewer holds operation-events:update; the server action is the + * authorization boundary, this panel is convenience only. + */ +export function ReconcilePanel({ events }: { events: ReconcilableEvent[] }) { + const [results, setResults] = useState>({}); + const [pending, startTransition] = useTransition(); + + if (events.length === 0) return null; + + const replay = (id: number) => { + startTransition(async () => { + const outcome = await reconcileOperationEvent(id); + setResults((prev) => ({ + ...prev, + [id]: outcome.success + ? `Replayed: status ${outcome.data?.status ?? "unknown"}, ${outcome.data?.effectsCreated ?? 0} effect(s) created` + : `Replay failed: ${outcome.error ?? "unknown error"}`, + })); + }); + }; + + return ( +
+

+ Reconciliation +

+
    + {events.map((ev) => ( +
  • + + {ev.eventId} + + {ev.status} + + {ev.errorCode && ( + + {ev.errorCode}: {ev.errorMessage ?? "no detail"} + + )} + + + {results[ev.id] && {results[ev.id]}} + + +
  • + ))} +
+
+ ); +} diff --git a/src/components/frontend/operations/StatusReadout.tsx b/src/components/frontend/operations/StatusReadout.tsx new file mode 100644 index 0000000..1098381 --- /dev/null +++ b/src/components/frontend/operations/StatusReadout.tsx @@ -0,0 +1,25 @@ +import { STATUS_TONE_CLASSES, type StatusTone } from "./tokens"; + +/** + * Squared status pill with military-style uppercase text. The tone drives a + * contrast-compliant -400 text color on a /20 background; the text itself is + * the only status signal (no duplicate indicator). + */ +export function StatusReadout({ + label, + tone = "neutral", +}: { + label: string; + tone?: StatusTone; +}) { + return ( + + {label} + + ); +} diff --git a/src/components/frontend/operations/StrategicSummary.tsx b/src/components/frontend/operations/StrategicSummary.tsx new file mode 100644 index 0000000..cb5e5e9 --- /dev/null +++ b/src/components/frontend/operations/StrategicSummary.tsx @@ -0,0 +1,53 @@ +import type { ReactNode } from "react"; +import { cn } from "@/lib/utils"; +import { ClassificationStrip } from "./ClassificationStrip"; +import { CornerBrackets } from "./CornerBrackets"; +import { LedgerRow, type ReadoutCell } from "./LedgerRow"; +import { DOSSIER } from "./tokens"; + +/** + * Tactical strategic-summary surface: squared corners, faint 28px grid + * texture, classification strip, corner brackets, and an optional permanent + * readout footer. This is the reusable card primitive extracted from the + * approved campaign-card dossier conventions. + * + * NOT for dense data UI, forms, dialogs, buttons, or shadcn-styled controls: + * those keep the conventional shadcn baseline. Decorative layers are + * aria-hidden; all motion is transition-only and disabled under + * prefers-reduced-motion. + */ +export function StrategicSummary({ + classificationLeft, + classificationRight, + readoutCells, + children, + className, +}: { + classificationLeft: string; + classificationRight: string; + readoutCells?: readonly ReadoutCell[]; + children: ReactNode; + className?: string; +}) { + return ( +
+ + ); +} diff --git a/src/components/frontend/operations/index.ts b/src/components/frontend/operations/index.ts new file mode 100644 index 0000000..5da369c --- /dev/null +++ b/src/components/frontend/operations/index.ts @@ -0,0 +1,21 @@ +export { ClassificationStrip } from "./ClassificationStrip"; +export { CornerBrackets } from "./CornerBrackets"; +export { StatusReadout } from "./StatusReadout"; +export { LedgerRow, type ReadoutCell } from "./LedgerRow"; +export { ProvenanceChip } from "./ProvenanceChip"; +export { NoticeState } from "./NoticeState"; +export { StrategicSummary } from "./StrategicSummary"; +export { PageShell } from "./PageShell"; +export { STATUS_TONE_CLASSES, DOSSIER, statusToneFor, type StatusTone } from "./tokens"; +export { OperationsRealtimeRefresher } from "./OperationsRealtimeRefresher"; +export { + RecentOperationsCard, + MyExtractionsCard, + ReservationsCard, + ReadinessCard, + PressureCard, + OperationsStrip, +} from "./OperationSections"; +export { LedgerTable, type LedgerTableFilters } from "./LedgerTable"; +export { AarView } from "./AarView"; +export { ReconcilePanel, type ReconcilableEvent } from "./ReconcilePanel"; diff --git a/src/components/frontend/operations/tokens.ts b/src/components/frontend/operations/tokens.ts new file mode 100644 index 0000000..5a2eb5b --- /dev/null +++ b/src/components/frontend/operations/tokens.ts @@ -0,0 +1,55 @@ +/** + * Shared tactical "dossier" tokens for operation primitives. + * + * Centralized so every operations surface renders status and readout text + * with the same contrast-compliant, reduced-motion-safe classes. No CSS file + * is added: tokens are expressed as Tailwind utility bundles (the frontend + * styles.css is a frozen dirty-worktree baseline). + */ + +export type StatusTone = "neutral" | "info" | "success" | "warning" | "danger"; + +/** Contrast-compliant status text: -400 text on /20 bg (CampaignCard convention). */ +export const STATUS_TONE_CLASSES: Record = { + neutral: "border-muted/40 bg-muted/20 text-muted-foreground", + info: "border-blue-500/40 bg-blue-500/20 text-blue-400", + success: "border-emerald-500/40 bg-emerald-500/20 text-emerald-400", + warning: "border-amber-500/40 bg-amber-500/20 text-amber-400", + danger: "border-rose-500/40 bg-rose-500/20 text-rose-400", +}; + +export function statusToneFor(status: string): StatusTone { + switch (status) { + case "validated": + case "applied": + return "success"; + case "rejected": + case "dead-letter": + return "warning"; + case "failed": + case "reversed": + return "danger"; + case "pending": + return "info"; + default: + return "neutral"; + } +} + +/** Shared dossier surface classes (classification strip, readouts, chips). */ +export const DOSSIER = { + strip: "border-b border-white/15 bg-black/50 px-3 py-1.5 backdrop-blur-sm", + stripLabel: "font-mono text-[9px] uppercase tracking-widest text-white/60", + readout: "border-t border-white/15 bg-black/60 py-1.5 backdrop-blur-sm", + readoutValue: "font-mono text-xs tabular-nums text-white/80", + readoutLabel: "text-[9px] uppercase tracking-wider text-white/50", + chip: "border border-white/25 bg-black/40 px-1.5 py-0.5 font-mono text-[9px] uppercase tracking-widest text-white/70", + focusRing: + "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background", + /** 1px white grid, 28px cells, faint; hover-safe, reduced-motion honored. */ + gridTexture: { + backgroundImage: + "linear-gradient(to right, white 1px, transparent 1px), linear-gradient(to bottom, white 1px, transparent 1px)", + backgroundSize: "28px 28px", + }, +} as const; diff --git a/src/lib/operations/surface.ts b/src/lib/operations/surface.ts new file mode 100644 index 0000000..9f0b61a --- /dev/null +++ b/src/lib/operations/surface.ts @@ -0,0 +1,315 @@ +/** + * Operation overview surface service: role-scoped summaries for /operations. + * Read-only. Queries run through collection access control (logged-in read); + * gated sections are additionally permission-checked and return `null` when + * the viewer lacks the permission. Raw effect payload JSON is never returned + * to the UI, only derived human summaries and provenance links. + */ +import type { Payload, Where } from "payload"; +import { hasPermission } from "@/utils/access-control/hasPermission"; +import { effectivePressure } from "./territory"; + +type PayloadType = Awaited>; +export type SurfaceUser = { id: number } | null; + +export interface SurfaceCapabilities { + canSeeReservations: boolean; + canSeeReadiness: boolean; + canSeePressure: boolean; +} + +export interface OperationSummary { + operationId: string; + eventCount: number; + appliedCount: number; + failedCount: number; + rejectedCount: number; + lastOccurredAt: string | null; +} + +export interface MyExtractionSummary { + eventId: number; + eventLabel: string; + operationId: string; + occurredAt: string; + effectStatus: string | null; +} + +export interface PressureSummary { + zoneId: number; + factionId: number | null; + state: string; + pressure: number; +} + +export interface SurfaceOverview { + capabilities: SurfaceCapabilities; + recentOperations: OperationSummary[]; + myExtractions: MyExtractionSummary[]; + activeReservations: { + reservationKey: string; + operationId: string; + status: string; + expiresAt: string | null; + }[]; + ownReadiness: { + score: number | null; + explanation: string | null; + windowLabel: string | null; + } | null; + pressure: PressureSummary[]; +} + +export interface OperationEventRow { + id: number; + eventId: string; + operationId: string; + type: string; + status: string; + occurredAt: string; + actorId: number | null; + missionId: number | null; + errorCode: string | null; + errorMessage: string | null; + notes: string | null; +} + +export interface OperationEffectRow { + id: number; + effectKey: string; + operationId: string; + effectType: string; + status: string; + appliedAt: string | null; + notes: string | null; +} + +export async function getSurfaceCapabilities( + payload: PayloadType, + user: SurfaceUser, +): Promise { + const [reservations, readiness, pressure] = await Promise.all([ + hasPermission(payload, user, "operation-reservations:read"), + hasPermission(payload, user, "operation-readiness:read"), + hasPermission(payload, user, "zone-pressure:read"), + ]); + return { canSeeReservations: reservations, canSeeReadiness: readiness, canSeePressure: pressure }; +} + +function relationId(value: unknown): number | null { + if (typeof value === "number") return value; + if (value !== null && typeof value === "object" && "id" in (value as { id: unknown })) { + const id = (value as { id: unknown }).id; + return typeof id === "number" ? id : null; + } + return null; +} + +/** Group recent events by operation id and attach effect status counts. */ +export async function getOperationOverview( + payload: PayloadType, + user: SurfaceUser, +): Promise { + const capabilities = await getSurfaceCapabilities(payload, user); + + const events = await payload.find({ + collection: "operation-events", + sort: "-occurredAt", + limit: 50, + depth: 0, + user: user ?? undefined, + }); + + const operationIds = [...new Set(events.docs.map((d) => (d as { operationId: string }).operationId))]; + const effectsByOperation = new Map(); + if (operationIds.length > 0) { + const effectsWhere: Where = { operationId: { in: operationIds } }; + const effects = await payload.find({ + collection: "operation-effects", + where: effectsWhere, + limit: 1000, + depth: 0, + user: user ?? undefined, + }); + for (const doc of effects.docs) { + const row = doc as unknown as { + operationId: string; + status: string; + }; + const bucket = effectsByOperation.get(row.operationId) ?? { applied: 0, failed: 0, rejected: 0 }; + if (row.status === "applied") bucket.applied += 1; + else if (row.status === "failed" || row.status === "reversed") bucket.failed += 1; + else bucket.rejected += 1; + effectsByOperation.set(row.operationId, bucket); + } + } + + const eventCountByOperation = new Map(); + const lastOccurred = new Map(); + for (const doc of events.docs) { + const row = doc as unknown as { operationId: string; occurredAt: string }; + eventCountByOperation.set(row.operationId, (eventCountByOperation.get(row.operationId) ?? 0) + 1); + if (!lastOccurred.has(row.operationId)) lastOccurred.set(row.operationId, row.occurredAt); + } + const recentOperations: OperationSummary[] = operationIds.map((operationId) => { + const counts = effectsByOperation.get(operationId) ?? { applied: 0, failed: 0, rejected: 0 }; + return { + operationId, + eventCount: eventCountByOperation.get(operationId) ?? 0, + appliedCount: counts.applied, + failedCount: counts.failed, + rejectedCount: counts.rejected, + lastOccurredAt: lastOccurred.get(operationId) ?? null, + }; + }); + + const myExtractions: MyExtractionSummary[] = user + ? await queryMyExtractions(payload, user.id) + : []; + + const activeReservations: SurfaceOverview["activeReservations"] = []; + if (capabilities.canSeeReservations) { + const reserved = await payload.find({ + collection: "operation-reservations", + where: { status: { equals: "reserved" } }, + sort: "-createdAt", + limit: 5, + depth: 0, + user: user ?? undefined, + }); + for (const doc of reserved.docs) { + const row = doc as unknown as { + reservationKey: string; + operationId: string; + status: string; + expiresAt: string | null; + }; + activeReservations.push({ + reservationKey: row.reservationKey, + operationId: row.operationId, + status: row.status, + expiresAt: row.expiresAt ?? null, + }); + } + } + + let ownReadiness: SurfaceOverview["ownReadiness"] = null; + if (user && capabilities.canSeeReadiness) { + const found = await payload.find({ + collection: "operation-readiness", + where: { + and: [ + { subjectType: { equals: "user" } }, + { subjectId: { equals: user.id } }, + ], + }, + sort: "-windowStart", + limit: 1, + depth: 0, + user, + }); + if (found.docs.length > 0) { + const row = found.docs[0] as unknown as { + readinessScore: number | null; + explanation: string | null; + windowLabel: string | null; + }; + ownReadiness = { + score: row.readinessScore, + explanation: row.explanation, + windowLabel: row.windowLabel, + }; + } + } + + const pressure: PressureSummary[] = []; + if (capabilities.canSeePressure) { + const rows = await payload.find({ + collection: "zone-pressure", + sort: "-createdAt", + limit: 500, + depth: 0, + user: user ?? undefined, + }); + const now = new Date(); + const byZone = new Map(); + for (const doc of rows.docs) { + const row = doc as unknown as { + zone: unknown; + faction: unknown; + delta: number; + createdAt: string; + }; + const zoneId = relationId(row.zone); + if (zoneId === null) continue; + const bucket = byZone.get(zoneId) ?? { factionId: relationId(row.faction), deltas: [] }; + bucket.deltas.push({ delta: row.delta, createdAt: row.createdAt }); + byZone.set(zoneId, bucket); + } + for (const [zoneId, bucket] of byZone) { + const zone = effectivePressure(bucket.deltas, now); + pressure.push({ + zoneId, + factionId: bucket.factionId, + state: zone.state, + pressure: zone.pressure, + }); + } + pressure.sort((a, b) => b.pressure - a.pressure); + } + + return { capabilities, recentOperations, myExtractions, activeReservations, ownReadiness, pressure }; +} + +async function queryMyExtractions( + payload: PayloadType, + userId: number, +): Promise { + const extractionWhere: Where = { + and: [ + { type: { equals: "operation.extraction" } }, + { actor: { equals: userId } }, + ], + }; + const extractions = await payload.find({ + collection: "operation-events", + where: extractionWhere, + sort: "-occurredAt", + limit: 10, + depth: 0, + user: { id: userId }, + }); + + const effectStatusByEvent = new Map(); + const eventIds = extractions.docs.map((d) => d.id as number); + if (eventIds.length > 0) { + const effects = await payload.find({ + collection: "operation-effects", + where: { operationEvent: { in: eventIds } }, + limit: 100, + depth: 0, + user: { id: userId }, + }); + for (const doc of effects.docs) { + const row = doc as unknown as { operationEvent: unknown; status: string }; + const eventId = relationId(row.operationEvent); + if (eventId !== null) effectStatusByEvent.set(eventId, row.status); + } + } + + return extractions.docs.map((doc) => { + const row = doc as unknown as { + id: number; + eventId: string; + operationId: string; + occurredAt: string; + }; + return { + eventId: row.id, + eventLabel: row.eventId, + operationId: row.operationId, + occurredAt: row.occurredAt, + effectStatus: effectStatusByEvent.get(row.id) ?? null, + }; + }); +} diff --git a/src/lib/operations/surfaceQuery.ts b/src/lib/operations/surfaceQuery.ts new file mode 100644 index 0000000..65b395a --- /dev/null +++ b/src/lib/operations/surfaceQuery.ts @@ -0,0 +1,259 @@ +/** + * Operation ledger drill-down and AAR query service. Strips raw effect payload + * JSON from every row: only event metadata, effect status, failure + * explanations, and provenance links reach the UI. Gated sections are + * permission-checked and return empty lists when the viewer lacks permission. + */ +import type { Payload, Where } from "payload"; +import { hasPermission } from "@/utils/access-control/hasPermission"; +import type { OperationEffectRow, OperationEventRow, SurfaceUser } from "./surface"; + +type PayloadType = Awaited>; + +export interface LedgerFilters { + operationId?: string; + status?: string; + type?: string; +} + +export interface LedgerResult { + events: OperationEventRow[]; + page: number; + totalDocs: number; + totalPages: number; + effectCounts: Record; +} + +export interface AarCounts { + events: number; + validated: number; + rejected: number; + deadLetter: number; + applied: number; + failed: number; + pending: number; + reversed: number; +} + +export interface AarResult { + operationId: string; + events: OperationEventRow[]; + effects: OperationEffectRow[]; + counts: AarCounts; + pressure: { zoneId: number; factionId: number | null; delta: number; createdAt: string }[]; + reservations: { reservationKey: string; status: string; expiresAt: string | null }[]; +} + +const LEDGER_PAGE_SIZE = 20; +const AAR_EVENT_LIMIT = 500; + +function toEventRow(doc: unknown): OperationEventRow { + const row = doc as { + id: number; + eventId: string; + operationId: string; + type: string; + status: string; + occurredAt: string; + actor?: number | { id: number } | null; + mission?: number | { id: number } | null; + error?: { code?: string; message?: string } | null; + reconciliationNotes?: string | null; + }; + return { + id: row.id, + eventId: row.eventId, + operationId: row.operationId, + type: row.type, + status: row.status, + occurredAt: row.occurredAt, + actorId: typeof row.actor === "number" ? row.actor : row.actor?.id ?? null, + missionId: typeof row.mission === "number" ? row.mission : row.mission?.id ?? null, + errorCode: row.error?.code ?? null, + errorMessage: row.error?.message ?? null, + notes: row.reconciliationNotes ?? null, + }; +} + +/** Effect rows never carry the payload JSON: only status, type, and notes. */ +function toEffectRow(doc: unknown): OperationEffectRow { + const row = doc as { + id: number; + effectKey: string; + operationId: string; + effectType: string; + status: string; + appliedAt: string | null; + reconciliationNotes?: string | null; + }; + return { + id: row.id, + effectKey: row.effectKey, + operationId: row.operationId, + effectType: row.effectType, + status: row.status, + appliedAt: row.appliedAt ?? null, + notes: row.reconciliationNotes ?? null, + }; +} + +function bump(counts: Record, key: string): void { + counts[key] = (counts[key] ?? 0) + 1; +} + +export async function getOperationLedger( + payload: PayloadType, + user: SurfaceUser, + filters: LedgerFilters, + requestedPage = 1, +): Promise { + const conditions: Where[] = []; + if (filters.operationId) conditions.push({ operationId: { equals: filters.operationId } }); + if (filters.status) conditions.push({ status: { equals: filters.status } }); + if (filters.type) conditions.push({ type: { equals: filters.type } }); + const where: Where | undefined = conditions.length > 0 ? { and: conditions } : undefined; + + const found = await payload.find({ + collection: "operation-events", + where, + sort: "-occurredAt", + page: Math.max(1, requestedPage), + limit: LEDGER_PAGE_SIZE, + depth: 0, + user: user ?? undefined, + }); + + const effectCounts: Record = {}; + if (filters.operationId) { + const effects = await payload.find({ + collection: "operation-effects", + where: { operationId: { equals: filters.operationId } }, + limit: 1000, + depth: 0, + user: user ?? undefined, + }); + for (const doc of effects.docs) { + const row = doc as { status: string }; + bump(effectCounts, row.status); + } + } + + return { + events: found.docs.map(toEventRow), + page: found.page ?? Math.max(1, requestedPage), + totalDocs: found.totalDocs ?? found.docs.length, + totalPages: found.totalPages ?? 1, + effectCounts, + }; +} + +export async function getOperationAar( + payload: PayloadType, + user: SurfaceUser, + operationId: string, +): Promise { + const eventsFound = await payload.find({ + collection: "operation-events", + where: { operationId: { equals: operationId } }, + sort: "-occurredAt", + limit: AAR_EVENT_LIMIT, + depth: 0, + user: user ?? undefined, + }); + if (eventsFound.docs.length === 0) return null; + + const effectsFound = await payload.find({ + collection: "operation-effects", + where: { operationId: { equals: operationId } }, + sort: "-createdAt", + limit: 500, + depth: 0, + user: user ?? undefined, + }); + + const counts: AarCounts = { + events: eventsFound.docs.length, + validated: 0, + rejected: 0, + deadLetter: 0, + applied: 0, + failed: 0, + pending: 0, + reversed: 0, + }; + for (const doc of eventsFound.docs) { + const status = (doc as { status: string }).status; + if (status === "validated") counts.validated += 1; + else if (status === "rejected") counts.rejected += 1; + else counts.deadLetter += 1; + } + for (const doc of effectsFound.docs) { + const status = (doc as { status: string }).status; + if (status === "applied") counts.applied += 1; + else if (status === "failed") counts.failed += 1; + else if (status === "pending") counts.pending += 1; + else if (status === "reversed") counts.reversed += 1; + } + + const canSeePressure = await hasPermission(payload, user, "zone-pressure:read"); + const canSeeReservations = await hasPermission(payload, user, "operation-reservations:read"); + + const pressure: AarResult["pressure"] = []; + if (canSeePressure) { + const rows = await payload.find({ + collection: "zone-pressure", + where: { operationId: { equals: operationId } }, + sort: "-createdAt", + limit: 100, + depth: 0, + user: user ?? undefined, + }); + for (const doc of rows.docs) { + const row = doc as unknown as { + zone: unknown; + faction: unknown; + delta: number; + createdAt: string; + }; + pressure.push({ + zoneId: typeof row.zone === "number" ? row.zone : (row.zone as { id: number } | null)?.id ?? 0, + factionId: typeof row.faction === "number" ? row.faction : (row.faction as { id: number } | null)?.id ?? null, + delta: row.delta, + createdAt: row.createdAt, + }); + } + } + + const reservations: AarResult["reservations"] = []; + if (canSeeReservations) { + const rows = await payload.find({ + collection: "operation-reservations", + where: { operationId: { equals: operationId } }, + sort: "-createdAt", + limit: 50, + depth: 0, + user: user ?? undefined, + }); + for (const doc of rows.docs) { + const row = doc as unknown as { + reservationKey: string; + status: string; + expiresAt: string | null; + }; + reservations.push({ + reservationKey: row.reservationKey, + status: row.status, + expiresAt: row.expiresAt ?? null, + }); + } + } + + return { + operationId, + events: eventsFound.docs.map(toEventRow), + effects: effectsFound.docs.map(toEffectRow), + counts, + pressure, + reservations, + }; +} diff --git a/tests/e2e/operations.e2e.spec.ts b/tests/e2e/operations.e2e.spec.ts new file mode 100644 index 0000000..2493af4 --- /dev/null +++ b/tests/e2e/operations.e2e.spec.ts @@ -0,0 +1,58 @@ +import { mkdirSync } from "node:fs"; +import { expect, test } from "@playwright/test"; + +const SHOT_DIR = ".omo/evidence/screenshots"; +async function shot(page: import("@playwright/test").Page, name: string): Promise { + mkdirSync(SHOT_DIR, { recursive: true }); + await page.screenshot({ path: `${SHOT_DIR}/${name}.png`, fullPage: true }); +} + +/** + * Operations surfaces: role-scoped visibility and failure-state behavior. + * NOTE: these tests exercise the real dev server and are run in the final + * verification wave (F3) under the dev-server protocol, not on every change. + */ +test.describe("Operations surfaces", () => { + test("guests are gated away from /operations by the landing page", async ({ page }) => { + await page.goto("/operations"); + await expect(page.getByRole("heading", { level: 1 })).toHaveText("Polaris Task Force"); + }); + + test("an authed user sees the operations overview with role sections", async ({ page }) => { + await page.goto("/login"); + await page.getByLabel("Username").fill("dev"); + await page.getByLabel("Password").fill("Test123"); + await page.getByRole("button", { name: "Log in" }).click(); + + await page.goto("/operations"); + await expect(page.getByRole("heading", { name: "Operations" })).toBeVisible(); + await expect(page.getByText("Recent operations")).toBeVisible(); + await shot(page, "operations-overview-authed"); + await expect(page.getByText("My extractions")).toBeVisible(); + }); + + test("the ledger shows empty and filtered states without raw payloads", async ({ page }) => { + await page.goto("/login"); + await page.getByLabel("Username").fill("dev"); + await page.getByLabel("Password").fill("Test123"); + await page.getByRole("button", { name: "Log in" }).click(); + + await page.goto("/operations/ledger?status=dead-letter"); + await expect(page.getByRole("heading", { name: "Operations ledger" })).toBeVisible(); + await expect(page.getByText("No operation events match this view.")).toBeVisible(); + await shot(page, "operations-ledger-dead-letter-empty"); + }); + + test("an unknown operation id renders the empty AAR state", async ({ page }) => { + await page.goto("/login"); + await page.getByLabel("Username").fill("dev"); + await page.getByLabel("Password").fill("Test123"); + await page.getByRole("button", { name: "Log in" }).click(); + + await page.goto("/operations/definitely-not-a-real-operation"); + await expect( + page.getByText("No operation events are recorded under"), + ).toBeVisible(); + await shot(page, "operations-aar-unknown-empty"); + }); +}); diff --git a/tests/int/operation-ui-primitives.int.spec.ts b/tests/int/operation-ui-primitives.int.spec.ts new file mode 100644 index 0000000..899d6d1 --- /dev/null +++ b/tests/int/operation-ui-primitives.int.spec.ts @@ -0,0 +1,184 @@ +import { createElement as h } from "react"; +import { afterEach, describe, expect, it } from "vitest"; +import { cleanup, render, screen, within } from "@testing-library/react"; +import { + ClassificationStrip, + CornerBrackets, + LedgerRow, + NoticeState, + PageShell, + ProvenanceChip, + StatusReadout, + StrategicSummary, + STATUS_TONE_CLASSES, + type StatusTone, +} from "@/components/frontend/operations"; + +// Vitest globals are off, so @testing-library auto-cleanup never registers; +// without this, rendered DOM accumulates across tests and role queries match +// stale elements. +afterEach(cleanup); + +/** + * Component tests documenting every operations-primitive variant: + * classification strip, corner brackets, status readout tones, ledger row, + * provenance chip (plain + link), notice states (empty/loading/error), + * the strategic summary composition, and the page shell. + * + * The file is .ts (the plan QA command names this exact path), so elements are + * built with createElement instead of JSX syntax. + * + * Accessibility contract: semantic roles, aria-hidden decoration, keyboard + * focus rings on interactive chips, contrast-compliant status text, and + * reduced-motion variants on every transition. + */ + +describe("ClassificationStrip", () => { + it("renders both classification labels", () => { + render(h(ClassificationStrip, { left: "Operations // Unit Command", right: "REF 001" })); + expect(screen.getByText("Operations // Unit Command")).toBeDefined(); + expect(screen.getByText("REF 001")).toBeDefined(); + }); +}); + +describe("CornerBrackets", () => { + it("renders four decorative corner marks with reduced-motion variants", () => { + const { container } = render(h(CornerBrackets)); + const slot = container.querySelector('[data-slot="corner-brackets"]'); + expect(slot?.getAttribute("aria-hidden")).toBe("true"); + expect(slot?.querySelectorAll("span").length).toBe(4); + expect(slot?.innerHTML).toContain("motion-reduce:transition-none"); + }); +}); + +describe("StatusReadout", () => { + const tones: StatusTone[] = ["neutral", "info", "success", "warning", "danger"]; + + it.each(tones)("renders the %s tone with contrast-compliant status text", (tone) => { + const { container } = render(h(StatusReadout, { label: `${tone} status`, tone })); + const pill = screen.getByRole("status"); + expect(pill.textContent).toBe(`${tone} status`); + expect(pill.getAttribute("data-tone")).toBe(tone); + // Squared corners (rounded-sm), token-driven contrast classes present. + expect(pill.className).toContain("rounded-sm"); + for (const cls of STATUS_TONE_CLASSES[tone].split(" ")) { + expect(pill.className).toContain(cls); + } + expect(container.firstElementChild).toBe(pill); + }); +}); + +describe("LedgerRow", () => { + it("renders permanent readout cells with mono values and hairline separators", () => { + const cells = [ + { label: "Msns", value: "3", mono: true }, + { label: "Duration", value: "4h 0m", mono: true }, + { label: "State", value: "Contested" }, + ] as const; + const { container } = render(h(LedgerRow, { cells })); + const row = container.querySelector('[data-slot="ledger-row"]') as HTMLElement; + expect(row.tagName).toBe("FOOTER"); + expect(row.className).toContain("border-t"); + expect(row.className).toContain("bg-black/60"); + + const rendered = Array.from(row.querySelectorAll("span")).map((v) => v.textContent); + expect(rendered).toContain("3"); + expect(rendered).toContain("4h 0m"); + expect(rendered).toContain("Contested"); + + const hairlines = row.querySelectorAll(".border-r"); + expect(hairlines.length).toBe(cells.length); + }); + + it("renders nothing when empty", () => { + const { container } = render(h(LedgerRow, { cells: [] })); + const row = container.querySelector('[data-slot="ledger-row"]') as HTMLElement; + expect(row.className).toContain("hidden"); + }); +}); + +describe("ProvenanceChip", () => { + it("renders a plain marker without href", () => { + const { container } = render(h(ProvenanceChip, { label: "OP 42" })); + const chip = container.querySelector('[data-slot="provenance-chip"]') as HTMLElement; + expect(chip.tagName).toBe("SPAN"); + expect(chip.textContent).toBe("OP 42"); + expect(chip.className).toContain("font-mono"); + }); + + it("renders a keyboard-focusable link with href", () => { + const { container } = render(h(ProvenanceChip, { label: "OP 42", href: "/logistics/structures/1" })); + const chip = container.querySelector('[data-slot="provenance-chip"]') as HTMLElement; + expect(chip.tagName).toBe("A"); + expect(chip.getAttribute("href")).toBe("/logistics/structures/1"); + expect(chip.className).toContain("focus-visible:ring-2"); + }); +}); + +describe("NoticeState", () => { + it("renders empty and loading variants as polite status with aria-busy on loading", () => { + const { container } = render( + h("div", null, + h(NoticeState, { variant: "empty", message: "No operations yet", hint: "Run an op to populate the ledger." }), + h(NoticeState, { variant: "loading", message: "Loading operation ledger" }), + ), + ); + const notices = container.querySelectorAll('[data-slot="notice-state"]'); + expect(notices.length).toBe(2); + const empty = notices[0] as HTMLElement; + const loading = notices[1] as HTMLElement; + expect(empty.getAttribute("data-variant")).toBe("empty"); + expect(empty.getAttribute("role")).toBe("status"); + expect(empty.className).not.toContain("rose"); + expect(empty.textContent).toContain("No operations yet"); + expect(loading.getAttribute("data-variant")).toBe("loading"); + expect(loading.getAttribute("role")).toBe("status"); + expect(loading.getAttribute("aria-busy")).toBe("true"); + }); + + it("renders the error variant as an assertive alert", () => { + render(h(NoticeState, { variant: "error", message: "Failed effect", hint: "Replay from reconciliation." })); + const alert = screen.getByRole("alert"); + expect(alert.getAttribute("data-variant")).toBe("error"); + expect(alert.className).toContain("border-rose-500/30"); + }); +}); + +describe("StrategicSummary", () => { + it("composes strip, brackets, grid texture, content, and readout footer", () => { + const { container } = render( + h(StrategicSummary, + { + classificationLeft: "Official // Unit Command", + classificationRight: "REF 007", + readoutCells: [{ label: "Msns", value: "2", mono: true }], + children: h("p", null, "Summary body"), + }, + ), + ); + const surface = container.querySelector('[data-slot="strategic-summary"]') as HTMLElement; + expect(surface.className).toContain("rounded-none"); + + within(surface).getByText("Official // Unit Command"); + within(surface).getByText("REF 007"); + expect(surface.querySelector('[data-slot="corner-brackets"]')).not.toBeNull(); + expect(surface.querySelector('[data-slot="ledger-row"]')).not.toBeNull(); + expect(surface.textContent).toContain("Summary body"); + + const texture = surface.querySelector('div[style*="linear-gradient"]') as HTMLElement; + expect(texture?.getAttribute("aria-hidden")).toBe("true"); + expect(texture?.style.backgroundSize).toBe("28px 28px"); + }); +}); + +describe("PageShell", () => { + it("renders the title, meta strip, and content responsively", () => { + render( + h(PageShell, { title: "Operation Ledger", meta: "Command // Weekly", children: h("p", null, "Shell content") }), + ); + const heading = screen.getByRole("heading", { level: 1 }); + expect(heading.textContent).toContain("Operation Ledger"); + expect(screen.getByText("Command // Weekly")).toBeDefined(); + expect(screen.getByText("Shell content")).toBeDefined(); + }); +}); diff --git a/tests/int/operations-surface.int.spec.ts b/tests/int/operations-surface.int.spec.ts new file mode 100644 index 0000000..925bd88 --- /dev/null +++ b/tests/int/operations-surface.int.spec.ts @@ -0,0 +1,305 @@ +import { getPayload, Payload } from "payload"; +import config from "@/payload.config"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { getOperationOverview, getSurfaceCapabilities } from "@/lib/operations/surface"; +import { getOperationAar, getOperationLedger } from "@/lib/operations/surfaceQuery"; + +let payload: Payload; +const RUN = `ops-surface-${Date.now().toString(36)}`; + +const syncEventIds: number[] = []; +const opEventIds: number[] = []; +const opEffectIds: number[] = []; +const userIds: number[] = []; +const roleIds: number[] = []; +const zoneIds: number[] = []; +const mapIds: number[] = []; +const factionIds: number[] = []; +let serverId: number; +let plainUserId: number; +let permUserId: number; +let adminUserId: number; +let mapId: number; +let zoneId: number; +let factionId: number; + +const OP_A = `${RUN}-op-alpha`; +const OP_B = `${RUN}-op-beta`; + +async function makeUser(label: string, roleDocIds?: number[]): Promise { + const user = await payload.create({ + collection: "users", + data: { + username: `${RUN}-${label}`, + discordUsername: `${RUN}-${label}-discord`, + displayName: `${RUN} ${label}`, + steamId: `${RUN}-steam-${label}`, + password: "Test1234", + ...(roleDocIds && roleDocIds.length > 0 ? { roleDocs: roleDocIds } : {}), + }, + overrideAccess: true, + depth: 0, + }); + userIds.push(user.id); + return user.id; +} + +async function makeRawEvent(label: string, type: string, payloadData: unknown): Promise { + const raw = await payload.create({ + collection: "arma-sync-events", + data: { + eventId: `${RUN}-${label}`, + server: serverId, + type, + occurredAt: new Date().toISOString(), + payload: payloadData as Record, + }, + overrideAccess: true, + depth: 0, + }); + syncEventIds.push(raw.id); + const found = await payload.find({ + collection: "operation-events", + where: { sourceEvent: { equals: raw.id } }, + limit: 1, + depth: 0, + overrideAccess: true, + }); + expect(found.docs).toHaveLength(1); + opEventIds.push(found.docs[0].id); + const effects = await payload.find({ + collection: "operation-effects", + where: { operationEvent: { equals: found.docs[0].id } }, + limit: 10, + depth: 0, + overrideAccess: true, + }); + for (const effect of effects.docs) opEffectIds.push(effect.id); + return raw.id; +} + +beforeAll(async () => { + const payloadConfig = await config; + payload = await getPayload({ config: payloadConfig }); + + const superRole = await payload.create({ + collection: "roles", + data: { name: `${RUN} Surface Admin`, slug: `${RUN}-surface-admin`, isSuperuser: true }, + overrideAccess: true, + depth: 0, + }); + roleIds.push(superRole.id); + + const permRole = await payload.create({ + collection: "roles", + data: { + name: `${RUN} Surface Viewer`, + slug: `${RUN}-surface-viewer`, + permissions: [ + "operation-reservations:read", + "zone-pressure:read", + "operation-readiness:read", + ], + }, + overrideAccess: true, + depth: 0, + }); + roleIds.push(permRole.id); + + const faction = await payload.create({ + collection: "factions", + data: { name: `${RUN} Faction`, type: "majorFaction" }, + overrideAccess: true, + depth: 0, + }); + factionIds.push(faction.id); + factionId = faction.id; + + const server = await payload.create({ + collection: "game-servers", + data: { serverId: `${RUN}-srv`, name: `${RUN} Server`, status: "online" }, + overrideAccess: true, + depth: 0, + }); + serverId = server.id; + + const map = await payload.create({ + collection: "maps", + data: { name: `${RUN} Map`, worldSizeWidth: 8192, worldSizeHeight: 8192, basemapMode: "image" }, + overrideAccess: true, + depth: 0, + }); + mapIds.push(map.id); + mapId = map.id; + + const zone = await payload.create({ + collection: "map-zones", + data: { + name: `${RUN} Zone Alpha`, + map: mapId, + type: "territory", + points: [ + [0, 0], + [100, 0], + [100, 100], + [0, 100], + ], + }, + overrideAccess: true, + depth: 0, + }); + zoneIds.push(zone.id); + zoneId = zone.id; + + plainUserId = await makeUser("plain"); + permUserId = await makeUser("perm", [permRole.id]); + adminUserId = await makeUser("admin", [superRole.id]); +}); + +afterAll(async () => { + if (!payload) return; + for (const id of opEffectIds) { + await payload.delete({ collection: "operation-effects", id, overrideAccess: true }).catch(() => {}); + } + for (const id of opEventIds) { + await payload.delete({ collection: "operation-events", id, overrideAccess: true }).catch(() => {}); + } + for (const id of syncEventIds) { + await payload.delete({ collection: "arma-sync-events", id, overrideAccess: true }).catch(() => {}); + } + for (const id of zoneIds) { + await payload.delete({ collection: "map-zones", id, overrideAccess: true }).catch(() => {}); + } + for (const id of mapIds) { + await payload.delete({ collection: "maps", id, overrideAccess: true }).catch(() => {}); + } + for (const id of factionIds) { + await payload.delete({ collection: "factions", id, overrideAccess: true }).catch(() => {}); + } + for (const id of userIds) { + await payload.delete({ collection: "users", id, overrideAccess: true }).catch(() => {}); + } + for (const id of roleIds) { + await payload.delete({ collection: "roles", id, overrideAccess: true }).catch(() => {}); + } +}); + +describe("operation surfaces: capabilities", () => { + it("grants a superuser every gated capability", async () => { + const adminUser = await payload.findByID({ + collection: "users", + id: adminUserId, + overrideAccess: true, + depth: 0, + }); + const caps = await getSurfaceCapabilities(payload, { id: adminUserId }); + expect(adminUser.id).toBe(adminUserId); + expect(caps.canSeeReservations).toBe(true); + expect(caps.canSeeReadiness).toBe(true); + expect(caps.canSeePressure).toBe(true); + }); + + it("denies a plain user every gated capability", async () => { + const caps = await getSurfaceCapabilities(payload, { id: plainUserId }); + expect(caps.canSeeReservations).toBe(false); + expect(caps.canSeeReadiness).toBe(false); + expect(caps.canSeePressure).toBe(false); + }); +}); + +describe("operation surfaces: overview", () => { + it("groups recent events by operation with effect status counts", async () => { + await makeRawEvent("start-a1", "operation.start", { operationId: OP_A, version: 1 }); + await makeRawEvent("start-a2", "operation.start", { operationId: OP_A, version: 1 }); + await makeRawEvent("start-b1", "operation.start", { operationId: OP_B, version: 1 }); + + const overview = await getOperationOverview(payload, { id: adminUserId }); + const opA = overview.recentOperations.find((op) => op.operationId === OP_A); + expect(opA).toBeDefined(); + expect(opA?.eventCount).toBe(2); + expect(opA?.appliedCount).toBe(2); + expect(opA?.failedCount).toBe(0); + expect(opA?.lastOccurredAt).toBeTruthy(); + }); + + it("scopes my extractions to the requesting actor only", async () => { + await makeRawEvent("extract-b1", "operation.extraction", { + operationId: OP_B, + version: 1, + actorId: plainUserId, + kind: "player", + items: [{ assetId: 999_999_999, quantity: 1 }], + }); + + const mine = await getOperationOverview(payload, { id: plainUserId }); + const myRows = mine.myExtractions.filter((ex) => ex.operationId === OP_B); + expect(myRows).toHaveLength(1); + expect(myRows[0].effectStatus).toBeTruthy(); + + const others = await getOperationOverview(payload, { id: permUserId }); + expect(others.myExtractions.some((ex) => ex.operationId === OP_B)).toBe(false); + }); + + it("hides gated sections from plain users but keeps the ledger visible", async () => { + const overview = await getOperationOverview(payload, { id: plainUserId }); + expect(overview.capabilities.canSeePressure).toBe(false); + expect(overview.pressure).toHaveLength(0); + expect(overview.recentOperations.length).toBeGreaterThan(0); + }); +}); + +describe("operation surfaces: ledger and AAR", () => { + it("filters the ledger by operation and reports effect counts without leaking payload", async () => { + const result = await getOperationLedger(payload, { id: adminUserId }, { operationId: OP_A }); + expect(result.events).toHaveLength(2); + expect(result.events.every((ev) => ev.operationId === OP_A)).toBe(true); + expect(result.effectCounts.applied).toBe(2); + for (const ev of result.events) { + expect("payload" in ev ? ev.payload : undefined).toBeUndefined(); + } + }); + + it("paginates the ledger", async () => { + const page1 = await getOperationLedger(payload, { id: adminUserId }, {}, 1); + expect(page1.page).toBe(1); + expect(page1.totalDocs).toBeGreaterThanOrEqual(3); + }); + + it("returns null for an unknown operation id", async () => { + const aar = await getOperationAar(payload, { id: adminUserId }, `${RUN}-does-not-exist`); + expect(aar).toBeNull(); + }); + + it("builds an AAR with counts and never includes raw effect payloads", async () => { + const aar = await getOperationAar(payload, { id: adminUserId }, OP_B); + expect(aar).not.toBeNull(); + expect(aar?.events.length).toBe(2); + expect(aar?.counts.validated).toBe(2); + expect(aar?.counts.applied).toBe(1); + const failed = aar?.effects.filter((eff) => eff.status === "failed") ?? []; + expect(failed.length).toBe(1); + expect(failed[0]?.effectType).toBe("operation.extraction-recorded"); + for (const eff of aar?.effects ?? []) { + expect("payload" in eff ? eff.payload : undefined).toBeUndefined(); + } + }); + + it("gates AAR pressure and reservation sections by permission", async () => { + await makeRawEvent("end-a1", "operation.end", { + operationId: OP_A, + version: 1, + outcome: "success", + zoneId, + factionId, + }); + + const forAdmin = await getOperationAar(payload, { id: adminUserId }, OP_A); + expect(forAdmin?.pressure).toHaveLength(1); + expect(forAdmin?.pressure[0]?.zoneId).toBe(zoneId); + expect(forAdmin?.pressure[0]?.delta).toBe(-25); + + const forPlain = await getOperationAar(payload, { id: plainUserId }, OP_A); + expect(forPlain?.pressure).toHaveLength(0); + expect(forPlain?.reservations).toHaveLength(0); + }); +});