1
0
Fork 0

Compare commits

..

No commits in common. "dcc308c2ae12a1097647930d952e558480b8e033" and "3af17928e8af8da5b6a63181f769cef8f433c301" have entirely different histories.

53 changed files with 474 additions and 24935 deletions

View file

@ -341,7 +341,6 @@ In development, `postgresAdapter` uses `push: false` — run `bun run payload mi
- **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 `_`).
- **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
@ -360,8 +359,6 @@ 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.
**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.
## Gotchas

View file

@ -1,6 +1,6 @@
{
"name": "polaris-task-force",
"version": "0.2.8",
"version": "0.2.6",
"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",
"type": "module",

View file

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

View file

@ -3,7 +3,7 @@ import { getPayload } from "payload";
import { notFound } from "next/navigation";
import Link from "next/link";
import { headers as nextHeaders } from "next/headers";
import type { Campaign, Map, Media, Mission, User } from "@/payload-types";
import type { Campaign, Map, Media, Mission } from "@/payload-types";
import {
ArrowLeftIcon,
CalendarDaysIcon,
@ -13,7 +13,6 @@ import {
FlagIcon,
MapPinIcon,
ServerIcon,
UserIcon,
} from "lucide-react";
import { Item, ItemContent, ItemHeader, ItemTitle } from "@/components/ui/item";
import { ClientDate } from "@/components/frontend/ClientDate";
@ -95,13 +94,6 @@ export default async function MissionDetailPage({ params }: MissionDetailPagePro
? (missionWithCoverImage.coverImage as Media)
: 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 durationMins = mission.classification.estimatedDuration % 60;
@ -336,17 +328,6 @@ export default async function MissionDetailPage({ params }: MissionDetailPagePro
</span>
</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 && (
<Link
href={`/intelligence/campaigns/${campaign.id}`}
@ -386,7 +367,7 @@ export default async function MissionDetailPage({ params }: MissionDetailPagePro
</div>
<div>
<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>
</ItemContent>

View file

@ -738,70 +738,6 @@ 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(
missionId: number,
campaignId: number,

View file

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

View file

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

View file

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

View file

@ -307,7 +307,7 @@ export default async function ProfilePage({ params }: ProfilePageProps) {
const airKills = scoreboard.airKills ?? 0;
const deaths = scoreboard.deaths ?? 0;
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 now = new Date();
@ -562,7 +562,7 @@ export default async function ProfilePage({ params }: ProfilePageProps) {
Deaths
</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 className="bg-muted/30">
<td className="px-4 py-2.5 flex items-center gap-2 font-semibold">

View file

@ -8,7 +8,6 @@ export interface BotConfig {
attendancePollMs: number;
notificationPollMs: number;
evaluationPollMs: number;
readyReminderPollMs: number;
}
const required = (name: string): string => {
@ -49,5 +48,4 @@ export const botConfig: BotConfig = {
attendancePollMs: pollMs("DISCORD_ATTENDANCE_POLL_MS", 60_000),
notificationPollMs: pollMs("DISCORD_NOTIFICATION_POLL_MS", 20_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 lines = links.map((link) => `• [${link.displayName}](${appUrl}${link.profilePath})`);
// Discord embed field values cap at 1024 chars.
return lines.join("\n").slice(0, 1024) || "-";
return lines.join("\n").slice(0, 1024) || "—";
};
const renderReminderEmbed = (

View file

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

View file

@ -147,7 +147,7 @@ export const renderAttendanceEmbed = (
summary: MissionAttendanceSummary,
): EmbedBuilder => {
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()
.setTitle(missionTitle(mission))
.setDescription(

View file

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

View file

@ -1,130 +0,0 @@
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: {
condition: (_data, siblingData) => siblingData?.audience === "percentage",
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,24 +216,6 @@ export const Missions: CollectionConfig = {
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",
type: "relationship",

View file

@ -83,7 +83,7 @@ export const MarketNegotiations: CollectionConfig = {
max: 100,
admin: {
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;
id: number | null;
} {
if (!account) return { label: "-", id: null };
if (!account) return { label: "—", id: null };
if (typeof account === "object") {
return { label: account.name ?? `#${account.id}`, id: account.id };
}

View file

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

View file

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

View file

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

View file

@ -1,485 +0,0 @@
"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}
>
<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 className="px-2 py-2 font-medium">
<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 Date(iso),
)
: "-";
: "—";
}
function isTicketStatus(value: string): value is TicketStatus {

View file

@ -1,7 +1,7 @@
"use client";
import Link from "next/link";
import type { Campaign, Map, Media, User } from "@/payload-types";
import type { Campaign, Map, Media } from "@/payload-types";
import { cn } from "@/lib/utils";
import {
operationTypeColors,
@ -22,7 +22,6 @@ import {
ClockIcon,
CrosshairIcon,
MapPinIcon,
UserIcon,
UsersIcon,
} from "lucide-react";
@ -53,7 +52,6 @@ export interface MissionCardProps {
readonly missionRoles?: {
readonly maxPlayers: number;
} | null;
readonly createdBy?: number | User | null;
readonly coverImage?: number | Media | null;
};
readonly variant?: "default" | "image";
@ -79,12 +77,6 @@ export function MissionCard({
typeof mission.classification.campaign === "object" && mission.classification.campaign !== null
? mission.classification.campaign
: 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 dateStr = startDate.toLocaleDateString("en-US", {
@ -130,7 +122,7 @@ export function MissionCard({
<ItemMedia variant="icon">
<CrosshairIcon />
</ItemMedia>
<ItemTitle className="flex flex-col items-start text-start">
<ItemTitle className="flex flex-col">
<span className="font-mono text-xs text-muted-foreground">{mission.codeName}</span>
<span>{mission.name}</span>
</ItemTitle>
@ -139,7 +131,7 @@ export function MissionCard({
<span
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} Operation
{operationTypeLabels[mission.operationType] ?? mission.operationType}
</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"}`}
@ -175,12 +167,6 @@ export function MissionCard({
{mission.missionRoles.maxPlayers} slots
</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>}
</div>
</ItemContent>

View file

@ -97,7 +97,7 @@ export function MissionComms({ commsAndDocs }: MissionCommsProps) {
{frequencies!.map((freq, index) => (
<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 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 text-muted-foreground">{freq.freqUsage}</td>
</tr>

View file

@ -112,7 +112,7 @@ export function MissionLogistics({ logisticsAndSupport }: MissionLogisticsProps)
<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 text-muted-foreground">
{asset.assetCount ?? "-"}
{asset.assetCount ?? "—"}
</td>
<td className="px-4 py-2 text-center">
{asset.doesRespawn ? (
@ -122,7 +122,7 @@ export function MissionLogistics({ logisticsAndSupport }: MissionLogisticsProps)
)}
</td>
<td className="px-4 py-2 text-muted-foreground font-mono text-xs">
{asset.assetCallsign ?? "-"}
{asset.assetCallsign ?? "—"}
</td>
</tr>
))}

View file

@ -41,10 +41,10 @@ function ObjectiveText({
className?: string;
}) {
if (!redacted) {
return <span className={className}>{text ?? "-"}</span>;
return <span className={className}>{text ?? "—"}</span>;
}
const display = redactText(text) ?? "-";
const display = redactText(text) ?? "—";
if (!canReveal) {
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>
</TooltipTrigger>
<TooltipContent className="max-w-sm whitespace-pre-wrap break-words">
{text ?? "-"}
{text ?? "—"}
</TooltipContent>
</Tooltip>
</TooltipProvider>

View file

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

View file

@ -3,13 +3,10 @@
import { useState, useTransition } from "react";
import { useRouter } from "next/navigation";
import { toast } from "sonner";
import { CheckCircleIcon, PencilIcon, XCircleIcon } from "lucide-react";
import { PencilIcon, XCircleIcon } from "lucide-react";
import { Item, ItemContent, ItemHeader, ItemTitle } from "@/components/ui/item";
import { Button } from "@/components/ui/button";
import {
cancelFridaySideOp,
markSideOpReady,
} from "@/app/(frontend)/intelligence/missions/actions";
import { cancelFridaySideOp } from "@/app/(frontend)/intelligence/missions/actions";
import {
CreateSideOpDialog,
type SideOpDialogInitial,
@ -37,19 +34,6 @@ export function SideOpControls({
const [isPending, startTransition] = useTransition();
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() {
if (!confirmingCancel) {
@ -80,12 +64,6 @@ export function SideOpControls({
</ItemHeader>
<ItemContent>
<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}>
<PencilIcon className="size-3.5" />
Edit
@ -101,9 +79,8 @@ export function SideOpControls({
</Button>
</div>
<p className="text-xs text-muted-foreground">
Marking the operation Ready locks in the details so the Discord roll-call can be posted
and the mission hooks delivered. Editing covers details, campaign, and narrative.
Cancelling frees your scheduling slot and updates the Discord embed.
Editing covers details, campaign, and narrative. Cancelling frees your scheduling slot
and updates the Discord embed.
</p>
</ItemContent>

View file

@ -119,7 +119,7 @@ export function LockerGridToolbar({
<div>
<dt className="text-muted-foreground/70">FOOTPRINT</dt>
<dd className="font-mono tabular-nums text-foreground/90">
{fp ? `${fp.width}×${fp.height}` : "-"}
{fp ? `${fp.width}×${fp.height}` : "—"}
</dd>
</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">
<span className="flex items-center gap-1">
<CalendarIcon className="size-3" />
{dateFor(sprint.startDate)} - {dateFor(sprint.endDate)}
{dateFor(sprint.startDate)} — {dateFor(sprint.endDate)}
</span>
</ItemFooter>
</Item>

View file

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

View file

@ -288,7 +288,7 @@ export function RadioLeaderboard({
}
>
<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 className="px-2 py-2 font-medium">
<span className="flex items-center gap-2">
@ -318,7 +318,7 @@ export function RadioLeaderboard({
</td>
{mode === "sprint" && (
<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 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>
<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>
<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>}
</div>}

View file

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

View file

@ -24,10 +24,6 @@ interface GridToolbarProps {
onRotate: (entryId: string) => Promise<void>;
onRemove: (entryId: string) => 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({
@ -41,8 +37,6 @@ export function GridToolbar({
onRotate,
onRemove,
onSplit,
canArrange = false,
canMutate = false,
}: GridToolbarProps) {
const occupancy = getGridOccupancy(storedResources, gridWidth, gridHeight);
const res = selectedItem ? (selectedItem.resource as Resource) : null;
@ -56,12 +50,10 @@ export function GridToolbar({
<div className="flex flex-col gap-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
{canArrange && (
<Button variant="outline" size="sm" onClick={onAutoArrange}>
<ShuffleIcon className="size-3.5 mr-1" />
Auto-Arrange
</Button>
)}
<Button variant="outline" size="sm" onClick={onAutoArrange}>
<ShuffleIcon className="size-3.5 mr-1" />
Auto-Arrange
</Button>
</div>
<div className="flex items-center gap-1">
@ -94,12 +86,12 @@ export function GridToolbar({
</div>
</div>
<div className="flex items-center gap-1">
{canArrange && res.rotatable !== false && (
{res.rotatable !== false && (
<Button variant="outline" size="sm" onClick={() => onRotate(selectedItem.id)}>
<RotateCwIcon className="size-3.5" />
</Button>
)}
{canArrange && res.stackable && selectedItem.amount > 1 && (
{res.stackable && selectedItem.amount > 1 && (
<Button
variant="outline"
size="sm"
@ -109,11 +101,9 @@ export function GridToolbar({
<ScissorsIcon className="size-3.5" />
</Button>
)}
{canMutate && (
<Button variant="outline" size="sm" onClick={() => onRemove(selectedItem.id)}>
<TrashIcon className="size-3.5" />
</Button>
)}
<Button variant="outline" size="sm" onClick={() => onRemove(selectedItem.id)}>
<TrashIcon className="size-3.5" />
</Button>
</div>
</div>
)}

View file

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

View file

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

View file

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

View file

@ -1,67 +0,0 @@
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 {
const date = new Date(ts);
if (Number.isNaN(date.getTime())) return "-";
if (Number.isNaN(date.getTime())) return "—";
const now = new Date();
const isToday = date.toDateString() === now.toDateString();
if (isToday) {

View file

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

View file

@ -1,13 +0,0 @@
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,267 +1,261 @@
import * as migration_20260820_004157 from './20260820_004157';
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_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_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_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_041615_add_campaign_mode from './20260828_041615_add_campaign_mode';
import * as migration_20260828_044506 from './20260828_044506';
import * as migration_20260831_142503 from './20260831_142503';
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_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_20260902_195336_add_medal_backgrounds from './20260902_195336_add_medal_backgrounds';
import * as migration_20260903_035544 from './20260903_035544';
import * as migration_20260903_044047 from './20260903_044047';
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_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_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_195456_labor_headcount from './20260905_195456_labor_headcount';
import * as migration_20260905_210310_labor_classifications from './20260905_210310_labor_classifications';
import * as migration_20260905_213824 from './20260905_213824';
import * as migration_20260905_215658 from './20260905_215658';
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_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_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_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_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_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_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_090245_add_mission_ready_reminder_fields from './20260907_090245_add_mission_ready_reminder_fields';
import * as migration_20260820_004157 from "./20260820_004157";
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_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_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_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_041615_add_campaign_mode from "./20260828_041615_add_campaign_mode";
import * as migration_20260828_044506 from "./20260828_044506";
import * as migration_20260831_142503 from "./20260831_142503";
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_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_20260902_195336_add_medal_backgrounds from "./20260902_195336_add_medal_backgrounds";
import * as migration_20260903_035544 from "./20260903_035544";
import * as migration_20260903_044047 from "./20260903_044047";
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_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_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_195456_labor_headcount from "./20260905_195456_labor_headcount";
import * as migration_20260905_210310_labor_classifications from "./20260905_210310_labor_classifications";
import * as migration_20260905_213824 from "./20260905_213824";
import * as migration_20260905_215658 from "./20260905_215658";
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_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_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_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_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_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_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";
export const migrations = [
{
up: migration_20260820_004157.up,
down: migration_20260820_004157.down,
name: '20260820_004157',
name: "20260820_004157",
},
{
up: migration_20260825_220000.up,
down: migration_20260825_220000.down,
name: '20260825_220000',
name: "20260825_220000",
},
{
up: migration_20260826_050000_repair_shims_auxiliary_tables.up,
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,
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,
down: migration_20260827_005757.down,
name: '20260827_005757',
name: "20260827_005757",
},
{
up: migration_20260827_022334_add_eod_minesweeper_best_flags.up,
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,
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,
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,
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,
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,
down: migration_20260828_044506.down,
name: '20260828_044506',
name: "20260828_044506",
},
{
up: migration_20260831_142503.up,
down: migration_20260831_142503.down,
name: '20260831_142503',
name: "20260831_142503",
},
{
up: migration_20260831_180233_assignment_transfers.up,
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,
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,
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,
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,
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,
down: migration_20260903_035544.down,
name: '20260903_035544',
name: "20260903_035544",
},
{
up: migration_20260903_044047.up,
down: migration_20260903_044047.down,
name: '20260903_044047',
name: "20260903_044047",
},
{
up: migration_20260903_053419.up,
down: migration_20260903_053419.down,
name: '20260903_053419',
name: "20260903_053419",
},
{
up: migration_20260903_060000_add_radio_mode_stats.up,
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,
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,
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,
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,
down: migration_20260905_183940_base_management.down,
name: '20260905_183940_base_management',
name: "20260905_183940_base_management",
},
{
up: migration_20260905_195456_labor_headcount.up,
down: migration_20260905_195456_labor_headcount.down,
name: '20260905_195456_labor_headcount',
name: "20260905_195456_labor_headcount",
},
{
up: migration_20260905_210310_labor_classifications.up,
down: migration_20260905_210310_labor_classifications.down,
name: '20260905_210310_labor_classifications',
name: "20260905_210310_labor_classifications",
},
{
up: migration_20260905_213824.up,
down: migration_20260905_213824.down,
name: '20260905_213824',
name: "20260905_213824",
},
{
up: migration_20260905_215658.up,
down: migration_20260905_215658.down,
name: '20260905_215658',
name: "20260905_215658",
},
{
up: migration_20260906_045502.up,
down: migration_20260906_045502.down,
name: '20260906_045502',
name: "20260906_045502",
},
{
up: migration_20260906_050900_add_evaluation_reminders_field.up,
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,
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,
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,
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,
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,
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,
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,
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,
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,
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,
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,
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,
down: migration_20260907_083010_add_voice_submission_description.down,
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'
name: "20260907_083010_add_voice_submission_description",
},
];

View file

@ -2380,14 +2380,6 @@ export interface Mission {
* When evaluation reminder DMs were sent for this mission (managed by the bot, one-shot).
*/
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.
*/
@ -3022,7 +3014,7 @@ export interface MarketNegotiation {
buyer: number | User;
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;
/**
@ -4497,8 +4489,6 @@ export interface MissionsSelect<T extends boolean = true> {
discordAttendanceSentAt?: T;
discordModlistHash?: T;
evaluationRemindersSentAt?: T;
readyReminderWebSentAt?: T;
readyReminderDmSentAt?: T;
createdBy?: T;
coverImage?: T;
classification?:
@ -5737,7 +5727,7 @@ export interface Shim {
targeting: {
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;
/**

View file

@ -1,7 +1,6 @@
import type { SanitizedConfig } from "payload";
import { getPayload } from "payload";
import { autoCompleteMissions } from "@/lib/intelligence/missionLifecycle";
import { sendWebReadyReminders } from "@/lib/intelligence/readyReminders";
import { createBinLogger, errorMessage } from "./lib/binFileLogger";
export const script = async (config: SanitizedConfig) => {
@ -14,13 +13,6 @@ export const script = async (config: SanitizedConfig) => {
const nowIso = new Date().toISOString();
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 notifySecret = process.env.GAME_TICK_NOTIFY_SECRET;
if (notifySecret) {

View file

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

View file

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