1
0
Fork 0

Compare commits

...

21 commits

Author SHA1 Message Date
dcc308c2ae v0.2.8 - dash sanitization sweep across user-facing UI, admin, and bot text 2026-09-07 05:20:04 -04:00
949459647d style(roster): use hyphen in the A-Z roster tab label 2026-09-07 05:19:53 -04:00
9f80361d14 style(projects): replace dash separator in sprint date ranges 2026-09-07 05:19:53 -04:00
eb3dc0231c style(locker): replace dash placeholder for grid footprint 2026-09-07 05:19:53 -04:00
5f5072399b style(helpdesk): replace dash placeholder for missing ticket dates 2026-09-07 05:19:53 -04:00
d485a82fa6 style(minigames): replace dashes in leaderboard and radio UI 2026-09-07 05:19:53 -04:00
12b19b3d6a style(banking): replace dash placeholders in activity feed and timestamps 2026-09-07 05:19:53 -04:00
a8d1a8870e style(collections): replace dashes in admin field descriptions 2026-09-07 05:19:50 -04:00
8ee02faa59 style(bot): replace dashes in embed and reminder fallback text 2026-09-07 05:19:50 -04:00
a399f3feab style(profile): replace dash placeholders in profile stats table 2026-09-07 05:19:50 -04:00
da9ea23271 style(logistics): replace dash placeholder on vehicle grid dimensions 2026-09-07 05:19:50 -04:00
a4ea3ea387 style(intelligence): replace dashes with ASCII in mission and campaign views 2026-09-07 05:19:48 -04:00
47d338c868 style(events): replace dashes with ASCII in event log UI 2026-09-07 05:19:48 -04:00
cdfd5c4278 docs(agents): document the no-dash rule and pre-release dash gate 2026-09-07 05:19:48 -04:00
ccfdc3b46e v0.2.7 - side op host credit, daily ready-check reminders with DMs, mark-as-Ready control, global event log page, sidebar regrouping, storage permission split 2026-09-07 05:07:55 -04:00
dcb4893b10 feat(storage): split storage permissions and rework manage-storage UI
Deposits/withdrawals of stock (economy-mutating) are now superuser-only, while moving items around (transfer/place/collect) stays open to logistics-qualified users; seed roles updated to match. ManageStorageDialog reworked alongside its toolbar/button/view wrappers, and the structure page wires up the new permission split.
2026-09-07 05:07:53 -04:00
91dc48ad2c refactor(sidebar): group logistics nav into Assets/Operations/Personnel sections
Logistics entries are collapsed into collapsible groups, Personnel moves from its own sidebar section into Logistics, and an Event Log entry is added to Intelligence.
2026-09-07 05:07:51 -04:00
29f7e2b57a feat(events): add global event log page
New /events page rendering the full game event ledger (system + narrative events) with type/target/source/date filtering, backed by a shared shadcn table component.
2026-09-07 05:07:51 -04:00
227885daf7 feat(missions): let side op creators mark their op as Ready
Adds a 'Mark as Ready' control on the mission detail page (owner or missions:update gated) plus the markSideOpReady server action. Status-gated to Concept/Planning/Scheduled; emits a mission:ready event. Stops the daily ready-reminders and lets missionEmbeds post the roll-call.
2026-09-07 05:07:48 -04:00
d757c58c5d feat(missions): daily ready-check reminders for unfinished side ops
Five days before a player-created side op starts, if it is not yet Ready, the creator gets one in-app notification per day (via mission-tick) plus one Discord DM per day (new bot service). Per-server-local-day dedupe via hidden readyReminderWebSentAt/readyReminderDmSentAt markers. The ready-reminder type is excluded from the notification bridge to avoid double-DMing. Markers are nulled on clone.
2026-09-07 05:07:46 -04:00
ffb2c2c5d9 feat(missions): show op host on side op cards and mission pages
Side ops now credit their creator: mission cards get a 'Hosted by X' meta entry and the mission detail page gets a Host chip. Display name preferred over username; only shown for side ops with a populated createdBy.
2026-09-07 05:07:43 -04:00
53 changed files with 24934 additions and 473 deletions

View file

@ -341,6 +341,7 @@ In development, `postgresAdapter` uses `push: false` — run `bun run payload mi
- **Prettier**: double quotes, trailing commas (all), 100 char print width, semicolons. - **Prettier**: double quotes, trailing commas (all), 100 char print width, semicolons.
- **ESLint**: `next/core-web-vitals` + `next/typescript`. `@typescript-eslint/no-unused-vars` warns (prefix unused with `_`). - **ESLint**: `next/core-web-vitals` + `next/typescript`. `@typescript-eslint/no-unused-vars` warns (prefix unused with `_`).
- **Tailwind v4**: no `tailwind.config` — configured via `@tailwindcss/postcss` in `postcss.config.mjs` and CSS imports. Use `cn()` from `@/lib/utils` for class merging. - **Tailwind v4**: no `tailwind.config` — configured via `@tailwindcss/postcss` in `postcss.config.mjs` and CSS imports. Use `cn()` from `@/lib/utils` for class merging.
- **No em-dashes in final outputs**: never emit em-dashes (`—`, `–`) in anything you produce as a final deliverable: chat replies, commit messages, PR descriptions, docs, comments, UI copy. Replace them with a comma, semicolon, colon, or parenthetical, or split into two sentences. (Earlier bullets in this section predate the rule; new writing must comply.)
## UI components ## UI components
@ -359,6 +360,8 @@ shadcn/ui components live in `src/components/ui/`. Use `bunx shadcn@latest add <
`bun run deploy` bumps the patch version (via `bun pm version patch`) and runs the production build. Push the resulting version commit to deploy through Coolify; the legacy `build/deploy.sh` path is no longer used. `bun run deploy` bumps the patch version (via `bun pm version patch`) and runs the production build. Push the resulting version commit to deploy through Coolify; the legacy `build/deploy.sh` path is no longer used.
**Pre-deploy dash gate**: before any production build, release version bump, or deploy, run the `dash-sanitize` skill (`/home/jason/.agents/skills/dash-sanitize/SKILL.md`) and show its verification output (re-scan classification + filtered tsc) before proceeding. A dirty user-facing dash scan pauses the release; a clean re-scan on an already-swept tree takes seconds and should still be run rather than assumed.
**Version bump after git commands**: whenever a `/git` or `/git-master` workflow creates commits, also bump the patch version with `bun pm version patch --message "v%s - <descriptor>"` and push it together with the work. `<descriptor>` is a short comma-separated summary of what the release ships (derived from the commits just created), e.g. `v0.2.4 - add voicelines, update git rules` — never a bare version number. The command itself creates the `vX.Y.Z` version commit **and** a local git tag. `push.followTags` is set in this repo, so a plain `git push` automatically carries the annotated release tag with the commits — but never use a bare `git push --tags`, which sends only tags and not the branch. The deployed site's version overlay reads `package.json`'s `version` (Coolify builds have no git metadata, so the displayed version is `<semver>+<commit sha>`), and an unbumped semver means every deploy shows the same `0.2.0`-style number even though the commit-hash suffix changes. Skipping the bump — or pushing the commit without its tag — makes releases indistinguishable and breaks version history. **Version bump after git commands**: whenever a `/git` or `/git-master` workflow creates commits, also bump the patch version with `bun pm version patch --message "v%s - <descriptor>"` and push it together with the work. `<descriptor>` is a short comma-separated summary of what the release ships (derived from the commits just created), e.g. `v0.2.4 - add voicelines, update git rules` — never a bare version number. The command itself creates the `vX.Y.Z` version commit **and** a local git tag. `push.followTags` is set in this repo, so a plain `git push` automatically carries the annotated release tag with the commits — but never use a bare `git push --tags`, which sends only tags and not the branch. The deployed site's version overlay reads `package.json`'s `version` (Coolify builds have no git metadata, so the displayed version is `<semver>+<commit sha>`), and an unbumped semver means every deploy shows the same `0.2.0`-style number even though the commit-hash suffix changes. Skipping the bump — or pushing the commit without its tag — makes releases indistinguishable and breaks version history.
## Gotchas ## Gotchas

View file

@ -1,6 +1,6 @@
{ {
"name": "polaris-task-force", "name": "polaris-task-force",
"version": "0.2.6", "version": "0.2.8",
"description": "Web app for the Arma 3 unit Polaris Task Force. Uses Next.js for client- and server-side rendering, with a Postgres database and some other neat libraries.", "description": "Web app for the Arma 3 unit Polaris Task Force. Uses Next.js for client- and server-side rendering, with a Postgres database and some other neat libraries.",
"license": "MIT", "license": "MIT",
"type": "module", "type": "module",

View file

@ -0,0 +1,22 @@
import type { Metadata } from "next";
import EventLogTable from "@/components/frontend/events/EventLogTable";
export const metadata: Metadata = {
title: "Event Log (Polaris Task Force)",
};
export default function EventLogPage() {
return (
<div className="flex flex-col gap-6 p-6">
<div>
<h1 className="text-2xl font-semibold tracking-tight">Event Log</h1>
<p className="text-muted-foreground text-sm">
The full game event ledger: system actions and GM narrative events. Filter by type,
target, source, or date range.
</p>
</div>
<EventLogTable />
</div>
);
}

View file

@ -166,7 +166,7 @@ export default async function CampaignDetailPage({ params }: CampaignDetailPageP
/> />
</span> </span>
)} )}
{campaign.startDate && campaign.endDate && <span>—</span>} {campaign.startDate && campaign.endDate && <span>-</span>}
{campaign.endDate ? ( {campaign.endDate ? (
<span> <span>
<ClientDate <ClientDate
@ -267,14 +267,14 @@ export default async function CampaignDetailPage({ params }: CampaignDetailPageP
)} )}
{campaign.endDate ? ( {campaign.endDate ? (
<> <>
{" — "} {" - "}
<ClientDate <ClientDate
date={campaign.endDate} date={campaign.endDate}
options={{ year: "numeric", month: "short", day: "numeric" }} options={{ year: "numeric", month: "short", day: "numeric" }}
/> />
</> </>
) : ( ) : (
" — Present" " - Present"
)} )}
</span> </span>
</div> </div>

View file

@ -3,7 +3,7 @@ import { getPayload } from "payload";
import { notFound } from "next/navigation"; import { notFound } from "next/navigation";
import Link from "next/link"; import Link from "next/link";
import { headers as nextHeaders } from "next/headers"; import { headers as nextHeaders } from "next/headers";
import type { Campaign, Map, Media, Mission } from "@/payload-types"; import type { Campaign, Map, Media, Mission, User } from "@/payload-types";
import { import {
ArrowLeftIcon, ArrowLeftIcon,
CalendarDaysIcon, CalendarDaysIcon,
@ -13,6 +13,7 @@ import {
FlagIcon, FlagIcon,
MapPinIcon, MapPinIcon,
ServerIcon, ServerIcon,
UserIcon,
} from "lucide-react"; } from "lucide-react";
import { Item, ItemContent, ItemHeader, ItemTitle } from "@/components/ui/item"; import { Item, ItemContent, ItemHeader, ItemTitle } from "@/components/ui/item";
import { ClientDate } from "@/components/frontend/ClientDate"; import { ClientDate } from "@/components/frontend/ClientDate";
@ -94,6 +95,13 @@ export default async function MissionDetailPage({ params }: MissionDetailPagePro
? (missionWithCoverImage.coverImage as Media) ? (missionWithCoverImage.coverImage as Media)
: null; : null;
const coverImageUrl = coverImage?.url ?? null; const coverImageUrl = coverImage?.url ?? null;
const host =
mission.operationType === "side" &&
typeof mission.createdBy === "object" &&
mission.createdBy !== null
? (mission.createdBy as User)
: null;
const hostLabel = host ? (host.displayName || host.username) : null;
const durationHours = Math.floor(mission.classification.estimatedDuration / 60); const durationHours = Math.floor(mission.classification.estimatedDuration / 60);
const durationMins = mission.classification.estimatedDuration % 60; const durationMins = mission.classification.estimatedDuration % 60;
@ -328,6 +336,17 @@ export default async function MissionDetailPage({ params }: MissionDetailPagePro
</span> </span>
</div> </div>
)} )}
{hostLabel && (
<div className="flex items-center gap-2 px-3 py-2">
<UserIcon className="size-4 text-white/60" />
<span className="flex flex-col">
<span className="text-[10px] font-semibold uppercase tracking-wider text-white/50">
Host
</span>
<span className="max-w-56 truncate text-sm text-white">{hostLabel}</span>
</span>
</div>
)}
{campaign && ( {campaign && (
<Link <Link
href={`/intelligence/campaigns/${campaign.id}`} href={`/intelligence/campaigns/${campaign.id}`}
@ -367,7 +386,7 @@ export default async function MissionDetailPage({ params }: MissionDetailPagePro
</div> </div>
<div> <div>
<span className="text-muted-foreground">Max Players</span> <span className="text-muted-foreground">Max Players</span>
<p className="font-medium">{mission.missionRoles?.maxPlayers ?? "—"}</p> <p className="font-medium">{mission.missionRoles?.maxPlayers ?? "-"}</p>
</div> </div>
</div> </div>
</ItemContent> </ItemContent>

View file

@ -738,6 +738,70 @@ export async function cancelFridaySideOp(
} }
} }
export async function markSideOpReady(
missionId: number,
): Promise<ActionResult<{ missionId: number }>> {
try {
const { payload, user } = await authenticate();
const userId = user.id as number;
const mission = await payload.findByID({
collection: "missions",
id: missionId,
depth: 0,
});
const canManage = await hasPermission(payload, user, "missions:update");
if (!canManage && !isSideOpOwner(mission, userId)) {
return {
success: false,
error: "Only the player who scheduled this side operation can mark it Ready.",
};
}
const status = mission.ownershipAndStatus.status;
if (status === "Ready") {
return { success: true, data: { missionId: mission.id } };
}
if (!["Concept", "Planning", "Scheduled"].includes(status)) {
return {
success: false,
error:
status === "Active"
? "This mission is already underway and cannot be marked Ready."
: `This mission is ${status.toLowerCase()} and can no longer be marked Ready.`,
};
}
const updated = await payload.update({
collection: "missions",
id: missionId,
depth: 0,
overrideAccess: true,
data: {
ownershipAndStatus: {
status: "Ready",
},
},
});
await emitGameEvent(payload, {
type: EventTypes.MissionReady,
message: `${user.displayName || user.username} marked side operation "${updated.name}" (${updated.codeName}) as Ready. Details are locked in and the roll-call can be posted.`,
actor: userId,
targetCollection: "missions",
targetId: updated.id,
});
return { success: true, data: { missionId: updated.id } };
} catch (error) {
const message = error instanceof Error ? error.message : "Unknown error";
return message === "Unauthorized"
? { success: false, error: "You must be logged in." }
: { success: false, error: message };
}
}
export async function moveFridaySideOpToCampaign( export async function moveFridaySideOpToCampaign(
missionId: number, missionId: number,
campaignId: number, campaignId: number,

View file

@ -21,6 +21,7 @@ import { pluralize } from "@/lib/utils";
import type { LaborClassificationOption, StructureStaffingOption } from "@/lib/base"; import type { LaborClassificationOption, StructureStaffingOption } from "@/lib/base";
import { currencyConfigFromGameRules } from "@/lib/banking"; import { currencyConfigFromGameRules } from "@/lib/banking";
import { hasLogisticsQualification } from "@/utils/access-control/hasLogisticsQualification"; import { hasLogisticsQualification } from "@/utils/access-control/hasLogisticsQualification";
import { isSuperuser } from "@/utils/access-control/hasPermission";
import { import {
ArrowLeftIcon, ArrowLeftIcon,
BoxIcon, BoxIcon,
@ -183,6 +184,8 @@ export default async function StructureDetailPage({ params }: StructureDetailPag
}; };
}); });
const canManage = await hasLogisticsQualification(payload, user); const canManage = await hasLogisticsQualification(payload, user);
const canManageStorage = await isSuperuser(payload, user);
const canTransferStorage = canManageStorage || canManage;
const classificationsRes = await payload.find({ const classificationsRes = await payload.find({
collection: "labor-classifications", collection: "labor-classifications",
@ -307,7 +310,13 @@ export default async function StructureDetailPage({ params }: StructureDetailPag
{structure.faction.name} {structure.faction.name}
</span> </span>
)} )}
{user && <ManageStorageButton structureId={structure.id} />} {user && (
<ManageStorageButton
structureId={structure.id}
canManage={canManageStorage}
canTransfer={canTransferStorage}
/>
)}
</div> </div>
</div> </div>
</header> </header>
@ -413,7 +422,11 @@ export default async function StructureDetailPage({ params }: StructureDetailPag
currencyConfig={currencyConfig} currencyConfig={currencyConfig}
/> />
<StructureStorageView structure={structure} /> <StructureStorageView
structure={structure}
canMutate={canManageStorage}
canArrange={canTransferStorage}
/>
<StorageRules <StorageRules
allowedStorage={structureType.allowedStorage} allowedStorage={structureType.allowedStorage}

View file

@ -3,7 +3,8 @@
import config from "@payload-config"; import config from "@payload-config";
import { getPayload } from "payload"; import { getPayload } from "payload";
import { GameStructure, Resource, Structure } from "@/payload-types"; import { GameStructure, Resource, Structure } from "@/payload-types";
import { hasPermission } from "@/utils/access-control/hasPermission"; import { isSuperuser } from "@/utils/access-control/hasPermission";
import { hasLogisticsQualification } from "@/utils/access-control/hasLogisticsQualification";
import { emitGameEvent } from "@/utils/event-log/emit"; import { emitGameEvent } from "@/utils/event-log/emit";
import { EventTypes } from "@/utils/event-log/eventTypes"; import { EventTypes } from "@/utils/event-log/eventTypes";
import { checkStorageDeposit, storageViolationMessage } from "@/lib/storageRules"; import { checkStorageDeposit, storageViolationMessage } from "@/lib/storageRules";
@ -14,6 +15,19 @@ interface ActionResult {
data?: GameStructure; data?: GameStructure;
} }
type PayloadClient = Awaited<ReturnType<typeof getPayload>>;
type AuthedUser = Awaited<ReturnType<typeof authenticate>>["user"];
// SECURITY: creates/destroys stored items, overriding economy/production — superusers only
async function canManageStorage(payload: PayloadClient, user: AuthedUser): Promise<boolean> {
return isSuperuser(payload, user);
}
// SECURITY: conserves quantities (moves items around) — logistics personnel + superusers
async function canHandleStorage(payload: PayloadClient, user: AuthedUser): Promise<boolean> {
return (await isSuperuser(payload, user)) || (await hasLogisticsQualification(payload, user));
}
async function authenticate() { async function authenticate() {
const payloadConfig = await config; const payloadConfig = await config;
const payload = await getPayload({ config: payloadConfig }); const payload = await getPayload({ config: payloadConfig });
@ -74,13 +88,30 @@ export async function addResource(
): Promise<ActionResult> { ): Promise<ActionResult> {
try { try {
const { payload, user } = await authenticate(); const { payload, user } = await authenticate();
if (!(await hasPermission(payload, user, "structures:update"))) { if (!(await canManageStorage(payload, user))) {
return { return {
success: false, success: false,
error: "Insufficient permissions. User, Admin, or Developer role required.", error: "Only superusers may deposit resources into structures.",
}; };
} }
return await addResourceInternal(payload, user, structureId, resourceId, amount);
} catch (error) {
const message = error instanceof Error ? error.message : "Unknown error";
if (message === "Unauthorized") {
return { success: false, error: "You must be logged in." };
}
return { success: false, error: message };
}
}
async function addResourceInternal(
payload: PayloadClient,
user: AuthedUser,
structureId: number,
resourceId: number,
amount: number,
): Promise<ActionResult> {
try {
if (amount <= 0) { if (amount <= 0) {
return { success: false, error: "Amount must be greater than zero." }; return { success: false, error: "Amount must be greater than zero." };
} }
@ -264,13 +295,30 @@ export async function removeResource(
): Promise<ActionResult> { ): Promise<ActionResult> {
try { try {
const { payload, user } = await authenticate(); const { payload, user } = await authenticate();
if (!(await hasPermission(payload, user, "structures:update"))) { if (!(await canManageStorage(payload, user))) {
return { return {
success: false, success: false,
error: "Insufficient permissions. Admin or Developer role required for withdrawal.", error: "Only superusers may withdraw resources from structures.",
}; };
} }
return await removeResourceInternal(payload, user, structureId, resourceId, amount);
} catch (error) {
const message = error instanceof Error ? error.message : "Unknown error";
if (message === "Unauthorized") {
return { success: false, error: "You must be logged in." };
}
return { success: false, error: message };
}
}
async function removeResourceInternal(
payload: PayloadClient,
user: AuthedUser,
structureId: number,
resourceId: number,
amount: number,
): Promise<ActionResult> {
try {
if (amount <= 0) { if (amount <= 0) {
return { success: false, error: "Amount must be greater than zero." }; return { success: false, error: "Amount must be greater than zero." };
} }
@ -373,10 +421,10 @@ export async function transferResource(
): Promise<ActionResult> { ): Promise<ActionResult> {
try { try {
const { payload, user } = await authenticate(); const { payload, user } = await authenticate();
if (!(await hasPermission(payload, user, "structures:update"))) { if (!(await canHandleStorage(payload, user))) {
return { return {
success: false, success: false,
error: "Insufficient permissions. User, Admin, or Developer role required for transfer.", error: "Logistics personnel or superuser role required for transfers.",
}; };
} }
@ -464,17 +512,23 @@ export async function transferResource(
}; };
} }
// Remove from source // Remove from source (unguarded internal — gate already applied above)
const removeResult = await removeResource(fromStructureId, resourceId, amount); const removeResult = await removeResourceInternal(
payload,
user,
fromStructureId,
resourceId,
amount,
);
if (!removeResult.success) { if (!removeResult.success) {
return removeResult; return removeResult;
} }
// Add to destination // Add to destination
const addResult = await addResource(toStructureId, resourceId, amount); const addResult = await addResourceInternal(payload, user, toStructureId, resourceId, amount);
if (!addResult.success) { if (!addResult.success) {
// Rollback: re-add to source // Rollback: re-add to source
await addResource(fromStructureId, resourceId, amount); await addResourceInternal(payload, user, fromStructureId, resourceId, amount);
return { success: false, error: `Transfer failed at destination: ${addResult.error}` }; return { success: false, error: `Transfer failed at destination: ${addResult.error}` };
} }
@ -524,10 +578,10 @@ export async function placeResourceOnGrid(
): Promise<GridActionResult> { ): Promise<GridActionResult> {
try { try {
const { payload, user } = await authenticate(); const { payload, user } = await authenticate();
if (!(await hasPermission(payload, user, "structures:update"))) { if (!(await canManageStorage(payload, user))) {
return { return {
success: false, success: false,
error: "Insufficient permissions. Admin or Developer role required for placement.", error: "Only superusers may place resources on the storage grid.",
}; };
} }
@ -641,10 +695,10 @@ export async function moveGridItem(
): Promise<GridActionResult> { ): Promise<GridActionResult> {
try { try {
const { payload, user } = await authenticate(); const { payload, user } = await authenticate();
if (!(await hasPermission(payload, user, "structures:update"))) { if (!(await canHandleStorage(payload, user))) {
return { return {
success: false, success: false,
error: "Insufficient permissions. User, Admin, or Developer role required.", error: "Logistics personnel or superuser role required.",
}; };
} }
const structure = await getStructure(payload, structureId); const structure = await getStructure(payload, structureId);
@ -730,10 +784,10 @@ export async function mergeGridStacks(
): Promise<GridActionResult> { ): Promise<GridActionResult> {
try { try {
const { payload, user } = await authenticate(); const { payload, user } = await authenticate();
if (!(await hasPermission(payload, user, "structures:update"))) { if (!(await canHandleStorage(payload, user))) {
return { return {
success: false, success: false,
error: "Insufficient permissions. User, Admin, or Developer role required.", error: "Logistics personnel or superuser role required.",
}; };
} }
const structure = await getStructure(payload, structureId); const structure = await getStructure(payload, structureId);
@ -812,10 +866,10 @@ export async function rotateGridItem(
): Promise<GridActionResult> { ): Promise<GridActionResult> {
try { try {
const { payload, user } = await authenticate(); const { payload, user } = await authenticate();
if (!(await hasPermission(payload, user, "structures:update"))) { if (!(await canHandleStorage(payload, user))) {
return { return {
success: false, success: false,
error: "Insufficient permissions. User, Admin, or Developer role required.", error: "Logistics personnel or superuser role required.",
}; };
} }
const structure = await getStructure(payload, structureId); const structure = await getStructure(payload, structureId);
@ -899,10 +953,10 @@ export async function removeGridItem(
): Promise<GridActionResult> { ): Promise<GridActionResult> {
try { try {
const { payload, user } = await authenticate(); const { payload, user } = await authenticate();
if (!(await hasPermission(payload, user, "structures:update"))) { if (!(await canManageStorage(payload, user))) {
return { return {
success: false, success: false,
error: "Insufficient permissions. Admin or Developer role required for removal.", error: "Only superusers may remove items from the storage grid.",
}; };
} }
const structure = await getStructure(payload, structureId); const structure = await getStructure(payload, structureId);
@ -953,10 +1007,10 @@ export async function splitGridStack(
): Promise<GridActionResult> { ): Promise<GridActionResult> {
try { try {
const { payload, user } = await authenticate(); const { payload, user } = await authenticate();
if (!(await hasPermission(payload, user, "structures:update"))) { if (!(await canHandleStorage(payload, user))) {
return { return {
success: false, success: false,
error: "Insufficient permissions. User, Admin, or Developer role required.", error: "Logistics personnel or superuser role required.",
}; };
} }
@ -1061,8 +1115,8 @@ export async function splitGridStack(
export async function retrieveFromVoid(structureId: number): Promise<GridActionResult> { export async function retrieveFromVoid(structureId: number): Promise<GridActionResult> {
try { try {
const { payload, user } = await authenticate(); const { payload, user } = await authenticate();
if (!(await hasPermission(payload, user, "structures:update"))) { if (!(await canHandleStorage(payload, user))) {
return { success: false, error: "Insufficient permissions." }; return { success: false, error: "Logistics personnel or superuser role required." };
} }
const structure = await getStructure(payload, structureId); const structure = await getStructure(payload, structureId);
const gridW = structure.type.xSize ?? 0; const gridW = structure.type.xSize ?? 0;
@ -1159,10 +1213,10 @@ export async function retrieveFromVoid(structureId: number): Promise<GridActionR
export async function autoArrangeGrid(structureId: number): Promise<GridActionResult> { export async function autoArrangeGrid(structureId: number): Promise<GridActionResult> {
try { try {
const { payload, user } = await authenticate(); const { payload, user } = await authenticate();
if (!(await hasPermission(payload, user, "structures:update"))) { if (!(await canHandleStorage(payload, user))) {
return { return {
success: false, success: false,
error: "Insufficient permissions. User, Admin, or Developer role required.", error: "Logistics personnel or superuser role required.",
}; };
} }
const structure = await getStructure(payload, structureId); const structure = await getStructure(payload, structureId);

View file

@ -263,7 +263,7 @@ export default async function VehicleDetailPage({ params }: VehicleDetailPagePro
<ItemContent> <ItemContent>
<ItemTitle>Grid Dimensions</ItemTitle> <ItemTitle>Grid Dimensions</ItemTitle>
<ItemDescription> <ItemDescription>
{vehicle.width ?? "—"}×{vehicle.height ?? "—"} {vehicle.width ?? "-"}×{vehicle.height ?? "-"}
</ItemDescription> </ItemDescription>
</ItemContent> </ItemContent>
</Item> </Item>

View file

@ -307,7 +307,7 @@ export default async function ProfilePage({ params }: ProfilePageProps) {
const airKills = scoreboard.airKills ?? 0; const airKills = scoreboard.airKills ?? 0;
const deaths = scoreboard.deaths ?? 0; const deaths = scoreboard.deaths ?? 0;
const totalKills = infantryKills + vehicleKills + armorKills + airKills; const totalKills = infantryKills + vehicleKills + armorKills + airKills;
const kdRatio = deaths > 0 ? (totalKills / deaths).toFixed(2) : totalKills > 0 ? "∞" : "—"; const kdRatio = deaths > 0 ? (totalKills / deaths).toFixed(2) : totalKills > 0 ? "∞" : "-";
const enlistDate = new Date(profile.dossier.enlistmentDate); const enlistDate = new Date(profile.dossier.enlistmentDate);
const now = new Date(); const now = new Date();
@ -562,7 +562,7 @@ export default async function ProfilePage({ params }: ProfilePageProps) {
Deaths Deaths
</td> </td>
<td className="px-4 py-2 text-right font-mono">{deaths.toLocaleString()}</td> <td className="px-4 py-2 text-right font-mono">{deaths.toLocaleString()}</td>
<td className="px-4 py-2 text-right font-mono text-muted-foreground">—</td> <td className="px-4 py-2 text-right font-mono text-muted-foreground">-</td>
</tr> </tr>
<tr className="bg-muted/30"> <tr className="bg-muted/30">
<td className="px-4 py-2.5 flex items-center gap-2 font-semibold"> <td className="px-4 py-2.5 flex items-center gap-2 font-semibold">

View file

@ -8,6 +8,7 @@ export interface BotConfig {
attendancePollMs: number; attendancePollMs: number;
notificationPollMs: number; notificationPollMs: number;
evaluationPollMs: number; evaluationPollMs: number;
readyReminderPollMs: number;
} }
const required = (name: string): string => { const required = (name: string): string => {
@ -48,4 +49,5 @@ export const botConfig: BotConfig = {
attendancePollMs: pollMs("DISCORD_ATTENDANCE_POLL_MS", 60_000), attendancePollMs: pollMs("DISCORD_ATTENDANCE_POLL_MS", 60_000),
notificationPollMs: pollMs("DISCORD_NOTIFICATION_POLL_MS", 20_000), notificationPollMs: pollMs("DISCORD_NOTIFICATION_POLL_MS", 20_000),
evaluationPollMs: pollMs("DISCORD_EVALUATION_POLL_MS", 60_000), evaluationPollMs: pollMs("DISCORD_EVALUATION_POLL_MS", 60_000),
readyReminderPollMs: pollMs("DISCORD_READY_REMINDER_POLL_MS", 60_000),
}; };

View file

@ -63,7 +63,7 @@ const getReminderMissions = async (payload: Payload): Promise<Mission[]> => {
const formatLinks = (links: ReminderLink[], appUrl: string): string => { const formatLinks = (links: ReminderLink[], appUrl: string): string => {
const lines = links.map((link) => `• [${link.displayName}](${appUrl}${link.profilePath})`); const lines = links.map((link) => `• [${link.displayName}](${appUrl}${link.profilePath})`);
// Discord embed field values cap at 1024 chars. // Discord embed field values cap at 1024 chars.
return lines.join("\n").slice(0, 1024) || "—"; return lines.join("\n").slice(0, 1024) || "-";
}; };
const renderReminderEmbed = ( const renderReminderEmbed = (

View file

@ -4,6 +4,7 @@ import { start as startMissionEmbeds } from "./missionEmbeds";
import { start as startNotificationBridge } from "./notificationBridge"; import { start as startNotificationBridge } from "./notificationBridge";
import { start as startTransferRequests } from "./transferRequests"; import { start as startTransferRequests } from "./transferRequests";
import { start as startEvaluationReminders } from "./evaluationReminders"; import { start as startEvaluationReminders } from "./evaluationReminders";
import { start as startReadyReminders } from "./readyReminders";
export type ServiceStarter = (client: Client, payload: Payload) => Promise<void> | void; export type ServiceStarter = (client: Client, payload: Payload) => Promise<void> | void;
@ -12,4 +13,5 @@ export const services: ServiceStarter[] = [
startNotificationBridge, startNotificationBridge,
startTransferRequests, startTransferRequests,
startEvaluationReminders, startEvaluationReminders,
startReadyReminders,
]; ];

View file

@ -147,7 +147,7 @@ export const renderAttendanceEmbed = (
summary: MissionAttendanceSummary, summary: MissionAttendanceSummary,
): EmbedBuilder => { ): EmbedBuilder => {
const startUnix = Math.floor(new Date(mission.classification.startDateTime).getTime() / 1000); const startUnix = Math.floor(new Date(mission.classification.startDateTime).getTime() / 1000);
const names = (users: User[]): string => users.map(memberName).join("\n").slice(0, 1024) || "—"; const names = (users: User[]): string => users.map(memberName).join("\n").slice(0, 1024) || "-";
return new EmbedBuilder() return new EmbedBuilder()
.setTitle(missionTitle(mission)) .setTitle(missionTitle(mission))
.setDescription( .setDescription(

View file

@ -3,14 +3,19 @@ import type { Payload } from "payload";
import type { User, UserNotification } from "@/payload-types"; import type { User, UserNotification } from "@/payload-types";
import { botConfig } from "@/bot/config"; import { botConfig } from "@/bot/config";
import { TRANSFER_NOTIFICATION_TYPES } from "@/lib/transfers/notificationTypes"; import { TRANSFER_NOTIFICATION_TYPES } from "@/lib/transfers/notificationTypes";
import { READY_REMINDER_NOTIFICATION_TYPE } from "@/lib/intelligence/readyReminders";
const MAX_SENDS_PER_TICK = 5; const MAX_SENDS_PER_TICK = 5;
const SEND_DELAY_MS = 300; const SEND_DELAY_MS = 300;
const EMBED_COLOR = 0x3b82f6; const EMBED_COLOR = 0x3b82f6;
// Transfer notifications are delivered by the dedicated transfer bot service // Transfer notifications are delivered by the dedicated transfer bot service
// (Phase 2), not this generic bridge. Exclude exactly these types. // (Phase 2), not this generic bridge. Ready reminders are delivered by the
const NON_BRIDGED_TYPES = new Set<string>(Object.values(TRANSFER_NOTIFICATION_TYPES)); // dedicated readyReminders DM service. Exclude exactly these types.
const NON_BRIDGED_TYPES = new Set<string>([
...Object.values(TRANSFER_NOTIFICATION_TYPES),
READY_REMINDER_NOTIFICATION_TYPE,
]);
let cursor: number | null = null; let cursor: number | null = null;

View file

@ -0,0 +1,130 @@
import { type Client, EmbedBuilder } from "discord.js";
import type { Payload } from "payload";
import type { Mission, User } from "@/payload-types";
import { botConfig } from "@/bot/config";
import {
READY_REMINDER_LEAD_DAYS,
daysUntil,
getReadyReminderMissions,
isReminderDue,
missionTitle,
} from "@/lib/intelligence/readyReminders";
/**
* Ready-reminder service: starting READY_REMINDER_LEAD_DAYS days before a
* player-scheduled side op, the op's creator gets a once-per-day DM nudging
* them to finish the op and mark it "Ready" — the status that posts the
* roll-call, locks in details, and delivers the mission hooks.
*
* The DM deliberately ignores `preferences.discord.enabled` (the notification
* bridge opt-in): that toggle defaults to false and is not yet exposed in the
* web UI, so honoring it here would silently DM nobody. This is a dedicated
* creator-prompt flow, not a forwarding of the generic notification stream
* (the web half of each reminder is excluded from the bridge via
* NON_BRIDGED_TYPES). A per-day marker on the mission (readyReminderDmSentAt)
* keeps the cadence to one DM per day.
*/
const EMBED_COLOR = 0xf59e0b;
const MISSIONS_PER_TICK = 10;
const SEND_DELAY_MS = 300;
let reconcileRunning = false;
const errorMessage = (error: unknown): string =>
error instanceof Error ? error.message : "Unknown error";
const creatorIdOf = (mission: Mission): number | null =>
typeof mission.createdBy === "object" ? (mission.createdBy?.id ?? null) : (mission.createdBy ?? null);
const renderReminderEmbed = (mission: Mission, now: Date): EmbedBuilder => {
const days = daysUntil(mission.classification.startDateTime, now);
const when = days === 0 ? "today" : days === 1 ? "tomorrow" : `in ${days} days`;
return new EmbedBuilder()
.setTitle(`⏰ Ready check: ${missionTitle(mission)}`)
.setDescription(
`Your op starts **${when}** and it isn't marked **Ready** yet.\n\n` +
"Finish up the operation and set the mission status to **Ready** so the roll-call can be posted, the details can be locked in, and all the proper mission hooks can be delivered.",
)
.setColor(EMBED_COLOR)
.addFields({
name: "Open the op",
value: `${botConfig.appUrl}/intelligence/missions/${mission.id}`,
})
.setFooter({ text: "You'll get this reminder once a day until the op is Ready." });
};
const processMission = async (
client: Client,
payload: Payload,
mission: Mission,
now: Date,
): Promise<boolean> => {
const creatorId = creatorIdOf(mission);
if (!creatorId) return false;
const creator = (await payload.findByID({
collection: "users",
id: creatorId,
depth: 0,
overrideAccess: true,
})) as unknown as User;
if (!creator.discordId) return false;
const target = await client.users.fetch(creator.discordId);
await target.send({ embeds: [renderReminderEmbed(mission, now)] });
await payload.update({
collection: "missions",
id: mission.id,
data: { readyReminderDmSentAt: now.toISOString() },
overrideAccess: true,
});
payload.logger.info(
`[Bot] Sent ready reminder DM for mission #${mission.id} (${mission.name}) to user #${creatorId}.`,
);
return true;
};
export interface ReconcileSummary {
processed: number;
sent: number;
}
export const reconcile = async (client: Client, payload: Payload): Promise<ReconcileSummary> => {
if (reconcileRunning) return { processed: 0, sent: 0 };
reconcileRunning = true;
let processed = 0;
let sent = 0;
try {
const now = new Date();
const missions = (await getReadyReminderMissions(payload, now)).slice(0, MISSIONS_PER_TICK);
for (const mission of missions) {
if (!isReminderDue(mission.readyReminderDmSentAt, now)) continue;
processed += 1;
try {
if (await processMission(client, payload, mission, now)) {
sent += 1;
await new Promise((resolve) => setTimeout(resolve, SEND_DELAY_MS));
}
} catch (error) {
payload.logger.error(
`[Bot] Ready reminder failed for mission #${mission.id}: ${errorMessage(error)}`,
);
}
}
} catch (error) {
payload.logger.error(`[Bot] Ready reminder reconcile failed: ${errorMessage(error)}`);
} finally {
reconcileRunning = false;
}
return { processed, sent };
};
export const start = (client: Client, payload: Payload): void => {
payload.logger.info("[Bot] Ready reminder service started");
void reconcile(client, payload);
setInterval(() => {
void reconcile(client, payload);
}, botConfig.readyReminderPollMs);
};

View file

@ -87,7 +87,7 @@ export const Shims: GlobalConfig = {
admin: { admin: {
condition: (_data, siblingData) => siblingData?.audience === "percentage", condition: (_data, siblingData) => siblingData?.audience === "percentage",
description: description:
"Percentage of users (0–100) who receive this shim. Assignment is deterministic per user.", "Percentage of users (0-100) who receive this shim. Assignment is deterministic per user.",
}, },
}, },
{ {

View file

@ -216,6 +216,24 @@ export const Missions: CollectionConfig = {
hidden: true, hidden: true,
}, },
}, },
{
name: "readyReminderWebSentAt",
type: "date",
admin: {
description:
"Server-local day the last web 'not Ready' reminder notification was sent to the op creator (managed by the mission tick, once per day).",
hidden: true,
},
},
{
name: "readyReminderDmSentAt",
type: "date",
admin: {
description:
"Server-local day the last 'not Ready' reminder DM was sent to the op creator (managed by the Discord bot, once per day).",
hidden: true,
},
},
{ {
name: "createdBy", name: "createdBy",
type: "relationship", type: "relationship",

View file

@ -83,7 +83,7 @@ export const MarketNegotiations: CollectionConfig = {
max: 100, max: 100,
admin: { admin: {
description: description:
"NPC vendor patience (0–100). Rises each bargaining round and sharply for lowball offers; at 100 the negotiation closes and the buyer can only buy at the asking price.", "NPC vendor patience (0-100). Rises each bargaining round and sharply for lowball offers; at 100 the negotiation closes and the buyer can only buy at the asking price.",
}, },
}, },
{ {

View file

@ -45,7 +45,7 @@ function resolveAccountLabel(account: (number | null) | BankAccount | undefined)
label: string; label: string;
id: number | null; id: number | null;
} { } {
if (!account) return { label: "—", id: null }; if (!account) return { label: "-", id: null };
if (typeof account === "object") { if (typeof account === "object") {
return { label: account.name ?? `#${account.id}`, id: account.id }; return { label: account.name ?? `#${account.id}`, id: account.id };
} }

View file

@ -9,6 +9,7 @@ import {
Users, Users,
Maximize, Maximize,
Minimize, Minimize,
ScrollText,
SparkleIcon, SparkleIcon,
} from "lucide-react"; } from "lucide-react";
@ -56,6 +57,11 @@ const data = {
url: "/intelligence/missions/calendar", url: "/intelligence/missions/calendar",
icon: CalendarDays, icon: CalendarDays,
}, },
{
title: "Event Log",
url: "/events",
icon: ScrollText,
},
], ],
...sidebarData, ...sidebarData,
}; };
@ -151,7 +157,6 @@ export function AppSidebar({
)} )}
<NavCore label="Helpdesk" items={data.navHelpdesk} /> <NavCore label="Helpdesk" items={data.navHelpdesk} />
<NavCore label="Commerce" items={data.navCommerce} /> <NavCore label="Commerce" items={data.navCommerce} />
<NavCore label="Personnel" items={data.navPersonnel} />
<NavCore label="Personal" items={data.navPersonal} /> <NavCore label="Personal" items={data.navPersonal} />
{/*<NavProjects projects={data.projects} />*/} {/*<NavProjects projects={data.projects} />*/}
</div> </div>

View file

@ -19,6 +19,7 @@ import {
TrendingUp, TrendingUp,
Truck, Truck,
UserCog, UserCog,
Users,
} from "lucide-react"; } from "lucide-react";
export const sidebarData = { export const sidebarData = {
@ -63,29 +64,60 @@ export const sidebarData = {
], ],
navLogistics: [ navLogistics: [
{ {
title: "Structures", title: "Assets",
url: "/logistics", url: "",
icon: Building2,
},
{
title: "Resources",
url: "/logistics/resources",
icon: Boxes, icon: Boxes,
items: [
{
title: "Structures",
url: "/logistics",
icon: Building2,
},
{
title: "Resources",
url: "/logistics/resources",
icon: Boxes,
},
{
title: "Vehicles",
url: "/logistics/vehicles",
icon: Truck,
},
],
}, },
{ {
title: "Vehicles", title: "Operations",
url: "/logistics/vehicles", url: "",
icon: Truck,
},
{
title: "Shipments",
url: "/logistics/shipments",
icon: Package, icon: Package,
items: [
{
title: "Shipments",
url: "/logistics/shipments",
icon: Package,
},
{
title: "Deployed Vehicles",
url: "/logistics/game-vehicles",
icon: Car,
},
],
}, },
{ {
title: "Deployed Vehicles", title: "Personnel",
url: "/logistics/game-vehicles", url: "",
icon: Car, icon: Users,
items: [
{
title: "Contacts",
url: "/personnel/contacts",
icon: Contact,
},
{
title: "Statistics",
url: "/personnel/statistics",
icon: BarChart3,
},
],
}, },
], ],
navCommerce: [ navCommerce: [
@ -100,18 +132,6 @@ export const sidebarData = {
icon: ShoppingBag, icon: ShoppingBag,
}, },
], ],
navPersonnel: [
{
title: "Contacts",
url: "/personnel/contacts",
icon: Contact,
},
{
title: "Statistics",
url: "/personnel/statistics",
icon: BarChart3,
},
],
navPersonal: [ navPersonal: [
{ {
title: "Awards", title: "Awards",

View file

@ -224,7 +224,7 @@ export function EodLeaderboard({
> >
<td className="px-4 py-2"> <td className="px-4 py-2">
<span className={rankClass(index)}> <span className={rankClass(index)}>
{row.isOutsideTopTen ? "—" : index + 1} {row.isOutsideTopTen ? "-" : index + 1}
</span> </span>
</td> </td>
<td className="px-2 py-2 font-medium"> <td className="px-2 py-2 font-medium">

View file

@ -0,0 +1,485 @@
"use client";
import * as React from "react";
import { ChevronLeft, ChevronRight, RotateCcw, Search } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { EventTypes } from "@/utils/event-log/eventTypes";
import { formatType } from "@/utils/event-log/formatType";
import { cn } from "@/lib/utils";
// Local label map mirroring GameEventLogs TARGET_COLLECTIONS (the collection
// file imports Payload server-side, so it cannot be imported from a client
// component).
const TARGET_COLLECTION_LABELS: Record<string, string> = {
"game-structures": "Game Structures",
"game-vehicles": "Game Vehicles",
"game-npcs": "Game NPCs",
"npc-staffing": "NPC Staffing",
structures: "Structures",
resources: "Resources",
assets: "Assets",
vehicles: "Vehicles",
factions: "Factions",
missions: "Missions",
campaigns: "Campaigns",
users: "Users",
technologies: "Technologies",
maps: "Maps",
shipments: "Shipments",
"bank-accounts": "Bank Accounts",
"bank-transactions": "Bank Transactions",
"ledger-entries": "Ledger Entries",
"locker-storages": "Locker Storages",
loadouts: "Loadouts",
"market-listings": "Market Listings",
"market-negotiations": "Market Negotiations",
"market-state": "Market State",
evaluations: "Evaluations",
tickets: "Tickets",
"wiki-pages": "Wiki Pages",
"wiki-revisions": "Wiki Revisions",
"wiki-templates": "Wiki Templates",
"game-servers": "Game Servers",
"assignment-transfers": "Assignment Transfers",
"voice-scripts": "Voice Scripts",
"voice-submissions": "Voice Submissions",
};
const TARGET_COLLECTION_OPTIONS = Object.entries(TARGET_COLLECTION_LABELS).sort((a, b) =>
a[1].localeCompare(b[1]),
);
const PAGE_SIZES = [25, 50, 100];
type SourceFilter = "all" | "system" | "narrative";
type SortDir = "desc" | "asc";
interface EventRow {
id: number;
system?: boolean | null;
timestamp: string;
type: string;
message: string;
actor?: number | null | { displayName?: string | null; username?: string };
structure?: number | null | { name?: string | null };
targetCollection?: string | null;
targetId?: number | null;
}
interface EventLogResponse {
docs: EventRow[];
totalDocs: number;
page?: number;
totalPages?: number;
}
/** Group event type values by their prefix (e.g. "market" from "market:sale"). */
function eventTypeGroups(): [string, string[]][] {
const groups = new Map<string, string[]>();
for (const value of Object.values(EventTypes)) {
const prefix = value.includes(":") ? value.split(":")[0] : "other";
const list = groups.get(prefix);
if (list) {
list.push(value);
} else {
groups.set(prefix, [value]);
}
}
return [...groups.entries()].sort((a, b) => a[0].localeCompare(b[0]));
}
const TYPE_GROUPS = eventTypeGroups();
function actorLabel(actor: EventRow["actor"]): string {
if (actor == null || typeof actor === "number") return "System";
return actor.displayName || actor.username || "Unknown";
}
function structureLabel(structure: EventRow["structure"]): string {
if (structure == null) return "-";
if (typeof structure === "number") return `#${structure}`;
return structure.name || "Unknown structure";
}
function formatTimestamp(ts: string): string {
const date = new Date(ts);
return date.toLocaleString(undefined, {
year: "numeric",
month: "short",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
});
}
export default function EventLogTable() {
const [searchInput, setSearchInput] = React.useState("");
const [search, setSearch] = React.useState("");
const [selectedTypes, setSelectedTypes] = React.useState<string[]>([]);
const [targetCollection, setTargetCollection] = React.useState<string>("all");
const [source, setSource] = React.useState<SourceFilter>("all");
const [dateFrom, setDateFrom] = React.useState("");
const [dateTo, setDateTo] = React.useState("");
const [sortDir, setSortDir] = React.useState<SortDir>("desc");
const [page, setPage] = React.useState(1);
const [pageSize, setPageSize] = React.useState(50);
const [rows, setRows] = React.useState<EventRow[]>([]);
const [totalDocs, setTotalDocs] = React.useState(0);
const [totalPages, setTotalPages] = React.useState(1);
const [loading, setLoading] = React.useState(true);
const [error, setError] = React.useState<string | null>(null);
// Debounce the search box.
React.useEffect(() => {
const t = setTimeout(() => setSearch(searchInput.trim()), 300);
return () => clearTimeout(t);
}, [searchInput]);
// Any filter change resets to page 1.
React.useEffect(() => {
setPage(1);
}, [search, selectedTypes, targetCollection, source, dateFrom, dateTo, sortDir, pageSize]);
const hasActiveFilters =
search !== "" ||
selectedTypes.length > 0 ||
targetCollection !== "all" ||
source !== "all" ||
dateFrom !== "" ||
dateTo !== "";
const resetFilters = React.useCallback(() => {
setSearchInput("");
setSelectedTypes([]);
setTargetCollection("all");
setSource("all");
setDateFrom("");
setDateTo("");
}, []);
React.useEffect(() => {
const controller = new AbortController();
const conditions: Record<string, unknown>[] = [];
if (search) {
conditions.push({ or: [{ message: { like: search } }, { type: { like: search } }] });
}
if (selectedTypes.length > 0) {
conditions.push({ type: { in: selectedTypes } });
}
if (targetCollection !== "all") {
conditions.push({ targetCollection: { equals: targetCollection } });
}
if (source !== "all") {
conditions.push({ system: { equals: source === "system" } });
}
if (dateFrom) {
conditions.push({ timestamp: { greater_than_equal: `${dateFrom}T00:00:00.000Z` } });
}
if (dateTo) {
conditions.push({ timestamp: { less_than_equal: `${dateTo}T23:59:59.999Z` } });
}
const params = new URLSearchParams({
depth: "1",
sort: `${sortDir === "desc" ? "-" : ""}timestamp`,
limit: String(pageSize),
page: String(page),
});
if (conditions.length > 0) {
const where = conditions.length === 1 ? conditions[0] : { and: conditions };
params.set("where", JSON.stringify(where));
}
async function load() {
setLoading(true);
setError(null);
try {
const res = await fetch(`/api/game-event-logs?${params.toString()}`, {
signal: controller.signal,
credentials: "include",
});
if (!res.ok) {
throw new Error(`Request failed (${res.status})`);
}
const data = (await res.json()) as EventLogResponse;
setRows(data.docs ?? []);
setTotalDocs(data.totalDocs ?? 0);
setTotalPages(Math.max(1, data.totalPages ?? 1));
} catch (err) {
if (controller.signal.aborted) return;
setError(err instanceof Error ? err.message : "Failed to load events");
setRows([]);
setTotalDocs(0);
} finally {
if (!controller.signal.aborted) {
setLoading(false);
}
}
}
void load();
return () => controller.abort();
}, [search, selectedTypes, targetCollection, source, dateFrom, dateTo, sortDir, page, pageSize]);
const toggleType = (value: string) => {
setSelectedTypes((prev) =>
prev.includes(value) ? prev.filter((v) => v !== value) : [...prev, value],
);
};
const from = totalDocs === 0 ? 0 : (page - 1) * pageSize + 1;
const to = Math.min(page * pageSize, totalDocs);
return (
<div className="flex flex-col gap-4">
{/* Toolbar */}
<div className="flex flex-wrap items-center gap-2">
<div className="relative w-full max-w-xs">
<Search className="text-muted-foreground absolute top-1/2 left-2 size-4 -translate-y-1/2" />
<Input
value={searchInput}
onChange={(e) => setSearchInput(e.target.value)}
placeholder="Search message or type…"
className="pl-8"
/>
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm">
Type
{selectedTypes.length > 0 && (
<Badge variant="secondary" className="ml-1 px-1.5 py-0 text-[10px]">
{selectedTypes.length}
</Badge>
)}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className="max-h-96 w-64 overflow-y-auto" align="start">
{TYPE_GROUPS.map(([prefix, types], groupIdx) => (
<React.Fragment key={prefix}>
{groupIdx > 0 && <DropdownMenuSeparator />}
<DropdownMenuLabel className="text-xs uppercase text-muted-foreground">
{prefix}
</DropdownMenuLabel>
{types.map((value) => (
<DropdownMenuCheckboxItem
key={value}
checked={selectedTypes.includes(value)}
onCheckedChange={() => toggleType(value)}
onSelect={(e) => e.preventDefault()}
>
{formatType(value)}
</DropdownMenuCheckboxItem>
))}
</React.Fragment>
))}
</DropdownMenuContent>
</DropdownMenu>
<Select value={targetCollection} onValueChange={setTargetCollection}>
<SelectTrigger className="h-8 w-44">
<SelectValue placeholder="Target" />
</SelectTrigger>
<SelectContent className="max-h-72">
<SelectItem value="all">All targets</SelectItem>
{TARGET_COLLECTION_OPTIONS.map(([value, label]) => (
<SelectItem key={value} value={value}>
{label}
</SelectItem>
))}
</SelectContent>
</Select>
<Select value={source} onValueChange={(v) => setSource(v as SourceFilter)}>
<SelectTrigger className="h-8 w-36">
<SelectValue placeholder="Source" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All sources</SelectItem>
<SelectItem value="system">System</SelectItem>
<SelectItem value="narrative">Narrative</SelectItem>
</SelectContent>
</Select>
<div className="flex items-center gap-1">
<Input
type="date"
value={dateFrom}
onChange={(e) => setDateFrom(e.target.value)}
className="w-36"
aria-label="From date"
/>
<span className="text-muted-foreground text-xs">-</span>
<Input
type="date"
value={dateTo}
onChange={(e) => setDateTo(e.target.value)}
className="w-36"
aria-label="To date"
/>
</div>
<Select value={sortDir} onValueChange={(v) => setSortDir(v as SortDir)}>
<SelectTrigger className="h-8 w-28">
<SelectValue placeholder="Sort" />
</SelectTrigger>
<SelectContent>
<SelectItem value="desc">Newest first</SelectItem>
<SelectItem value="asc">Oldest first</SelectItem>
</SelectContent>
</Select>
{hasActiveFilters && (
<Button variant="ghost" size="sm" onClick={resetFilters}>
<RotateCcw className="size-4" />
Reset
</Button>
)}
<span className="text-muted-foreground ml-auto text-xs">
{totalDocs.toLocaleString()} event{totalDocs === 1 ? "" : "s"}
</span>
</div>
{/* Table */}
<div className="rounded-lg border">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-44">Time</TableHead>
<TableHead className="w-44">Type</TableHead>
<TableHead>Message</TableHead>
<TableHead className="w-32">Actor</TableHead>
<TableHead className="w-44">Structure</TableHead>
<TableHead className="w-40">Target</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{loading && rows.length === 0 ? (
<TableRow>
<TableCell colSpan={6} className="text-muted-foreground py-8 text-center">
Loading events…
</TableCell>
</TableRow>
) : error ? (
<TableRow>
<TableCell colSpan={6} className="text-destructive py-8 text-center">
{error}
</TableCell>
</TableRow>
) : rows.length === 0 ? (
<TableRow>
<TableCell colSpan={6} className="text-muted-foreground py-8 text-center">
No events match the current filters.
</TableCell>
</TableRow>
) : (
rows.map((row) => {
const narrative = row.system === false;
return (
<TableRow
key={row.id}
className={cn(narrative && "border-l-2 border-l-amber-500/70")}
>
<TableCell className="text-muted-foreground whitespace-nowrap text-xs">
{formatTimestamp(row.timestamp)}
</TableCell>
<TableCell>
<Badge variant="outline" className="max-w-40 truncate font-normal">
{formatType(row.type)}
</Badge>
</TableCell>
<TableCell className="max-w-md whitespace-normal">{row.message}</TableCell>
<TableCell className="text-xs">{actorLabel(row.actor)}</TableCell>
<TableCell className="text-xs">{structureLabel(row.structure)}</TableCell>
<TableCell className="text-muted-foreground text-xs">
{row.targetCollection
? `${TARGET_COLLECTION_LABELS[row.targetCollection] ?? row.targetCollection}${
row.targetId != null ? ` #${row.targetId}` : ""
}`
: "-"}
</TableCell>
</TableRow>
);
})
)}
</TableBody>
</Table>
</div>
{/* Pagination */}
<div className="flex flex-wrap items-center justify-between gap-2">
<span className="text-muted-foreground text-xs">
Showing {from.toLocaleString()}-{to.toLocaleString()} of {totalDocs.toLocaleString()}
</span>
<div className="flex items-center gap-2">
<Select
value={String(pageSize)}
onValueChange={(v) => setPageSize(Number(v))}
>
<SelectTrigger className="h-8 w-28">
<SelectValue />
</SelectTrigger>
<SelectContent>
{PAGE_SIZES.map((size) => (
<SelectItem key={size} value={String(size)}>
{size} / page
</SelectItem>
))}
</SelectContent>
</Select>
<Button
variant="outline"
size="sm"
disabled={page <= 1 || loading}
onClick={() => setPage((p) => Math.max(1, p - 1))}
>
<ChevronLeft className="size-4" />
Prev
</Button>
<span className="text-muted-foreground text-xs">
Page {page} / {totalPages}
</span>
<Button
variant="outline"
size="sm"
disabled={page >= totalPages || loading}
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
>
Next
<ChevronRight className="size-4" />
</Button>
</div>
</div>
</div>
);
}

View file

@ -159,7 +159,7 @@ export function FlappyLeaderboard({ refreshKey = 0, currentUserId, activePilots
title={row.isOutsideTopTen ? "Pilot status: not currently ranked in the top 10" : undefined} title={row.isOutsideTopTen ? "Pilot status: not currently ranked in the top 10" : undefined}
> >
<td className="px-4 py-2"> <td className="px-4 py-2">
<span className={rankClass(i)}>{row.isOutsideTopTen ? "—" : i + 1}</span> <span className={rankClass(i)}>{row.isOutsideTopTen ? "-" : i + 1}</span>
</td> </td>
<td className="px-2 py-2 font-medium"> <td className="px-2 py-2 font-medium">
<span className="flex items-center gap-2"> <span className="flex items-center gap-2">

View file

@ -58,7 +58,7 @@ function formatDate(iso: string | null | undefined): string {
? new Intl.DateTimeFormat(undefined, { dateStyle: "medium", timeStyle: "short" }).format( ? new Intl.DateTimeFormat(undefined, { dateStyle: "medium", timeStyle: "short" }).format(
new Date(iso), new Date(iso),
) )
: "—"; : "-";
} }
function isTicketStatus(value: string): value is TicketStatus { function isTicketStatus(value: string): value is TicketStatus {

View file

@ -1,7 +1,7 @@
"use client"; "use client";
import Link from "next/link"; import Link from "next/link";
import type { Campaign, Map, Media } from "@/payload-types"; import type { Campaign, Map, Media, User } from "@/payload-types";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { import {
operationTypeColors, operationTypeColors,
@ -22,6 +22,7 @@ import {
ClockIcon, ClockIcon,
CrosshairIcon, CrosshairIcon,
MapPinIcon, MapPinIcon,
UserIcon,
UsersIcon, UsersIcon,
} from "lucide-react"; } from "lucide-react";
@ -52,6 +53,7 @@ export interface MissionCardProps {
readonly missionRoles?: { readonly missionRoles?: {
readonly maxPlayers: number; readonly maxPlayers: number;
} | null; } | null;
readonly createdBy?: number | User | null;
readonly coverImage?: number | Media | null; readonly coverImage?: number | Media | null;
}; };
readonly variant?: "default" | "image"; readonly variant?: "default" | "image";
@ -77,6 +79,12 @@ export function MissionCard({
typeof mission.classification.campaign === "object" && mission.classification.campaign !== null typeof mission.classification.campaign === "object" && mission.classification.campaign !== null
? mission.classification.campaign ? mission.classification.campaign
: null; : null;
const createdByUser =
typeof mission.createdBy === "object" && mission.createdBy !== null
? mission.createdBy
: null;
const hostLabel = createdByUser ? (createdByUser.displayName || createdByUser.username) : null;
const showHost = mission.operationType === "side" && hostLabel !== null;
const startDate = new Date(mission.classification.startDateTime); const startDate = new Date(mission.classification.startDateTime);
const dateStr = startDate.toLocaleDateString("en-US", { const dateStr = startDate.toLocaleDateString("en-US", {
@ -122,7 +130,7 @@ export function MissionCard({
<ItemMedia variant="icon"> <ItemMedia variant="icon">
<CrosshairIcon /> <CrosshairIcon />
</ItemMedia> </ItemMedia>
<ItemTitle className="flex flex-col"> <ItemTitle className="flex flex-col items-start text-start">
<span className="font-mono text-xs text-muted-foreground">{mission.codeName}</span> <span className="font-mono text-xs text-muted-foreground">{mission.codeName}</span>
<span>{mission.name}</span> <span>{mission.name}</span>
</ItemTitle> </ItemTitle>
@ -131,7 +139,7 @@ export function MissionCard({
<span <span
className={`text-[10px] font-semibold uppercase tracking-wider px-1.5 py-0.5 rounded ${operationTypeColors[mission.operationType] ?? "bg-muted text-muted-foreground"}`} className={`text-[10px] font-semibold uppercase tracking-wider px-1.5 py-0.5 rounded ${operationTypeColors[mission.operationType] ?? "bg-muted text-muted-foreground"}`}
> >
{operationTypeLabels[mission.operationType] ?? mission.operationType} {operationTypeLabels[mission.operationType] ?? mission.operationType} Operation
</span> </span>
<span <span
className={`text-[10px] font-semibold uppercase tracking-wider px-1.5 py-0.5 rounded ${statusColors[mission.ownershipAndStatus.status] ?? "bg-muted text-muted-foreground"}`} className={`text-[10px] font-semibold uppercase tracking-wider px-1.5 py-0.5 rounded ${statusColors[mission.ownershipAndStatus.status] ?? "bg-muted text-muted-foreground"}`}
@ -167,6 +175,12 @@ export function MissionCard({
{mission.missionRoles.maxPlayers} slots {mission.missionRoles.maxPlayers} slots
</span> </span>
)} )}
{showHost && (
<span className="flex items-center gap-1">
<UserIcon className="size-3" />
Hosted by {hostLabel}
</span>
)}
{campaign && <span className="text-primary">{campaign.name}</span>} {campaign && <span className="text-primary">{campaign.name}</span>}
</div> </div>
</ItemContent> </ItemContent>

View file

@ -97,7 +97,7 @@ export function MissionComms({ commsAndDocs }: MissionCommsProps) {
{frequencies!.map((freq, index) => ( {frequencies!.map((freq, index) => (
<tr key={index} className="border-b border-border/50"> <tr key={index} className="border-b border-border/50">
<td className="px-4 py-2 font-mono font-medium">{freq.frequency}</td> <td className="px-4 py-2 font-mono font-medium">{freq.frequency}</td>
<td className="px-4 py-2 text-muted-foreground">{freq.channel ?? "—"}</td> <td className="px-4 py-2 text-muted-foreground">{freq.channel ?? "-"}</td>
<td className="px-4 py-2 font-medium">{freq.netName}</td> <td className="px-4 py-2 font-medium">{freq.netName}</td>
<td className="px-4 py-2 text-muted-foreground">{freq.freqUsage}</td> <td className="px-4 py-2 text-muted-foreground">{freq.freqUsage}</td>
</tr> </tr>

View file

@ -112,7 +112,7 @@ export function MissionLogistics({ logisticsAndSupport }: MissionLogisticsProps)
<tr key={index} className="border-b border-border/50"> <tr key={index} className="border-b border-border/50">
<td className="px-4 py-2 font-medium">{asset.assetType}</td> <td className="px-4 py-2 font-medium">{asset.assetType}</td>
<td className="px-4 py-2 text-muted-foreground"> <td className="px-4 py-2 text-muted-foreground">
{asset.assetCount ?? "—"} {asset.assetCount ?? "-"}
</td> </td>
<td className="px-4 py-2 text-center"> <td className="px-4 py-2 text-center">
{asset.doesRespawn ? ( {asset.doesRespawn ? (
@ -122,7 +122,7 @@ export function MissionLogistics({ logisticsAndSupport }: MissionLogisticsProps)
)} )}
</td> </td>
<td className="px-4 py-2 text-muted-foreground font-mono text-xs"> <td className="px-4 py-2 text-muted-foreground font-mono text-xs">
{asset.assetCallsign ?? "—"} {asset.assetCallsign ?? "-"}
</td> </td>
</tr> </tr>
))} ))}

View file

@ -41,10 +41,10 @@ function ObjectiveText({
className?: string; className?: string;
}) { }) {
if (!redacted) { if (!redacted) {
return <span className={className}>{text ?? "—"}</span>; return <span className={className}>{text ?? "-"}</span>;
} }
const display = redactText(text) ?? "—"; const display = redactText(text) ?? "-";
if (!canReveal) { if (!canReveal) {
return <span className={`${className ?? ""} select-none break-all`}>{display}</span>; return <span className={`${className ?? ""} select-none break-all`}>{display}</span>;
@ -57,7 +57,7 @@ function ObjectiveText({
<span className={`${className ?? ""} cursor-help select-none break-all`}>{display}</span> <span className={`${className ?? ""} cursor-help select-none break-all`}>{display}</span>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent className="max-w-sm whitespace-pre-wrap break-words"> <TooltipContent className="max-w-sm whitespace-pre-wrap break-words">
{text ?? "—"} {text ?? "-"}
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
</TooltipProvider> </TooltipProvider>

View file

@ -84,14 +84,14 @@ export function MissionRoles({ roles, notes, maxPlayers }: MissionRolesProps) {
<td className="px-4 py-2 text-muted-foreground"> <td className="px-4 py-2 text-muted-foreground">
{role.requiredMos && role.requiredMos.length > 0 {role.requiredMos && role.requiredMos.length > 0
? resolveNames(role.requiredMos) ? resolveNames(role.requiredMos)
: "—"} : "-"}
</td> </td>
<td className="px-4 py-2 text-muted-foreground"> <td className="px-4 py-2 text-muted-foreground">
{typeof role.minimumRank === "object" && role.minimumRank !== null {typeof role.minimumRank === "object" && role.minimumRank !== null
? role.minimumRank.name ? role.minimumRank.name
: typeof role.minimumRank === "number" : typeof role.minimumRank === "number"
? `#${role.minimumRank}` ? `#${role.minimumRank}`
: "—"} : "-"}
</td> </td>
</tr> </tr>
))} ))}

View file

@ -3,10 +3,13 @@
import { useState, useTransition } from "react"; import { useState, useTransition } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { toast } from "sonner"; import { toast } from "sonner";
import { PencilIcon, XCircleIcon } from "lucide-react"; import { CheckCircleIcon, PencilIcon, XCircleIcon } from "lucide-react";
import { Item, ItemContent, ItemHeader, ItemTitle } from "@/components/ui/item"; import { Item, ItemContent, ItemHeader, ItemTitle } from "@/components/ui/item";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { cancelFridaySideOp } from "@/app/(frontend)/intelligence/missions/actions"; import {
cancelFridaySideOp,
markSideOpReady,
} from "@/app/(frontend)/intelligence/missions/actions";
import { import {
CreateSideOpDialog, CreateSideOpDialog,
type SideOpDialogInitial, type SideOpDialogInitial,
@ -34,6 +37,19 @@ export function SideOpControls({
const [isPending, startTransition] = useTransition(); const [isPending, startTransition] = useTransition();
const isTerminal = status === "Completed" || status === "Cancelled"; const isTerminal = status === "Completed" || status === "Cancelled";
const canMarkReady = ["Concept", "Planning", "Scheduled"].includes(status);
function handleMarkReady() {
startTransition(async () => {
const result = await markSideOpReady(initial.missionId);
if (!result.success) {
toast.error(result.error ?? "Failed to mark the side operation as Ready.");
return;
}
toast.success("Side operation marked as Ready. Roll-call can now be posted.");
router.refresh();
});
}
function handleCancel() { function handleCancel() {
if (!confirmingCancel) { if (!confirmingCancel) {
@ -64,6 +80,12 @@ export function SideOpControls({
</ItemHeader> </ItemHeader>
<ItemContent> <ItemContent>
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
{canMarkReady && (
<Button size="sm" variant="default" onClick={handleMarkReady} disabled={isPending}>
<CheckCircleIcon className="size-3.5" />
Mark as Ready
</Button>
)}
<Button size="sm" variant="outline" onClick={() => setEditOpen(true)} disabled={isPending}> <Button size="sm" variant="outline" onClick={() => setEditOpen(true)} disabled={isPending}>
<PencilIcon className="size-3.5" /> <PencilIcon className="size-3.5" />
Edit Edit
@ -79,8 +101,9 @@ export function SideOpControls({
</Button> </Button>
</div> </div>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
Editing covers details, campaign, and narrative. Cancelling frees your scheduling slot Marking the operation Ready locks in the details so the Discord roll-call can be posted
and updates the Discord embed. and the mission hooks delivered. Editing covers details, campaign, and narrative.
Cancelling frees your scheduling slot and updates the Discord embed.
</p> </p>
</ItemContent> </ItemContent>

View file

@ -119,7 +119,7 @@ export function LockerGridToolbar({
<div> <div>
<dt className="text-muted-foreground/70">FOOTPRINT</dt> <dt className="text-muted-foreground/70">FOOTPRINT</dt>
<dd className="font-mono tabular-nums text-foreground/90"> <dd className="font-mono tabular-nums text-foreground/90">
{fp ? `${fp.width}×${fp.height}` : "—"} {fp ? `${fp.width}×${fp.height}` : "-"}
</dd> </dd>
</div> </div>
<div> <div>

View file

@ -185,7 +185,7 @@ export function SprintsView({ project, sprints, isAdmin }: SprintsViewProps) {
<ItemFooter className="w-full flex-wrap justify-start gap-x-4 gap-y-1 text-xs text-muted-foreground"> <ItemFooter className="w-full flex-wrap justify-start gap-x-4 gap-y-1 text-xs text-muted-foreground">
<span className="flex items-center gap-1"> <span className="flex items-center gap-1">
<CalendarIcon className="size-3" /> <CalendarIcon className="size-3" />
{dateFor(sprint.startDate)} — {dateFor(sprint.endDate)} {dateFor(sprint.startDate)} - {dateFor(sprint.endDate)}
</span> </span>
</ItemFooter> </ItemFooter>
</Item> </Item>

View file

@ -178,7 +178,7 @@ export function AimTrainerLeaderboard({
> >
<td className="px-4 py-2"> <td className="px-4 py-2">
<span className={rankClass(index)}> <span className={rankClass(index)}>
{row.isOutsideTopTen ? "—" : index + 1} {row.isOutsideTopTen ? "-" : index + 1}
</span> </span>
</td> </td>
<td className="px-2 py-2 font-medium"> <td className="px-2 py-2 font-medium">

View file

@ -288,7 +288,7 @@ export function RadioLeaderboard({
} }
> >
<td className="px-4 py-2"> <td className="px-4 py-2">
<span className={rankClass(index)}>{row.isOutsideTopTen ? "—" : index + 1}</span> <span className={rankClass(index)}>{row.isOutsideTopTen ? "-" : index + 1}</span>
</td> </td>
<td className="px-2 py-2 font-medium"> <td className="px-2 py-2 font-medium">
<span className="flex items-center gap-2"> <span className="flex items-center gap-2">
@ -318,7 +318,7 @@ export function RadioLeaderboard({
</td> </td>
{mode === "sprint" && ( {mode === "sprint" && (
<td className="px-2 py-2 text-right tabular-nums"> <td className="px-2 py-2 text-right tabular-nums">
{row.bestSprintTimeMs > 0 ? `${(row.bestSprintTimeMs / 1000).toFixed(1)}s` : "—"} {row.bestSprintTimeMs > 0 ? `${(row.bestSprintTimeMs / 1000).toFixed(1)}s` : "-"}
</td> </td>
)} )}
<td className="px-4 py-2 text-right tabular-nums text-muted-foreground"> <td className="px-4 py-2 text-right tabular-nums text-muted-foreground">

View file

@ -620,7 +620,7 @@ export function RadioTraffic({
<div className="flex flex-col gap-2 sm:flex-row"><Input ref={inputRef} id="radio-traffic-input" value={input} onKeyDown={handleKeystroke} onChange={(event) => { setInput(event.target.value.toUpperCase()); playKeystroke(); }} onPaste={(event) => { event.preventDefault(); rejectInsertion(); }} onDrop={(event) => { event.preventDefault(); rejectInsertion(); }} onDragOver={(event) => event.preventDefault()} autoCapitalize="characters" autoComplete="off" autoCorrect="off" autoFocus spellCheck={false} aria-describedby="radio-traffic-hint" className="font-mono sm:flex-1" /><Button type="submit" className="sm:min-w-24">Relay</Button></div> <div className="flex flex-col gap-2 sm:flex-row"><Input ref={inputRef} id="radio-traffic-input" value={input} onKeyDown={handleKeystroke} onChange={(event) => { setInput(event.target.value.toUpperCase()); playKeystroke(); }} onPaste={(event) => { event.preventDefault(); rejectInsertion(); }} onDrop={(event) => { event.preventDefault(); rejectInsertion(); }} onDragOver={(event) => event.preventDefault()} autoCapitalize="characters" autoComplete="off" autoCorrect="off" autoFocus spellCheck={false} aria-describedby="radio-traffic-hint" className="font-mono sm:flex-1" /><Button type="submit" className="sm:min-w-24">Relay</Button></div>
<p id="radio-traffic-hint" className="text-xs text-muted-foreground">Input is auto-capitalized, so no Shift needed. Match callsigns, grid words, punctuation, and spacing.</p> <p id="radio-traffic-hint" className="text-xs text-muted-foreground">Input is auto-capitalized, so no Shift needed. Match callsigns, grid words, punctuation, and spacing.</p>
</form> </form>
<div className="flex flex-wrap justify-between gap-3 border-t border-border pt-3 text-xs text-muted-foreground"><span>Current accuracy <strong className="ml-1 text-foreground">{liveKeyTotal === 0 ? "—" : formatAccuracy(currentAccuracy)}</strong></span><span>Round score <strong className="ml-1 font-mono text-foreground">{provisionalScore}</strong></span><span>Completed <strong className="ml-1 font-mono text-foreground">{results.length}</strong></span></div> <div className="flex flex-wrap justify-between gap-3 border-t border-border pt-3 text-xs text-muted-foreground"><span>Current accuracy <strong className="ml-1 text-foreground">{liveKeyTotal === 0 ? "-" : formatAccuracy(currentAccuracy)}</strong></span><span>Round score <strong className="ml-1 font-mono text-foreground">{provisionalScore}</strong></span><span>Completed <strong className="ml-1 font-mono text-foreground">{results.length}</strong></span></div>
{feedback && <p role="status" className={feedback.correct ? "text-sm text-emerald-400" : "text-sm text-destructive"}>{feedback.message}</p>} {feedback && <p role="status" className={feedback.correct ? "text-sm text-emerald-400" : "text-sm text-destructive"}>{feedback.message}</p>}
</div>} </div>}

View file

@ -541,7 +541,7 @@ export function RosterView({
</TabsTrigger> </TabsTrigger>
<TabsTrigger value="alpha" className="gap-1.5"> <TabsTrigger value="alpha" className="gap-1.5">
<ListIcon className="size-3" /> <ListIcon className="size-3" />
A–Z A-Z
</TabsTrigger> </TabsTrigger>
<TabsTrigger value="seniority" className="gap-1.5"> <TabsTrigger value="seniority" className="gap-1.5">
<CalendarDaysIcon className="size-3" /> <CalendarDaysIcon className="size-3" />

View file

@ -24,6 +24,10 @@ interface GridToolbarProps {
onRotate: (entryId: string) => Promise<void>; onRotate: (entryId: string) => Promise<void>;
onRemove: (entryId: string) => Promise<void>; onRemove: (entryId: string) => Promise<void>;
onSplit: (entryId: string, amount: number) => Promise<void>; onSplit: (entryId: string, amount: number) => Promise<void>;
/** Rearranging/rotating/splitting conserves quantities — logistics personnel or superuser. */
canArrange?: boolean;
/** Removing grid items destroys stored stock — superuser only. */
canMutate?: boolean;
} }
export function GridToolbar({ export function GridToolbar({
@ -37,6 +41,8 @@ export function GridToolbar({
onRotate, onRotate,
onRemove, onRemove,
onSplit, onSplit,
canArrange = false,
canMutate = false,
}: GridToolbarProps) { }: GridToolbarProps) {
const occupancy = getGridOccupancy(storedResources, gridWidth, gridHeight); const occupancy = getGridOccupancy(storedResources, gridWidth, gridHeight);
const res = selectedItem ? (selectedItem.resource as Resource) : null; const res = selectedItem ? (selectedItem.resource as Resource) : null;
@ -50,10 +56,12 @@ export function GridToolbar({
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Button variant="outline" size="sm" onClick={onAutoArrange}> {canArrange && (
<ShuffleIcon className="size-3.5 mr-1" /> <Button variant="outline" size="sm" onClick={onAutoArrange}>
Auto-Arrange <ShuffleIcon className="size-3.5 mr-1" />
</Button> Auto-Arrange
</Button>
)}
</div> </div>
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
@ -86,12 +94,12 @@ export function GridToolbar({
</div> </div>
</div> </div>
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
{res.rotatable !== false && ( {canArrange && res.rotatable !== false && (
<Button variant="outline" size="sm" onClick={() => onRotate(selectedItem.id)}> <Button variant="outline" size="sm" onClick={() => onRotate(selectedItem.id)}>
<RotateCwIcon className="size-3.5" /> <RotateCwIcon className="size-3.5" />
</Button> </Button>
)} )}
{res.stackable && selectedItem.amount > 1 && ( {canArrange && res.stackable && selectedItem.amount > 1 && (
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
@ -101,9 +109,11 @@ export function GridToolbar({
<ScissorsIcon className="size-3.5" /> <ScissorsIcon className="size-3.5" />
</Button> </Button>
)} )}
<Button variant="outline" size="sm" onClick={() => onRemove(selectedItem.id)}> {canMutate && (
<TrashIcon className="size-3.5" /> <Button variant="outline" size="sm" onClick={() => onRemove(selectedItem.id)}>
</Button> <TrashIcon className="size-3.5" />
</Button>
)}
</div> </div>
</div> </div>
)} )}

View file

@ -7,18 +7,32 @@ import { ManageStorageDialog } from "@/components/frontend/storage/ManageStorage
interface ManageStorageButtonProps { interface ManageStorageButtonProps {
structureId: number; structureId: number;
canManage: boolean;
canTransfer: boolean;
} }
export function ManageStorageButton({ structureId }: ManageStorageButtonProps) { export function ManageStorageButton({
structureId,
canManage,
canTransfer,
}: ManageStorageButtonProps) {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
if (!canManage && !canTransfer) return null;
return ( return (
<> <>
<Button variant="outline" size="sm" onClick={() => setOpen(true)}> <Button variant="outline" size="sm" onClick={() => setOpen(true)}>
<SettingsIcon className="size-3.5 mr-1.5" /> <SettingsIcon className="size-3.5 mr-1.5" />
Manage Manage
</Button> </Button>
<ManageStorageDialog structureId={structureId} open={open} onOpenChange={setOpen} /> <ManageStorageDialog
structureId={structureId}
open={open}
onOpenChange={setOpen}
canManage={canManage}
canTransfer={canTransfer}
/>
</> </>
); );
} }

View file

@ -46,9 +46,17 @@ interface ManageStorageDialogProps {
structureId: number; structureId: number;
open: boolean; open: boolean;
onOpenChange: (open: boolean) => void; onOpenChange: (open: boolean) => void;
canManage: boolean;
canTransfer: boolean;
} }
export function ManageStorageDialog({ structureId, open, onOpenChange }: ManageStorageDialogProps) { export function ManageStorageDialog({
structureId,
open,
onOpenChange,
canManage,
canTransfer,
}: ManageStorageDialogProps) {
const [structure, setStructure] = useState<(GameStructure & { type: Structure }) | null>(null); const [structure, setStructure] = useState<(GameStructure & { type: Structure }) | null>(null);
const [allStructures, setAllStructures] = useState<(GameStructure & { type: Structure })[]>([]); const [allStructures, setAllStructures] = useState<(GameStructure & { type: Structure })[]>([]);
const [allResources, setAllResources] = useState<Resource[]>([]); const [allResources, setAllResources] = useState<Resource[]>([]);
@ -641,265 +649,288 @@ export function ManageStorageDialog({ structureId, open, onOpenChange }: ManageS
</div> </div>
</div> </div>
)} )}
<Tabs defaultValue="deposit" className="w-full"> <Tabs defaultValue={canManage ? "deposit" : "transfer"} className="w-full">
<TabsList className="grid w-full grid-cols-3"> <TabsList
<TabsTrigger value="deposit" className="flex items-center gap-1.5"> className={cn(
<ArrowDownToLineIcon className="size-3.5" /> "grid w-full",
Deposit canManage && canTransfer
</TabsTrigger> ? "grid-cols-3"
<TabsTrigger value="withdraw" className="flex items-center gap-1.5"> : canManage
<ArrowUpFromLineIcon className="size-3.5" /> ? "grid-cols-2"
Withdraw : "grid-cols-1",
</TabsTrigger> )}
<TabsTrigger value="transfer" className="flex items-center gap-1.5"> >
<ArrowRightLeftIcon className="size-3.5" /> {canManage && (
Transfer <TabsTrigger value="deposit" className="flex items-center gap-1.5">
</TabsTrigger> <ArrowDownToLineIcon className="size-3.5" />
Deposit
</TabsTrigger>
)}
{canManage && (
<TabsTrigger value="withdraw" className="flex items-center gap-1.5">
<ArrowUpFromLineIcon className="size-3.5" />
Withdraw
</TabsTrigger>
)}
{canTransfer && (
<TabsTrigger value="transfer" className="flex items-center gap-1.5">
<ArrowRightLeftIcon className="size-3.5" />
Transfer
</TabsTrigger>
)}
</TabsList> </TabsList>
<TabsContent value="deposit" className="flex flex-col gap-4 pt-4"> {canManage && (
<div className="flex flex-col gap-2"> <TabsContent value="deposit" className="flex flex-col gap-4 pt-4">
<Label>Resource</Label> <div className="flex flex-col gap-2">
<Select <Label>Resource</Label>
value={depositResourceId} <Select
onValueChange={(val) => { value={depositResourceId}
setDepositResourceId(val); onValueChange={(val) => {
setDepositAmount( setDepositResourceId(val);
clampAmount(depositAmount, getDepositLimitInfoFor(val)?.max), setDepositAmount(
); clampAmount(depositAmount, getDepositLimitInfoFor(val)?.max),
}} );
}}
>
<SelectTrigger>
<SelectValue placeholder="Select a resource..." />
</SelectTrigger>
<SelectContent>
{allResources
.filter((r) => isResourceAllowed(r.id))
.map((resource) => {
const cap = getAllowedDepositCap(resource.id);
return (
<SelectItem key={resource.id} value={String(resource.id)}>
{resource.name}
{cap !== undefined && (
<span className="text-muted-foreground">
{" "}
({cap.toLocaleString()} max)
</span>
)}
</SelectItem>
);
})}
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-2">
<Label>Amount</Label>
<Input
type="number"
min="1"
max={getDepositMax()}
value={depositAmount}
onChange={(e) =>
setDepositAmount(clampAmount(e.target.value, getDepositMax()))
}
/>
{getDepositLimitInfoFor(depositResourceId) !== undefined && (
<span className="text-xs text-muted-foreground">
Max: {getDepositLimitInfoFor(depositResourceId)!.max.toLocaleString()}{" "}
(limited by {getDepositLimitInfoFor(depositResourceId)!.reason})
</span>
)}
</div>
{renderCapacityPreview("deposit")}
<Button
onClick={handleDeposit}
disabled={isPending || !depositResourceId}
className="w-full"
> >
<SelectTrigger> {isPending ? (
<SelectValue placeholder="Select a resource..." /> <Loader2Icon className="size-4 animate-spin mr-2" />
</SelectTrigger> ) : (
<SelectContent> <ArrowDownToLineIcon className="size-4 mr-2" />
{allResources )}
.filter((r) => isResourceAllowed(r.id)) Deposit
.map((resource) => { </Button>
const cap = getAllowedDepositCap(resource.id); </TabsContent>
)}
{canManage && (
<TabsContent value="withdraw" className="flex flex-col gap-4 pt-4">
<div className="flex flex-col gap-2">
<Label>Resource</Label>
<Select
value={withdrawResourceId}
onValueChange={(val) => {
setWithdrawResourceId(val);
const stored = findStoredResource(val);
setWithdrawAmount(clampAmount(withdrawAmount, stored?.amount));
}}
>
<SelectTrigger>
<SelectValue placeholder="Select a resource..." />
</SelectTrigger>
<SelectContent>
{storedResources.map((entry) => {
const resource = entry.resource as Resource;
if (!resource || typeof resource === "number") return null;
return ( return (
<SelectItem key={resource.id} value={String(resource.id)}> <SelectItem key={entry.id ?? resource.id} value={String(resource.id)}>
{resource.name} {resource.name} ({entry.amount.toLocaleString()}{" "}
{cap !== undefined && ( {pluralize(entry.amount, resource.unitOfMeasure)})
<span className="text-muted-foreground">
{" "}
({cap.toLocaleString()} max)
</span>
)}
</SelectItem> </SelectItem>
); );
})} })}
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<Label>Amount</Label> <Label>Amount</Label>
<Input <Input
type="number" type="number"
min="1" min="1"
max={getDepositMax()} max={getWithdrawMax()}
value={depositAmount} value={withdrawAmount}
onChange={(e) => setDepositAmount(clampAmount(e.target.value, getDepositMax()))} onChange={(e) =>
/> setWithdrawAmount(clampAmount(e.target.value, getWithdrawMax()))
{getDepositLimitInfoFor(depositResourceId) !== undefined && ( }
<span className="text-xs text-muted-foreground"> />
Max: {getDepositLimitInfoFor(depositResourceId)!.max.toLocaleString()}{" "} {getWithdrawMax() !== undefined && (
(limited by {getDepositLimitInfoFor(depositResourceId)!.reason}) <span className="text-xs text-muted-foreground">
</span> Max: {getWithdrawMax()!.toLocaleString()} stored
)} </span>
</div> )}
{renderCapacityPreview("deposit")} </div>
<Button {renderCapacityPreview("withdraw")}
onClick={handleDeposit} <Button
disabled={isPending || !depositResourceId} onClick={handleWithdraw}
className="w-full" disabled={isPending || !withdrawResourceId}
> className="w-full"
{isPending ? ( variant="destructive"
<Loader2Icon className="size-4 animate-spin mr-2" /> >
) : ( {isPending ? (
<ArrowDownToLineIcon className="size-4 mr-2" /> <Loader2Icon className="size-4 animate-spin mr-2" />
)} ) : (
Deposit <ArrowUpFromLineIcon className="size-4 mr-2" />
</Button> )}
</TabsContent> Withdraw
</Button>
</TabsContent>
)}
<TabsContent value="withdraw" className="flex flex-col gap-4 pt-4"> {canTransfer && (
<div className="flex flex-col gap-2"> <TabsContent value="transfer" className="flex flex-col gap-4 pt-4">
<Label>Resource</Label> <div className="flex flex-col gap-2">
<Select <Label>Destination</Label>
value={withdrawResourceId} <Select
onValueChange={(val) => { value={transferToId}
setWithdrawResourceId(val); onValueChange={(val) => {
const stored = findStoredResource(val); setTransferToId(val);
setWithdrawAmount(clampAmount(withdrawAmount, stored?.amount)); setTransferAmount(clampAmount(transferAmount, getTransferMaxFor(val)));
}} }}
> >
<SelectTrigger> <SelectTrigger>
<SelectValue placeholder="Select a resource..." /> <SelectValue placeholder="Select a structure..." />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{storedResources.map((entry) => { {allStructures.map((s) => (
const resource = entry.resource as Resource; <SelectItem key={s.id} value={String(s.id)}>
if (!resource || typeof resource === "number") return null; {s.name}
return (
<SelectItem key={entry.id ?? resource.id} value={String(resource.id)}>
{resource.name} ({entry.amount.toLocaleString()}{" "}
{pluralize(entry.amount, resource.unitOfMeasure)})
</SelectItem> </SelectItem>
); ))}
})} </SelectContent>
</SelectContent> </Select>
</Select> </div>
</div> <div className="flex flex-col gap-2">
<div className="flex flex-col gap-2"> <Label>Resource</Label>
<Label>Amount</Label> <Select
<Input value={transferResourceId}
type="number" onValueChange={(val) => {
min="1" setTransferResourceId(val);
max={getWithdrawMax()} const stored = findStoredResource(val);
value={withdrawAmount} setTransferAmount(clampAmount(transferAmount, stored?.amount));
onChange={(e) => }}
setWithdrawAmount(clampAmount(e.target.value, getWithdrawMax())) >
} <SelectTrigger>
/> <SelectValue placeholder="Select a resource..." />
{getWithdrawMax() !== undefined && ( </SelectTrigger>
<span className="text-xs text-muted-foreground"> <SelectContent>
Max: {getWithdrawMax()!.toLocaleString()} stored {storedResources.map((entry) => {
</span> const resource = entry.resource as Resource;
)} if (!resource || typeof resource === "number") return null;
</div> return (
{renderCapacityPreview("withdraw")} <SelectItem key={entry.id ?? resource.id} value={String(resource.id)}>
<Button {resource.name} ({entry.amount.toLocaleString()}{" "}
onClick={handleWithdraw} {pluralize(entry.amount, resource.unitOfMeasure)})
disabled={isPending || !withdrawResourceId} </SelectItem>
className="w-full" );
variant="destructive" })}
> </SelectContent>
{isPending ? ( </Select>
<Loader2Icon className="size-4 animate-spin mr-2" /> </div>
) : ( <div className="flex flex-col gap-2">
<ArrowUpFromLineIcon className="size-4 mr-2" /> <Label>Amount</Label>
)} <Input
Withdraw type="number"
</Button> min="1"
</TabsContent> max={getTransferMax()}
value={transferAmount}
<TabsContent value="transfer" className="flex flex-col gap-4 pt-4"> onChange={(e) =>
<div className="flex flex-col gap-2"> setTransferAmount(clampAmount(e.target.value, getTransferMax()))
<Label>Destination</Label> }
<Select />
value={transferToId} {getTransferMax() !== undefined && (
onValueChange={(val) => { <span className="text-xs text-muted-foreground">
setTransferToId(val); Max: {getTransferMax()!.toLocaleString()} stored
setTransferAmount(clampAmount(transferAmount, getTransferMaxFor(val))); </span>
}} )}
> {transferToId &&
<SelectTrigger> transferResourceId &&
<SelectValue placeholder="Select a structure..." /> (() => {
</SelectTrigger> const dest = allStructures.find((s) => s.id === Number(transferToId));
<SelectContent> const destType = dest?.type;
{allStructures.map((s) => ( if (!dest || !destType) return null;
<SelectItem key={s.id} value={String(s.id)}> const stored = findStoredResource(transferResourceId);
{s.name} if (!stored) return null;
</SelectItem> const resId = stored.resource.id;
))} if (isResourceProhibited(destType, resId)) {
</SelectContent> return (
</Select> <span className="text-xs text-red-400">
</div> Destination prohibits this resource.
<div className="flex flex-col gap-2"> </span>
<Label>Resource</Label> );
<Select }
value={transferResourceId} if (!isResourceWhitelisted(destType, resId)) {
onValueChange={(val) => { return (
setTransferResourceId(val); <span className="text-xs text-amber-400">
const stored = findStoredResource(val); Destination only accepts items on its allowed storage list. This
setTransferAmount(clampAmount(transferAmount, stored?.amount)); resource is not permitted.
}} </span>
> );
<SelectTrigger> }
<SelectValue placeholder="Select a resource..." /> const cap = getResourceStorageCap(destType, resId);
</SelectTrigger> const destStored = getStoredAmountOf(dest, resId);
<SelectContent>
{storedResources.map((entry) => {
const resource = entry.resource as Resource;
if (!resource || typeof resource === "number") return null;
return ( return (
<SelectItem key={entry.id ?? resource.id} value={String(resource.id)}> <span className="text-xs text-muted-foreground">
{resource.name} ({entry.amount.toLocaleString()}{" "} {cap != null
{pluralize(entry.amount, resource.unitOfMeasure)}) ? `Destination allowance: ${Math.max(0, cap - destStored).toLocaleString()} / ${cap.toLocaleString()}`
</SelectItem> : "Destination has no per-item storage limit"}
);
})}
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-2">
<Label>Amount</Label>
<Input
type="number"
min="1"
max={getTransferMax()}
value={transferAmount}
onChange={(e) =>
setTransferAmount(clampAmount(e.target.value, getTransferMax()))
}
/>
{getTransferMax() !== undefined && (
<span className="text-xs text-muted-foreground">
Max: {getTransferMax()!.toLocaleString()} stored
</span>
)}
{transferToId &&
transferResourceId &&
(() => {
const dest = allStructures.find((s) => s.id === Number(transferToId));
const destType = dest?.type;
if (!dest || !destType) return null;
const stored = findStoredResource(transferResourceId);
if (!stored) return null;
const resId = stored.resource.id;
if (isResourceProhibited(destType, resId)) {
return (
<span className="text-xs text-red-400">
Destination prohibits this resource.
</span> </span>
); );
} })()}
if (!isResourceWhitelisted(destType, resId)) { </div>
return ( {renderCapacityPreview("transfer")}
<span className="text-xs text-amber-400"> <Button
Destination only accepts items on its allowed storage list. This onClick={handleTransfer}
resource is not permitted. disabled={
</span> isPending || !transferToId || !transferResourceId || getTransferMax() === 0
); }
} className="w-full"
const cap = getResourceStorageCap(destType, resId); >
const destStored = getStoredAmountOf(dest, resId); {isPending ? (
return ( <Loader2Icon className="size-4 animate-spin mr-2" />
<span className="text-xs text-muted-foreground"> ) : (
{cap != null <ArrowRightLeftIcon className="size-4 mr-2" />
? `Destination allowance: ${Math.max(0, cap - destStored).toLocaleString()} / ${cap.toLocaleString()}` )}
: "Destination has no per-item storage limit"} Transfer
</span> </Button>
); </TabsContent>
})()} )}
</div>
{renderCapacityPreview("transfer")}
<Button
onClick={handleTransfer}
disabled={
isPending || !transferToId || !transferResourceId || getTransferMax() === 0
}
className="w-full"
>
{isPending ? (
<Loader2Icon className="size-4 animate-spin mr-2" />
) : (
<ArrowRightLeftIcon className="size-4 mr-2" />
)}
Transfer
</Button>
</TabsContent>
</Tabs> </Tabs>
</> </>
)} )}

View file

@ -45,9 +45,17 @@ function getResourceIcon(type: Resource["type"]) {
interface StructureStorageViewProps { interface StructureStorageViewProps {
structure: GameStructure & { type: Structure }; structure: GameStructure & { type: Structure };
/** Rearranging/rotating/splitting conserves quantities — logistics personnel or superuser. */
canArrange?: boolean;
/** Removing grid items / withdrawing destroys stored stock — superuser only. */
canMutate?: boolean;
} }
export function StructureStorageView({ structure }: StructureStorageViewProps) { export function StructureStorageView({
structure,
canArrange = false,
canMutate = false,
}: StructureStorageViewProps) {
const router = useRouter(); const router = useRouter();
const [viewMode, setViewMode] = useState<"grid" | "list">("grid"); const [viewMode, setViewMode] = useState<"grid" | "list">("grid");
const [selectedItem, setSelectedItem] = useState<GridItem | null>(null); const [selectedItem, setSelectedItem] = useState<GridItem | null>(null);
@ -80,64 +88,72 @@ export function StructureStorageView({ structure }: StructureStorageViewProps) {
const handleMoveItem = useCallback( const handleMoveItem = useCallback(
async (entryId: string, gridX: number, gridY: number, rotated?: boolean) => { async (entryId: string, gridX: number, gridY: number, rotated?: boolean) => {
if (!canArrange) return;
const result = await moveGridItem(structure.id, entryId, gridX, gridY, rotated); const result = await moveGridItem(structure.id, entryId, gridX, gridY, rotated);
if (result.success) refresh(); if (result.success) refresh();
}, },
[structure.id, refresh], [structure.id, refresh, canArrange],
); );
const handleMergeStacks = useCallback( const handleMergeStacks = useCallback(
async (sourceId: string, targetId: string) => { async (sourceId: string, targetId: string) => {
if (!canArrange) return;
const result = await mergeGridStacks(structure.id, sourceId, targetId); const result = await mergeGridStacks(structure.id, sourceId, targetId);
if (result.success) refresh(); if (result.success) refresh();
}, },
[structure.id, refresh], [structure.id, refresh, canArrange],
); );
const handleRotateItem = useCallback( const handleRotateItem = useCallback(
async (entryId: string) => { async (entryId: string) => {
if (!canArrange) return;
const result = await rotateGridItem(structure.id, entryId); const result = await rotateGridItem(structure.id, entryId);
if (result.success) refresh(); if (result.success) refresh();
}, },
[structure.id, refresh], [structure.id, refresh, canArrange],
); );
const handleRemoveItem = useCallback( const handleRemoveItem = useCallback(
async (entryId: string) => { async (entryId: string) => {
if (!canMutate) return;
const result = await removeGridItem(structure.id, entryId); const result = await removeGridItem(structure.id, entryId);
if (result.success) { if (result.success) {
setSelectedItem(null); setSelectedItem(null);
refresh(); refresh();
} }
}, },
[structure.id, refresh], [structure.id, refresh, canMutate],
); );
const handleSplitStack = useCallback( const handleSplitStack = useCallback(
async (entryId: string, amount: number) => { async (entryId: string, amount: number) => {
if (!canArrange) return;
const result = await splitGridStack(structure.id, entryId, amount); const result = await splitGridStack(structure.id, entryId, amount);
if (result.success) refresh(); if (result.success) refresh();
}, },
[structure.id, refresh], [structure.id, refresh, canArrange],
); );
const handleAutoArrange = useCallback(async () => { const handleAutoArrange = useCallback(async () => {
if (!canArrange) return;
const result = await autoArrangeGrid(structure.id); const result = await autoArrangeGrid(structure.id);
if (result.success) refresh(); if (result.success) refresh();
}, [structure.id, refresh]); }, [structure.id, refresh, canArrange]);
const handleWithdrawFromVoid = useCallback( const handleWithdrawFromVoid = useCallback(
async (resourceId: number, amount: number) => { async (resourceId: number, amount: number) => {
if (!canMutate) return;
const result = await removeResource(structure.id, resourceId, amount); const result = await removeResource(structure.id, resourceId, amount);
if (result.success) refresh(); if (result.success) refresh();
}, },
[structure.id, refresh], [structure.id, refresh, canMutate],
); );
const handleRetrieveFromVoid = useCallback(async () => { const handleRetrieveFromVoid = useCallback(async () => {
if (!canArrange) return;
const result = await retrieveFromVoid(structure.id); const result = await retrieveFromVoid(structure.id);
if (result.success) refresh(); if (result.success) refresh();
}, [structure.id, refresh]); }, [structure.id, refresh, canArrange]);
if (!hasGrid) { if (!hasGrid) {
return ( return (
@ -179,6 +195,8 @@ export function StructureStorageView({ structure }: StructureStorageViewProps) {
onRotate={handleRotateItem} onRotate={handleRotateItem}
onRemove={handleRemoveItem} onRemove={handleRemoveItem}
onSplit={handleSplitStack} onSplit={handleSplitStack}
canArrange={canArrange}
canMutate={canMutate}
/> />
{viewMode === "grid" ? ( {viewMode === "grid" ? (
@ -213,13 +231,15 @@ export function StructureStorageView({ structure }: StructureStorageViewProps) {
{voidStorageItems.length} item{voidStorageItems.length !== 1 ? "s" : ""}: these no {voidStorageItems.length} item{voidStorageItems.length !== 1 ? "s" : ""}: these no
longer fit in the grid. longer fit in the grid.
</span> </span>
<button {canArrange && (
type="button" <button
onClick={handleRetrieveFromVoid} type="button"
className="text-xs bg-amber-500/10 text-amber-400 hover:bg-amber-500/20 px-2 py-0.5 rounded transition-colors" onClick={handleRetrieveFromVoid}
> className="text-xs bg-amber-500/10 text-amber-400 hover:bg-amber-500/20 px-2 py-0.5 rounded transition-colors"
Retrieve All to Grid >
</button> Retrieve All to Grid
</button>
)}
</ItemDescription> </ItemDescription>
</ItemHeader> </ItemHeader>
<ItemContent> <ItemContent>
@ -262,14 +282,16 @@ export function StructureStorageView({ structure }: StructureStorageViewProps) {
{mass > 0 && `${mass.toLocaleString()} kg`} {mass > 0 && `${mass.toLocaleString()} kg`}
</div> </div>
</div> </div>
<button {canMutate && (
type="button" <button
onClick={() => handleWithdrawFromVoid(entry.resourceId, entry.amount)} type="button"
className="text-xs text-muted-foreground hover:text-foreground px-2 py-1 rounded transition-colors shrink-0" onClick={() => handleWithdrawFromVoid(entry.resourceId, entry.amount)}
title="Withdraw from structure" className="text-xs text-muted-foreground hover:text-foreground px-2 py-1 rounded transition-colors shrink-0"
> title="Withdraw from structure"
Withdraw >
</button> Withdraw
</button>
)}
</div> </div>
{index < voidStorageItems.length - 1 && <ItemSeparator />} {index < voidStorageItems.length - 1 && <ItemSeparator />}
</div> </div>

View file

@ -0,0 +1,67 @@
import * as React from "react";
import { cn } from "@/lib/utils";
function Table({ className, ...props }: React.ComponentProps<"table">) {
return (
<div className="relative w-full overflow-auto">
<table className={cn("w-full caption-bottom text-sm", className)} {...props} />
</div>
);
}
function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
return <thead className={cn("[&_tr]:border-b", className)} {...props} />;
}
function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
return <tbody className={cn("[&_tr:last-child]:border-0", className)} {...props} />;
}
function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
return (
<tfoot
className={cn("bg-muted/50 border-t font-medium [&>tr]:last:border-b-0", className)}
{...props}
/>
);
}
function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
return (
<tr
className={cn(
"hover:bg-muted/50 data-[state=selected]:bg-muted border-b transition-colors",
className,
)}
{...props}
/>
);
}
function TableHead({ className, ...props }: React.ComponentProps<"th">) {
return (
<th
className={cn(
"text-muted-foreground h-10 px-2 text-left align-middle font-medium [&:has([role=checkbox])]:pr-0",
className,
)}
{...props}
/>
);
}
function TableCell({ className, ...props }: React.ComponentProps<"td">) {
return (
<td
className={cn("p-2 align-middle [&:has([role=checkbox])]:pr-0", className)}
{...props}
/>
);
}
function TableCaption({ className, ...props }: React.ComponentProps<"caption">) {
return <caption className={cn("text-muted-foreground mt-4 text-sm", className)} {...props} />;
}
export { Table, TableHeader, TableBody, TableFooter, TableHead, TableRow, TableCell, TableCaption };

View file

@ -77,7 +77,7 @@ export function txAmountPrefix(flow: TxFlow): string {
*/ */
export function formatTxTimestamp(ts: string): string { export function formatTxTimestamp(ts: string): string {
const date = new Date(ts); const date = new Date(ts);
if (Number.isNaN(date.getTime())) return "—"; if (Number.isNaN(date.getTime())) return "-";
const now = new Date(); const now = new Date();
const isToday = date.toDateString() === now.toDateString(); const isToday = date.toDateString() === now.toDateString();
if (isToday) { if (isToday) {

View file

@ -0,0 +1,138 @@
import type { Payload } from "payload";
import type { Mission } from "@/payload-types";
import { notifyUser } from "@/lib/notifications";
/**
* Ready-reminder system for player-scheduled side ops.
*
* Starting READY_REMINDER_LEAD_DAYS days before an op's scheduled start, the
* creator gets a once-per-day reminder (web notification + Discord DM) to
* finish the op and mark it "Ready" — the status that triggers the roll-call
* embed, locks in details, and delivers the mission hooks. Reminders stop the
* moment the op is Ready (or cancelled/completed/active), and a marker field
* records the last server-local day each channel was sent so the cadence is
* exactly one reminder per day per channel.
*
* Scope: only ops with a `createdBy` (player-scheduled via the Friday Ops
* calendar — always `operationType: "side"`). Staff/admin-created missions are
* never reminded.
*
* Shared by the mission-tick bin (web notifications) and the Discord bot's
* readyReminders service (DMs) — keep this module free of discord.js imports.
*/
/** How many days before the op the daily reminders begin. */
export const READY_REMINDER_LEAD_DAYS = 5;
/** Notification type used for the web-app reminder (labels + bridge exclusion). */
export const READY_REMINDER_NOTIFICATION_TYPE = "mission:ready-reminder";
/**
* Statuses that still need the creator's attention. "Ready" is the committed
* state the reminders push toward; Active/Completed/Cancelled are terminal or
* already underway.
*/
export const READY_REMINDER_ELIGIBLE_STATUSES = ["Concept", "Planning", "Scheduled"] as const;
export const missionTitle = (mission: Mission): string =>
mission.codeName ? `${mission.name} (${mission.codeName})` : mission.name;
/** Server-local YYYY-MM-DD key used for the once-per-day cadence. */
export const dayKey = (date: Date): string =>
`${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(
date.getDate(),
).padStart(2, "0")}`;
/**
* A channel is due when it has never sent, or last sent on a previous
* server-local day. Day-based (not 24h-based) so retries within the same day
* stay deduped, and a status flapping Ready → Scheduled resumes reminders the
* next day instead of being suppressed forever.
*/
export const isReminderDue = (marker: string | null | undefined, now: Date): boolean => {
if (!marker) return true;
const markerDate = new Date(marker);
if (Number.isNaN(markerDate.getTime())) return true;
return dayKey(markerDate) !== dayKey(now);
};
export const daysUntil = (startIso: string, now: Date): number =>
Math.max(0, Math.ceil((new Date(startIso).getTime() - now.getTime()) / 86_400_000));
/**
* Ops due for reminders: player-created (createdBy set), still not Ready /
* terminal, and starting within the lead window (future start, at most
* READY_REMINDER_LEAD_DAYS days out). Marker filtering is done per-channel by
* the caller via isReminderDue — the "not sent today" condition can't be
* expressed in a single where clause.
*/
export const getReadyReminderMissions = async (payload: Payload, now = new Date()): Promise<Mission[]> => {
const nowIso = now.toISOString();
const windowEndIso = new Date(
now.getTime() + READY_REMINDER_LEAD_DAYS * 86_400_000,
).toISOString();
const res = await payload.find({
collection: "missions",
overrideAccess: true,
depth: 0,
sort: "classification.startDateTime",
limit: 50,
where: {
and: [
{ createdBy: { exists: true } },
{ "ownershipAndStatus.status": { in: [...READY_REMINDER_ELIGIBLE_STATUSES] } },
{ "classification.startDateTime": { greater_than: nowIso } },
{ "classification.startDateTime": { less_than_equal: windowEndIso } },
],
},
});
return res.docs as unknown as Mission[];
};
export interface ReadyReminderSummary {
missions: number;
notified: number;
}
/**
* Web sweep: one in-app notification per due mission per server-local day.
* Called from the mission-tick bin. Respects each user's notification mutes
* (notifyUser handles suppression) and stamps the per-day marker.
*/
export const sendWebReadyReminders = async (
payload: Payload,
now = new Date(),
): Promise<ReadyReminderSummary> => {
const missions = await getReadyReminderMissions(payload, now);
let notified = 0;
for (const mission of missions) {
if (!isReminderDue(mission.readyReminderWebSentAt, now)) continue;
if (mission.createdBy == null) continue;
const creatorId =
typeof mission.createdBy === "object" ? mission.createdBy.id : mission.createdBy;
if (!creatorId) continue;
const days = daysUntil(mission.classification.startDateTime, now);
const when =
days === 0
? "today"
: days === 1
? "tomorrow"
: `in ${days} days`;
await notifyUser(payload, {
userId: creatorId,
type: READY_REMINDER_NOTIFICATION_TYPE,
title: `Ready check: ${missionTitle(mission)}`,
message: `Your op starts ${when}. Finish up the details and set the mission status to Ready so the roll-call can be posted and the mission hooks delivered.`,
link: `/intelligence/missions/${mission.id}`,
});
await payload.update({
collection: "missions",
id: mission.id,
data: { readyReminderWebSentAt: now.toISOString() },
overrideAccess: true,
});
notified += 1;
}
return { missions: missions.length, notified };
};

View file

@ -91,6 +91,8 @@ export function notificationLabel(type?: string | null): string {
return "New reply"; return "New reply";
case "ticket:status": case "ticket:status":
return "Ticket updated"; return "Ticket updated";
case "mission:ready-reminder":
return "Ready reminder";
case "assignment-transfer:request": case "assignment-transfer:request":
return "Transfer request"; return "Transfer request";
case "assignment-transfer:approved": case "assignment-transfer:approved":

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,13 @@
import { MigrateUpArgs, MigrateDownArgs, sql } from '@payloadcms/db-postgres'
export async function up({ db, payload, req }: MigrateUpArgs): Promise<void> {
await db.execute(sql`
ALTER TABLE "missions" ADD COLUMN "ready_reminder_web_sent_at" timestamp(3) with time zone;
ALTER TABLE "missions" ADD COLUMN "ready_reminder_dm_sent_at" timestamp(3) with time zone;`)
}
export async function down({ db, payload, req }: MigrateDownArgs): Promise<void> {
await db.execute(sql`
ALTER TABLE "missions" DROP COLUMN "ready_reminder_web_sent_at";
ALTER TABLE "missions" DROP COLUMN "ready_reminder_dm_sent_at";`)
}

View file

@ -1,261 +1,267 @@
import * as migration_20260820_004157 from "./20260820_004157"; import * as migration_20260820_004157 from './20260820_004157';
import * as migration_20260825_220000 from "./20260825_220000"; import * as migration_20260825_220000 from './20260825_220000';
import * as migration_20260826_050000_repair_shims_auxiliary_tables from "./20260826_050000_repair_shims_auxiliary_tables"; import * as migration_20260826_050000_repair_shims_auxiliary_tables from './20260826_050000_repair_shims_auxiliary_tables';
import * as migration_20260826_060000_add_game_servers_event_target from "./20260826_060000_add_game_servers_event_target"; import * as migration_20260826_060000_add_game_servers_event_target from './20260826_060000_add_game_servers_event_target';
import * as migration_20260827_005757 from "./20260827_005757"; import * as migration_20260827_005757 from './20260827_005757';
import * as migration_20260827_022334_add_eod_minesweeper_best_flags from "./20260827_022334_add_eod_minesweeper_best_flags"; import * as migration_20260827_022334_add_eod_minesweeper_best_flags from './20260827_022334_add_eod_minesweeper_best_flags';
import * as migration_20260827_213857_add_evaluations_collection from "./20260827_213857_add_evaluations_collection"; import * as migration_20260827_213857_add_evaluations_collection from './20260827_213857_add_evaluations_collection';
import * as migration_20260827_233224_add_mission_completed_at from "./20260827_233224_add_mission_completed_at"; import * as migration_20260827_233224_add_mission_completed_at from './20260827_233224_add_mission_completed_at';
import * as migration_20260828_011500_add_background_texture_preference from "./20260828_011500_add_background_texture_preference"; import * as migration_20260828_011500_add_background_texture_preference from './20260828_011500_add_background_texture_preference';
import * as migration_20260828_041615_add_campaign_mode from "./20260828_041615_add_campaign_mode"; import * as migration_20260828_041615_add_campaign_mode from './20260828_041615_add_campaign_mode';
import * as migration_20260828_044506 from "./20260828_044506"; import * as migration_20260828_044506 from './20260828_044506';
import * as migration_20260831_142503 from "./20260831_142503"; import * as migration_20260831_142503 from './20260831_142503';
import * as migration_20260831_180233_assignment_transfers from "./20260831_180233_assignment_transfers"; import * as migration_20260831_180233_assignment_transfers from './20260831_180233_assignment_transfers';
import * as migration_20260901_014006_add_assignment_transfer_event_target from "./20260901_014006_add_assignment_transfer_event_target"; import * as migration_20260901_014006_add_assignment_transfer_event_target from './20260901_014006_add_assignment_transfer_event_target';
import * as migration_20260901_213108_add_ticket_votes_and_edit_history from "./20260901_213108_add_ticket_votes_and_edit_history"; import * as migration_20260901_213108_add_ticket_votes_and_edit_history from './20260901_213108_add_ticket_votes_and_edit_history';
import * as migration_20260901_222200_add_admin_page_permissions from "./20260901_222200_add_admin_page_permissions"; import * as migration_20260901_222200_add_admin_page_permissions from './20260901_222200_add_admin_page_permissions';
import * as migration_20260902_195336_add_medal_backgrounds from "./20260902_195336_add_medal_backgrounds"; import * as migration_20260902_195336_add_medal_backgrounds from './20260902_195336_add_medal_backgrounds';
import * as migration_20260903_035544 from "./20260903_035544"; import * as migration_20260903_035544 from './20260903_035544';
import * as migration_20260903_044047 from "./20260903_044047"; import * as migration_20260903_044047 from './20260903_044047';
import * as migration_20260903_053419 from "./20260903_053419"; import * as migration_20260903_053419 from './20260903_053419';
import * as migration_20260903_060000_add_radio_mode_stats from "./20260903_060000_add_radio_mode_stats"; import * as migration_20260903_060000_add_radio_mode_stats from './20260903_060000_add_radio_mode_stats';
import * as migration_20260903_061000_add_radio_incorrect_counts from "./20260903_061000_add_radio_incorrect_counts"; import * as migration_20260903_061000_add_radio_incorrect_counts from './20260903_061000_add_radio_incorrect_counts';
import * as migration_20260903_062000_add_aim_trainer_stats from "./20260903_062000_add_aim_trainer_stats"; import * as migration_20260903_062000_add_aim_trainer_stats from './20260903_062000_add_aim_trainer_stats';
import * as migration_20260904_230838_add_wiki_collections from "./20260904_230838_add_wiki_collections"; import * as migration_20260904_230838_add_wiki_collections from './20260904_230838_add_wiki_collections';
import * as migration_20260905_183940_base_management from "./20260905_183940_base_management"; import * as migration_20260905_183940_base_management from './20260905_183940_base_management';
import * as migration_20260905_195456_labor_headcount from "./20260905_195456_labor_headcount"; import * as migration_20260905_195456_labor_headcount from './20260905_195456_labor_headcount';
import * as migration_20260905_210310_labor_classifications from "./20260905_210310_labor_classifications"; import * as migration_20260905_210310_labor_classifications from './20260905_210310_labor_classifications';
import * as migration_20260905_213824 from "./20260905_213824"; import * as migration_20260905_213824 from './20260905_213824';
import * as migration_20260905_215658 from "./20260905_215658"; import * as migration_20260905_215658 from './20260905_215658';
import * as migration_20260906_045502 from "./20260906_045502"; import * as migration_20260906_045502 from './20260906_045502';
import * as migration_20260906_050900_add_evaluation_reminders_field from "./20260906_050900_add_evaluation_reminders_field"; import * as migration_20260906_050900_add_evaluation_reminders_field from './20260906_050900_add_evaluation_reminders_field';
import * as migration_20260906_061451_add_wiki_template_css from "./20260906_061451_add_wiki_template_css"; import * as migration_20260906_061451_add_wiki_template_css from './20260906_061451_add_wiki_template_css';
import * as migration_20260906_063753_add_personal_excerpt_richtext from "./20260906_063753_add_personal_excerpt_richtext"; import * as migration_20260906_063753_add_personal_excerpt_richtext from './20260906_063753_add_personal_excerpt_richtext';
import * as migration_20260906_065405_add_wiki_template_html from "./20260906_065405_add_wiki_template_html"; import * as migration_20260906_065405_add_wiki_template_html from './20260906_065405_add_wiki_template_html';
import * as migration_20260906_100908_add_market_economy_state from "./20260906_100908_add_market_economy_state"; import * as migration_20260906_100908_add_market_economy_state from './20260906_100908_add_market_economy_state';
import * as migration_20260906_165837_add_market_state_event_target from "./20260906_165837_add_market_state_event_target"; import * as migration_20260906_165837_add_market_state_event_target from './20260906_165837_add_market_state_event_target';
import * as migration_20260906_202925_add_voice_submissions from "./20260906_202925_add_voice_submissions"; import * as migration_20260906_202925_add_voice_submissions from './20260906_202925_add_voice_submissions';
import * as migration_20260907_012326_add_missions_created_by from "./20260907_012326_add_missions_created_by"; import * as migration_20260907_012326_add_missions_created_by from './20260907_012326_add_missions_created_by';
import * as migration_20260907_013724_make_missions_campaign_optional from "./20260907_013724_make_missions_campaign_optional"; import * as migration_20260907_013724_make_missions_campaign_optional from './20260907_013724_make_missions_campaign_optional';
import * as migration_20260907_041051_add_voice_script_spoiler from "./20260907_041051_add_voice_script_spoiler"; import * as migration_20260907_041051_add_voice_script_spoiler from './20260907_041051_add_voice_script_spoiler';
import * as migration_20260907_043339_add_voice_script_description from "./20260907_043339_add_voice_script_description"; import * as migration_20260907_043339_add_voice_script_description from './20260907_043339_add_voice_script_description';
import * as migration_20260907_044934_add_missions_discord_modlist_hash from "./20260907_044934_add_missions_discord_modlist_hash"; import * as migration_20260907_044934_add_missions_discord_modlist_hash from './20260907_044934_add_missions_discord_modlist_hash';
import * as migration_20260907_083010_add_voice_submission_description from "./20260907_083010_add_voice_submission_description"; import * as migration_20260907_083010_add_voice_submission_description from './20260907_083010_add_voice_submission_description';
import * as migration_20260907_090245_add_mission_ready_reminder_fields from './20260907_090245_add_mission_ready_reminder_fields';
export const migrations = [ export const migrations = [
{ {
up: migration_20260820_004157.up, up: migration_20260820_004157.up,
down: migration_20260820_004157.down, down: migration_20260820_004157.down,
name: "20260820_004157", name: '20260820_004157',
}, },
{ {
up: migration_20260825_220000.up, up: migration_20260825_220000.up,
down: migration_20260825_220000.down, down: migration_20260825_220000.down,
name: "20260825_220000", name: '20260825_220000',
}, },
{ {
up: migration_20260826_050000_repair_shims_auxiliary_tables.up, up: migration_20260826_050000_repair_shims_auxiliary_tables.up,
down: migration_20260826_050000_repair_shims_auxiliary_tables.down, down: migration_20260826_050000_repair_shims_auxiliary_tables.down,
name: "20260826_050000_repair_shims_auxiliary_tables", name: '20260826_050000_repair_shims_auxiliary_tables',
}, },
{ {
up: migration_20260826_060000_add_game_servers_event_target.up, up: migration_20260826_060000_add_game_servers_event_target.up,
down: migration_20260826_060000_add_game_servers_event_target.down, down: migration_20260826_060000_add_game_servers_event_target.down,
name: "20260826_060000_add_game_servers_event_target", name: '20260826_060000_add_game_servers_event_target',
}, },
{ {
up: migration_20260827_005757.up, up: migration_20260827_005757.up,
down: migration_20260827_005757.down, down: migration_20260827_005757.down,
name: "20260827_005757", name: '20260827_005757',
}, },
{ {
up: migration_20260827_022334_add_eod_minesweeper_best_flags.up, up: migration_20260827_022334_add_eod_minesweeper_best_flags.up,
down: migration_20260827_022334_add_eod_minesweeper_best_flags.down, down: migration_20260827_022334_add_eod_minesweeper_best_flags.down,
name: "20260827_022334_add_eod_minesweeper_best_flags", name: '20260827_022334_add_eod_minesweeper_best_flags',
}, },
{ {
up: migration_20260827_213857_add_evaluations_collection.up, up: migration_20260827_213857_add_evaluations_collection.up,
down: migration_20260827_213857_add_evaluations_collection.down, down: migration_20260827_213857_add_evaluations_collection.down,
name: "20260827_213857_add_evaluations_collection", name: '20260827_213857_add_evaluations_collection',
}, },
{ {
up: migration_20260827_233224_add_mission_completed_at.up, up: migration_20260827_233224_add_mission_completed_at.up,
down: migration_20260827_233224_add_mission_completed_at.down, down: migration_20260827_233224_add_mission_completed_at.down,
name: "20260827_233224_add_mission_completed_at", name: '20260827_233224_add_mission_completed_at',
}, },
{ {
up: migration_20260828_011500_add_background_texture_preference.up, up: migration_20260828_011500_add_background_texture_preference.up,
down: migration_20260828_011500_add_background_texture_preference.down, down: migration_20260828_011500_add_background_texture_preference.down,
name: "20260828_011500_add_background_texture_preference", name: '20260828_011500_add_background_texture_preference',
}, },
{ {
up: migration_20260828_041615_add_campaign_mode.up, up: migration_20260828_041615_add_campaign_mode.up,
down: migration_20260828_041615_add_campaign_mode.down, down: migration_20260828_041615_add_campaign_mode.down,
name: "20260828_041615_add_campaign_mode", name: '20260828_041615_add_campaign_mode',
}, },
{ {
up: migration_20260828_044506.up, up: migration_20260828_044506.up,
down: migration_20260828_044506.down, down: migration_20260828_044506.down,
name: "20260828_044506", name: '20260828_044506',
}, },
{ {
up: migration_20260831_142503.up, up: migration_20260831_142503.up,
down: migration_20260831_142503.down, down: migration_20260831_142503.down,
name: "20260831_142503", name: '20260831_142503',
}, },
{ {
up: migration_20260831_180233_assignment_transfers.up, up: migration_20260831_180233_assignment_transfers.up,
down: migration_20260831_180233_assignment_transfers.down, down: migration_20260831_180233_assignment_transfers.down,
name: "20260831_180233_assignment_transfers", name: '20260831_180233_assignment_transfers',
}, },
{ {
up: migration_20260901_014006_add_assignment_transfer_event_target.up, up: migration_20260901_014006_add_assignment_transfer_event_target.up,
down: migration_20260901_014006_add_assignment_transfer_event_target.down, down: migration_20260901_014006_add_assignment_transfer_event_target.down,
name: "20260901_014006_add_assignment_transfer_event_target", name: '20260901_014006_add_assignment_transfer_event_target',
}, },
{ {
up: migration_20260901_213108_add_ticket_votes_and_edit_history.up, up: migration_20260901_213108_add_ticket_votes_and_edit_history.up,
down: migration_20260901_213108_add_ticket_votes_and_edit_history.down, down: migration_20260901_213108_add_ticket_votes_and_edit_history.down,
name: "20260901_213108_add_ticket_votes_and_edit_history", name: '20260901_213108_add_ticket_votes_and_edit_history',
}, },
{ {
up: migration_20260901_222200_add_admin_page_permissions.up, up: migration_20260901_222200_add_admin_page_permissions.up,
down: migration_20260901_222200_add_admin_page_permissions.down, down: migration_20260901_222200_add_admin_page_permissions.down,
name: "20260901_222200_add_admin_page_permissions", name: '20260901_222200_add_admin_page_permissions',
}, },
{ {
up: migration_20260902_195336_add_medal_backgrounds.up, up: migration_20260902_195336_add_medal_backgrounds.up,
down: migration_20260902_195336_add_medal_backgrounds.down, down: migration_20260902_195336_add_medal_backgrounds.down,
name: "20260902_195336_add_medal_backgrounds", name: '20260902_195336_add_medal_backgrounds',
}, },
{ {
up: migration_20260903_035544.up, up: migration_20260903_035544.up,
down: migration_20260903_035544.down, down: migration_20260903_035544.down,
name: "20260903_035544", name: '20260903_035544',
}, },
{ {
up: migration_20260903_044047.up, up: migration_20260903_044047.up,
down: migration_20260903_044047.down, down: migration_20260903_044047.down,
name: "20260903_044047", name: '20260903_044047',
}, },
{ {
up: migration_20260903_053419.up, up: migration_20260903_053419.up,
down: migration_20260903_053419.down, down: migration_20260903_053419.down,
name: "20260903_053419", name: '20260903_053419',
}, },
{ {
up: migration_20260903_060000_add_radio_mode_stats.up, up: migration_20260903_060000_add_radio_mode_stats.up,
down: migration_20260903_060000_add_radio_mode_stats.down, down: migration_20260903_060000_add_radio_mode_stats.down,
name: "20260903_060000_add_radio_mode_stats", name: '20260903_060000_add_radio_mode_stats',
}, },
{ {
up: migration_20260903_061000_add_radio_incorrect_counts.up, up: migration_20260903_061000_add_radio_incorrect_counts.up,
down: migration_20260903_061000_add_radio_incorrect_counts.down, down: migration_20260903_061000_add_radio_incorrect_counts.down,
name: "20260903_061000_add_radio_incorrect_counts", name: '20260903_061000_add_radio_incorrect_counts',
}, },
{ {
up: migration_20260903_062000_add_aim_trainer_stats.up, up: migration_20260903_062000_add_aim_trainer_stats.up,
down: migration_20260903_062000_add_aim_trainer_stats.down, down: migration_20260903_062000_add_aim_trainer_stats.down,
name: "20260903_062000_add_aim_trainer_stats", name: '20260903_062000_add_aim_trainer_stats',
}, },
{ {
up: migration_20260904_230838_add_wiki_collections.up, up: migration_20260904_230838_add_wiki_collections.up,
down: migration_20260904_230838_add_wiki_collections.down, down: migration_20260904_230838_add_wiki_collections.down,
name: "20260904_230838_add_wiki_collections", name: '20260904_230838_add_wiki_collections',
}, },
{ {
up: migration_20260905_183940_base_management.up, up: migration_20260905_183940_base_management.up,
down: migration_20260905_183940_base_management.down, down: migration_20260905_183940_base_management.down,
name: "20260905_183940_base_management", name: '20260905_183940_base_management',
}, },
{ {
up: migration_20260905_195456_labor_headcount.up, up: migration_20260905_195456_labor_headcount.up,
down: migration_20260905_195456_labor_headcount.down, down: migration_20260905_195456_labor_headcount.down,
name: "20260905_195456_labor_headcount", name: '20260905_195456_labor_headcount',
}, },
{ {
up: migration_20260905_210310_labor_classifications.up, up: migration_20260905_210310_labor_classifications.up,
down: migration_20260905_210310_labor_classifications.down, down: migration_20260905_210310_labor_classifications.down,
name: "20260905_210310_labor_classifications", name: '20260905_210310_labor_classifications',
}, },
{ {
up: migration_20260905_213824.up, up: migration_20260905_213824.up,
down: migration_20260905_213824.down, down: migration_20260905_213824.down,
name: "20260905_213824", name: '20260905_213824',
}, },
{ {
up: migration_20260905_215658.up, up: migration_20260905_215658.up,
down: migration_20260905_215658.down, down: migration_20260905_215658.down,
name: "20260905_215658", name: '20260905_215658',
}, },
{ {
up: migration_20260906_045502.up, up: migration_20260906_045502.up,
down: migration_20260906_045502.down, down: migration_20260906_045502.down,
name: "20260906_045502", name: '20260906_045502',
}, },
{ {
up: migration_20260906_050900_add_evaluation_reminders_field.up, up: migration_20260906_050900_add_evaluation_reminders_field.up,
down: migration_20260906_050900_add_evaluation_reminders_field.down, down: migration_20260906_050900_add_evaluation_reminders_field.down,
name: "20260906_050900_add_evaluation_reminders_field", name: '20260906_050900_add_evaluation_reminders_field',
}, },
{ {
up: migration_20260906_061451_add_wiki_template_css.up, up: migration_20260906_061451_add_wiki_template_css.up,
down: migration_20260906_061451_add_wiki_template_css.down, down: migration_20260906_061451_add_wiki_template_css.down,
name: "20260906_061451_add_wiki_template_css", name: '20260906_061451_add_wiki_template_css',
}, },
{ {
up: migration_20260906_063753_add_personal_excerpt_richtext.up, up: migration_20260906_063753_add_personal_excerpt_richtext.up,
down: migration_20260906_063753_add_personal_excerpt_richtext.down, down: migration_20260906_063753_add_personal_excerpt_richtext.down,
name: "20260906_063753_add_personal_excerpt_richtext", name: '20260906_063753_add_personal_excerpt_richtext',
}, },
{ {
up: migration_20260906_065405_add_wiki_template_html.up, up: migration_20260906_065405_add_wiki_template_html.up,
down: migration_20260906_065405_add_wiki_template_html.down, down: migration_20260906_065405_add_wiki_template_html.down,
name: "20260906_065405_add_wiki_template_html", name: '20260906_065405_add_wiki_template_html',
}, },
{ {
up: migration_20260906_100908_add_market_economy_state.up, up: migration_20260906_100908_add_market_economy_state.up,
down: migration_20260906_100908_add_market_economy_state.down, down: migration_20260906_100908_add_market_economy_state.down,
name: "20260906_100908_add_market_economy_state", name: '20260906_100908_add_market_economy_state',
}, },
{ {
up: migration_20260906_165837_add_market_state_event_target.up, up: migration_20260906_165837_add_market_state_event_target.up,
down: migration_20260906_165837_add_market_state_event_target.down, down: migration_20260906_165837_add_market_state_event_target.down,
name: "20260906_165837_add_market_state_event_target", name: '20260906_165837_add_market_state_event_target',
}, },
{ {
up: migration_20260906_202925_add_voice_submissions.up, up: migration_20260906_202925_add_voice_submissions.up,
down: migration_20260906_202925_add_voice_submissions.down, down: migration_20260906_202925_add_voice_submissions.down,
name: "20260906_202925_add_voice_submissions", name: '20260906_202925_add_voice_submissions',
}, },
{ {
up: migration_20260907_012326_add_missions_created_by.up, up: migration_20260907_012326_add_missions_created_by.up,
down: migration_20260907_012326_add_missions_created_by.down, down: migration_20260907_012326_add_missions_created_by.down,
name: "20260907_012326_add_missions_created_by", name: '20260907_012326_add_missions_created_by',
}, },
{ {
up: migration_20260907_013724_make_missions_campaign_optional.up, up: migration_20260907_013724_make_missions_campaign_optional.up,
down: migration_20260907_013724_make_missions_campaign_optional.down, down: migration_20260907_013724_make_missions_campaign_optional.down,
name: "20260907_013724_make_missions_campaign_optional", name: '20260907_013724_make_missions_campaign_optional',
}, },
{ {
up: migration_20260907_041051_add_voice_script_spoiler.up, up: migration_20260907_041051_add_voice_script_spoiler.up,
down: migration_20260907_041051_add_voice_script_spoiler.down, down: migration_20260907_041051_add_voice_script_spoiler.down,
name: "20260907_041051_add_voice_script_spoiler", name: '20260907_041051_add_voice_script_spoiler',
}, },
{ {
up: migration_20260907_043339_add_voice_script_description.up, up: migration_20260907_043339_add_voice_script_description.up,
down: migration_20260907_043339_add_voice_script_description.down, down: migration_20260907_043339_add_voice_script_description.down,
name: "20260907_043339_add_voice_script_description", name: '20260907_043339_add_voice_script_description',
}, },
{ {
up: migration_20260907_044934_add_missions_discord_modlist_hash.up, up: migration_20260907_044934_add_missions_discord_modlist_hash.up,
down: migration_20260907_044934_add_missions_discord_modlist_hash.down, down: migration_20260907_044934_add_missions_discord_modlist_hash.down,
name: "20260907_044934_add_missions_discord_modlist_hash", name: '20260907_044934_add_missions_discord_modlist_hash',
}, },
{ {
up: migration_20260907_083010_add_voice_submission_description.up, up: migration_20260907_083010_add_voice_submission_description.up,
down: migration_20260907_083010_add_voice_submission_description.down, down: migration_20260907_083010_add_voice_submission_description.down,
name: "20260907_083010_add_voice_submission_description", name: '20260907_083010_add_voice_submission_description',
},
{
up: migration_20260907_090245_add_mission_ready_reminder_fields.up,
down: migration_20260907_090245_add_mission_ready_reminder_fields.down,
name: '20260907_090245_add_mission_ready_reminder_fields'
}, },
]; ];

View file

@ -2380,6 +2380,14 @@ export interface Mission {
* When evaluation reminder DMs were sent for this mission (managed by the bot, one-shot). * When evaluation reminder DMs were sent for this mission (managed by the bot, one-shot).
*/ */
evaluationRemindersSentAt?: string | null; evaluationRemindersSentAt?: string | null;
/**
* Server-local day the last web 'not Ready' reminder notification was sent to the op creator (managed by the mission tick, once per day).
*/
readyReminderWebSentAt?: string | null;
/**
* Server-local day the last 'not Ready' reminder DM was sent to the op creator (managed by the Discord bot, once per day).
*/
readyReminderDmSentAt?: string | null;
/** /**
* Player who scheduled this mission via the Friday Ops calendar (ownership + scheduling-cap tracking). Set automatically; leave empty for staff-created missions. * Player who scheduled this mission via the Friday Ops calendar (ownership + scheduling-cap tracking). Set automatically; leave empty for staff-created missions.
*/ */
@ -3014,7 +3022,7 @@ export interface MarketNegotiation {
buyer: number | User; buyer: number | User;
status: 'open' | 'accepted' | 'rejected' | 'closed' | 'cancelled' | 'expired'; status: 'open' | 'accepted' | 'rejected' | 'closed' | 'cancelled' | 'expired';
/** /**
* NPC vendor patience (0–100). Rises each bargaining round and sharply for lowball offers; at 100 the negotiation closes and the buyer can only buy at the asking price. * NPC vendor patience (0-100). Rises each bargaining round and sharply for lowball offers; at 100 the negotiation closes and the buyer can only buy at the asking price.
*/ */
patience?: number | null; patience?: number | null;
/** /**
@ -4489,6 +4497,8 @@ export interface MissionsSelect<T extends boolean = true> {
discordAttendanceSentAt?: T; discordAttendanceSentAt?: T;
discordModlistHash?: T; discordModlistHash?: T;
evaluationRemindersSentAt?: T; evaluationRemindersSentAt?: T;
readyReminderWebSentAt?: T;
readyReminderDmSentAt?: T;
createdBy?: T; createdBy?: T;
coverImage?: T; coverImage?: T;
classification?: classification?:
@ -5727,7 +5737,7 @@ export interface Shim {
targeting: { targeting: {
audience: 'all' | 'percentage' | 'users' | 'roles'; audience: 'all' | 'percentage' | 'users' | 'roles';
/** /**
* Percentage of users (0–100) who receive this shim. Assignment is deterministic per user. * Percentage of users (0-100) who receive this shim. Assignment is deterministic per user.
*/ */
percentage?: number | null; percentage?: number | null;
/** /**

View file

@ -1,6 +1,7 @@
import type { SanitizedConfig } from "payload"; import type { SanitizedConfig } from "payload";
import { getPayload } from "payload"; import { getPayload } from "payload";
import { autoCompleteMissions } from "@/lib/intelligence/missionLifecycle"; import { autoCompleteMissions } from "@/lib/intelligence/missionLifecycle";
import { sendWebReadyReminders } from "@/lib/intelligence/readyReminders";
import { createBinLogger, errorMessage } from "./lib/binFileLogger"; import { createBinLogger, errorMessage } from "./lib/binFileLogger";
export const script = async (config: SanitizedConfig) => { export const script = async (config: SanitizedConfig) => {
@ -13,6 +14,13 @@ export const script = async (config: SanitizedConfig) => {
const nowIso = new Date().toISOString(); const nowIso = new Date().toISOString();
const result = await autoCompleteMissions(payload); const result = await autoCompleteMissions(payload);
const reminders = await sendWebReadyReminders(payload);
if (reminders.notified > 0) {
log.info(
`[Mission Tick] Sent ${reminders.notified} ready reminder notification(s) across ${reminders.missions} due op(s).`,
);
}
const notifyUrl = (process.env.APP_URL ?? "http://localhost:3000").replace(/\/$/, ""); const notifyUrl = (process.env.APP_URL ?? "http://localhost:3000").replace(/\/$/, "");
const notifySecret = process.env.GAME_TICK_NOTIFY_SECRET; const notifySecret = process.env.GAME_TICK_NOTIFY_SECRET;
if (notifySecret) { if (notifySecret) {

View file

@ -53,7 +53,6 @@ const USER_PERMISSIONS: Permission[] = [
"shims:read", "shims:read",
"shipments:create", "shipments:create",
"shipments:update", "shipments:update",
"structures:update",
"structures:delete", "structures:delete",
]; ];
@ -87,6 +86,7 @@ const ADMIN_PERMISSIONS: Permission[] = [
"discord:staff", "discord:staff",
"discord:announce", "discord:announce",
"structures:create", "structures:create",
"structures:update",
...ALL_COLLECTION_READS, ...ALL_COLLECTION_READS,
...ALL_ADMIN_PAGE_MANAGE, ...ALL_ADMIN_PAGE_MANAGE,
]), ]),

View file

@ -126,6 +126,7 @@ export const EventTypes = {
MissionAutoComplete: "mission:auto-complete", MissionAutoComplete: "mission:auto-complete",
MissionObjectiveRedactedToggle: "mission:objective-redacted-toggle", MissionObjectiveRedactedToggle: "mission:objective-redacted-toggle",
MissionSchedule: "mission:schedule", MissionSchedule: "mission:schedule",
MissionReady: "mission:ready",
MissionUpdate: "mission:update", MissionUpdate: "mission:update",
MissionCancel: "mission:cancel", MissionCancel: "mission:cancel",
MissionMoveCampaign: "mission:move-campaign", MissionMoveCampaign: "mission:move-campaign",