"use client"; import { useState, useMemo, useEffect, useRef, useCallback, useTransition } from "react"; import Link from "next/link"; import { useRouter } from "next/navigation"; import { publishStop } from "@/actions/stops"; import { useToast } from "@/components/admin/design-system"; import type { StopForView } from "./StopsViewClient"; type Props = { stops: StopForView[]; }; /* ================================================================== */ /* Date helpers — work with YYYY-MM-DD strings to avoid TZ drift */ /* ================================================================== */ function ymd(d: Date): string { const y = d.getFullYear(); const m = String(d.getMonth() + 1).padStart(2, "0"); const day = String(d.getDate()).padStart(2, "0"); return `${y}-${m}-${day}`; } function parseYmd(s: string): Date | null { // Treat YYYY-MM-DD as a local date (no UTC shift) const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(s); if (!m) return null; const [, y, mo, d] = m; return new Date(Number(y), Number(mo) - 1, Number(d)); } function todayYmd(): string { return ymd(new Date()); } const MONTH_NAMES = [ "January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December", ]; const MONTH_NAMES_ITALIC = [ "January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December", ]; const DOW_LABELS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]; function buildMonthGrid(month: Date): Date[] { // Start at the Sunday on or before the 1st of the month const first = new Date(month.getFullYear(), month.getMonth(), 1); const startDayOfWeek = first.getDay(); // 0 = Sun const start = new Date(first); start.setDate(1 - startDayOfWeek); // 6 weeks = 42 cells const cells: Date[] = []; for (let i = 0; i < 42; i++) { const d = new Date(start); d.setDate(start.getDate() + i); cells.push(d); } return cells; } function statusOf(stop: StopForView): "active" | "draft" | "inactive" { if (stop.status === "draft") return "draft"; if (stop.active) return "active"; return "inactive"; } function statusLabel(s: "active" | "draft" | "inactive"): string { return s === "draft" ? "Draft" : s === "active" ? "Active" : "Inactive"; } function brandName(s: StopForView): string { return Array.isArray(s.brands) ? s.brands[0]?.name ?? "—" : s.brands?.name ?? "—"; } function formatTime12(t: string | null | undefined): string { if (!t) return "—"; // t is "HH:MM" or "HH:MM:SS" const [hStr = "0", mStr = "00"] = t.split(":"); const h = Number(hStr); const ampm = h >= 12 ? "pm" : "am"; const h12 = h === 0 ? 12 : h > 12 ? h - 12 : h; return `${h12}:${mStr} ${ampm}`; } function formatTimeCompact(t: string | null | undefined): string { if (!t) return "—"; const [hStr = "0", mStr = "00"] = t.split(":"); const h = Number(hStr); const ampm = h >= 12 ? "p" : "a"; const h12 = h === 0 ? 12 : h > 12 ? h - 12 : h; return `${h12}:${mStr}${ampm}`; } function formatDateLong(d: Date): string { const weekday = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"][d.getDay()]; const month = MONTH_NAMES[d.getMonth()]; return `${weekday}, ${month} ${d.getDate()}`; } function formatDateLongSpelled(d: Date): string { const weekday = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"][d.getDay()]; const month = MONTH_NAMES[d.getMonth()]; return `${weekday}, ${month} ${d.getDate()}, ${d.getFullYear()}`; } /* ================================================================== */ /* Component */ /* ================================================================== */ const MAX_EVENTS_PER_CELL = 3; export default function StopsCalendarClient({ stops }: Props) { const router = useRouter(); const { success: showSuccess, error: showError } = useToast(); const [, startTransition] = useTransition(); const [viewMonth, setViewMonth] = useState(() => { const today = new Date(); return new Date(today.getFullYear(), today.getMonth(), 1); }); const [selectedDate, setSelectedDate] = useState(null); const [activePopover, setActivePopover] = useState<{ stopId: string; cellRect: DOMRect; } | null>(null); const [publishingId, setPublishingId] = useState(null); // Bucket stops by YYYY-MM-DD const stopsByDate = useMemo(() => { const map = new Map(); for (const s of stops) { if (!s.date) continue; const list = map.get(s.date) ?? []; list.push(s); map.set(s.date, list); } // Sort each day by time for (const list of map.values()) { list.sort((a, b) => (a.time || "").localeCompare(b.time || "")); } return map; }, [stops]); const todayStr = useMemo(() => todayYmd(), []); const monthCells = useMemo(() => buildMonthGrid(viewMonth), [viewMonth]); // Earliest/latest stop dates — used to enable/disable nav const dateRange = useMemo(() => { if (stops.length === 0) return null; const dates = stops.map((s) => s.date).filter(Boolean).sort(); return { min: dates[0], max: dates[dates.length - 1] }; }, [stops]); const isFirstMonth = useMemo(() => { if (!dateRange?.min) return false; const minDate = parseYmd(dateRange.min); if (!minDate) return false; return viewMonth.getFullYear() === minDate.getFullYear() && viewMonth.getMonth() === minDate.getMonth(); }, [dateRange, viewMonth]); const isLastMonth = useMemo(() => { if (!dateRange?.max) return false; const maxDate = parseYmd(dateRange.max); if (!maxDate) return false; return viewMonth.getFullYear() === maxDate.getFullYear() && viewMonth.getMonth() === maxDate.getMonth(); }, [dateRange, viewMonth]); function goPrevMonth() { setViewMonth(new Date(viewMonth.getFullYear(), viewMonth.getMonth() - 1, 1)); } function goNextMonth() { setViewMonth(new Date(viewMonth.getFullYear(), viewMonth.getMonth() + 1, 1)); } function goToday() { const today = new Date(); setViewMonth(new Date(today.getFullYear(), today.getMonth(), 1)); setSelectedDate(todayYmd()); } // Close popover on outside click / Escape useEffect(() => { if (!activePopover) return; function onDown(e: MouseEvent) { const target = e.target as HTMLElement; if (target.closest("[data-event-popover]") || target.closest("[data-event-chip]")) return; setActivePopover(null); } function onKey(e: KeyboardEvent) { if (e.key === "Escape") setActivePopover(null); } document.addEventListener("mousedown", onDown); document.addEventListener("keydown", onKey); return () => { document.removeEventListener("mousedown", onDown); document.removeEventListener("keydown", onKey); }; }, [activePopover]); // Close drawer on Escape useEffect(() => { if (!selectedDate) return; function onKey(e: KeyboardEvent) { if (e.key === "Escape") setSelectedDate(null); } document.addEventListener("keydown", onKey); return () => document.removeEventListener("keydown", onKey); }, [selectedDate]); // Lock body scroll when drawer is open useEffect(() => { if (selectedDate) { const prev = document.body.style.overflow; document.body.style.overflow = "hidden"; return () => { document.body.style.overflow = prev; }; } }, [selectedDate]); const openStop = useCallback((stop: StopForView, anchorRect: DOMRect) => { setActivePopover({ stopId: stop.id, cellRect: anchorRect }); }, []); const handlePublish = useCallback( async (stop: StopForView) => { setActivePopover(null); setPublishingId(stop.id); const result = await publishStop(stop.id, stop.brand_id); setPublishingId(null); if (result.success) { showSuccess("Stop published", `${stop.city}, ${stop.state} is now visible to customers`); startTransition(() => router.refresh()); } else { showError("Failed to publish", result.error ?? "Please try again"); } }, [router, showSuccess, showError] ); const activeStop = useMemo( () => (activePopover ? stops.find((s) => s.id === activePopover.stopId) ?? null : null), [activePopover, stops] ); const selectedDayStops = useMemo(() => { if (!selectedDate) return []; return stopsByDate.get(selectedDate) ?? []; }, [selectedDate, stopsByDate]); const selectedDayDate = useMemo(() => (selectedDate ? parseYmd(selectedDate) : null), [selectedDate]); // Compute popover position with viewport edge detection const popoverStyle = useMemo(() => { if (!activePopover) return null; const POPOVER_W = 288; // 18rem const MARGIN = 8; const rect = activePopover.cellRect; const vw = typeof window !== "undefined" ? window.innerWidth : 1024; // Default: place below the chip, left-aligned let left = rect.left + rect.width / 2 - POPOVER_W / 2; const top = rect.bottom + MARGIN; if (left + POPOVER_W > vw - 8) left = vw - POPOVER_W - 8; if (left < 8) left = 8; return { left, top }; }, [activePopover]); const popoverArrowStyle = useMemo(() => { if (!activePopover || !popoverStyle) return null; const rect = activePopover.cellRect; const arrowLeft = rect.left + rect.width / 2 - (popoverStyle.left as number) - 5; return { left: Math.max(8, Math.min(arrowLeft, 270)) }; }, [activePopover, popoverStyle]); // === Render === return ( <>
{/* Header */}
Tour Almanac · {stops.length} {stops.length === 1 ? "stop" : "stops"} on file

{MONTH_NAMES_ITALIC[viewMonth.getMonth()]} {viewMonth.getFullYear()}

{/* DOW header */}
{DOW_LABELS.map((label) => (
{label}
))}
{/* Grid */}
{monthCells.map((d) => { const key = ymd(d); const dayStops = stopsByDate.get(key) ?? []; const isOut = d.getMonth() !== viewMonth.getMonth(); const isToday = key === todayStr; const hasStops = dayStops.length > 0; const dow = d.getDay(); const isWeekend = dow === 0 || dow === 6; const isSelected = selectedDate === key; return (
hasStops && setSelectedDate(key)} onKeyDown={(e) => { if (hasStops && (e.key === "Enter" || e.key === " ")) { e.preventDefault(); setSelectedDate(key); } }} className={[ "ha-calendar-cell", isOut ? "ha-calendar-cell--out" : "", isWeekend ? "ha-calendar-cell--weekend" : "", hasStops ? "ha-calendar-cell--has-stops" : "", isToday ? "ha-calendar-cell--today" : "", isSelected ? "ha-calendar-cell--selected" : "", ] .filter(Boolean) .join(" ")} aria-label={hasStops ? `${formatDateLong(d)} — ${dayStops.length} stop${dayStops.length !== 1 ? "s" : ""}` : formatDateLong(d)} >
{d.getDate()} {isToday && }
{dayStops.slice(0, MAX_EVENTS_PER_CELL).map((stop) => ( openStop(stop, rect)} isActive={activePopover?.stopId === stop.id} /> ))} {dayStops.length > MAX_EVENTS_PER_CELL && ( )}
); })}
{/* Legend */}
Active Draft Inactive Tip — click any day with stops to see the full route · click an event for details
{/* Event popover */} {activeStop && activePopover && popoverStyle && ( handlePublish(activeStop)} onOpenRoute={() => { setActivePopover(null); setSelectedDate(activeStop.date); }} style={popoverStyle} arrowStyle={popoverArrowStyle} /> )} {/* Day-route drawer */} {selectedDate && selectedDayDate && ( setSelectedDate(null)} onPublish={handlePublish} /> )} ); } /* ================================================================== */ /* EventChip — single stop on a day cell */ /* ================================================================== */ function EventChip({ stop, onOpen, isActive, isPublishing, }: { stop: StopForView; onOpen: (rect: DOMRect) => void; isActive: boolean; isPublishing: boolean; }) { const ref = useRef(null); const status = statusOf(stop); return ( ); } /* ================================================================== */ /* EventPopover — floating detail card */ /* ================================================================== */ function EventPopover({ stop, isPublishing, onPublish, onOpenRoute, style, arrowStyle, }: { stop: StopForView; isPublishing: boolean; onPublish: () => void; onOpenRoute: () => void; style: React.CSSProperties; arrowStyle: React.CSSProperties | null; }) { const status = statusOf(stop); return (
e.stopPropagation()} > {arrowStyle &&
}
{statusLabel(status)} · {brandName(stop)}

{stop.city}, {stop.state}

{stop.location}

Pickup {formatTime12(stop.time)}
Cutoff {stop.cutoff_time ? formatTime12(stop.cutoff_time) : "—"}
{stop.address && (
Address {stop.address}{stop.zip ? `, ${stop.zip}` : ""}
)}
{status === "draft" && ( )} Edit
); } /* ================================================================== */ /* DayRouteDrawer — full day's route on the side */ /* ================================================================== */ function DayRouteDrawer({ date, dateStr, stops, onClose, onPublish, }: { date: Date; dateStr: string; stops: StopForView[]; onClose: () => void; onPublish: (stop: StopForView) => void; }) { const sorted = useMemo(() => [...stops].sort((a, b) => (a.time || "").localeCompare(b.time || "")), [stops]); const counts = useMemo(() => { const active = sorted.filter((s) => s.status !== "draft" && s.active).length; const draft = sorted.filter((s) => s.status === "draft").length; const total = sorted.length; return { active, draft, total }; }, [sorted]); const brands = useMemo(() => Array.from(new Set(sorted.map(brandName))), [sorted]); const routeNumber = useMemo(() => { // Editorial "Route 03" — count of stops with status badge return String(sorted.length).padStart(2, "0"); }, [sorted.length]); const earliest = sorted[0]?.time; const latest = sorted[sorted.length - 1]?.time; return ( <>