1
0
Fork 0

feat(ui): tactical operation primitives and role surfaces

Reusable dossier primitives (classification strips, brackets, readouts, provenance chips, notice states) plus server-rendered /operations overview, ledger drill-down, and AAR with permission-gated sections and an authorized reconciliation panel. Realtime refresh is enhancement only.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
Jason Fraley 2026-09-20 02:44:53 -04:00
parent 3c70160dfc
commit c19d0d05ee
26 changed files with 2311 additions and 0 deletions

View file

@ -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 (
<div className="mx-auto flex w-full max-w-7xl flex-col gap-6 px-4 py-6">
<OperationsRealtimeRefresher />
<PageShell title={`Operation ${operationId}`} meta="AFTER-ACTION REPORT // PROVENANCE LINKED">
{aar === null ? (
<NoticeState
variant="empty"
message={`No operation events are recorded under "${operationId}".`}
hint="The operation id may be wrong, or its events have not synced yet. Check the operations ledger."
action={
<Link
href="/operations/ledger"
className="border border-white/25 px-3 py-1.5 font-mono text-[10px] uppercase tracking-widest text-white/80 hover:border-white/40 hover:text-white"
>
Open operations ledger
</Link>
}
/>
) : (
<>
<AarView aar={aar} />
<ReconcilePanel events={reconcilable} />
</>
)}
</PageShell>
<OperationsStrip left="OPERATION AAR // UNIT COMMAND" />
</div>
);
}

View file

@ -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<T> {
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<ActionResult<{ status?: string; effectsCreated?: number }>> {
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<ActionResult<{ processed: number; skipped: number }>> {
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" };
}
}

View file

@ -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<LedgerSearchParams>;
}) {
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 (
<div className="mx-auto flex w-full max-w-7xl flex-col gap-6 px-4 py-6">
<OperationsRealtimeRefresher />
<PageShell title="Operations ledger" meta="SOURCE EVENTS // EFFECT LEDGER">
<p className="max-w-2xl text-sm text-white/70">
Every validated, rejected, and dead-lettered operation event with its derived effects.
Rejected and dead-lettered entries are retained as evidence and never applied.
</p>
</PageShell>
<LedgerTable result={result} filters={filters} />
<OperationsStrip left="OPERATIONS LEDGER // UNIT COMMAND" />
</div>
);
}

View file

@ -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 (
<div className="mx-auto flex w-full max-w-7xl flex-col gap-6 px-4 py-6">
<OperationsRealtimeRefresher />
<PageShell title="Operations" meta="OPERATION FEED // AFTER-ACTION RECORDS">
<p className="max-w-2xl text-sm text-white/70">
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.
</p>
</PageShell>
<div className="flex flex-col gap-6">
<RecentOperationsCard operations={overview.recentOperations} />
<div className="grid gap-6 lg:grid-cols-2">
<MyExtractionsCard extractions={overview.myExtractions} signedIn={user !== null} />
<ReadinessCard readiness={overview.ownReadiness} canSee={overview.capabilities.canSeeReadiness} />
<ReservationsCard
reservations={overview.activeReservations}
canSee={overview.capabilities.canSeeReservations}
/>
<PressureCard pressure={overview.pressure} canSee={overview.capabilities.canSeePressure} />
</div>
</div>
<OperationsStrip left="OPERATIONS // UNIT COMMAND" />
</div>
);
}

View file

@ -153,6 +153,7 @@ export function AppSidebar({
{hasIntelligence && (
<NavCore label="Intelligence" items={data.navIntelligence} />
)}
<NavCore label="Operations" items={data.navOperations} />
{hasLogistics && (
<NavCore label="Infrastructure" items={data.navLogistics} />
)}

View file

@ -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",

View file

@ -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 (
<section data-slot="aar-section" className="flex flex-col gap-2">
<h2 className="font-mono text-[9px] uppercase tracking-widest text-white/60">{title}</h2>
{children}
</section>
);
}
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 (
<div className="flex flex-col gap-6" data-slot="operations-aar-view">
<LedgerRow cells={countsRow(aar)} />
<Section title="Timeline">
{aar.events.length === 0 ? (
<NoticeState variant="empty" message="No events recorded for this operation." />
) : (
<table className="w-full border-collapse">
<thead>
<tr className="border-b border-white/10">
<th className={headCell}>Event</th>
<th className={headCell}>Type</th>
<th className={headCell}>Status</th>
<th className={headCell}>Occurred</th>
</tr>
</thead>
<tbody>
{aar.events.map((ev) => (
<tr key={ev.id} className="border-b border-white/5 last:border-0">
<td className={`${cell} font-mono text-white/85`}>{ev.eventId}</td>
<td className={`${cell} font-mono`}>{ev.type}</td>
<td className={cell}>
<span
className={`border px-1.5 py-0.5 font-mono text-[10px] uppercase tracking-widest ${STATUS_TONE_CLASSES[statusToneFor(ev.status)]}`}
>
{ev.status}
</span>
{ev.errorCode && (
<span className="ml-2 text-[10px] text-rose-300">
{ev.errorCode}: {ev.errorMessage ?? "see notes"}
</span>
)}
</td>
<td className={`${cell} font-mono tabular-nums text-white/70`}>{ev.occurredAt}</td>
</tr>
))}
</tbody>
</table>
)}
</Section>
<Section title="Applied effects">
{appliedEffects.length === 0 ? (
<NoticeState variant="empty" message="No applied effects yet for this operation." />
) : (
<ul className="flex flex-col gap-1">
{appliedEffects.map((eff) => (
<li
key={eff.id}
className="flex items-center justify-between gap-2 border border-white/10 px-2 py-1.5"
>
<span className="min-w-0 truncate font-mono text-xs text-white/80">{eff.effectKey}</span>
<span className="flex shrink-0 items-center gap-2">
<span className="font-mono text-[10px] uppercase tracking-widest text-white/50">
{eff.effectType}
</span>
<ProvenanceChip label={eff.status} href="/operations/ledger" />
</span>
</li>
))}
</ul>
)}
</Section>
<Section title="Failed or reversed effects">
{failedEffects.length === 0 ? (
<NoticeState variant="empty" message="No failed or reversed effects. Clean run." />
) : (
<ul className="flex flex-col gap-1">
{failedEffects.map((eff) => (
<li key={eff.id} className="border border-rose-500/30 px-2 py-1.5">
<p className="font-mono text-xs text-rose-300">{eff.effectKey}</p>
<p className="text-xs text-white/70">
{eff.notes ?? "Effect did not apply; see the operations ledger for reconciliation state."}
</p>
</li>
))}
</ul>
)}
</Section>
<Section title="Zone pressure">
{aar.pressure.length === 0 ? (
<NoticeState variant="empty" message="No pressure effects recorded for this operation." />
) : (
<ul className="flex flex-col gap-1">
{aar.pressure.map((p, i) => (
<li key={`${p.zoneId}-${i}`} className="flex items-center justify-between border border-white/10 px-2 py-1.5">
<span className="font-mono text-xs text-white/80">Zone {p.zoneId}</span>
<span
className={`font-mono text-[10px] uppercase tracking-widest ${p.delta >= 0 ? "text-emerald-400" : "text-rose-400"}`}
>
{p.delta >= 0 ? `+${p.delta}` : p.delta}
</span>
</li>
))}
</ul>
)}
</Section>
<Section title="Reservations">
{aar.reservations.length === 0 ? (
<NoticeState variant="empty" message="No reservations linked to this operation." />
) : (
<ul className="flex flex-col gap-1">
{aar.reservations.map((r) => (
<li key={r.reservationKey} className="flex items-center justify-between border border-white/10 px-2 py-1.5">
<span className="min-w-0 truncate font-mono text-xs text-white/80">{r.reservationKey}</span>
<span className="font-mono text-[10px] uppercase tracking-widest text-white/50">{r.status}</span>
</li>
))}
</ul>
)}
</Section>
<p className="text-xs text-white/50">
<Link href="/operations/ledger" className="underline-offset-4 hover:underline">
Open the full operations ledger
</Link>
</p>
</div>
);
}

View file

@ -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 (
<div className={`${DOSSIER.strip} flex items-center justify-between`} data-slot="classification-strip">
<span className={DOSSIER.stripLabel}>{left}</span>
<span className={DOSSIER.stripLabel}>{right}</span>
</div>
);
}

View file

@ -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 (
<div aria-hidden="true" className="pointer-events-none absolute inset-2 z-30" data-slot="corner-brackets">
<span className="absolute left-0 top-0 size-3 border-l border-t border-white/25 transition-colors duration-300 group-hover:border-white/60 motion-reduce:transition-none" />
<span className="absolute right-0 top-0 size-3 border-r border-t border-white/25 transition-colors duration-300 group-hover:border-white/60 motion-reduce:transition-none" />
<span className="absolute bottom-0 left-0 size-3 border-b border-l border-white/25 transition-colors duration-300 group-hover:border-white/60 motion-reduce:transition-none" />
<span className="absolute bottom-0 right-0 size-3 border-b border-r border-white/25 transition-colors duration-300 group-hover:border-white/60 motion-reduce:transition-none" />
</div>
);
}

View file

@ -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 (
<footer
data-slot="ledger-row"
className={`flex items-stretch ${DOSSIER.readout} ${cells.length ? "" : "hidden"}`}
>
{cells.map((cell, index) => (
<div
key={cell.label}
className="flex min-w-0 flex-1 items-center justify-center gap-1.5 border-r border-white/15 px-1 last:border-r-0"
>
<span className={`truncate text-xs text-white/80 ${cell.mono ? "font-mono tabular-nums" : ""}`}>
{cell.value}
</span>
<span className={`shrink-0 ${DOSSIER.readoutLabel}`}>{cell.label}</span>
</div>
))}
</footer>
);
}

View file

@ -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 (
<div className="flex flex-col gap-3" data-slot="operations-ledger-table">
{countCells.length > 0 && <LedgerRow cells={countCells} />}
<div className="flex flex-wrap gap-2">
{(["", "validated", "rejected", "dead-letter"] as const).map((status) => (
<Link
key={status || "all"}
href={filterHref({ ...filters, status: status || undefined }, 1)}
className={`border px-2 py-1 font-mono text-[10px] uppercase tracking-widest ${
(filters.status ?? "") === status
? "border-white/40 bg-white/10 text-white"
: "border-white/15 text-white/60 hover:border-white/30 hover:text-white/90"
}`}
>
{status || "all statuses"}
</Link>
))}
</div>
{result.events.length === 0 ? (
<NoticeState
variant="empty"
message="No operation events match this view."
hint="Adjust the filters, or wait for the next operation to report in."
/>
) : (
<table className="w-full border-collapse">
<thead>
<tr className="border-b border-white/10">
<th className={headCell}>Event</th>
<th className={headCell}>Operation</th>
<th className={headCell}>Type</th>
<th className={headCell}>Status</th>
<th className={headCell}>Occurred</th>
<th className={headCell}>Detail</th>
</tr>
</thead>
<tbody>
{result.events.map((ev) => (
<tr key={ev.id} className="border-b border-white/5 last:border-0">
<td className={`${cell} font-mono text-white/85`}>{ev.eventId}</td>
<td className={cell}>
<Link
href={`/operations/${encodeURIComponent(ev.operationId)}`}
className="font-mono text-[10px] uppercase tracking-widest text-white/60 underline-offset-4 hover:underline"
>
{ev.operationId}
</Link>
</td>
<td className={`${cell} font-mono`}>{ev.type}</td>
<td className={cell}>
<span
className={`border px-1.5 py-0.5 font-mono text-[10px] uppercase tracking-widest ${STATUS_TONE_CLASSES[statusToneFor(ev.status)]}`}
>
{ev.status}
</span>
</td>
<td className={`${cell} font-mono tabular-nums text-white/70`}>{ev.occurredAt}</td>
<td className={cell}>
{ev.errorCode || ev.errorMessage ? (
<span className="text-rose-300">
{ev.errorCode ?? "error"}: {ev.errorMessage ?? "see reconciliation notes"}
</span>
) : ev.notes ? (
<span className="text-white/60">{ev.notes}</span>
) : (
<span className="text-white/30">n/a</span>
)}
</td>
</tr>
))}
</tbody>
</table>
)}
<div className="flex items-center justify-between">
<span className="font-mono text-[10px] uppercase tracking-widest text-white/50">
Page {result.page} of {Math.max(1, result.totalPages)} ({result.totalDocs} events)
</span>
<span className="flex gap-2">
{result.page > 1 && (
<Link
href={filterHref(filters, result.page - 1)}
className="border border-white/15 px-2 py-1 font-mono text-[10px] uppercase tracking-widest text-white/70 hover:border-white/30 hover:text-white"
>
Prev
</Link>
)}
{result.page < result.totalPages && (
<Link
href={filterHref(filters, result.page + 1)}
className="border border-white/15 px-2 py-1 font-mono text-[10px] uppercase tracking-widest text-white/70 hover:border-white/30 hover:text-white"
>
Next
</Link>
)}
</span>
</div>
</div>
);
}

View file

@ -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 (
<div
role={role}
data-slot="notice-state"
data-variant={variant}
aria-busy={variant === "loading" || undefined}
className={`flex flex-col items-center justify-center gap-1.5 rounded-none border border-dashed px-6 py-10 text-center ${tone}`}
>
<p className="text-sm font-medium">{message}</p>
{hint ? <p className="max-w-prose text-xs text-muted-foreground">{hint}</p> : null}
{action ? <div className="mt-2">{action}</div> : null}
</div>
);
}

View file

@ -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 (
<section className="group relative overflow-hidden rounded-none border border-border/70 bg-card" data-slot="operation-card">
<div
aria-hidden="true"
className="pointer-events-none absolute inset-0 z-[5] opacity-[0.04] transition-opacity duration-500 group-hover:opacity-[0.07] motion-reduce:transition-none"
style={DOSSIER.gridTexture}
/>
<CornerBrackets />
<header className={`${DOSSIER.strip} relative z-10`}>
<h2 className={DOSSIER.stripLabel}>{title}</h2>
</header>
<div className="relative z-10 p-3">{children}</div>
</section>
);
}
export function RecentOperationsCard({ operations }: { operations: SurfaceOverview["recentOperations"] }) {
return (
<CardShell title="Recent operations">
{operations.length === 0 ? (
<NoticeState variant="empty" message="No operation activity recorded yet." />
) : (
<table className="w-full border-collapse">
<thead>
<tr className="border-b border-white/10">
<th className={headCell}>Operation</th>
<th className={headCell}>Events</th>
<th className={headCell}>Applied</th>
<th className={headCell}>Failed</th>
<th className={headCell}>Rejected</th>
<th className={headCell}>Last activity</th>
</tr>
</thead>
<tbody>
{operations.map((op) => (
<tr key={op.operationId} className="border-b border-white/5 last:border-0">
<td className={cell}>
<Link
href={`/operations/${encodeURIComponent(op.operationId)}`}
className="font-mono text-xs text-white/90 underline-offset-4 hover:underline"
>
{op.operationId}
</Link>
</td>
<td className={`${cell} font-mono tabular-nums`}>{op.eventCount}</td>
<td className={`${cell} font-mono tabular-nums text-emerald-400`}>{op.appliedCount}</td>
<td className={`${cell} font-mono tabular-nums text-rose-400`}>{op.failedCount}</td>
<td className={`${cell} font-mono tabular-nums text-amber-400`}>{op.rejectedCount}</td>
<td className={`${cell} font-mono tabular-nums text-white/70`}>{op.lastOccurredAt ?? "n/a"}</td>
</tr>
))}
</tbody>
</table>
)}
</CardShell>
);
}
export function MyExtractionsCard({
extractions,
signedIn,
}: {
extractions: SurfaceOverview["myExtractions"];
signedIn: boolean;
}) {
if (!signedIn) {
return (
<CardShell title="My extractions">
<NoticeState variant="empty" message="Sign in to see your extraction history." />
</CardShell>
);
}
return (
<CardShell title="My extractions">
{extractions.length === 0 ? (
<NoticeState variant="empty" message="No extractions recorded for you yet." />
) : (
<ul className="flex flex-col gap-1">
{extractions.map((ex) => (
<li
key={ex.eventId}
className="flex items-center justify-between gap-2 border border-white/10 px-2 py-1.5"
>
<span className="min-w-0 truncate font-mono text-xs text-white/80">{ex.eventLabel}</span>
<span className="flex shrink-0 items-center gap-2">
<Link
href={`/operations/${encodeURIComponent(ex.operationId)}`}
className="font-mono text-[10px] uppercase tracking-widest text-white/50 underline-offset-4 hover:underline"
>
{ex.operationId}
</Link>
<ProvenanceChip
label={ex.effectStatus ?? "pending"}
href={ex.effectStatus === "applied" ? "/operations/ledger?status=validated" : undefined}
/>
</span>
</li>
))}
</ul>
)}
</CardShell>
);
}
export function ReservationsCard({
reservations,
canSee,
}: {
reservations: SurfaceOverview["activeReservations"];
canSee: boolean;
}) {
if (!canSee) {
return (
<CardShell title="Active reservations">
<NoticeState variant="empty" message="Reservation summaries require the reservations permission." />
</CardShell>
);
}
return (
<CardShell title="Active reservations">
{reservations.length === 0 ? (
<NoticeState variant="empty" message="No active reservations." />
) : (
<ul className="flex flex-col gap-1">
{reservations.map((r) => {
const cells: ReadoutCell[] = [
{ label: "Key", value: r.reservationKey, mono: true },
{ label: "Expires", value: r.expiresAt ?? "n/a" },
];
return <LedgerRow key={r.reservationKey} cells={cells} />;
})}
</ul>
)}
</CardShell>
);
}
export function ReadinessCard({
readiness,
canSee,
}: {
readiness: SurfaceOverview["ownReadiness"];
canSee: boolean;
}) {
if (!canSee) {
return (
<CardShell title="Readiness">
<NoticeState variant="empty" message="Readiness aggregates require the readiness permission." />
</CardShell>
);
}
return (
<CardShell title="Readiness">
{readiness === null ? (
<NoticeState variant="empty" message="No readiness aggregate computed for you yet." />
) : (
<div className="flex flex-col gap-2">
<p className="font-mono text-2xl tabular-nums text-white">{readiness.score ?? "n/a"}</p>
{readiness.explanation && <p className="text-xs text-white/70">{readiness.explanation}</p>}
{readiness.windowLabel && (
<p className="font-mono text-[9px] uppercase tracking-widest text-white/50">
Window {readiness.windowLabel}
</p>
)}
</div>
)}
</CardShell>
);
}
export function PressureCard({
pressure,
canSee,
}: {
pressure: SurfaceOverview["pressure"];
canSee: boolean;
}) {
if (!canSee) {
return (
<CardShell title="Zone pressure">
<NoticeState variant="empty" message="Pressure summaries require the zone pressure permission." />
</CardShell>
);
}
return (
<CardShell title="Zone pressure">
{pressure.length === 0 ? (
<NoticeState variant="empty" message="No zone pressure recorded yet." />
) : (
<ul className="flex flex-col gap-1">
{pressure.map((p) => (
<li
key={p.zoneId}
className="flex items-center justify-between border border-white/10 px-2 py-1.5"
>
<span className="font-mono text-xs text-white/80">Zone {p.zoneId}</span>
<span
className={`font-mono text-[10px] uppercase tracking-widest ${
p.state === "controlled"
? "text-emerald-400"
: p.state === "contested"
? "text-amber-400"
: "text-white/60"
}`}
>
{p.state} ({p.pressure})
</span>
</li>
))}
</ul>
)}
</CardShell>
);
}
export function OperationsStrip({ left }: { left: string }) {
return <ClassificationStrip left={left} right="Polaris // Ops" />;
}

View file

@ -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;
}

View file

@ -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 (
<div data-slot="page-shell" className="mx-auto flex w-full max-w-7xl flex-col gap-4 px-4 py-6">
<header className="flex flex-col gap-1">
<h1 className="text-xl font-bold tracking-tight text-foreground">{title}</h1>
{meta ? <ClassificationStrip left={meta} right="Polaris // Ops" /> : null}
</header>
<div className="flex min-w-0 flex-col gap-4">{children}</div>
</div>
);
}

View file

@ -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 (
<a href={href} data-slot="provenance-chip" className={className}>
{label}
</a>
);
}
return (
<span data-slot="provenance-chip" className={className}>
{label}
</span>
);
}

View file

@ -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<Record<number, string>>({});
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 (
<section data-slot="reconcile-panel" className="flex flex-col gap-2">
<h2 className="font-mono text-[9px] uppercase tracking-widest text-white/60">
Reconciliation
</h2>
<ul className="flex flex-col gap-1">
{events.map((ev) => (
<li
key={ev.id}
className="flex flex-wrap items-center justify-between gap-2 border border-amber-500/30 px-2 py-1.5"
>
<span className="min-w-0">
<span className="font-mono text-xs text-white/85">{ev.eventId}</span>
<span className="ml-2 font-mono text-[10px] uppercase tracking-widest text-amber-400">
{ev.status}
</span>
{ev.errorCode && (
<span className="ml-2 text-xs text-white/60">
{ev.errorCode}: {ev.errorMessage ?? "no detail"}
</span>
)}
</span>
<span className="flex items-center gap-2">
{results[ev.id] && <span className="text-xs text-white/60">{results[ev.id]}</span>}
<button
type="button"
onClick={() => 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"}
</button>
</span>
</li>
))}
</ul>
</section>
);
}

View file

@ -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 (
<span
role="status"
data-slot="status-readout"
data-tone={tone}
className={`inline-block rounded-sm border px-2 py-1 text-[10px] font-semibold uppercase tracking-wider ${STATUS_TONE_CLASSES[tone]}`}
>
{label}
</span>
);
}

View file

@ -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 (
<div
data-slot="strategic-summary"
className={cn(
"group relative overflow-hidden rounded-none border border-border/70 bg-card transition-colors duration-300 hover:border-foreground/30 motion-reduce:transition-none",
className,
)}
>
<div
aria-hidden="true"
className="pointer-events-none absolute inset-0 z-[5] opacity-[0.04] transition-opacity duration-500 group-hover:opacity-[0.07] motion-reduce:transition-none"
style={DOSSIER.gridTexture}
/>
<CornerBrackets />
<div className="relative z-10 flex flex-col">
<ClassificationStrip left={classificationLeft} right={classificationRight} />
<div className="px-4 py-4">{children}</div>
{readoutCells ? <LedgerRow cells={readoutCells} /> : null}
</div>
</div>
);
}

View file

@ -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";

View file

@ -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<StatusTone, string> = {
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;

View file

@ -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<ReturnType<typeof import("payload").getPayload>>;
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<SurfaceCapabilities> {
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<SurfaceOverview> {
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<string, { applied: number; failed: number; rejected: number }>();
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<string, number>();
const lastOccurred = new Map<string, string | null>();
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<number, { factionId: number | null; deltas: { delta: number; createdAt: string }[] }>();
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<MyExtractionSummary[]> {
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<number, string>();
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,
};
});
}

View file

@ -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<ReturnType<typeof import("payload").getPayload>>;
export interface LedgerFilters {
operationId?: string;
status?: string;
type?: string;
}
export interface LedgerResult {
events: OperationEventRow[];
page: number;
totalDocs: number;
totalPages: number;
effectCounts: Record<string, number>;
}
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<string, number>, key: string): void {
counts[key] = (counts[key] ?? 0) + 1;
}
export async function getOperationLedger(
payload: PayloadType,
user: SurfaceUser,
filters: LedgerFilters,
requestedPage = 1,
): Promise<LedgerResult> {
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<string, number> = {};
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<AarResult | null> {
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,
};
}

View file

@ -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<void> {
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");
});
});

View file

@ -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();
});
});

View file

@ -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<number> {
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<number> {
const raw = await payload.create({
collection: "arma-sync-events",
data: {
eventId: `${RUN}-${label}`,
server: serverId,
type,
occurredAt: new Date().toISOString(),
payload: payloadData as Record<string, unknown>,
},
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);
});
});