Add general calendar events (recording, social, task, general); nav links; v1.1.16

- Four event types on the calendar: General, Recording, Social, Task — each color-coded
- + Event button opens form with type picker, title, date, notes, reminder
- Task events have a Mark Complete toggle; completed events show strikethrough
- Edit/delete popover for each event
- Email reminders work for calendar events same as episodes
- Added Email/Calendar nav links to /contacts header
- Added Email link to /calendar header (already had Admin)
- /email already links to Contacts and Calendar

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
nmemmert
2026-07-28 11:26:48 -04:00
parent de201ff356
commit f0635753f1
10 changed files with 469 additions and 44 deletions
+260 -5
View File
@@ -30,6 +30,27 @@ const REMINDER_OPTIONS = [
{ value: '14', label: '2 weeks before' },
]
type CalendarEventType = 'general' | 'recording' | 'social' | 'task'
interface CalendarEvent {
id: string
type: CalendarEventType
title: string
date: string
notes: string
completed: boolean
reminderDays: number
reminderSentAt?: string
createdAt: string
}
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: '✅' },
]
interface PodcastChecklistData {
tasks: PodcastChecklistTask[]
episodes: PodcastChecklistEpisode[]
@@ -165,13 +186,28 @@ function CalendarClient() {
const [newForm, setNewForm] = useState({ series: '', episodeNumber: '', title: '', datePublished: '', reminderDays: '0' })
const [newBusy, setNewBusy] = useState(false)
const [events, setEvents] = useState<CalendarEvent[]>([])
const [newEvOpen, setNewEvOpen] = useState(false)
const [newEvForm, setNewEvForm] = useState({ type: 'general' as CalendarEventType, title: '', date: '', notes: '', reminderDays: '0' })
const [newEvBusy, setNewEvBusy] = useState(false)
const [editEv, setEditEv] = useState<CalendarEvent | null>(null)
const [editEvForm, setEditEvForm] = useState({ type: 'general' as CalendarEventType, title: '', date: '', notes: '', reminderDays: '0' })
const [evSaving, setEvSaving] = useState(false)
const load = useCallback(async () => {
try {
const res = await fetch('/api/admin-podcast-checklist', { credentials: 'include' })
if (res.ok) {
const data = await res.json() as { checklist: PodcastChecklistData }
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)
}, [])
@@ -229,6 +265,17 @@ function CalendarClient() {
[checklist]
)
const eventsByDate = useMemo(() => {
const map = new Map<string, CalendarEvent[]>()
for (const ev of events) {
if (!ev.date) continue
const arr = map.get(ev.date) ?? []
arr.push(ev)
map.set(ev.date, arr)
}
return map
}, [events])
const todayKey = toDateKey(today)
function prevMonth() {
@@ -295,6 +342,86 @@ function CalendarClient() {
setEditEp(null)
}
async function handleNewEvent(e: React.FormEvent) {
e.preventDefault()
if (!newEvForm.title.trim() || !newEvForm.date) return
setNewEvBusy(true)
try {
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,
notes: newEvForm.notes.trim(),
reminderDays: Number(newEvForm.reminderDays),
}),
})
if (res.ok) {
const data = await res.json() as { event: CalendarEvent }
setEvents(prev => [data.event, ...prev])
setNewEvForm({ type: 'general', title: '', date: '', notes: '', reminderDays: '0' })
setNewEvOpen(false)
}
} catch { /* silent */ }
setNewEvBusy(false)
}
function openEditEvent(ev: CalendarEvent) {
setEditEv(ev)
setEditEvForm({ type: ev.type, title: ev.title, date: ev.date, notes: ev.notes, reminderDays: String(ev.reminderDays) })
}
async function saveEditEvent() {
if (!editEv) return
setEvSaving(true)
try {
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,
notes: editEvForm.notes.trim(),
reminderDays: Number(editEvForm.reminderDays),
}),
})
if (res.ok) {
setEvents(prev => prev.map(ev => ev.id === editEv.id
? { ...ev, ...editEvForm, reminderDays: Number(editEvForm.reminderDays),
reminderSentAt: (editEvForm.date !== editEv.date || Number(editEvForm.reminderDays) !== editEv.reminderDays) ? undefined : ev.reminderSentAt }
: ev
))
setEditEv(null)
}
} catch { /* silent */ }
setEvSaving(false)
}
async function deleteEvent(ev: CalendarEvent) {
try {
const res = await fetch(`/api/admin-calendar-events/${ev.id}`, { method: 'DELETE', credentials: 'include' })
if (res.ok) { setEvents(prev => prev.filter(e => e.id !== ev.id)); 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 */ }
}
async function handleNewEpisode(e: React.FormEvent) {
e.preventDefault()
if (!checklist) return
@@ -346,10 +473,18 @@ function CalendarClient() {
<button
type="button"
className="em-btn em-btn--secondary em-btn--sm"
onClick={() => setNewEpOpen(o => !o)}
onClick={() => { setNewEpOpen(o => !o); setNewEvOpen(false) }}
>
{newEpOpen ? 'Cancel' : '+ New Episode'}
{newEpOpen ? 'Cancel' : '+ Episode'}
</button>
<button
type="button"
className="em-btn em-btn--secondary em-btn--sm"
onClick={() => { setNewEvOpen(o => !o); setNewEpOpen(false) }}
>
{newEvOpen ? 'Cancel' : '+ Event'}
</button>
<Link to="/email" className="em-btn em-btn--ghost em-btn--sm"> Email</Link>
<Link to="/admin" className="em-btn em-btn--ghost em-btn--sm"> Admin</Link>
</div>
</header>
@@ -391,6 +526,47 @@ function CalendarClient() {
</div>
)}
{newEvOpen && (
<div className="cal-new-ep-banner">
<form className="cal-new-ep-form" onSubmit={handleNewEvent}>
<h3 className="cal-new-ep-title">New Event</h3>
<div className="cal-new-ep-row">
<label className="cal-new-ep-label">
Type
<select className="ct-input" value={newEvForm.type} onChange={e => setNewEvForm(f => ({ ...f, type: e.target.value as CalendarEventType }))}>
{EVENT_TYPE_OPTIONS.map(o => <option key={o.value} value={o.value}>{o.icon} {o.label}</option>)}
</select>
</label>
<label className="cal-new-ep-label" style={{ flex: 2 }}>
Title
<input className="ct-input" type="text" required placeholder="Event title" value={newEvForm.title} onChange={e => setNewEvForm(f => ({ ...f, title: e.target.value }))} />
</label>
<label className="cal-new-ep-label">
Date
<input className="ct-input" type="date" required value={newEvForm.date} onChange={e => setNewEvForm(f => ({ ...f, date: e.target.value }))} />
</label>
<label className="cal-new-ep-label">
Email Reminder
<select className="ct-input" value={newEvForm.reminderDays} onChange={e => setNewEvForm(f => ({ ...f, reminderDays: e.target.value }))}>
{REMINDER_OPTIONS.map(o => <option key={o.value} value={o.value}>{o.label}</option>)}
</select>
</label>
</div>
<div className="cal-new-ep-row">
<label className="cal-new-ep-label" style={{ flex: 1 }}>
Notes
<input className="ct-input" type="text" placeholder="Optional notes" value={newEvForm.notes} onChange={e => setNewEvForm(f => ({ ...f, notes: e.target.value }))} />
</label>
</div>
<div className="cal-new-ep-actions">
<button type="submit" className="em-btn em-btn--primary em-btn--sm" disabled={newEvBusy}>
{newEvBusy ? 'Adding…' : 'Add Event'}
</button>
</div>
</form>
</div>
)}
<div className="cal-body">
<div className="cal-main">
{loading ? (
@@ -434,6 +610,22 @@ function CalendarClient() {
{(ep.reminderDays ?? 0) > 0 && <span className="cal-ep-bell" title={`Reminder: ${ep.reminderDays}d before${ep.reminderSentAt ? ' (sent)' : ''}`}>{ep.reminderSentAt ? '✓' : '🔔'}</span>}
</button>
))}
{(eventsByDate.get(dk) ?? []).map(ev => {
const opt = EVENT_TYPE_OPTIONS.find(o => o.value === ev.type)
return (
<button
key={ev.id}
type="button"
className={`cal-ev-chip cal-ev-chip--${ev.type}${ev.completed ? ' cal-ev-chip--done' : ''}`}
title={ev.title + (ev.notes ? ` · ${ev.notes}` : '')}
onClick={e => { e.stopPropagation(); openEditEvent(ev) }}
>
<span className="cal-ev-icon">{opt?.icon}</span>
<span className="cal-ev-title">{ev.title.slice(0, 20)}{ev.title.length > 20 ? '…' : ''}</span>
{(ev.reminderDays ?? 0) > 0 && <span className="cal-ep-bell">{ev.reminderSentAt ? '✓' : '🔔'}</span>}
</button>
)
})}
</div>
</div>
)
@@ -561,6 +753,69 @@ function CalendarClient() {
</div>
</div>
)}
{/* Event edit popover */}
{editEv && (
<div className="cal-popover-overlay" onClick={() => setEditEv(null)}>
<div className="cal-popover" onClick={e => e.stopPropagation()}>
<div className="cal-popover-head">
<h3>Edit Event</h3>
<button type="button" className="cal-popover-close" onClick={() => setEditEv(null)}>×</button>
</div>
<div className="cal-popover-body">
<label className="cal-popover-label">
Type
<select className="ct-input" value={editEvForm.type} onChange={e => setEditEvForm(f => ({ ...f, type: e.target.value as CalendarEventType }))}>
{EVENT_TYPE_OPTIONS.map(o => <option key={o.value} value={o.value}>{o.icon} {o.label}</option>)}
</select>
</label>
<label className="cal-popover-label">
Title
<input className="ct-input" type="text" value={editEvForm.title} onChange={e => setEditEvForm(f => ({ ...f, title: e.target.value }))} />
</label>
<label className="cal-popover-label">
Date
<input className="ct-input" type="date" value={editEvForm.date} onChange={e => setEditEvForm(f => ({ ...f, date: e.target.value }))} />
</label>
<label className="cal-popover-label">
Notes
<input className="ct-input" type="text" value={editEvForm.notes} onChange={e => setEditEvForm(f => ({ ...f, notes: e.target.value }))} />
</label>
<label className="cal-popover-label">
Email Reminder
<select className="ct-input" value={editEvForm.reminderDays} onChange={e => setEditEvForm(f => ({ ...f, reminderDays: e.target.value }))}>
{REMINDER_OPTIONS.map(o => <option key={o.value} value={o.value}>{o.label}</option>)}
</select>
{editEv.reminderSentAt && (
<span className="cal-reminder-sent">Reminder sent {new Date(editEv.reminderSentAt).toLocaleDateString()}</span>
)}
</label>
{editEv.type === 'task' && (
<label className="cal-popover-label" style={{ flexDirection: 'row', alignItems: 'center', gap: '0.5rem', cursor: 'pointer' }}>
<input
type="checkbox"
checked={editEv.completed}
onChange={() => toggleComplete(editEv)}
style={{ width: 'auto' }}
/>
Mark as completed
</label>
)}
</div>
<div className="cal-popover-actions">
<button type="button" className="em-btn em-btn--primary em-btn--sm" onClick={saveEditEvent} disabled={evSaving}>
{evSaving ? 'Saving…' : 'Save'}
</button>
<button type="button" className="em-btn em-btn--danger em-btn--sm" onClick={() => deleteEvent(editEv)}>
Delete
</button>
<button type="button" className="em-btn em-btn--ghost em-btn--sm" onClick={() => setEditEv(null)}>
Cancel
</button>
</div>
</div>
</div>
)}
</div>
)
}