1
0
Fork 0

feat(missions): schedule Friday side ops in the unit timezone

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

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
Jason Fraley 2026-09-07 00:34:35 -04:00
parent 11ed89fd10
commit db73170da1
4 changed files with 135 additions and 36 deletions

View file

@ -10,6 +10,14 @@ import { isPayloadUser } from "@/utils/access-control/isPayloadUser";
import { emitGameEvent } from "@/utils/event-log/emit"; import { emitGameEvent } from "@/utils/event-log/emit";
import { EventTypes } from "@/utils/event-log/eventTypes"; import { EventTypes } from "@/utils/event-log/eventTypes";
import { textToLexical } from "@/lib/intelligence/briefingText"; import { textToLexical } from "@/lib/intelligence/briefingText";
import {
nextDayKey,
parseDateKey,
wallTimeToInstant,
zonedDateKey,
zonedParts,
zonedWeekday,
} from "@/lib/intelligence/fridayTime";
import type { Mission } from "@/payload-types"; import type { Mission } from "@/payload-types";
export interface ActionResult<T = undefined> { export interface ActionResult<T = undefined> {
@ -74,14 +82,18 @@ function isFridayDate(value: string): boolean {
if (Number.isNaN(date.getTime())) { if (Number.isNaN(date.getTime())) {
return false; return false;
} }
return date.getDay() === 5; return zonedWeekday(date) === 5;
} }
// Side ops always start 20:00 server-local (Eastern) on the validated Friday. // Side ops always start 20:00 America/New_York on the validated Friday,
// regardless of the server's own timezone (prod runs UTC).
function fridayStartDateTime(value: string): string { function fridayStartDateTime(value: string): string {
const date = new Date(value); const date = new Date(value);
date.setHours(20, 0, 0, 0); if (Number.isNaN(date.getTime())) {
return date.toISOString(); return date.toISOString();
}
const p = zonedParts(date);
return wallTimeToInstant(p.year, p.month, p.day, 20, 0).toISOString();
} }
type SideOpMissionUser = { type SideOpMissionUser = {
@ -317,10 +329,11 @@ async function findActiveMissionOnFriday(
excludeMissionId?: number, excludeMissionId?: number,
): Promise<{ id: number; name: string; codeName: string } | null> { ): Promise<{ id: number; name: string; codeName: string } | null> {
const start = new Date(fridayStartDateTime(startDateTime)); const start = new Date(fridayStartDateTime(startDateTime));
const dayStart = new Date(start); const key = zonedDateKey(start);
dayStart.setHours(0, 0, 0, 0); const { year, month, day } = parseDateKey(key);
const dayEnd = new Date(dayStart); const next = parseDateKey(nextDayKey(key));
dayEnd.setDate(dayEnd.getDate() + 1); const dayStart = wallTimeToInstant(year, month, day);
const dayEnd = wallTimeToInstant(next.year, next.month, next.day);
const res = await payload.find({ const res = await payload.find({
collection: "missions", collection: "missions",

View file

@ -7,6 +7,13 @@ import {
type FridayCalendarMission, type FridayCalendarMission,
} from "@/components/frontend/intelligence/FridayCalendar"; } from "@/components/frontend/intelligence/FridayCalendar";
import type { SideOpDialogOption } from "@/components/frontend/intelligence/CreateSideOpDialog"; import type { SideOpDialogOption } from "@/components/frontend/intelligence/CreateSideOpDialog";
import {
dateKeyOf,
nextDayKey,
parseDateKey,
wallTimeToInstant,
zonedDateKey,
} from "@/lib/intelligence/fridayTime";
export const metadata = { export const metadata = {
title: "Friday Operations", title: "Friday Operations",
@ -16,15 +23,16 @@ const MAX_SCHEDULED_AHEAD_MISSIONS = 2;
const FRIDAYS_SHOWN = 8; const FRIDAYS_SHOWN = 8;
function nextFridays(count: number): Date[] { function nextFridays(count: number): string[] {
const fridays: Date[] = []; const fridays: string[] = [];
const cursor = new Date(); const cursor = zonedDateKey(new Date());
cursor.setHours(0, 0, 0, 0); const [y, m, d] = cursor.split("-").map(Number);
const utc = new Date(Date.UTC(y, m - 1, d));
while (fridays.length < count) { while (fridays.length < count) {
if (cursor.getDay() === 5) { if (utc.getUTCDay() === 5) {
fridays.push(new Date(cursor)); fridays.push(dateKeyOf(utc.getUTCFullYear(), utc.getUTCMonth() + 1, utc.getUTCDate()));
} }
cursor.setDate(cursor.getDate() + 1); utc.setUTCDate(utc.getUTCDate() + 1);
} }
return fridays; return fridays;
} }
@ -38,9 +46,10 @@ export default async function FridayCalendarPage() {
}); });
const fridays = nextFridays(FRIDAYS_SHOWN); const fridays = nextFridays(FRIDAYS_SHOWN);
const rangeStart = fridays[0]; const first = parseDateKey(fridays[0]);
const rangeEnd = new Date(fridays[fridays.length - 1]); const last = parseDateKey(nextDayKey(fridays[fridays.length - 1]));
rangeEnd.setDate(rangeEnd.getDate() + 1); const rangeStart = wallTimeToInstant(first.year, first.month, first.day);
const rangeEnd = wallTimeToInstant(last.year, last.month, last.day);
const missionsRes = await payload.find({ const missionsRes = await payload.find({
collection: "missions", collection: "missions",
@ -63,15 +72,9 @@ export default async function FridayCalendarPage() {
}, },
}); });
const entries: FridayCalendarEntry[] = fridays.map((friday) => { const entries: FridayCalendarEntry[] = fridays.map((fridayKey) => {
const dayStart = new Date(friday);
const dayEnd = new Date(friday);
dayEnd.setDate(dayEnd.getDate() + 1);
const missions: FridayCalendarMission[] = missionsRes.docs const missions: FridayCalendarMission[] = missionsRes.docs
.filter((m) => { .filter((m) => zonedDateKey(new Date(m.classification.startDateTime)) === fridayKey)
const start = new Date(m.classification.startDateTime);
return start >= dayStart && start < dayEnd;
})
.map((m) => { .map((m) => {
const map = m.classification.map; const map = m.classification.map;
const createdBy = m.createdBy; const createdBy = m.createdBy;
@ -88,7 +91,12 @@ export default async function FridayCalendarPage() {
(typeof createdBy === "object" ? createdBy?.id : createdBy) === user.id, (typeof createdBy === "object" ? createdBy?.id : createdBy) === user.id,
}; };
}); });
return { fridayIso: friday.toISOString(), missions }; const k = parseDateKey(fridayKey);
return {
fridayIso: fridayKey,
startIso: wallTimeToInstant(k.year, k.month, k.day, 20, 0).toISOString(),
missions,
};
}); });
let remainingSlots = 0; let remainingSlots = 0;

View file

@ -7,6 +7,7 @@ import { CalendarDaysIcon, ChevronRightIcon, PlusIcon } from "lucide-react";
import { Item, ItemContent, ItemHeader, ItemTitle } from "@/components/ui/item"; import { Item, ItemContent, ItemHeader, ItemTitle } from "@/components/ui/item";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { CreateSideOpDialog, type SideOpDialogOption } from "./CreateSideOpDialog"; import { CreateSideOpDialog, type SideOpDialogOption } from "./CreateSideOpDialog";
import { parseDateKey } from "@/lib/intelligence/fridayTime";
export interface FridayCalendarMission { export interface FridayCalendarMission {
id: number; id: number;
@ -21,6 +22,7 @@ export interface FridayCalendarMission {
export interface FridayCalendarEntry { export interface FridayCalendarEntry {
fridayIso: string; fridayIso: string;
startIso: string;
missions: FridayCalendarMission[]; missions: FridayCalendarMission[];
} }
@ -44,7 +46,8 @@ const statusColors: Record<string, string> = {
}; };
function formatFriday(iso: string): string { function formatFriday(iso: string): string {
return new Date(iso).toLocaleDateString(undefined, { const { year, month, day } = parseDateKey(iso);
return new Date(year, month - 1, day).toLocaleDateString(undefined, {
weekday: "long", weekday: "long",
month: "long", month: "long",
day: "numeric", day: "numeric",
@ -58,12 +61,6 @@ function formatTime(iso: string): string {
}); });
} }
function defaultStartForFriday(fridayIso: string): string {
const date = new Date(fridayIso);
date.setHours(20, 0, 0, 0);
return date.toISOString();
}
export function FridayCalendar({ export function FridayCalendar({
entries, entries,
remainingSlots, remainingSlots,
@ -119,7 +116,7 @@ export function FridayCalendar({
size="sm" size="sm"
variant="outline" variant="outline"
disabled={!canSchedule} disabled={!canSchedule}
onClick={() => setScheduleFor(entry.fridayIso)} onClick={() => setScheduleFor(entry.startIso)}
> >
<PlusIcon className="size-3.5" /> <PlusIcon className="size-3.5" />
{fridayTaken ? "Taken" : "Schedule"} {fridayTaken ? "Taken" : "Schedule"}
@ -182,7 +179,7 @@ export function FridayCalendar({
maps={maps} maps={maps}
campaigns={campaigns} campaigns={campaigns}
modLists={modLists} modLists={modLists}
defaultStartDateTime={scheduleFor ? defaultStartForFriday(scheduleFor) : undefined} defaultStartDateTime={scheduleFor ?? undefined}
onSaved={() => router.refresh()} onSaved={() => router.refresh()}
/> />
</div> </div>

View file

@ -0,0 +1,81 @@
export const UNIT_TIME_ZONE = "America/New_York";
export interface ZonedParts {
year: number;
month: number;
day: number;
hour: number;
minute: number;
second: number;
}
export function zonedParts(date: Date, timeZone: string = UNIT_TIME_ZONE): ZonedParts {
const parts = new Intl.DateTimeFormat("en-US", {
timeZone,
hour12: false,
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
}).formatToParts(date);
const get = (type: string) => Number(parts.find((p) => p.type === type)?.value ?? 0);
return {
year: get("year"),
month: get("month"),
day: get("day"),
hour: get("hour") % 24,
minute: get("minute"),
second: get("second"),
};
}
function pad2(n: number): string {
return String(n).padStart(2, "0");
}
export function dateKeyOf(year: number, month: number, day: number): string {
return `${String(year).padStart(4, "0")}-${pad2(month)}-${pad2(day)}`;
}
export function zonedDateKey(date: Date, timeZone: string = UNIT_TIME_ZONE): string {
const p = zonedParts(date, timeZone);
return dateKeyOf(p.year, p.month, p.day);
}
function tzOffsetMs(date: Date, timeZone: string): number {
const p = zonedParts(date, timeZone);
const asUtc = Date.UTC(p.year, p.month - 1, p.day, p.hour, p.minute, p.second);
return asUtc - date.getTime();
}
export function wallTimeToInstant(
year: number,
month: number,
day: number,
hour = 0,
minute = 0,
timeZone: string = UNIT_TIME_ZONE,
): Date {
const guess = Date.UTC(year, month - 1, day, hour, minute);
let ts = guess - tzOffsetMs(new Date(guess), timeZone);
ts = guess - tzOffsetMs(new Date(ts), timeZone);
return new Date(ts);
}
export function nextDayKey(key: string): string {
const [y, m, d] = key.split("-").map(Number);
const next = new Date(Date.UTC(y, m - 1, d + 1));
return dateKeyOf(next.getUTCFullYear(), next.getUTCMonth() + 1, next.getUTCDate());
}
export function zonedWeekday(date: Date, timeZone: string = UNIT_TIME_ZONE): number {
const p = zonedParts(date, timeZone);
return new Date(Date.UTC(p.year, p.month - 1, p.day)).getUTCDay();
}
export function parseDateKey(key: string): { year: number; month: number; day: number } {
const [y, m, d] = key.split("-").map(Number);
return { year: y, month: m, day: d };
}