import { useCallback, useEffect, useMemo, useState } from "react"; import { Link } from "react-router-dom"; import { api } from "../api/client"; import { useToast } from "../contexts/ToastContext"; interface BlackoutEntry { userId: number; username: string; reason: string | null; } interface ScheduleEntry { date: string; gameNightId: number; campaignId: number | null; campaignName: string | null; blackouts: BlackoutEntry[]; campaignDmUserIds: number[]; } interface Campaign { id: number; name: string; } interface Member { userId: number; username: string; avatarUrl: string | null; role: string; } interface DashboardData { role: string; myUserId: number; defaultGameDay: number | null; // 0=Sun … 6=Sat members: Member[]; entries: ScheduleEntry[]; myCampaigns: Campaign[]; allCampaigns: Campaign[]; } // ─── Helpers ────────────────────────────────────────────────────────────────── function parseLocalDate(dateStr: string): Date { const [y, m, d] = dateStr.split("-").map(Number); return new Date(y, m - 1, d); } function formatDate(dateStr: string) { return parseLocalDate(dateStr).toLocaleDateString("en-US", { month: "short", day: "numeric", }); } function formatDateLong(dateStr: string) { return parseLocalDate(dateStr).toLocaleDateString("en-US", { weekday: "long", month: "long", day: "numeric", }); } function getWeekday(dateStr: string) { return parseLocalDate(dateStr).toLocaleDateString("en-US", { weekday: "long" }); } function todayStr() { return new Date().toISOString().slice(0, 10); } function toLocalDateStr(d: Date) { return [ d.getFullYear(), String(d.getMonth() + 1).padStart(2, "0"), String(d.getDate()).padStart(2, "0"), ].join("-"); } /** Returns the next unscheduled date that falls on defaultDay (0=Sun…6=Sat). */ function nextDefaultNight(defaultDay: number, scheduledDates: Set): string { const d = new Date(); d.setHours(0, 0, 0, 0); // Advance to the nearest upcoming occurrence of defaultDay (today counts) const daysUntil = (defaultDay - d.getDay() + 7) % 7; d.setDate(d.getDate() + daysUntil); // Skip weeks that are already scheduled while (scheduledDates.has(toLocalDateStr(d))) { d.setDate(d.getDate() + 7); } return toLocalDateStr(d); } // ─── Tonight hero card ──────────────────────────────────────────────────────── function TonightCard({ entry }: { entry: ScheduleEntry | undefined }) { if (!entry) { return (
Tonight
No session scheduled for tonight.
); } return (
Tonight · {formatDateLong(entry.date)}
{entry.campaignName ? ( entry.campaignId ? ( {entry.campaignName} ) : (
{entry.campaignName}
) ) : (
No campaign selected yet.
)} {entry.blackouts.length > 0 && (
{entry.blackouts.map((b) => ( {b.username} out ))}
)}
); } // ─── Avatar stack ───────────────────────────────────────────────────────────── const MAX_AVATARS = 8; function AvatarStack({ members, blackoutUserIds, campaignDmUserIds, allIn, }: { members: Member[]; blackoutUserIds: Set; campaignDmUserIds: Set; allIn: boolean; }) { const visible = members.slice(0, MAX_AVATARS); const overflow = members.length - MAX_AVATARS; return (
{visible.map((m) => { const isOut = blackoutUserIds.has(m.userId); const isDm = campaignDmUserIds.has(m.userId); return (
{isDm && 👑} {m.avatarUrl ? ( {m.username} ) : (
{m.username[0].toUpperCase()}
)}
); })} {overflow > 0 && (
+{overflow}
)} {allIn && }
); } // ─── Tab nav icons ──────────────────────────────────────────────────────────── function IconTabBoard() { return ( ); } function IconTabCharacters() { return ( ); } function IconTabRecaps() { return ( ); } function IconTabWiki() { return ( ); } function IconTabNotes() { return ( ); } // ─── Schedule row ───────────────────────────────────────────────────────────── interface ScheduleRowProps { entry: ScheduleEntry; role: string; myUserId: number; members: Member[]; availableCampaigns: Campaign[]; expanded: boolean; saving: boolean; onToggle: () => void; onAssign: (date: string, campaignId: number) => void; onUnassign: (date: string) => void; onBlackout: (date: string) => void; onRemoveBlackout: (date: string) => void; } function ScheduleRow({ entry, role, myUserId, members, availableCampaigns, expanded, saving, onToggle, onAssign, onUnassign, onBlackout, onRemoveBlackout, }: ScheduleRowProps) { const canManage = role === "dm" || role === "admin"; const myBlackout = entry.blackouts.find((b) => b.userId === myUserId); const blackoutUserIds = new Set(entry.blackouts.map((b) => b.userId)); const campaignDmUserIds = new Set(entry.campaignDmUserIds); return (
{formatDate(entry.date)}
{getWeekday(entry.date)}
{/* Avatar stack fills the middle */}
{entry.campaignId ? ( e.stopPropagation()} title={`Open ${entry.campaignName}`} > {entry.campaignName} ) : ( No campaign )} {expanded ? "▲" : "▼"}
{expanded && (
{/* Campaign + Availability — combined row */}
Campaign
{canManage ? ( availableCampaigns.length > 0 ? (
{entry.campaignId && ( )}
) : ( No campaigns yet —{" "} create one first ) ) : entry.campaignName ? ( {entry.campaignName} ) : ( No campaign assigned )}
My Availability
{/* Others out */} {entry.blackouts.filter((b) => b.userId !== myUserId).length > 0 && (
{entry.blackouts .filter((b) => b.userId !== myUserId) .map((b) => ( {b.username} out ))}
)} {/* Quick-nav tab buttons — only when a campaign is assigned */} {entry.campaignId && (
Quick Nav
Board Characters Recaps Wiki My Notes
)}
)}
); } // ─── Right panel ────────────────────────────────────────────────────────────── const ROLE_ORDER = ["admin", "dm", "pending_dm", "player"]; function roleBadgeClass(role: string) { if (role === "admin") return "badge badge-admin"; if (role === "dm") return "badge badge-dm"; if (role === "pending_dm") return "badge badge-pending"; return "badge badge-archived"; } function RightPanel({ data, saving, onRemoveBlackout, }: { data: DashboardData; saving: string | null; onRemoveBlackout: (date: string) => void; }) { const myBlackouts = data.entries.filter((e) => e.blackouts.some((b) => b.userId === data.myUserId) ); const sortedMembers = [...data.members].sort( (a, b) => ROLE_ORDER.indexOf(a.role) - ROLE_ORDER.indexOf(b.role) || a.username.localeCompare(b.username) ); return (
{/* Dates I'm Out */}
Dates I'm Out {myBlackouts.length > 0 && ( {myBlackouts.length} )}
{myBlackouts.length === 0 ? (

All clear — you're in for every session.

) : (
{myBlackouts.map((e, i) => (
{formatDate(e.date)}
{getWeekday(e.date)}
))}
)}
{/* Guild roster */}
Players {sortedMembers.length}
{sortedMembers.map((m) => (
{m.avatarUrl ? ( {m.username} ) : (
{m.username[0]}
)} {m.username} {m.role === "pending_dm" ? "pending" : m.role}
))}
); } // ─── Dashboard page ─────────────────────────────────────────────────────────── export function DashboardPage() { const toast = useToast(); const [data, setData] = useState(null); const [expandedDate, setExpandedDate] = useState(null); const [saving, setSaving] = useState(null); const [error, setError] = useState(""); const [addDate, setAddDate] = useState(""); const [addCampaignId, setAddCampaignId] = useState(""); const [creating, setCreating] = useState(false); const load = useCallback(() => { api("/dashboard") .then(setData) .catch(() => { toast.addToast("Failed to load dashboard. Please try again.", "error"); }); }, [toast]); useEffect(() => { load(); }, [load]); async function run(key: string, fn: () => Promise) { setSaving(key); setError(""); try { await fn(); load(); } catch (e) { toast.addToast("Operation failed. Please try again.", "error"); } finally { setSaving(null); } } function assignCampaign(date: string, campaignId: number) { run(`assign:${date}`, () => api(`/game-nights/${date}/select-campaign`, { method: "POST", body: JSON.stringify({ campaignId }), }) ).then(() => { toast.addToast(`Campaign assigned to ${formatDate(date)}`, "success"); }); } function unassignCampaign(date: string) { run(`unassign:${date}`, () => api(`/game-nights/${date}/campaign`, { method: "DELETE" }) ).then(() => { toast.addToast("Campaign removed", "success"); }); } function addBlackout(date: string) { run(`blackout:${date}`, () => api(`/game-nights/${date}/blackout`, { method: "POST", body: JSON.stringify({}) }) ).then(() => { toast.addToast(`You're marked as out on ${formatDate(date)}`, "info"); }); } function removeBlackout(date: string) { run(`rmblackout:${date}`, () => api(`/game-nights/${date}/blackout`, { method: "DELETE" }) ).then(() => { toast.addToast(`You're back in for ${formatDate(date)}`, "success"); }); } async function createNight() { const dateToUse = addDate || suggestedDate; if (!dateToUse) return; setCreating(true); setError(""); try { const body: Record = { date: dateToUse }; if (addCampaignId) body.campaignId = Number(addCampaignId); await api("/game-nights", { method: "POST", body: JSON.stringify(body) }); setAddDate(""); // clears any manual override; suggestedDate advances automatically on reload setAddCampaignId(""); load(); toast.addToast(`Game night scheduled for ${formatDate(dateToUse)}`, "success"); } catch (e) { toast.addToast("Failed to schedule game night. Please try again.", "error"); } finally { setCreating(false); } } // Auto-suggest the next unscheduled occurrence of the default game day // Must be before any early return to satisfy Rules of Hooks const suggestedDate = useMemo(() => { if (!data || data.defaultGameDay == null) return ""; const scheduled = new Set(data.entries.map((e) => e.date)); return nextDefaultNight(data.defaultGameDay, scheduled); }, [data]); if (!data) { return (
); } const today = todayStr(); const todayEntry = data.entries.find((e) => e.date === today); const upcomingEntries = data.entries.filter((e) => e.date >= today); const role = data.role; const canManage = role === "dm" || role === "admin"; const availableCampaigns = role === "admin" ? data.allCampaigns : data.myCampaigns; // The displayed value: user override takes precedence, else the suggestion const displayDate = addDate || suggestedDate; return (

Dashboard

Upcoming sessions and shared availability

{/* ── Left column: tonight + schedule ── */}
Upcoming Schedule {upcomingEntries.length > 0 && ( {upcomingEntries.length} )}
{upcomingEntries.length === 0 ? (
📅
Nothing scheduled yet
{canManage && (
Add a date below to schedule your next session.
)}
) : ( upcomingEntries.map((entry) => ( setExpandedDate(expandedDate === entry.date ? null : entry.date) } onAssign={assignCampaign} onUnassign={unassignCampaign} onBlackout={addBlackout} onRemoveBlackout={removeBlackout} /> )) )}
{/* Add game night — DM and admin only */} {canManage && (
Add a game night
setAddDate(e.target.value)} /> {availableCampaigns.length > 0 && ( )}
)}
{/* ── Right column: availability + roster ── */}
); }