diff --git a/src/app/(frontend)/intelligence/missions/actions.ts b/src/app/(frontend)/intelligence/missions/actions.ts index ca8d8a6..c999663 100644 --- a/src/app/(frontend)/intelligence/missions/actions.ts +++ b/src/app/(frontend)/intelligence/missions/actions.ts @@ -10,6 +10,14 @@ import { isPayloadUser } from "@/utils/access-control/isPayloadUser"; import { emitGameEvent } from "@/utils/event-log/emit"; import { EventTypes } from "@/utils/event-log/eventTypes"; import { textToLexical } from "@/lib/intelligence/briefingText"; +import { + nextDayKey, + parseDateKey, + wallTimeToInstant, + zonedDateKey, + zonedParts, + zonedWeekday, +} from "@/lib/intelligence/fridayTime"; import type { Mission } from "@/payload-types"; export interface ActionResult { @@ -74,14 +82,18 @@ function isFridayDate(value: string): boolean { if (Number.isNaN(date.getTime())) { 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 { const date = new Date(value); - date.setHours(20, 0, 0, 0); - return date.toISOString(); + if (Number.isNaN(date.getTime())) { + return date.toISOString(); + } + const p = zonedParts(date); + return wallTimeToInstant(p.year, p.month, p.day, 20, 0).toISOString(); } type SideOpMissionUser = { @@ -317,10 +329,11 @@ async function findActiveMissionOnFriday( excludeMissionId?: number, ): Promise<{ id: number; name: string; codeName: string } | null> { const start = new Date(fridayStartDateTime(startDateTime)); - const dayStart = new Date(start); - dayStart.setHours(0, 0, 0, 0); - const dayEnd = new Date(dayStart); - dayEnd.setDate(dayEnd.getDate() + 1); + const key = zonedDateKey(start); + const { year, month, day } = parseDateKey(key); + const next = parseDateKey(nextDayKey(key)); + const dayStart = wallTimeToInstant(year, month, day); + const dayEnd = wallTimeToInstant(next.year, next.month, next.day); const res = await payload.find({ collection: "missions", diff --git a/src/app/(frontend)/intelligence/missions/calendar/page.tsx b/src/app/(frontend)/intelligence/missions/calendar/page.tsx index 9055eb8..0d434a8 100644 --- a/src/app/(frontend)/intelligence/missions/calendar/page.tsx +++ b/src/app/(frontend)/intelligence/missions/calendar/page.tsx @@ -7,6 +7,13 @@ import { type FridayCalendarMission, } from "@/components/frontend/intelligence/FridayCalendar"; import type { SideOpDialogOption } from "@/components/frontend/intelligence/CreateSideOpDialog"; +import { + dateKeyOf, + nextDayKey, + parseDateKey, + wallTimeToInstant, + zonedDateKey, +} from "@/lib/intelligence/fridayTime"; export const metadata = { title: "Friday Operations", @@ -16,15 +23,16 @@ const MAX_SCHEDULED_AHEAD_MISSIONS = 2; const FRIDAYS_SHOWN = 8; -function nextFridays(count: number): Date[] { - const fridays: Date[] = []; - const cursor = new Date(); - cursor.setHours(0, 0, 0, 0); +function nextFridays(count: number): string[] { + const fridays: string[] = []; + const cursor = zonedDateKey(new Date()); + const [y, m, d] = cursor.split("-").map(Number); + const utc = new Date(Date.UTC(y, m - 1, d)); while (fridays.length < count) { - if (cursor.getDay() === 5) { - fridays.push(new Date(cursor)); + if (utc.getUTCDay() === 5) { + fridays.push(dateKeyOf(utc.getUTCFullYear(), utc.getUTCMonth() + 1, utc.getUTCDate())); } - cursor.setDate(cursor.getDate() + 1); + utc.setUTCDate(utc.getUTCDate() + 1); } return fridays; } @@ -38,9 +46,10 @@ export default async function FridayCalendarPage() { }); const fridays = nextFridays(FRIDAYS_SHOWN); - const rangeStart = fridays[0]; - const rangeEnd = new Date(fridays[fridays.length - 1]); - rangeEnd.setDate(rangeEnd.getDate() + 1); + const first = parseDateKey(fridays[0]); + const last = parseDateKey(nextDayKey(fridays[fridays.length - 1])); + const rangeStart = wallTimeToInstant(first.year, first.month, first.day); + const rangeEnd = wallTimeToInstant(last.year, last.month, last.day); const missionsRes = await payload.find({ collection: "missions", @@ -63,15 +72,9 @@ export default async function FridayCalendarPage() { }, }); - const entries: FridayCalendarEntry[] = fridays.map((friday) => { - const dayStart = new Date(friday); - const dayEnd = new Date(friday); - dayEnd.setDate(dayEnd.getDate() + 1); + const entries: FridayCalendarEntry[] = fridays.map((fridayKey) => { const missions: FridayCalendarMission[] = missionsRes.docs - .filter((m) => { - const start = new Date(m.classification.startDateTime); - return start >= dayStart && start < dayEnd; - }) + .filter((m) => zonedDateKey(new Date(m.classification.startDateTime)) === fridayKey) .map((m) => { const map = m.classification.map; const createdBy = m.createdBy; @@ -88,7 +91,12 @@ export default async function FridayCalendarPage() { (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; diff --git a/src/components/frontend/intelligence/FridayCalendar.tsx b/src/components/frontend/intelligence/FridayCalendar.tsx index fd43057..71cb76c 100644 --- a/src/components/frontend/intelligence/FridayCalendar.tsx +++ b/src/components/frontend/intelligence/FridayCalendar.tsx @@ -7,6 +7,7 @@ import { CalendarDaysIcon, ChevronRightIcon, PlusIcon } from "lucide-react"; import { Item, ItemContent, ItemHeader, ItemTitle } from "@/components/ui/item"; import { Button } from "@/components/ui/button"; import { CreateSideOpDialog, type SideOpDialogOption } from "./CreateSideOpDialog"; +import { parseDateKey } from "@/lib/intelligence/fridayTime"; export interface FridayCalendarMission { id: number; @@ -21,6 +22,7 @@ export interface FridayCalendarMission { export interface FridayCalendarEntry { fridayIso: string; + startIso: string; missions: FridayCalendarMission[]; } @@ -44,7 +46,8 @@ const statusColors: Record = { }; 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", month: "long", 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({ entries, remainingSlots, @@ -119,7 +116,7 @@ export function FridayCalendar({ size="sm" variant="outline" disabled={!canSchedule} - onClick={() => setScheduleFor(entry.fridayIso)} + onClick={() => setScheduleFor(entry.startIso)} > {fridayTaken ? "Taken" : "Schedule"} @@ -182,7 +179,7 @@ export function FridayCalendar({ maps={maps} campaigns={campaigns} modLists={modLists} - defaultStartDateTime={scheduleFor ? defaultStartForFriday(scheduleFor) : undefined} + defaultStartDateTime={scheduleFor ?? undefined} onSaved={() => router.refresh()} /> diff --git a/src/lib/intelligence/fridayTime.ts b/src/lib/intelligence/fridayTime.ts new file mode 100644 index 0000000..26762f3 --- /dev/null +++ b/src/lib/intelligence/fridayTime.ts @@ -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 }; +}