1
0
Fork 0

feat(missions): auto-start and auto-complete ops, plus side-op scheduling

- The mission tick now runs two idempotent sweeps: auto-start flips Scheduled
  missions whose start time has arrived to Active, and auto-complete sets
  missions whose scheduled window (start plus duration) has fully passed to
  Completed with a stamped completedAt.
- Side ops are created as quiet Ready drafts and committed with a "Mark as
  Scheduled" control, which is what posts the Discord roll-call and hands the
  op to the lifecycle above.
- Daily schedule reminders nudge the creator to set a draft live (web
  notification plus Discord DM).
- Adds the mission lifecycle/commit event types and the matching notification
  labels, and updates the bot docs.
This commit is contained in:
Jason Fraley 2026-10-03 03:29:35 -04:00
parent 62573f74e6
commit 74564afb2b
16 changed files with 399 additions and 149 deletions

View file

@ -543,7 +543,11 @@ export async function createFridaySideOp(
...(input.coverImageId ? { coverImage: input.coverImageId } : {}), ...(input.coverImageId ? { coverImage: input.coverImageId } : {}),
createdBy: userId, createdBy: userId,
ownershipAndStatus: { ownershipAndStatus: {
status: "Scheduled", // Side ops are created as a quiet "Ready" draft. The creator commits
// them to "Scheduled" (markSideOpScheduled) to lock the Friday slot
// and trigger the roll-call; only then does the mission lifecycle
// (auto-start -> auto-complete) drive them.
status: "Ready",
visibility: "unit", visibility: "unit",
authors: [userId], authors: [userId],
}, },
@ -823,7 +827,7 @@ export async function cancelFridaySideOp(
} }
} }
export async function markSideOpReady( export async function markSideOpScheduled(
missionId: number, missionId: number,
): Promise<ActionResult<{ missionId: number }>> { ): Promise<ActionResult<{ missionId: number }>> {
try { try {
@ -840,21 +844,21 @@ export async function markSideOpReady(
if (!canManage && !isSideOpOwner(mission, userId)) { if (!canManage && !isSideOpOwner(mission, userId)) {
return { return {
success: false, success: false,
error: "Only the player who scheduled this side operation can mark it Ready.", error: "Only the player who scheduled this side operation can commit it to the schedule.",
}; };
} }
const status = mission.ownershipAndStatus.status; const status = mission.ownershipAndStatus.status;
if (status === "Ready") { if (status === "Scheduled") {
return { success: true, data: { missionId: mission.id } }; return { success: true, data: { missionId: mission.id } };
} }
if (!["Concept", "Planning", "Scheduled"].includes(status)) { if (!["Concept", "Planning", "Ready"].includes(status)) {
return { return {
success: false, success: false,
error: error:
status === "Active" status === "Active"
? "This mission is already underway and cannot be marked Ready." ? "This mission is already underway and cannot be scheduled."
: `This mission is ${status.toLowerCase()} and can no longer be marked Ready.`, : `This mission is ${status.toLowerCase()} and can no longer be scheduled.`,
}; };
} }
@ -865,14 +869,14 @@ export async function markSideOpReady(
overrideAccess: true, overrideAccess: true,
data: { data: {
ownershipAndStatus: { ownershipAndStatus: {
status: "Ready", status: "Scheduled",
}, },
}, },
}); });
await emitGameEvent(payload, { await emitGameEvent(payload, {
type: EventTypes.MissionReady, type: EventTypes.MissionCommit,
message: `${user.displayName || user.username} marked side operation "${missionRef(updated)}" as Ready. Details are locked in and the roll-call can be posted.`, message: `${user.displayName || user.username} committed side operation "${missionRef(updated)}" to the schedule. The roll-call will be posted and the mission hooks delivered.`,
actor: userId, actor: userId,
targetCollection: "missions", targetCollection: "missions",
targetId: updated.id, targetId: updated.id,

View file

@ -53,7 +53,7 @@ bot/
## Feature flow: attendance ## Feature flow: attendance
Bot posts RSVP embeds (Yes/Tentative/No) for future, Ready/Scheduled, visibility:"unit" missions into ops channel. Stores `discordMessageId` + `discordAttendanceHash` on mission. Reconciles hash changes every poll tick (web ↔ Discord two-way sync). The embed title links to the mission page, a mission summary sits under the title (truncated to 300 characters with an ellipsis), and an "Open the op" field carries the URL; `attendanceHash` includes an embed schema version so layout-only changes re-render live roll-calls once (idempotent). Web UI: `MissionAttendance` component. Bot posts RSVP embeds (Yes/Tentative/No) for future, Scheduled, visibility:"unit" missions into ops channel. Stores `discordMessageId` + `discordAttendanceHash` on mission. Reconciles hash changes every poll tick (web ↔ Discord two-way sync). The embed title links to the mission page, a mission summary sits under the title (truncated to 300 characters with an ellipsis), and an "Open the op" field carries the URL; `attendanceHash` includes an embed schema version so layout-only changes re-render live roll-calls once (idempotent). Web UI: `MissionAttendance` component.
## Feature flow: notifications ## Feature flow: notifications
@ -65,7 +65,7 @@ Once a mission is evaluable (`Completed`, or `Scheduled`/`Active` past its start
## Feature flow: imminent op reminder ## Feature flow: imminent op reminder
15 minutes before a posted op starts (`visibility: "unit"`, Ready/Scheduled, live roll-call message), `imminentReminders` opens a thread on the roll-call message ("Op starting soon: <name>") and pings every yes-RSVP via `<@discordId>` (pings ignore `preferences.discord.enabled`, same exception as evaluation reminders). One-shot via the `imminentReminderSentAt` marker on Missions; the cancel flow clears it so a rescheduled op re-reminds. Grace bound: a missed reminder still fires within 15 minutes after the start, never later. Query: `src/lib/intelligence/imminentReminders.ts` (window constants + `getImminentReminderMissions`). 15 minutes before a posted op starts (`visibility: "unit"`, Scheduled, live roll-call message), `imminentReminders` opens a thread on the roll-call message ("Op starting soon: <name>") and pings every yes-RSVP via `<@discordId>` (pings ignore `preferences.discord.enabled`, same exception as evaluation reminders). One-shot via the `imminentReminderSentAt` marker on Missions; the cancel flow clears it so a rescheduled op re-reminds. Grace bound: a missed reminder still fires within 15 minutes after the start, never later. Query: `src/lib/intelligence/imminentReminders.ts` (window constants + `getImminentReminderMissions`).
## Feature flow: Friday open-slot notice ## Feature flow: Friday open-slot notice

View file

@ -38,12 +38,12 @@ const missionTitle = (mission: Mission): string =>
const memberName = (user: User): string => user.displayName ?? user.username; const memberName = (user: User): string => user.displayName ?? user.username;
/** /**
* Roll call is sent once: as soon as the mission is Ready, or three days * Roll call is sent once, three days before a Scheduled mission starts. Side
* before a Scheduled mission, whichever happens first. * ops are created as a quiet "Ready" draft and only post a roll-call after the
* creator commits them to "Scheduled" (markSideOpScheduled).
*/ */
export const isRollCallDue = (mission: Mission, now = Date.now()): boolean => { export const isRollCallDue = (mission: Mission, now = Date.now()): boolean => {
if (mission.discordAttendanceSentAt) return false; if (mission.discordAttendanceSentAt) return false;
if (mission.ownershipAndStatus.status === "Ready") return true;
if (mission.ownershipAndStatus.status !== "Scheduled") return false; if (mission.ownershipAndStatus.status !== "Scheduled") return false;
const start = new Date(mission.classification.startDateTime).getTime(); const start = new Date(mission.classification.startDateTime).getTime();
@ -118,12 +118,6 @@ export const getEligibleMissions = async (payload: Payload): Promise<Mission[]>
{ "ownershipAndStatus.visibility": { equals: "unit" } }, { "ownershipAndStatus.visibility": { equals: "unit" } },
{ {
or: [ or: [
{
and: [
{ "classification.startDateTime": { greater_than: new Date().toISOString() } },
{ "ownershipAndStatus.status": { equals: "Ready" } },
],
},
{ {
and: [ and: [
{ "classification.startDateTime": { greater_than: new Date().toISOString() } }, { "classification.startDateTime": { greater_than: new Date().toISOString() } },
@ -158,9 +152,9 @@ const getCancelledMissions = async (payload: Payload): Promise<Mission[]> => {
}; };
/** /**
* Cancelled missions that are live again (status back to Ready/Scheduled) and * Cancelled missions that are live again (status back to Scheduled) and still
* still carry the cancellation marker: the reschedule announcement has not * carry the cancellation marker: the reschedule announcement has not happened
* happened yet. * yet.
*/ */
const getRescheduledMissions = async (payload: Payload): Promise<Mission[]> => { const getRescheduledMissions = async (payload: Payload): Promise<Mission[]> => {
const res = await payload.find({ const res = await payload.find({
@ -172,7 +166,7 @@ const getRescheduledMissions = async (payload: Payload): Promise<Mission[]> => {
where: { where: {
and: [ and: [
{ "ownershipAndStatus.visibility": { equals: "unit" } }, { "ownershipAndStatus.visibility": { equals: "unit" } },
{ "ownershipAndStatus.status": { in: ["Ready", "Scheduled"] } }, { "ownershipAndStatus.status": { in: ["Scheduled"] } },
{ "classification.startDateTime": { greater_than: new Date().toISOString() } }, { "classification.startDateTime": { greater_than: new Date().toISOString() } },
{ discordCancelledAt: { exists: true } }, { discordCancelledAt: { exists: true } },
], ],
@ -516,7 +510,7 @@ export const reconcileMission = async (
if ( if (
mission.ownershipAndStatus.visibility !== "unit" || mission.ownershipAndStatus.visibility !== "unit" ||
!["Ready", "Scheduled"].includes(mission.ownershipAndStatus.status) || !["Scheduled"].includes(mission.ownershipAndStatus.status) ||
(!isRollCallDue(mission) && !mission.discordMessageId && !mission.discordAttendanceSentAt) (!isRollCallDue(mission) && !mission.discordMessageId && !mission.discordAttendanceSentAt)
) { ) {
return; return;
@ -566,7 +560,7 @@ const cancelMissionEmbed = async (
}; };
/** /**
* A cancelled op came back (status Cancelled -> Ready/Scheduled): announce the * A cancelled op came back (status Cancelled -> Scheduled): announce the
* new start time immediately, note when the fresh roll-call goes up (the usual * new start time immediately, note when the fresh roll-call goes up (the usual
* lead time before the op, or right away when it is already due), then clear * lead time before the op, or right away when it is already due), then clear
* the marker so the reconcile loop posts the roll-call as usual. * the marker so the reconcile loop posts the roll-call as usual.
@ -579,8 +573,7 @@ const announceReschedule = async (
): Promise<void> => { ): Promise<void> => {
const start = new Date(mission.classification.startDateTime).getTime(); const start = new Date(mission.classification.startDateTime).getTime();
const rollCallAlreadyLive = !!mission.discordAttendanceSentAt; const rollCallAlreadyLive = !!mission.discordAttendanceSentAt;
const rollCallDueNow = const rollCallDueNow = start - Date.now() <= ROLL_CALL_LEAD_TIME_MS;
mission.ownershipAndStatus.status === "Ready" || start - Date.now() <= ROLL_CALL_LEAD_TIME_MS;
const rollCallDueUnix = const rollCallDueUnix =
rollCallAlreadyLive || rollCallDueNow rollCallAlreadyLive || rollCallDueNow
? null ? null

View file

@ -13,8 +13,9 @@ import {
/** /**
* Ready-reminder service: starting READY_REMINDER_LEAD_DAYS days before a * 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 * 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 * them to finish the op and commit it to "Scheduled" (markSideOpScheduled): the
* roll-call, locks in details, and delivers the mission hooks. * status that posts the roll-call, locks in details, and delivers the mission
* hooks.
* *
* The DM deliberately ignores `preferences.discord.enabled` (the notification * The DM deliberately ignores `preferences.discord.enabled` (the notification
* bridge opt-in): that toggle defaults to false and is not yet exposed in the * bridge opt-in): that toggle defaults to false and is not yet exposed in the
@ -41,17 +42,17 @@ const renderReminderEmbed = (mission: Mission, now: Date): EmbedBuilder => {
const days = daysUntil(mission.classification.startDateTime, now); const days = daysUntil(mission.classification.startDateTime, now);
const when = days === 0 ? "today" : days === 1 ? "tomorrow" : `in ${days} days`; const when = days === 0 ? "today" : days === 1 ? "tomorrow" : `in ${days} days`;
return new EmbedBuilder() return new EmbedBuilder()
.setTitle(`⏰ Ready check: ${missionTitle(mission)}`) .setTitle(`⏰ Schedule check: ${missionTitle(mission)}`)
.setDescription( .setDescription(
`Your op starts **${when}** and it isn't marked **Ready** yet.\n\n` + `Your op starts **${when}** and it isn't marked **Scheduled** 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.", "Finish up the operation and set the mission status to **Scheduled** 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) .setColor(EMBED_COLOR)
.addFields({ .addFields({
name: "Open the op", name: "Open the op",
value: `${botConfig.appUrl}/intelligence/missions/${mission.id}`, value: `${botConfig.appUrl}/intelligence/missions/${mission.id}`,
}) })
.setFooter({ text: "You'll get this reminder once a day until the op is Ready." }); .setFooter({ text: "You'll get this reminder once a day until the op is Scheduled." });
}; };
const processMission = async ( const processMission = async (

View file

@ -41,6 +41,8 @@ const TARGET_COLLECTIONS = [
{ label: "Custom Radio Tests", value: "custom-radio-tests" }, { label: "Custom Radio Tests", value: "custom-radio-tests" },
{ label: "Custom Radio Test Scores", value: "custom-radio-test-scores" }, { label: "Custom Radio Test Scores", value: "custom-radio-test-scores" },
{ label: "Ribbon Submissions", value: "ribbon-submissions" }, { label: "Ribbon Submissions", value: "ribbon-submissions" },
{ label: "Promotion Nominations", value: "promotion-nominations" },
{ label: "Leadership Transfers", value: "leadership-transfers" },
] as const; ] as const;
export const GameEventLogs: CollectionConfig = { export const GameEventLogs: CollectionConfig = {

View file

@ -272,7 +272,7 @@ export const Missions: CollectionConfig = {
type: "date", type: "date",
admin: { admin: {
description: 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).", "Server-local day the last web 'not Scheduled' reminder notification was sent to the op creator (managed by the mission tick, once per day).",
hidden: true, hidden: true,
}, },
}, },
@ -281,7 +281,7 @@ export const Missions: CollectionConfig = {
type: "date", type: "date",
admin: { admin: {
description: description:
"Server-local day the last 'not Ready' reminder DM was sent to the op creator (managed by the Discord bot, once per day).", "Server-local day the last 'not Scheduled' reminder DM was sent to the op creator (managed by the Discord bot, once per day).",
hidden: true, hidden: true,
}, },
}, },
@ -421,6 +421,10 @@ export const Missions: CollectionConfig = {
], ],
defaultValue: "Concept", defaultValue: "Concept",
required: true, required: true,
admin: {
description:
"Side operations start as Ready (a quiet draft) and are committed to Scheduled by their creator, which posts the Discord roll-call. Only Scheduled/Active missions are driven by the automatic start/complete lifecycle; Concept/Planning/Ready are drafts.",
},
}, },
{ {
name: "visibility", name: "visibility",

View file

@ -8,7 +8,7 @@ import { Item, ItemContent, ItemHeader, ItemTitle } from "@/components/ui/item";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
cancelFridaySideOp, cancelFridaySideOp,
markSideOpReady, markSideOpScheduled,
} from "@/app/(frontend)/intelligence/missions/actions"; } from "@/app/(frontend)/intelligence/missions/actions";
import { import {
CreateSideOpDialog, CreateSideOpDialog,
@ -37,16 +37,16 @@ export function SideOpControls({
const [isPending, startTransition] = useTransition(); const [isPending, startTransition] = useTransition();
const isTerminal = status === "Completed" || status === "Cancelled"; const isTerminal = status === "Completed" || status === "Cancelled";
const canMarkReady = ["Concept", "Planning", "Scheduled"].includes(status); const canMarkScheduled = ["Concept", "Planning", "Ready"].includes(status);
function handleMarkReady() { function handleMarkScheduled() {
startTransition(async () => { startTransition(async () => {
const result = await markSideOpReady(initial.missionId); const result = await markSideOpScheduled(initial.missionId);
if (!result.success) { if (!result.success) {
toast.error(result.error ?? "Failed to mark the side operation as Ready."); toast.error(result.error ?? "Failed to commit the side operation to the schedule.");
return; return;
} }
toast.success("Side operation marked as Ready. Roll-call can now be posted."); toast.success("Side operation scheduled. The roll-call will be posted soon.");
router.refresh(); router.refresh();
}); });
} }
@ -80,10 +80,10 @@ export function SideOpControls({
</ItemHeader> </ItemHeader>
<ItemContent> <ItemContent>
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
{canMarkReady && ( {canMarkScheduled && (
<Button size="sm" variant="default" onClick={handleMarkReady} disabled={isPending}> <Button size="sm" variant="default" onClick={handleMarkScheduled} disabled={isPending}>
<CheckCircleIcon className="size-3.5" /> <CheckCircleIcon className="size-3.5" />
Mark as Ready Mark as Scheduled
</Button> </Button>
)} )}
<Button size="sm" variant="outline" onClick={() => setEditOpen(true)} disabled={isPending}> <Button size="sm" variant="outline" onClick={() => setEditOpen(true)} disabled={isPending}>
@ -101,9 +101,9 @@ export function SideOpControls({
</Button> </Button>
</div> </div>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
Marking the operation Ready locks in the details so the Discord roll-call can be posted Scheduling the operation commits it: the Discord roll-call is posted within three days of
and the mission hooks delivered. Editing covers details, campaign, and narrative. the op and the mission hooks are delivered. Editing covers details, campaign, and
Cancelling frees your scheduling slot and updates the Discord embed. narrative. Cancelling frees your scheduling slot and updates the Discord embed.
</p> </p>
</ItemContent> </ItemContent>

View file

@ -26,9 +26,9 @@ export const IMMINENT_REMINDER_WINDOW_MS = 15 * 60 * 1000;
export const IMMINENT_REMINDER_GRACE_MS = 15 * 60 * 1000; export const IMMINENT_REMINDER_GRACE_MS = 15 * 60 * 1000;
/** /**
* Ops due for the imminent reminder: unit-visible, Ready/Scheduled, with a * Ops due for the imminent reminder: unit-visible, Scheduled, with a live
* live roll-call message, starting within the reminder window (or just past * roll-call message, starting within the reminder window (or just past it,
* it, within the grace bound), and without the one-shot marker yet. * within the grace bound), and without the one-shot marker yet.
*/ */
export const getImminentReminderMissions = async ( export const getImminentReminderMissions = async (
payload: Payload, payload: Payload,
@ -45,7 +45,7 @@ export const getImminentReminderMissions = async (
where: { where: {
and: [ and: [
{ "ownershipAndStatus.visibility": { equals: "unit" } }, { "ownershipAndStatus.visibility": { equals: "unit" } },
{ "ownershipAndStatus.status": { in: ["Ready", "Scheduled"] } }, { "ownershipAndStatus.status": { in: ["Scheduled"] } },
{ discordMessageId: { exists: true } }, { discordMessageId: { exists: true } },
{ imminentReminderSentAt: { exists: false } }, { imminentReminderSentAt: { exists: false } },
{ "classification.startDateTime": { greater_than: windowStartIso } }, { "classification.startDateTime": { greater_than: windowStartIso } },

View file

@ -6,52 +6,113 @@ import { EventTypes } from "@/utils/event-log/eventTypes";
type PayloadType = Awaited<ReturnType<typeof import("payload").getPayload>>; type PayloadType = Awaited<ReturnType<typeof import("payload").getPayload>>;
/** /**
* Statuses that count as "committed to run" and are auto-completed once the * Statuses that are committed to run and are auto-started (flipped to Active)
* calendar day of their scheduled start has fully passed. Draft statuses * the moment their scheduled start time arrives. Draft statuses
* (Concept/Planning/Ready) are left untouched — a mission that never ran * (Concept/Planning/Ready) are left untouched — a mission that was never
* should not silently become Completed — and terminal statuses * scheduled should not silently become Active.
*/
const AUTO_STARTABLE_STATUSES = ["Scheduled"] as const;
/**
* Statuses that count as "committed to run" and are auto-completed once their
* scheduled window (start + estimated duration) has fully passed. Draft
* statuses (Concept/Planning/Ready) are left untouched — a mission that never
* ran should not silently become Completed — and terminal statuses
* (Completed/Cancelled) are never re-processed. * (Completed/Cancelled) are never re-processed.
*/ */
const AUTO_COMPLETABLE_STATUSES = ["Scheduled", "Active"] as const; const AUTO_COMPLETABLE_STATUSES = ["Scheduled", "Active"] as const;
export interface MissionAutoCompleteSummary { /**
completed: number; * Fallback duration (minutes) when a mission's `estimatedDuration` is missing
missions: { id: number; name: string; codeName: string | null }[]; * or invalid. Mirrors the field's own default so a malformed mission still
* reaches a terminal state instead of being stuck Active forever.
*/
export const DEFAULT_MISSION_DURATION_MINUTES = 240;
export interface MissionLifecycleEntry {
id: number;
name: string;
codeName: string | null;
} }
export interface MissionAutoCompleteOptions { export interface MissionAutoStartSummary {
started: number;
missions: MissionLifecycleEntry[];
}
export interface MissionAutoCompleteSummary {
completed: number;
missions: MissionLifecycleEntry[];
}
export interface MissionLifecycleOptions {
/** Restrict the sweep to specific mission ids (lets tests stay hermetic). */ /** Restrict the sweep to specific mission ids (lets tests stay hermetic). */
missionIds?: number[]; missionIds?: number[];
} }
/** Start of `now`'s calendar day in the server's local timezone. */ /**
export function startOfToday(now: Date = new Date()): Date { * Resolve a mission's end time (start + estimated duration), or null when the
const start = new Date(now); * start timestamp is missing/unparseable. A missing or non-positive duration
start.setHours(0, 0, 0, 0); * falls back to DEFAULT_MISSION_DURATION_MINUTES.
return start; */
export function missionEndTime(
startDateTime: string | null | undefined,
estimatedDurationMinutes: number | null | undefined,
): Date | null {
const start = new Date(startDateTime ?? "");
if (Number.isNaN(start.getTime())) return null;
const minutes =
typeof estimatedDurationMinutes === "number" &&
Number.isFinite(estimatedDurationMinutes) &&
estimatedDurationMinutes > 0
? estimatedDurationMinutes
: DEFAULT_MISSION_DURATION_MINUTES;
return new Date(start.getTime() + minutes * 60_000);
} }
/** /**
* True once the calendar day of `startDateTime` (server-local) has fully * True once the mission's scheduled start time has arrived. Unparseable dates
* ended — i.e. from the start of the day AFTER the scheduled date. A mission * fall closed so malformed data is never auto-started.
* scheduled for today never counts, even after its start time has passed.
* Unparseable dates fall closed so malformed data is never auto-completed.
*/ */
export function isPastScheduledDay(startDateTime: string, now: Date = new Date()): boolean { export function hasMissionStarted(
const start = new Date(startDateTime); startDateTime: string | null | undefined,
now: Date = new Date(),
): boolean {
const start = new Date(startDateTime ?? "");
if (Number.isNaN(start.getTime())) return false; if (Number.isNaN(start.getTime())) return false;
return start.getTime() < startOfToday(now).getTime(); return start.getTime() <= now.getTime();
} }
export async function autoCompleteMissions( /**
* True once the mission's scheduled window (start + estimated duration) has
* fully passed. Unparseable dates fall closed so malformed data is never
* auto-completed.
*/
export function isMissionWindowComplete(
startDateTime: string | null | undefined,
estimatedDurationMinutes: number | null | undefined,
now: Date = new Date(),
): boolean {
const end = missionEndTime(startDateTime, estimatedDurationMinutes);
if (!end) return false;
return end.getTime() <= now.getTime();
}
/**
* Sweep missions that are committed to run and whose scheduled start time has
* arrived, flipping them to Active. Missions whose entire window has already
* elapsed are skipped here (auto-complete owns those) so an op that ran while
* the tick was down does not emit a spurious "started" event on its way to
* Completed. Idempotent: once Active, a mission is no longer startable.
*/
export async function autoStartMissions(
payload: PayloadType, payload: PayloadType,
now: Date = new Date(), now: Date = new Date(),
options: MissionAutoCompleteOptions = {}, options: MissionLifecycleOptions = {},
): Promise<MissionAutoCompleteSummary> { ): Promise<MissionAutoStartSummary> {
const cutoff = startOfToday(now).toISOString();
const conditions: Where[] = [ const conditions: Where[] = [
{ "ownershipAndStatus.status": { in: [...AUTO_COMPLETABLE_STATUSES] } }, { "ownershipAndStatus.status": { in: [...AUTO_STARTABLE_STATUSES] } },
{ "classification.startDateTime": { less_than: cutoff } }, { "classification.startDateTime": { less_than_equal: now.toISOString() } },
]; ];
if (options.missionIds) { if (options.missionIds) {
conditions.push({ id: { in: options.missionIds } }); conditions.push({ id: { in: options.missionIds } });
@ -66,13 +127,91 @@ export async function autoCompleteMissions(
overrideAccess: true, overrideAccess: true,
})) as unknown as { docs: Mission[] }; })) as unknown as { docs: Mission[] };
const completed: MissionAutoCompleteSummary["missions"] = []; const started: MissionLifecycleEntry[] = [];
for (const mission of due.docs) { for (const mission of due.docs) {
// A window that already fully elapsed belongs to auto-complete; starting it
// first would emit a misleading "started" event for an op that is over.
if (
isMissionWindowComplete(
mission.classification?.startDateTime,
mission.classification?.estimatedDuration,
now,
)
) {
continue;
}
const previousStatus = mission.ownershipAndStatus?.status ?? null;
try {
await payload.update({
collection: "missions",
id: mission.id,
data: { ownershipAndStatus: { status: "Active" } },
overrideAccess: true,
});
await emitGameEvent(payload, {
type: EventTypes.MissionAutoStart,
message: `Mission "${mission.name}"${mission.codeName ? ` (${mission.codeName})` : ""} is now active: the scheduled start time has arrived.`,
targetCollection: "missions",
targetId: mission.id,
data: { previousStatus },
});
started.push({ id: mission.id, name: mission.name, codeName: mission.codeName ?? null });
} catch (error) {
const message = error instanceof Error ? error.message : "Unknown error";
payload.logger.error(
`[Mission Tick] Failed to auto-start mission ${mission.id} (${mission.name}): ${message}`,
);
}
}
return { started: started.length, missions: started };
}
/**
* Sweep missions whose scheduled window (start + estimated duration) has fully
* passed and mark them Completed. Passing the sweep's `now` explicitly keeps
* the completion timestamp deterministic for tests; the collection's
* beforeChange hook preserves it because it only stamps when empty.
* Idempotent: Completed/Cancelled are never re-processed.
*/
export async function autoCompleteMissions(
payload: PayloadType,
now: Date = new Date(),
options: MissionLifecycleOptions = {},
): Promise<MissionAutoCompleteSummary> {
const conditions: Where[] = [
{ "ownershipAndStatus.status": { in: [...AUTO_COMPLETABLE_STATUSES] } },
// Coarse prefilter: end <= now implies start < now, since duration > 0.
{ "classification.startDateTime": { less_than: now.toISOString() } },
];
if (options.missionIds) {
conditions.push({ id: { in: options.missionIds } });
}
const due = (await payload.find({
collection: "missions",
where: { and: conditions },
sort: "classification.startDateTime",
limit: 500,
depth: 0,
overrideAccess: true,
})) as unknown as { docs: Mission[] };
const completed: MissionLifecycleEntry[] = [];
for (const mission of due.docs) {
if (
!isMissionWindowComplete(
mission.classification?.startDateTime,
mission.classification?.estimatedDuration,
now,
)
) {
continue;
}
const previousStatus = mission.ownershipAndStatus?.status ?? null; const previousStatus = mission.ownershipAndStatus?.status ?? null;
try { try {
// Pass the sweep's `now` explicitly so the completion timestamp is the
// exact tick time (deterministic for tests); the collection's
// beforeChange hook preserves it because it only stamps when empty.
await payload.update({ await payload.update({
collection: "missions", collection: "missions",
id: mission.id, id: mission.id,
@ -81,7 +220,7 @@ export async function autoCompleteMissions(
}); });
await emitGameEvent(payload, { await emitGameEvent(payload, {
type: EventTypes.MissionAutoComplete, type: EventTypes.MissionAutoComplete,
message: `Mission "${mission.name}"${mission.codeName ? ` (${mission.codeName})` : ""} auto-completed: the scheduled day has passed.`, message: `Mission "${mission.name}"${mission.codeName ? ` (${mission.codeName})` : ""} auto-completed: the scheduled window has ended.`,
targetCollection: "missions", targetCollection: "missions",
targetId: mission.id, targetId: mission.id,
data: { previousStatus }, data: { previousStatus },

View file

@ -7,18 +7,26 @@ import { notifyUser } from "@/lib/notifications";
* *
* Starting READY_REMINDER_LEAD_DAYS days before an op's scheduled start, the * 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 * 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 * finish the op and commit it to "Scheduled" (markSideOpScheduled): the status
* embed, locks in details, and delivers the mission hooks. Reminders stop the * that triggers the roll-call embed, locks in details, and delivers the mission
* moment the op is Ready (or cancelled/completed/active), and a marker field * hooks. Reminders stop the moment the op is Scheduled (or
* records the last server-local day each channel was sent so the cadence is * cancelled/completed/active), and a marker field records the last server-local
* exactly one reminder per day per channel. * day each channel was sent so the cadence is exactly one reminder per day per
* channel.
*
* Side ops are created as a quiet "Ready" draft (createFridaySideOp), so a
* reminder is due while the op is still Concept/Planning/Ready.
* *
* Scope: only ops with a `createdBy` (player-scheduled via the Friday Ops * Scope: only ops with a `createdBy` (player-scheduled via the Friday Ops
* calendar — always `operationType: "side"`). Staff/admin-created missions are * calendar, always `operationType: "side"`). Staff/admin-created missions are
* never reminded. * never reminded.
* *
* Shared by the mission-tick bin (web notifications) and the Discord bot's * Shared by the mission-tick bin (web notifications) and the Discord bot's
* readyReminders service (DMs) — keep this module free of discord.js imports. * readyReminders service (DMs): keep this module free of discord.js imports.
*
* Note: the module name, notification type ("mission:ready-reminder"), and
* marker fields keep their legacy "ready" spelling to avoid a data migration;
* only the user-facing copy talks about scheduling.
*/ */
/** How many days before the op the daily reminders begin. */ /** How many days before the op the daily reminders begin. */
@ -28,11 +36,11 @@ export const READY_REMINDER_LEAD_DAYS = 5;
export const READY_REMINDER_NOTIFICATION_TYPE = "mission:ready-reminder"; export const READY_REMINDER_NOTIFICATION_TYPE = "mission:ready-reminder";
/** /**
* Statuses that still need the creator's attention. "Ready" is the committed * Statuses that still need the creator's attention. "Scheduled" is the
* state the reminders push toward; Active/Completed/Cancelled are terminal or * committed state the reminders push toward; Active/Completed/Cancelled are
* already underway. * terminal or already underway.
*/ */
export const READY_REMINDER_ELIGIBLE_STATUSES = ["Concept", "Planning", "Scheduled"] as const; export const READY_REMINDER_ELIGIBLE_STATUSES = ["Concept", "Planning", "Ready"] as const;
export const missionTitle = (mission: Mission): string => export const missionTitle = (mission: Mission): string =>
mission.codeName ? `${mission.name} (${mission.codeName})` : mission.name; mission.codeName ? `${mission.name} (${mission.codeName})` : mission.name;
@ -46,8 +54,8 @@ export const dayKey = (date: Date): string =>
/** /**
* A channel is due when it has never sent, or last sent on a previous * 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 * 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 * stay deduped, and a status flapping Scheduled back to Ready resumes reminders
* next day instead of being suppressed forever. * the next day instead of being suppressed forever.
*/ */
export const isReminderDue = (marker: string | null | undefined, now: Date): boolean => { export const isReminderDue = (marker: string | null | undefined, now: Date): boolean => {
if (!marker) return true; if (!marker) return true;
@ -60,10 +68,10 @@ export const daysUntil = (startIso: string, now: Date): number =>
Math.max(0, Math.ceil((new Date(startIso).getTime() - now.getTime()) / 86_400_000)); 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 / * Ops due for reminders: player-created (createdBy set), still not Scheduled /
* terminal, and starting within the lead window (future start, at most * terminal, and starting within the lead window (future start, at most
* READY_REMINDER_LEAD_DAYS days out). Marker filtering is done per-channel by * 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 * the caller via isReminderDue: the "not sent today" condition can't be
* expressed in a single where clause. * expressed in a single where clause.
*/ */
export const getReadyReminderMissions = async (payload: Payload, now = new Date()): Promise<Mission[]> => { export const getReadyReminderMissions = async (payload: Payload, now = new Date()): Promise<Mission[]> => {
@ -122,8 +130,8 @@ export const sendWebReadyReminders = async (
await notifyUser(payload, { await notifyUser(payload, {
userId: creatorId, userId: creatorId,
type: READY_REMINDER_NOTIFICATION_TYPE, type: READY_REMINDER_NOTIFICATION_TYPE,
title: `Ready check: ${missionTitle(mission)}`, title: `Schedule 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.`, message: `Your op starts ${when}. Finish up the details and set the mission status to Scheduled so the roll-call can be posted and the mission hooks delivered.`,
link: `/intelligence/missions/${mission.id}`, link: `/intelligence/missions/${mission.id}`,
}); });
await payload.update({ await payload.update({

View file

@ -96,7 +96,7 @@ export function notificationLabel(type?: string | null): string {
case "ticket:status": case "ticket:status":
return "Ticket updated"; return "Ticket updated";
case "mission:ready-reminder": case "mission:ready-reminder":
return "Ready reminder"; return "Schedule reminder";
case "assignment-transfer:request": case "assignment-transfer:request":
return "Transfer request"; return "Transfer request";
case "assignment-transfer:approved": case "assignment-transfer:approved":
@ -117,6 +117,24 @@ export function notificationLabel(type?: string | null): string {
return "Ribbon not approved"; return "Ribbon not approved";
case "personnel:promoted": case "personnel:promoted":
return "Promotion"; return "Promotion";
case "personnel:transferred":
return "Transferred";
case "promotion:nomination":
return "Promotion nomination";
case "promotion:endorsed":
return "Nomination endorsed";
case "promotion:approved":
return "Promotion approved";
case "promotion:rejected":
return "Promotion declined";
case "assignment:leadership-transfer-requested":
return "Removal request";
case "assignment:leadership-transfer":
return "Leadership transfer";
case "assignment:leadership-transfer-approved":
return "Removal approved";
case "assignment:leadership-transfer-denied":
return "Removal denied";
case "mission:role-promoted": case "mission:role-promoted":
return "Role slot granted"; return "Role slot granted";
default: default:

View file

@ -1,6 +1,6 @@
import type { SanitizedConfig } from "payload"; import type { SanitizedConfig } from "payload";
import { getPayload } from "payload"; import { getPayload } from "payload";
import { autoCompleteMissions } from "@/lib/intelligence/missionLifecycle"; import { autoCompleteMissions, autoStartMissions } from "@/lib/intelligence/missionLifecycle";
import { sendWebReadyReminders } from "@/lib/intelligence/readyReminders"; import { sendWebReadyReminders } from "@/lib/intelligence/readyReminders";
import { createBinLogger, errorMessage } from "./lib/binFileLogger"; import { createBinLogger, errorMessage } from "./lib/binFileLogger";
@ -12,6 +12,9 @@ export const script = async (config: SanitizedConfig) => {
log.info("[Mission Tick] Starting mission tick..."); log.info("[Mission Tick] Starting mission tick...");
const nowIso = new Date().toISOString(); const nowIso = new Date().toISOString();
// Start before complete so a mission whose whole window elapsed between
// ticks is still driven through Active on its way to Completed.
const startResult = await autoStartMissions(payload);
const result = await autoCompleteMissions(payload); const result = await autoCompleteMissions(payload);
const reminders = await sendWebReadyReminders(payload); const reminders = await sendWebReadyReminders(payload);
@ -38,7 +41,9 @@ export const script = async (config: SanitizedConfig) => {
log.warn("[Mission Tick] GAME_TICK_NOTIFY_SECRET not set; skipping client notification"); log.warn("[Mission Tick] GAME_TICK_NOTIFY_SECRET not set; skipping client notification");
} }
log.info(`[Mission Tick] Complete. Auto-completed ${result.completed} mission(s).`); log.info(
`[Mission Tick] Complete. Auto-started ${startResult.started} mission(s), auto-completed ${result.completed} mission(s).`,
);
process.exit(0); process.exit(0);
} catch (error) { } catch (error) {
log.error( log.error(

View file

@ -45,7 +45,9 @@ interface GameEventInput {
| "custom-minefield-scores" | "custom-minefield-scores"
| "custom-radio-tests" | "custom-radio-tests"
| "custom-radio-test-scores" | "custom-radio-test-scores"
| "ribbon-submissions"; | "ribbon-submissions"
| "promotion-nominations"
| "leadership-transfers";
targetId?: number; targetId?: number;
data?: Record<string, unknown>; data?: Record<string, unknown>;
} }

View file

@ -139,6 +139,8 @@ export const EventTypes = {
// Personnel // Personnel
PersonnelPromoted: "personnel:promoted", PersonnelPromoted: "personnel:promoted",
PersonnelTransferred: "personnel:transferred",
PromotionNominationApproved: "promotion:approved",
// Awards // Awards
AwardGrant: "award:grant", AwardGrant: "award:grant",
@ -148,10 +150,11 @@ export const EventTypes = {
AwardRibbonRejected: "award:ribbon-rejected", AwardRibbonRejected: "award:ribbon-rejected",
// Missions // Missions
MissionAutoStart: "mission:auto-start",
MissionAutoComplete: "mission:auto-complete", MissionAutoComplete: "mission:auto-complete",
MissionObjectiveRedactedToggle: "mission:objective-redacted-toggle", MissionObjectiveRedactedToggle: "mission:objective-redacted-toggle",
MissionSchedule: "mission:schedule", MissionSchedule: "mission:schedule",
MissionReady: "mission:ready", MissionCommit: "mission:commit",
MissionUpdate: "mission:update", MissionUpdate: "mission:update",
MissionCancel: "mission:cancel", MissionCancel: "mission:cancel",
MissionMoveCampaign: "mission:move-campaign", MissionMoveCampaign: "mission:move-campaign",

View file

@ -149,15 +149,16 @@ describe("Imminent-op reminder query", () => {
return mission; return mission;
}; };
it("finds posted Ready/Scheduled ops starting inside the 15 minute window", async () => { it("finds posted Scheduled ops starting inside the 15 minute window, not Ready drafts", async () => {
const now = new Date(); const now = new Date();
const start = new Date(now.getTime() + IMMINENT_REMINDER_WINDOW_MS - 60_000); const start = new Date(now.getTime() + IMMINENT_REMINDER_WINDOW_MS - 60_000);
const ready = await makeMission("in-window-ready", { status: "Ready", start }); const ready = await makeMission("in-window-ready", { status: "Ready", start });
const scheduled = await makeMission("in-window-scheduled", { status: "Scheduled", start }); const scheduled = await makeMission("in-window-scheduled", { status: "Scheduled", start });
const found = await getImminentReminderMissions(payload, now); const found = await getImminentReminderMissions(payload, now);
expect(found.map((m) => m.id)).toContain(ready.id);
expect(found.map((m) => m.id)).toContain(scheduled.id); expect(found.map((m) => m.id)).toContain(scheduled.id);
// Ready is now a quiet draft (no roll-call), so it is never imminent-reminded.
expect(found.map((m) => m.id)).not.toContain(ready.id);
}); });
it("includes the exact window edge and the grace zone just past the start", async () => { it("includes the exact window edge and the grace zone just past the start", async () => {

View file

@ -4,20 +4,26 @@ import config from "@/payload.config";
import { afterAll, beforeAll, describe, expect, it } from "vitest"; import { afterAll, beforeAll, describe, expect, it } from "vitest";
import type { Campaign, Map as MissionMap, Mission, User } from "@/payload-types"; import type { Campaign, Map as MissionMap, Mission, User } from "@/payload-types";
import { import {
DEFAULT_MISSION_DURATION_MINUTES,
autoCompleteMissions, autoCompleteMissions,
isPastScheduledDay, autoStartMissions,
startOfToday, hasMissionStarted,
isMissionWindowComplete,
missionEndTime,
} from "@/lib/intelligence/missionLifecycle"; } from "@/lib/intelligence/missionLifecycle";
let payload: Payload; let payload: Payload;
const RUN = `mlc-${Date.now().toString(36)}`; const RUN = `mlc-${Date.now().toString(36)}`;
describe("Mission auto-complete", () => { describe("Mission lifecycle (auto-start + auto-complete)", () => {
let mapId: number; let mapId: number;
let campaignId: number; let campaignId: number;
let authorId: number; let authorId: number;
const missionIds: number[] = []; const missionIds: number[] = [];
// Ids completed by the window sweep, so the idempotency check can scope to
// exactly those (the suite deliberately leaves other due missions behind).
const completedBySweep: number[] = [];
beforeAll(async () => { beforeAll(async () => {
const payloadConfig = await config; const payloadConfig = await config;
@ -102,20 +108,21 @@ describe("Mission auto-complete", () => {
label: string, label: string,
status: Mission["ownershipAndStatus"]["status"], status: Mission["ownershipAndStatus"]["status"],
start: Date, start: Date,
estimatedDuration = 60,
): Promise<Mission> => { ): Promise<Mission> => {
const mission = (await payload.create({ const mission = (await payload.create({
collection: "missions", collection: "missions",
data: { data: {
name: `${RUN} ${label}`, name: `${RUN} ${label}`,
codeName: `${RUN}-${label}`, codeName: `${RUN}-${label}`,
summary: "Auto-complete boundary test mission", summary: "Mission lifecycle boundary test mission",
operationType: "main", operationType: "main",
classification: { classification: {
map: mapId, map: mapId,
missionType: "PvE", missionType: "PvE",
campaign: campaignId, campaign: campaignId,
startDateTime: start.toISOString(), startDateTime: start.toISOString(),
estimatedDuration: 60, estimatedDuration,
}, },
ownershipAndStatus: { ownershipAndStatus: {
authors: [authorId], authors: [authorId],
@ -148,41 +155,37 @@ describe("Mission auto-complete", () => {
overrideAccess: true, overrideAccess: true,
}) as unknown as Promise<Mission>; }) as unknown as Promise<Mission>;
it("completes Scheduled/Active missions only after the day after their scheduled date starts", async () => { it("auto-starts only Scheduled missions whose start has arrived and whose window is still open", async () => {
const now = new Date(); const now = new Date();
now.setHours(12, 0, 0, 0); now.setHours(12, 0, 0, 0);
const at = (dayOffset: number, hour: number): Date => { const at = (hourOffset: number): Date => {
const d = new Date(now); const d = new Date(now);
d.setDate(now.getDate() + dayOffset); d.setHours(now.getHours() + hourOffset);
d.setHours(hour, 0, 0, 0);
return d; return d;
}; };
const dueScheduled = await makeMission("due-scheduled", "Scheduled", at(-1, 20)); const inWindow = await makeMission("start-in-window", "Scheduled", at(-1), 240);
const dueActive = await makeMission("due-active", "Active", at(-3, 20)); const future = await makeMission("start-future", "Scheduled", at(1), 240);
const midnightEdge = await makeMission("midnight-edge", "Scheduled", at(-1, 23)); const elapsed = await makeMission("start-elapsed", "Scheduled", at(-5), 60);
const sameDayEarlier = await makeMission("same-day-earlier", "Scheduled", at(0, 8)); const alreadyActive = await makeMission("start-already-active", "Active", at(-1), 240);
const sameDayLater = await makeMission("same-day-later", "Scheduled", at(0, 13)); const cancelled = await makeMission("start-cancelled", "Cancelled", at(-1), 240);
const draftReady = await makeMission("draft-ready", "Ready", at(-1, 20)); const ids = [inWindow.id, future.id, elapsed.id, alreadyActive.id, cancelled.id];
const cancelled = await makeMission("cancelled", "Cancelled", at(-1, 20));
const result = await autoCompleteMissions(payload, now, { missionIds }); const result = await autoStartMissions(payload, now, { missionIds: ids });
expect(result.missions.map((m) => m.id).sort()).toEqual( expect(result.started).toBe(1);
[dueScheduled.id, dueActive.id, midnightEdge.id].sort(), expect(result.missions.map((m) => m.id)).toEqual([inWindow.id]);
);
expect((await fetchMission(dueScheduled.id)).ownershipAndStatus.status).toBe("Completed"); expect((await fetchMission(inWindow.id)).ownershipAndStatus.status).toBe("Active");
expect((await fetchMission(dueActive.id)).ownershipAndStatus.status).toBe("Completed"); expect((await fetchMission(future.id)).ownershipAndStatus.status).toBe("Scheduled");
expect((await fetchMission(midnightEdge.id)).ownershipAndStatus.status).toBe("Completed"); // Fully elapsed windows are auto-complete's job, not a spurious start.
expect((await fetchMission(sameDayEarlier.id)).ownershipAndStatus.status).toBe("Scheduled"); expect((await fetchMission(elapsed.id)).ownershipAndStatus.status).toBe("Scheduled");
expect((await fetchMission(sameDayLater.id)).ownershipAndStatus.status).toBe("Scheduled"); expect((await fetchMission(alreadyActive.id)).ownershipAndStatus.status).toBe("Active");
expect((await fetchMission(draftReady.id)).ownershipAndStatus.status).toBe("Ready");
expect((await fetchMission(cancelled.id)).ownershipAndStatus.status).toBe("Cancelled"); expect((await fetchMission(cancelled.id)).ownershipAndStatus.status).toBe("Cancelled");
// The partial group update must not wipe sibling fields. // The partial group update must not wipe sibling fields.
const updated = await fetchMission(dueScheduled.id); const updated = await fetchMission(inWindow.id);
expect(updated.ownershipAndStatus.visibility).toBe("unit"); expect(updated.ownershipAndStatus.visibility).toBe("unit");
const authorRefs = Array.isArray(updated.ownershipAndStatus.authors) const authorRefs = Array.isArray(updated.ownershipAndStatus.authors)
? updated.ownershipAndStatus.authors ? updated.ownershipAndStatus.authors
@ -190,10 +193,49 @@ describe("Mission auto-complete", () => {
expect(authorRefs).toContain(authorId); expect(authorRefs).toContain(authorId);
}); });
it("is idempotent — a second sweep finds nothing left to complete", async () => { it("is idempotent — a second auto-start sweep finds nothing left to start", async () => {
const now = new Date(); const now = new Date();
now.setHours(12, 0, 0, 0); now.setHours(12, 0, 0, 0);
const result = await autoCompleteMissions(payload, now, { missionIds }); const result = await autoStartMissions(payload, now, { missionIds });
expect(result.started).toBe(0);
expect(result.missions).toEqual([]);
});
it("auto-completes Scheduled and Active missions once start + estimatedDuration has passed", async () => {
const now = new Date();
now.setHours(12, 0, 0, 0);
const at = (hourOffset: number): Date => {
const d = new Date(now);
d.setHours(now.getHours() + hourOffset);
return d;
};
const fromScheduled = await makeMission("done-from-scheduled", "Scheduled", at(-6), 240);
const fromActive = await makeMission("done-from-active", "Active", at(-5), 60);
const inWindow = await makeMission("done-in-window", "Active", at(-1), 240);
const readyPast = await makeMission("done-ready", "Ready", at(-5), 60);
const cancelledPast = await makeMission("done-cancelled", "Cancelled", at(-5), 60);
const ids = [fromScheduled.id, fromActive.id, inWindow.id, readyPast.id, cancelledPast.id];
const result = await autoCompleteMissions(payload, now, { missionIds: ids });
expect(result.completed).toBe(2);
expect(result.missions.map((m) => m.id).sort()).toEqual([fromScheduled.id, fromActive.id].sort());
completedBySweep.push(...result.missions.map((m) => m.id));
expect((await fetchMission(fromScheduled.id)).ownershipAndStatus.status).toBe("Completed");
expect((await fetchMission(fromActive.id)).ownershipAndStatus.status).toBe("Completed");
expect((await fetchMission(inWindow.id)).ownershipAndStatus.status).toBe("Active");
// Draft and terminal statuses are never auto-completed.
expect((await fetchMission(readyPast.id)).ownershipAndStatus.status).toBe("Ready");
expect((await fetchMission(cancelledPast.id)).ownershipAndStatus.status).toBe("Cancelled");
});
it("is idempotent — a second auto-complete sweep finds nothing left to complete", async () => {
const now = new Date();
now.setHours(12, 0, 0, 0);
const result = await autoCompleteMissions(payload, now, { missionIds: completedBySweep });
expect(result.completed).toBe(0); expect(result.completed).toBe(0);
expect(result.missions).toEqual([]); expect(result.missions).toEqual([]);
}); });
@ -202,10 +244,10 @@ describe("Mission auto-complete", () => {
const now = new Date(); const now = new Date();
now.setHours(15, 0, 0, 0); now.setHours(15, 0, 0, 0);
const start = new Date(now); const start = new Date(now);
start.setDate(now.getDate() - 1); start.setDate(now.getDate() - 2);
start.setHours(20, 0, 0, 0); start.setHours(20, 0, 0, 0);
const due = await makeMission("timestamp-due", "Scheduled", start); const due = await makeMission("timestamp-due", "Scheduled", start, 60);
const result = await autoCompleteMissions(payload, now, { missionIds: [due.id] }); const result = await autoCompleteMissions(payload, now, { missionIds: [due.id] });
expect(result.completed).toBe(1); expect(result.completed).toBe(1);
@ -217,11 +259,11 @@ describe("Mission auto-complete", () => {
it("stamps completedAt once on manual transition to Completed and never overwrites it", async () => { it("stamps completedAt once on manual transition to Completed and never overwrites it", async () => {
const start = new Date(); const start = new Date();
// Future start date — the auto-complete sweep must never touch this mission. // Future start date — neither sweep must ever touch this mission.
start.setDate(start.getDate() + 1); start.setDate(start.getDate() + 1);
start.setHours(20, 0, 0, 0); start.setHours(20, 0, 0, 0);
const mission = await makeMission("manual-complete", "Scheduled", start); const mission = await makeMission("manual-complete", "Scheduled", start, 60);
expect(mission.ownershipAndStatus.completedAt).toBeNull(); expect(mission.ownershipAndStatus.completedAt).toBeNull();
// Non-terminal transitions must not stamp a completion time. // Non-terminal transitions must not stamp a completion time.
@ -271,13 +313,41 @@ describe("Mission auto-complete", () => {
); );
}); });
it("classifies day boundaries deterministically", () => { it("classifies start and window boundaries deterministically", () => {
const now = new Date(2026, 7, 24, 12, 0, 0); // missionEndTime adds the duration in minutes to the start.
expect(startOfToday(now).getTime()).toBe(new Date(2026, 7, 24, 0, 0, 0).getTime()); expect(missionEndTime("2026-08-24T20:00:00.000Z", 240)?.toISOString()).toBe(
expect(isPastScheduledDay("2026-08-23T23:59:59.999", now)).toBe(true); "2026-08-25T00:00:00.000Z",
expect(isPastScheduledDay("2026-08-24T00:00:00.000", now)).toBe(false); );
expect(isPastScheduledDay("2026-08-24T19:30:00.000", now)).toBe(false); // Missing / invalid durations fall back to the default.
expect(isPastScheduledDay("2026-08-25T00:00:00.000", now)).toBe(false); expect(missionEndTime("2026-08-24T20:00:00.000Z", null)?.toISOString()).toBe(
expect(isPastScheduledDay("not-a-date", now)).toBe(false); new Date(
new Date("2026-08-24T20:00:00.000Z").getTime() +
DEFAULT_MISSION_DURATION_MINUTES * 60_000,
).toISOString(),
);
expect(missionEndTime("not-a-date", 60)).toBeNull();
const now = new Date("2026-08-24T20:00:00.000Z");
expect(hasMissionStarted("2026-08-24T19:59:59.000Z", now)).toBe(true);
expect(hasMissionStarted("2026-08-24T20:00:00.000Z", now)).toBe(true);
expect(hasMissionStarted("2026-08-24T20:00:01.000Z", now)).toBe(false);
expect(hasMissionStarted("not-a-date", now)).toBe(false);
expect(isMissionWindowComplete("2026-08-24T20:00:00.000Z", 240, now)).toBe(false);
expect(
isMissionWindowComplete(
"2026-08-24T20:00:00.000Z",
240,
new Date("2026-08-25T00:00:00.000Z"),
),
).toBe(true);
expect(
isMissionWindowComplete(
"2026-08-24T20:00:00.000Z",
240,
new Date("2026-08-24T23:59:59.999Z"),
),
).toBe(false);
expect(isMissionWindowComplete("not-a-date", 240, now)).toBe(false);
}); });
}); });