1
0
Fork 0
polaris-task-force/src/components/frontend/intelligence/FridayCalendar.tsx
Z8MB1E 03f699d852 feat(missions): add Friday Ops calendar page and side-op dialog UI
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-09-06 23:29:54 -04:00

190 lines
6.7 KiB
TypeScript

"use client";
import { useMemo, useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
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";
export interface FridayCalendarMission {
id: number;
name: string;
codeName: string;
status: string;
startDateTime: string;
operationType: string;
mapName: string | null;
isOwn: boolean;
}
export interface FridayCalendarEntry {
fridayIso: string;
missions: FridayCalendarMission[];
}
interface FridayCalendarProps {
entries: FridayCalendarEntry[];
remainingSlots: number;
maxSlots: number;
maps: SideOpDialogOption[];
campaigns: SideOpDialogOption[];
modLists: SideOpDialogOption[];
}
const statusColors: Record<string, string> = {
Concept: "bg-muted text-muted-foreground",
Planning: "bg-muted text-muted-foreground",
Ready: "bg-blue-500/20 text-blue-400",
Scheduled: "bg-amber-500/20 text-amber-400",
Active: "bg-green-500/20 text-green-400",
Completed: "bg-emerald-500/20 text-emerald-400",
Cancelled: "bg-red-500/20 text-red-400",
};
function formatFriday(iso: string): string {
return new Date(iso).toLocaleDateString(undefined, {
weekday: "long",
month: "long",
day: "numeric",
});
}
function formatTime(iso: string): string {
return new Date(iso).toLocaleTimeString(undefined, {
hour: "2-digit",
minute: "2-digit",
});
}
function defaultStartForFriday(fridayIso: string): string {
const date = new Date(fridayIso);
date.setHours(20, 0, 0, 0);
return date.toISOString();
}
export function FridayCalendar({
entries,
remainingSlots,
maxSlots,
maps,
campaigns,
modLists,
}: FridayCalendarProps) {
const router = useRouter();
const [scheduleFor, setScheduleFor] = useState<string | null>(null);
const upcoming = useMemo(() => entries, [entries]);
return (
<div className="flex flex-col gap-6">
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<h1 className="text-2xl font-bold tracking-tight">Friday Operations</h1>
<p className="text-sm text-muted-foreground">
Schedule and manage player-run Friday side operations. Each Friday gets Discord
roll-call and RSVP automatically.
</p>
</div>
<div className="flex items-center gap-2">
<span
className={`rounded-md border px-2.5 py-1 text-xs font-medium ${
remainingSlots > 0
? "border-emerald-500/30 bg-emerald-500/10 text-emerald-400"
: "border-red-500/30 bg-red-500/10 text-red-400"
}`}
>
{remainingSlots > 0
? `You can schedule ${remainingSlots} more mission${remainingSlots === 1 ? "" : "s"}`
: `Scheduling limit reached (${maxSlots} ahead)`}
</span>
</div>
</div>
<div className="flex flex-col gap-4">
{upcoming.map((entry) => {
const fridayTaken = entry.missions.some(
(m) => m.status !== "Completed" && m.status !== "Cancelled",
);
const canSchedule = remainingSlots > 0 && !fridayTaken;
return (
<Item key={entry.fridayIso} variant="outline">
<ItemHeader>
<div className="flex items-center gap-2">
<CalendarDaysIcon className="size-4 text-muted-foreground" />
<ItemTitle>{formatFriday(entry.fridayIso)}</ItemTitle>
</div>
<Button
size="sm"
variant="outline"
disabled={!canSchedule}
onClick={() => setScheduleFor(entry.fridayIso)}
>
<PlusIcon className="size-3.5" />
{fridayTaken ? "Taken" : "Schedule"}
</Button>
</ItemHeader>
<ItemContent>
{entry.missions.length === 0 ? (
<p className="text-sm text-muted-foreground">
Nothing scheduled yet — claim this Friday.
</p>
) : (
<div className="flex flex-col gap-1.5">
{entry.missions.map((m) => (
<Link
key={m.id}
href={`/intelligence/missions/${m.id}`}
className="group flex items-center gap-3 rounded-md p-1.5 transition-colors hover:bg-muted/50"
>
<span className="w-14 shrink-0 font-mono text-xs text-muted-foreground">
{formatTime(m.startDateTime)}
</span>
<div className="flex min-w-0 flex-1 flex-col">
<span className="truncate text-sm transition-colors group-hover:text-primary">
{m.name}
</span>
<span className="truncate font-mono text-[10px] text-muted-foreground">
{m.codeName}
{m.mapName ? ` · ${m.mapName}` : ""}
{m.isOwn ? " · yours" : ""}
</span>
</div>
<span
className={`shrink-0 rounded px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wider ${
statusColors[m.status] ?? "bg-muted text-muted-foreground"
}`}
>
{m.status}
</span>
<ChevronRightIcon className="size-3 shrink-0 text-muted-foreground opacity-0 transition-opacity group-hover:opacity-100" />
</Link>
))}
</div>
)}
</ItemContent>
</Item>
);
})}
</div>
<p className="text-sm text-muted-foreground text-center">
Fridays beyond this date are unavailable for scheduling via the web app.<br/><br/>If you want to schedule an
op further out than the dates listed here, please contact <b>Jason (Z8MB1E)</b> directly.
</p>
<CreateSideOpDialog
open={scheduleFor !== null}
onOpenChange={(open) => {
if (!open) setScheduleFor(null);
}}
maps={maps}
campaigns={campaigns}
modLists={modLists}
defaultStartDateTime={scheduleFor ? defaultStartForFriday(scheduleFor) : undefined}
onSaved={() => router.refresh()}
/>
</div>
);
}