import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { Link } from 'react-router-dom' // ── Types ──────────────────────────────────────────────────────────────────── interface PodcastChecklistTask { id: string label: string required: boolean } interface PodcastChecklistEpisode { id: string series: string episodeNumber: number | null title: string datePublished: string startTime?: string expanded: boolean tasks: Record reminderDays?: number reminderSentAt?: string } type RecurrenceFreq = 'none' | 'weekly' | 'biweekly' | 'monthly' interface Recurrence { freq: RecurrenceFreq until: string | null } type CalendarEventType = 'general' | 'recording' | 'social' | 'task' interface CalendarEvent { id: string type: CalendarEventType title: string date: string startTime?: string notes: string completed: boolean reminderDays: number reminderSentAt?: string recurrence?: Recurrence createdAt: string recurrenceOf?: string // runtime only: id of base event if this is an expanded instance } interface PodcastChecklistData { tasks: PodcastChecklistTask[] episodes: PodcastChecklistEpisode[] } type ViewMode = 'month' | 'week' | 'day' // ── Constants ───────────────────────────────────────────────────────────────── const REMINDER_OPTIONS = [ { value: '0', label: 'No reminder' }, { value: '1', label: '1 day before' }, { value: '2', label: '2 days before' }, { value: '3', label: '3 days before' }, { value: '7', label: '1 week before' }, { value: '14', label: '2 weeks before' }, ] const EVENT_TYPE_OPTIONS: { value: CalendarEventType; label: string; icon: string }[] = [ { value: 'general', label: 'General', icon: '📌' }, { value: 'recording', label: 'Recording', icon: '🎙️' }, { value: 'social', label: 'Social', icon: '📱' }, { value: 'task', label: 'Task', icon: '✅' }, ] const RECURRENCE_OPTIONS: { value: RecurrenceFreq; label: string }[] = [ { value: 'none', label: 'Does not repeat' }, { value: 'weekly', label: 'Weekly' }, { value: 'biweekly', label: 'Every 2 weeks' }, { value: 'monthly', label: 'Monthly' }, ] const MONTH_NAMES = [ 'January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December', ] // ── Helpers ─────────────────────────────────────────────────────────────────── function toDateKey(date: Date) { return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}` } function getMondayOf(date: Date): Date { const d = new Date(date) const dow = d.getDay() const diff = dow === 0 ? -6 : 1 - dow d.setDate(d.getDate() + diff) return d } function addDays(date: Date, n: number): Date { const d = new Date(date) d.setDate(d.getDate() + n) return d } function addMonths(date: Date, n: number): Date { const d = new Date(date) d.setMonth(d.getMonth() + n) return d } function formatTimeDisplay(t: string | undefined): string { if (!t) return '' const [h, m] = t.split(':').map(Number) if (isNaN(h)) return '' const ampm = h >= 12 ? 'PM' : 'AM' const h12 = h % 12 || 12 return `${h12}:${String(m).padStart(2, '0')} ${ampm}` } function expandRecurring(events: CalendarEvent[], firstKey: string, lastKey: string): CalendarEvent[] { const result: CalendarEvent[] = [] for (const ev of events) { // always include base event if it falls in range if (ev.date >= firstKey && ev.date <= lastKey) result.push(ev) if (!ev.recurrence || ev.recurrence.freq === 'none') continue const base = new Date(ev.date + 'T12:00:00') const untilDate = ev.recurrence.until ? new Date(ev.recurrence.until + 'T23:59:59') : new Date(base.getFullYear() + 2, base.getMonth(), base.getDate()) // 2-year horizon let d = new Date(base) for (let i = 0; i < 1000; i++) { if (ev.recurrence.freq === 'weekly') d = addDays(d, 7) else if (ev.recurrence.freq === 'biweekly') d = addDays(d, 14) else d = new Date(d.getFullYear(), d.getMonth() + 1, d.getDate()) if (d > untilDate) break const dk = toDateKey(d) if (dk > lastKey) break // past visible range if (dk === ev.date) continue // skip base date duplicate if (dk < firstKey) continue // before visible range, keep iterating // Don't add if it's the same as a date that's already included as a non-recurring event result.push({ ...ev, id: `${ev.id}:${dk}`, date: dk, recurrenceOf: ev.id }) } } return result } // ── Auth Shell ──────────────────────────────────────────────────────────────── export default function CalendarShell() { const [authState, setAuthState] = useState<'checking' | 'needs-password' | 'needs-totp' | 'ok'>('checking') const [password, setPassword] = useState('') const [totp, setTotp] = useState('') const [authError, setAuthError] = useState('') const [authBusy, setAuthBusy] = useState(false) const [pendingToken, setPendingToken] = useState('') useEffect(() => { fetch('/api/admin-auth/status', { credentials: 'include' }) .then(r => r.json()) .then((data: { authenticated?: boolean }) => { setAuthState(data.authenticated ? 'ok' : 'needs-password') }) .catch(() => setAuthState('needs-password')) }, []) async function handleLogin(e: React.FormEvent) { e.preventDefault() setAuthBusy(true) setAuthError('') try { const res = await fetch('/api/admin-auth/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ password }), credentials: 'include', }) const data = await res.json() as { ok?: boolean; totpRequired?: boolean; pendingToken?: string; message?: string } if (!res.ok) { setAuthError(data.message ?? 'Invalid password.'); setAuthBusy(false); return } if (data.totpRequired && data.pendingToken) { setPendingToken(data.pendingToken); setAuthState('needs-totp'); setAuthBusy(false); return } setAuthState('ok') } catch { setAuthError('Login failed.') } setAuthBusy(false) } async function handleTotp(e: React.FormEvent) { e.preventDefault() setAuthBusy(true) setAuthError('') try { const res = await fetch('/api/admin-auth/totp-verify', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ pendingToken, code: totp }), credentials: 'include', }) const data = await res.json() as { ok?: boolean; message?: string } if (!res.ok) { setAuthError(data.message ?? 'Invalid code.'); setAuthBusy(false); return } setAuthState('ok') } catch { setAuthError('Verification failed.') } setAuthBusy(false) } if (authState === 'checking') return
Loading…
if (authState === 'needs-password') { return (

Release Calendar

{authError &&

{authError}

}
) } if (authState === 'needs-totp') { return (

Two-factor code

{authError &&

{authError}

}
) } return } // ── Calendar Client ─────────────────────────────────────────────────────────── function CalendarClient() { const today = useMemo(() => new Date(), []) const [checklist, setChecklist] = useState(null) const [loading, setLoading] = useState(true) const [saving, setSaving] = useState(false) const [events, setEvents] = useState([]) // View const [viewMode, setViewMode] = useState('month') const [viewDate, setViewDate] = useState(() => new Date()) // Episode scheduling (click-to-place) const [selectedEpisodeId, setSelectedEpisodeId] = useState(null) // Episode edit popover const [editEp, setEditEp] = useState(null) const [editForm, setEditForm] = useState({ series: '', episodeNumber: '', title: '', datePublished: '', startTime: '', reminderDays: '0' }) // New episode form const [newEpOpen, setNewEpOpen] = useState(false) const [newForm, setNewForm] = useState({ series: '', episodeNumber: '', title: '', datePublished: '', startTime: '', reminderDays: '0' }) const [newBusy, setNewBusy] = useState(false) // New event form const [newEvOpen, setNewEvOpen] = useState(false) const [newEvForm, setNewEvForm] = useState({ type: 'general' as CalendarEventType, title: '', date: '', startTime: '', notes: '', reminderDays: '0', recurrenceFreq: 'none' as RecurrenceFreq, recurrenceUntil: '' }) const [newEvBusy, setNewEvBusy] = useState(false) // Event edit popover const [editEv, setEditEv] = useState(null) const [editEvForm, setEditEvForm] = useState({ type: 'general' as CalendarEventType, title: '', date: '', startTime: '', notes: '', reminderDays: '0', recurrenceFreq: 'none' as RecurrenceFreq, recurrenceUntil: '' }) const [evSaving, setEvSaving] = useState(false) // Drag state const [dragOverKey, setDragOverKey] = useState(null) const dragDataRef = useRef<{ type: 'episode' | 'event'; id: string } | null>(null) // ── Load ── const load = useCallback(async () => { try { const [clRes, evRes] = await Promise.all([ fetch('/api/admin-podcast-checklist', { credentials: 'include' }), fetch('/api/admin-calendar-events', { credentials: 'include' }), ]) if (clRes.ok) { const data = await clRes.json() as { checklist: PodcastChecklistData } setChecklist(data.checklist) } if (evRes.ok) { const data = await evRes.json() as { events: CalendarEvent[] } setEvents(Array.isArray(data.events) ? data.events : []) } } catch { /* silent */ } setLoading(false) }, []) useEffect(() => { load() }, [load]) // ── Navigation ── const viewYear = viewDate.getFullYear() const viewMonth = viewDate.getMonth() function prevPeriod() { if (viewMode === 'month') setViewDate(d => addMonths(d, -1)) else if (viewMode === 'week') setViewDate(d => addDays(d, -7)) else setViewDate(d => addDays(d, -1)) } function nextPeriod() { if (viewMode === 'month') setViewDate(d => addMonths(d, 1)) else if (viewMode === 'week') setViewDate(d => addDays(d, 7)) else setViewDate(d => addDays(d, 1)) } function gotoToday() { setViewDate(new Date(today)) } // ── Computed days ── const calDays = useMemo(() => { if (viewMode === 'week') { const monday = getMondayOf(viewDate) return Array.from({ length: 7 }, (_, i) => ({ date: addDays(monday, i), inMonth: true })) } if (viewMode === 'day') { return [{ date: new Date(viewDate), inMonth: true }] } // month const first = new Date(viewYear, viewMonth, 1) const last = new Date(viewYear, viewMonth + 1, 0) const days: Array<{ date: Date; inMonth: boolean }> = [] const startDow = (first.getDay() + 6) % 7 for (let i = startDow - 1; i >= 0; i--) { days.push({ date: new Date(viewYear, viewMonth, -i), inMonth: false }) } for (let d = 1; d <= last.getDate(); d++) { days.push({ date: new Date(viewYear, viewMonth, d), inMonth: true }) } const rem = (7 - (days.length % 7)) % 7 for (let i = 1; i <= rem; i++) { days.push({ date: new Date(viewYear, viewMonth + 1, i), inMonth: false }) } return days }, [viewMode, viewDate, viewYear, viewMonth]) const firstKey = calDays.length > 0 ? toDateKey(calDays[0].date) : '' const lastKey = calDays.length > 0 ? toDateKey(calDays[calDays.length - 1].date) : '' // ── Expanded events (with recurring instances) ── const expandedEventsByDate = useMemo(() => { const all = expandRecurring(events, firstKey, lastKey) const map = new Map() for (const ev of all) { const arr = map.get(ev.date) ?? [] arr.push(ev) map.set(ev.date, arr) } return map }, [events, firstKey, lastKey]) const episodesByDate = useMemo(() => { const map = new Map() for (const ep of (checklist?.episodes ?? [])) { if (!ep.datePublished?.trim()) continue const key = ep.datePublished.trim().slice(0, 10) const arr = map.get(key) ?? [] arr.push(ep) map.set(key, arr) } return map }, [checklist]) const unscheduled = useMemo( () => (checklist?.episodes ?? []).filter(ep => !ep.datePublished?.trim()), [checklist] ) const todayKey = toDateKey(today) // ── View header label ── const viewLabel = useMemo(() => { if (viewMode === 'month') return `${MONTH_NAMES[viewMonth]} ${viewYear}` if (viewMode === 'week') { const monday = getMondayOf(viewDate) const sunday = addDays(monday, 6) if (monday.getMonth() === sunday.getMonth()) { return `${MONTH_NAMES[monday.getMonth()]} ${monday.getDate()}–${sunday.getDate()}, ${monday.getFullYear()}` } return `${MONTH_NAMES[monday.getMonth()]} ${monday.getDate()} – ${MONTH_NAMES[sunday.getMonth()]} ${sunday.getDate()}, ${monday.getFullYear()}` } return `${MONTH_NAMES[viewDate.getMonth()]} ${viewDate.getDate()}, ${viewDate.getFullYear()}` }, [viewMode, viewDate, viewYear, viewMonth]) // ── Checklist save ── async function saveChecklist(episodes: PodcastChecklistEpisode[]) { if (!checklist) return setSaving(true) try { const updated = { ...checklist, episodes } const res = await fetch('/api/admin-podcast-checklist', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, credentials: 'include', body: JSON.stringify({ checklist: updated }), }) if (res.ok) setChecklist(updated) } catch { /* silent */ } setSaving(false) } // ── Episode actions ── function handleDayClick(date: Date, inMonth: boolean) { if (!inMonth || !selectedEpisodeId || !checklist) return const dk = toDateKey(date) const updated = checklist.episodes.map(ep => ep.id === selectedEpisodeId ? { ...ep, datePublished: dk } : ep ) saveChecklist(updated) setSelectedEpisodeId(null) } function openEdit(ep: PodcastChecklistEpisode) { setEditEp(ep) setEditForm({ series: ep.series, episodeNumber: ep.episodeNumber != null ? String(ep.episodeNumber) : '', title: ep.title, datePublished: ep.datePublished?.trim() ?? '', startTime: ep.startTime ?? '', reminderDays: String(ep.reminderDays ?? 0), }) } function saveEdit() { if (!editEp || !checklist) return const newDate = editForm.datePublished.trim() const newReminder = Number(editForm.reminderDays) const dateChanged = newDate !== (editEp.datePublished?.trim() ?? '') const reminderChanged = newReminder !== (editEp.reminderDays ?? 0) const updated = checklist.episodes.map(ep => ep.id === editEp.id ? { ...ep, series: editForm.series.trim(), episodeNumber: editForm.episodeNumber.trim() ? Number(editForm.episodeNumber) : null, title: editForm.title.trim(), datePublished: newDate, startTime: editForm.startTime || undefined, reminderDays: newReminder, reminderSentAt: dateChanged || reminderChanged ? undefined : ep.reminderSentAt, } : ep ) saveChecklist(updated) setEditEp(null) } function unschedule(ep: PodcastChecklistEpisode) { if (!checklist) return const updated = checklist.episodes.map(e => e.id === ep.id ? { ...e, datePublished: '' } : e) saveChecklist(updated) setEditEp(null) } async function handleNewEpisode(e: React.FormEvent) { e.preventDefault() if (!checklist) return setNewBusy(true) const id = typeof crypto?.randomUUID === 'function' ? crypto.randomUUID() : `ep-${Date.now()}-${Math.random().toString(36).slice(2)}` const ep: PodcastChecklistEpisode = { id, series: newForm.series.trim(), episodeNumber: newForm.episodeNumber.trim() ? Number(newForm.episodeNumber) : null, title: newForm.title.trim(), datePublished: newForm.datePublished.trim(), startTime: newForm.startTime || undefined, reminderDays: Number(newForm.reminderDays), expanded: false, tasks: {}, } await saveChecklist([...checklist.episodes, ep]) setNewForm({ series: '', episodeNumber: '', title: '', datePublished: '', startTime: '', reminderDays: '0' }) setNewEpOpen(false) setNewBusy(false) } // ── Event actions ── async function handleNewEvent(e: React.FormEvent) { e.preventDefault() if (!newEvForm.title.trim() || !newEvForm.date) return setNewEvBusy(true) try { const recurrence = newEvForm.recurrenceFreq !== 'none' ? { freq: newEvForm.recurrenceFreq, until: newEvForm.recurrenceUntil || null } : null const res = await fetch('/api/admin-calendar-events', { method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'include', body: JSON.stringify({ type: newEvForm.type, title: newEvForm.title.trim(), date: newEvForm.date, startTime: newEvForm.startTime || undefined, notes: newEvForm.notes.trim(), reminderDays: Number(newEvForm.reminderDays), recurrence, }), }) if (res.ok) { const data = await res.json() as { event: CalendarEvent } setEvents(prev => [data.event, ...prev]) setNewEvForm({ type: 'general', title: '', date: '', startTime: '', notes: '', reminderDays: '0', recurrenceFreq: 'none', recurrenceUntil: '' }) setNewEvOpen(false) } } catch { /* silent */ } setNewEvBusy(false) } function openEditEvent(ev: CalendarEvent) { // If clicking a recurring instance, edit the base event const baseId = ev.recurrenceOf ?? ev.id const base = events.find(e => e.id === baseId) ?? ev setEditEv(base) setEditEvForm({ type: base.type, title: base.title, date: base.date, startTime: base.startTime ?? '', notes: base.notes, reminderDays: String(base.reminderDays), recurrenceFreq: base.recurrence?.freq ?? 'none', recurrenceUntil: base.recurrence?.until ?? '', }) } async function saveEditEvent() { if (!editEv) return setEvSaving(true) try { const recurrence = editEvForm.recurrenceFreq !== 'none' ? { freq: editEvForm.recurrenceFreq, until: editEvForm.recurrenceUntil || null } : null const res = await fetch(`/api/admin-calendar-events/${editEv.id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, credentials: 'include', body: JSON.stringify({ type: editEvForm.type, title: editEvForm.title.trim(), date: editEvForm.date, startTime: editEvForm.startTime || null, notes: editEvForm.notes.trim(), reminderDays: Number(editEvForm.reminderDays), recurrence, }), }) if (res.ok) { setEvents(prev => prev.map(ev => { if (ev.id !== editEv.id) return ev const dateChanged = editEvForm.date !== ev.date const reminderChanged = Number(editEvForm.reminderDays) !== ev.reminderDays return { ...ev, type: editEvForm.type, title: editEvForm.title.trim(), date: editEvForm.date, startTime: editEvForm.startTime || undefined, notes: editEvForm.notes.trim(), reminderDays: Number(editEvForm.reminderDays), recurrence: recurrence ?? undefined, reminderSentAt: (dateChanged || reminderChanged) ? undefined : ev.reminderSentAt, } })) setEditEv(null) } } catch { /* silent */ } setEvSaving(false) } async function deleteEvent(ev: CalendarEvent) { const baseId = ev.recurrenceOf ?? ev.id try { const res = await fetch(`/api/admin-calendar-events/${baseId}`, { method: 'DELETE', credentials: 'include' }) if (res.ok) { setEvents(prev => prev.filter(e => e.id !== baseId)); setEditEv(null) } } catch { /* silent */ } } async function toggleComplete(ev: CalendarEvent) { const next = !ev.completed try { const res = await fetch(`/api/admin-calendar-events/${ev.id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, credentials: 'include', body: JSON.stringify({ completed: next }), }) if (res.ok) setEvents(prev => prev.map(e => e.id === ev.id ? { ...e, completed: next } : e)) } catch { /* silent */ } } // ── Drag-to-reschedule ── function onChipDragStart(e: React.DragEvent, type: 'episode' | 'event', id: string) { dragDataRef.current = { type, id } e.dataTransfer.effectAllowed = 'move' e.dataTransfer.setData('text/plain', JSON.stringify({ type, id })) } async function onDayDrop(e: React.DragEvent, date: Date, inMonth: boolean) { e.preventDefault() setDragOverKey(null) if (!inMonth && viewMode === 'month') return const dk = toDateKey(date) let raw: { type: string; id: string } | null = null try { raw = JSON.parse(e.dataTransfer.getData('text/plain')) } catch {} const dragged = raw ?? dragDataRef.current dragDataRef.current = null if (!dragged) return if (dragged.type === 'episode' && checklist) { const updated = checklist.episodes.map(ep => ep.id === dragged.id ? { ...ep, datePublished: dk } : ep ) saveChecklist(updated) } else if (dragged.type === 'event') { // Strip recurrenceOf prefix if this is an instance id (e.g. "baseId:2026-08-01") const baseId = dragged.id.includes(':') ? dragged.id.split(':')[0] : dragged.id try { const res = await fetch(`/api/admin-calendar-events/${baseId}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, credentials: 'include', body: JSON.stringify({ date: dk }), }) if (res.ok) setEvents(prev => prev.map(ev => ev.id === baseId ? { ...ev, date: dk } : ev)) } catch { /* silent */ } } } // ── Render chip ── function renderEpChip(ep: PodcastChecklistEpisode, dk: string) { const isScheduled = ep.datePublished === dk return ( ) } function renderEvChip(ev: CalendarEvent) { const opt = EVENT_TYPE_OPTIONS.find(o => o.value === ev.type) const isInstance = Boolean(ev.recurrenceOf) return ( ) } // ── Day cell renderer ── function renderDayCell({ date, inMonth }: { date: Date; inMonth: boolean }, i: number) { const dk = toDateKey(date) const eps = episodesByDate.get(dk) ?? [] const evs = expandedEventsByDate.get(dk) ?? [] const isToday = dk === todayKey const isSelecting = Boolean(selectedEpisodeId) const isDragOver = dragOverKey === dk return (
handleDayClick(date, inMonth)} onDragOver={e => { if (inMonth || viewMode !== 'month') { e.preventDefault(); setDragOverKey(dk) } }} onDragLeave={() => setDragOverKey(null)} onDrop={e => onDayDrop(e, date, inMonth)} >
{ e.stopPropagation(); setViewDate(date); setViewMode('day') }} title="Go to day view" > {viewMode !== 'month' ? `${MONTH_NAMES[date.getMonth()].slice(0,3)} ${date.getDate()}` : date.getDate()}
{eps.map(ep => renderEpChip(ep, dk))} {evs.map(ev => renderEvChip(ev))}
) } // ── Main render ── return (
{/* Header */}

{viewLabel}

{saving && Saving…}
{(['month', 'week', 'day'] as ViewMode[]).map(mode => ( ))}
{selectedEpisodeId && ( Click a date to schedule ·{' '} )} 📅 iCal ✉ Email ← Admin
{/* New episode banner */} {newEpOpen && (

New Episode

)} {/* New event banner */} {newEvOpen && (

New Event

{newEvForm.recurrenceFreq !== 'none' && ( )}
)} {/* Calendar body */}
{loading ? (

Loading calendar…

) : ( <> {/* Day-of-week headers */} {viewMode !== 'day' && (
{['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'].map(d => (
{d}
))}
)} {/* Grid */}
{calDays.map((day, i) => renderDayCell(day, i))}
)}
{/* Sidebar — unscheduled */}
{/* Episode edit popover */} {editEp && (
setEditEp(null)}>
e.stopPropagation()}>

Edit Episode

)} {/* Event edit popover */} {editEv && (
setEditEv(null)}>
e.stopPropagation()}>

Edit Event

{editEvForm.recurrenceFreq !== 'none' && ( )} {editEv.type === 'task' && ( )}
)}
) }