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 (
+
+ );
+}
+
+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 ? (
+
+ ) : (
+
+
+
+ Event
+ Type
+ Status
+ Occurred
+
+
+
+ {aar.events.map((ev) => (
+
+ {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 ? (
+
+ ) : (
+
+ )}
+
+
+
+ {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 (
+
+ );
+}
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 ? (
+
+ ) : (
+
+
+
+ Event
+ Operation
+ Type
+ Status
+ Occurred
+ Detail
+
+
+
+ {result.events.map((ev) => (
+
+ {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 ? (
+
+ ) : (
+
+
+
+ Operation
+ Events
+ Applied
+ Failed
+ Rejected
+ Last activity
+
+
+
+ {operations.map((op) => (
+
+
+
+ {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]} }
+ replay(ev.id)}
+ disabled={pending}
+ className="border border-white/25 px-2 py-1 font-mono text-[10px] uppercase tracking-widest text-white/80 hover:border-white/40 hover:text-white disabled:opacity-50"
+ >
+ {pending ? "Replaying..." : "Replay"}
+
+
+
+ ))}
+
+
+ );
+}
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 (
+
+
+
+
+
+
{children}
+ {readoutCells ?
: null}
+
+
+ );
+}
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);
+ });
+});