diff --git a/package.json b/package.json index b72145c..e984f9b 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "siteforge", "private": true, - "version": "1.1.15", + "version": "1.1.16", "type": "module", "scripts": { "dev": "vite", diff --git a/server.js b/server.js index 957fd68..4f33161 100644 --- a/server.js +++ b/server.js @@ -24,6 +24,7 @@ import { loadPodcastChecklistFromDisk, loadAnalyticsEventsFromDisk, loadEmailSettingsFromDisk, + loadCalendarEventsFromDisk, createBackupSnapshot, refreshContentCaches, queueHitStatsWrite, @@ -50,6 +51,7 @@ import { register as registerStudyComments } from './server/routes/study-comment import { register as registerStudyCertificate } from './server/routes/study-certificate.js' import { register as registerEpisodeScripts } from './server/routes/episode-scripts.js' import { register as registerContact } from './server/routes/contact.js' +import { register as registerCalendarEvents } from './server/routes/calendar-events.js' import { startReminderScheduler } from './server/reminder.js' import { register as registerInboundEmail } from './server/routes/inbound-email.js' import { register as registerQuestions } from './server/routes/questions.js' @@ -97,6 +99,7 @@ registerStudyComments(app) registerStudyCertificate(app) registerEpisodeScripts(app) registerContact(app) +registerCalendarEvents(app) registerInboundEmail(app) registerQuestions(app) registerAnalytics(app) @@ -146,6 +149,7 @@ Promise.all([ loadPodcastChecklistFromDisk(), loadAnalyticsEventsFromDisk(), loadEmailSettingsFromDisk(), + loadCalendarEventsFromDisk(), refreshContentCaches(), ]) .catch(err => { diff --git a/server/config.js b/server/config.js index d2dd1d3..efbc431 100644 --- a/server/config.js +++ b/server/config.js @@ -59,6 +59,7 @@ export const QR_CODES_FILE = path.join(DATA_DIR, 'qr-codes.json') export const EPISODE_PLAYS_FILE = path.join(DATA_DIR, 'episode-plays.json') export const ANALYTICS_EVENTS_FILE = path.join(DATA_DIR, 'analytics-events.json') export const EMAIL_SETTINGS_FILE = path.join(DATA_DIR, 'email-settings.json') +export const CALENDAR_EVENTS_FILE = path.join(DATA_DIR, 'calendar-events.json') export const MAX_EPISODE_SCRIPT_LENGTH = 200_000 // ~150k words, well beyond any sermon export const DIST_DIR = path.join(ROOT_DIR, 'dist') diff --git a/server/data.js b/server/data.js index 8acd857..dd17b49 100644 --- a/server/data.js +++ b/server/data.js @@ -31,6 +31,7 @@ import { EPISODE_PLAYS_FILE, ANALYTICS_EVENTS_FILE, EMAIL_SETTINGS_FILE, + CALENDAR_EVENTS_FILE, EMPTY_HIT_STATS, EMPTY_VISITOR_STATS, DEFAULT_REPLY_TEMPLATES, @@ -416,6 +417,36 @@ export function loadEmailSettingsFromDisk() { }) } +// ── Calendar events ──────────────────────────────────────────────────────── + +export function queueCalendarEventsWrite() { + state.calendarEventsWritePromise = state.calendarEventsWritePromise + .then(async () => { + await mkdir(DATA_DIR, { recursive: true }) + await writeFile( + CALENDAR_EVENTS_FILE, + JSON.stringify({ events: state.calendarEvents, updatedAt: new Date().toISOString() }, null, 2), + 'utf8', + ) + }) + .catch(err => { + console.error('[calendar-events] failed to write:', err) + }) +} + +export function loadCalendarEventsFromDisk() { + return readFile(CALENDAR_EVENTS_FILE, 'utf8') + .then(raw => { + const parsed = JSON.parse(raw) + if (Array.isArray(parsed?.events)) { + state.calendarEvents = parsed.events + } + }) + .catch(() => { + // No file yet — start with empty array + }) +} + // ── Podcast checklist ────────────────────────────────────────────────────── export function queuePodcastChecklistWrite() { diff --git a/server/reminder.js b/server/reminder.js index 8ed93b0..16a3c2e 100644 --- a/server/reminder.js +++ b/server/reminder.js @@ -1,6 +1,6 @@ import { Resend } from 'resend' import { state } from './state.js' -import { queuePodcastChecklistWrite } from './data.js' +import { queuePodcastChecklistWrite, queueCalendarEventsWrite } from './data.js' import { DEFAULT_RESEND_FROM, DEFAULT_RESEND_TO } from './config.js' function toDateKey(date) { @@ -13,35 +13,62 @@ export function startReminderScheduler() { } async function checkReminders() { - const episodes = state.podcastChecklist?.episodes - if (!Array.isArray(episodes)) return - const todayKey = toDateKey(new Date()) - let changed = false - for (const ep of episodes) { - if (!ep.datePublished || !(ep.reminderDays > 0) || ep.reminderSentAt) continue - - const publish = new Date(ep.datePublished + 'T12:00:00Z') - if (isNaN(publish.getTime())) continue - - const reminderDate = new Date(publish) - reminderDate.setUTCDate(reminderDate.getUTCDate() - ep.reminderDays) - const reminderKey = toDateKey(reminderDate) - - if (todayKey >= reminderKey && todayKey <= toDateKey(publish)) { - const sent = await sendReminderEmail(ep) - if (sent) { - ep.reminderSentAt = new Date().toISOString() - changed = true + // Episodes + const episodes = state.podcastChecklist?.episodes + if (Array.isArray(episodes)) { + let changed = false + for (const ep of episodes) { + if (!ep.datePublished || !(ep.reminderDays > 0) || ep.reminderSentAt) continue + const publish = new Date(ep.datePublished + 'T12:00:00Z') + if (isNaN(publish.getTime())) continue + const reminderDate = new Date(publish) + reminderDate.setUTCDate(reminderDate.getUTCDate() - ep.reminderDays) + if (todayKey >= toDateKey(reminderDate) && todayKey <= toDateKey(publish)) { + const sent = await sendReminderEmail({ + label: ep.episodeNumber ? `Episode ${ep.episodeNumber}` : 'Episode', + title: ep.title || 'Untitled', + series: ep.series, + date: ep.datePublished, + reminderDays: ep.reminderDays, + type: 'episode', + }) + if (sent) { ep.reminderSentAt = new Date().toISOString(); changed = true } } } + if (changed) queuePodcastChecklistWrite() } - if (changed) queuePodcastChecklistWrite() + // Calendar events + const events = state.calendarEvents + if (Array.isArray(events)) { + let changed = false + for (const ev of events) { + if (!ev.date || !(ev.reminderDays > 0) || ev.reminderSentAt) continue + const publish = new Date(ev.date + 'T12:00:00Z') + if (isNaN(publish.getTime())) continue + const reminderDate = new Date(publish) + reminderDate.setUTCDate(reminderDate.getUTCDate() - ev.reminderDays) + if (todayKey >= toDateKey(reminderDate) && todayKey <= toDateKey(publish)) { + const sent = await sendReminderEmail({ + label: ev.type.charAt(0).toUpperCase() + ev.type.slice(1), + title: ev.title, + series: null, + date: ev.date, + reminderDays: ev.reminderDays, + type: ev.type, + }) + if (sent) { ev.reminderSentAt = new Date().toISOString(); changed = true } + } + } + if (changed) queueCalendarEventsWrite() + } } -async function sendReminderEmail(ep) { +const TYPE_ICONS = { episode: '📅', general: '📌', recording: '🎙️', social: '📱', task: '✅' } + +async function sendReminderEmail({ label, title, series, date, reminderDays, type }) { if (!process.env.RESEND_API_KEY) return false try { @@ -49,37 +76,33 @@ async function sendReminderEmail(ep) { const from = process.env.RESEND_FROM ?? DEFAULT_RESEND_FROM const to = process.env.RESEND_TO ?? DEFAULT_RESEND_TO - const publishDate = new Date(ep.datePublished + 'T12:00:00Z') - const msLeft = publishDate.getTime() - Date.now() + const eventDate = new Date(date + 'T12:00:00Z') + const msLeft = eventDate.getTime() - Date.now() const daysLeft = Math.max(0, Math.ceil(msLeft / (1000 * 60 * 60 * 24))) - const label = ep.episodeNumber ? `Episode ${ep.episodeNumber}` : 'Episode' - const title = ep.title || 'Untitled' - const series = ep.series ? ` (${ep.series})` : '' + const seriesStr = series ? ` (${series})` : '' const daysText = daysLeft === 0 ? 'today' : daysLeft === 1 ? 'in 1 day' : `in ${daysLeft} days` - const subject = `Reminder: "${label}: ${title}" publishes ${daysText}` + const icon = TYPE_ICONS[type] ?? '📅' + const subject = `Reminder: ${label}: ${title} — ${daysText}` const html = `
-

📅 Release Reminder

+

${icon} Calendar Reminder

- ${label}: ${title}${series}
- Publishes ${ep.datePublished} — ${daysText} + ${label}: ${title}${seriesStr}
+ Scheduled for ${date} — ${daysText}


- This reminder was set ${ep.reminderDays} day${ep.reminderDays === 1 ? '' : 's'} before the publish date. + This reminder was set ${reminderDays} day${reminderDays === 1 ? '' : 's'} before the date. To change or remove it, open the Calendar in your admin panel.

` const { error } = await resend.emails.send({ from, to, subject, html }) - if (error) { - console.error('[reminder] send error:', error) - return false - } - console.log(`[reminder] sent for episode "${title}" (${ep.datePublished})`) + if (error) { console.error('[reminder] send error:', error); return false } + console.log(`[reminder] sent for "${title}" (${date})`) return true } catch (err) { console.error('[reminder] send exception:', err) diff --git a/server/routes/calendar-events.js b/server/routes/calendar-events.js new file mode 100644 index 0000000..7013904 --- /dev/null +++ b/server/routes/calendar-events.js @@ -0,0 +1,80 @@ +import { randomUUID } from 'node:crypto' +import { state } from '../state.js' +import { queueCalendarEventsWrite } from '../data.js' +import { requireAdminAuth } from '../auth.js' + +const VALID_TYPES = ['general', 'recording', 'social', 'task'] +const DATE_RE = /^\d{4}-\d{2}-\d{2}$/ + +function sanitize(ev) { + return { + id: ev.id, + type: VALID_TYPES.includes(ev.type) ? ev.type : 'general', + title: typeof ev.title === 'string' ? ev.title.slice(0, 300) : '', + date: typeof ev.date === 'string' && DATE_RE.test(ev.date) ? ev.date : '', + notes: typeof ev.notes === 'string' ? ev.notes.slice(0, 2000) : '', + completed: Boolean(ev.completed), + reminderDays: Number.isFinite(ev.reminderDays) && ev.reminderDays > 0 ? ev.reminderDays : 0, + reminderSentAt: typeof ev.reminderSentAt === 'string' ? ev.reminderSentAt : undefined, + createdAt: typeof ev.createdAt === 'string' ? ev.createdAt : new Date().toISOString(), + } +} + +export function register(app) { + app.get('/api/admin-calendar-events', requireAdminAuth, (_req, res) => { + res.json({ events: state.calendarEvents }) + }) + + app.post('/api/admin-calendar-events', requireAdminAuth, (req, res) => { + const { type, title, date, notes, reminderDays } = req.body ?? {} + if (!title?.trim()) { res.status(400).json({ message: 'Title is required.' }); return } + if (!date || !DATE_RE.test(date)) { res.status(400).json({ message: 'Valid date is required.' }); return } + if (type && !VALID_TYPES.includes(type)) { res.status(400).json({ message: 'Invalid type.' }); return } + + const ev = sanitize({ + id: randomUUID(), + type: type ?? 'general', + title: String(title).trim(), + date, + notes: notes ?? '', + completed: false, + reminderDays: reminderDays ?? 0, + createdAt: new Date().toISOString(), + }) + + state.calendarEvents.unshift(ev) + queueCalendarEventsWrite() + res.json({ ok: true, event: ev }) + }) + + app.patch('/api/admin-calendar-events/:id', requireAdminAuth, (req, res) => { + const { id } = req.params + let found = false + state.calendarEvents = state.calendarEvents.map(ev => { + if (ev.id !== id) return ev + found = true + const patch = {} + if (typeof req.body?.title === 'string') patch.title = req.body.title.trim().slice(0, 300) + if (typeof req.body?.date === 'string' && DATE_RE.test(req.body.date)) patch.date = req.body.date + if (typeof req.body?.notes === 'string') patch.notes = req.body.notes.trim().slice(0, 2000) + if (typeof req.body?.completed === 'boolean') patch.completed = req.body.completed + if (VALID_TYPES.includes(req.body?.type)) patch.type = req.body.type + if (Number.isFinite(req.body?.reminderDays)) patch.reminderDays = req.body.reminderDays + const dateChanged = patch.date && patch.date !== ev.date + const reminderChanged = 'reminderDays' in patch && patch.reminderDays !== ev.reminderDays + if (dateChanged || reminderChanged) patch.reminderSentAt = undefined + return { ...ev, ...patch } + }) + if (!found) { res.status(404).json({ message: 'Event not found.' }); return } + queueCalendarEventsWrite() + res.json({ ok: true }) + }) + + app.delete('/api/admin-calendar-events/:id', requireAdminAuth, (req, res) => { + const before = state.calendarEvents.length + state.calendarEvents = state.calendarEvents.filter(ev => ev.id !== req.params.id) + if (state.calendarEvents.length === before) { res.status(404).json({ message: 'Event not found.' }); return } + queueCalendarEventsWrite() + res.json({ ok: true }) + }) +} diff --git a/server/state.js b/server/state.js index 22ad8d2..264ea71 100644 --- a/server/state.js +++ b/server/state.js @@ -93,6 +93,9 @@ export const state = { emailSettings: { signature: 'Grace and peace,\nVerse by Verse with Nate' }, emailSettingsWritePromise: Promise.resolve(), + calendarEvents: [], + calendarEventsWritePromise: Promise.resolve(), + lastBackupStatus: { ok: true, at: null, error: null, file: null }, lastCachePurgeStatus: { ok: true, at: null, error: null }, lastDeployHookStatus: { ok: true, at: null, error: null }, diff --git a/src/App.css b/src/App.css index 9499936..dfaff62 100644 --- a/src/App.css +++ b/src/App.css @@ -10575,6 +10575,33 @@ .cal-ep-chip-title { color: #4a8a58; } +/* Calendar event chips (general / recording / social / task) */ +.cal-ev-chip { + display: flex; + align-items: center; + gap: 2px; + font-size: 0.68rem; + padding: 0.2rem 0.4rem; + border-radius: 4px; + cursor: pointer; + text-align: left; + width: 100%; + border: 1px solid transparent; + transition: opacity 0.12s; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.cal-ev-chip--general { background: #1a2340; border-color: #243060; color: #93c5fd; } +.cal-ev-chip--recording { background: #28183a; border-color: #3a205a; color: #c084fc; } +.cal-ev-chip--social { background: #3a1a28; border-color: #5a2038; color: #f9a8d4; } +.cal-ev-chip--task { background: #2a2010; border-color: #3a3010; color: #fbbf24; } +.cal-ev-chip--done { opacity: 0.45; text-decoration: line-through; } +.cal-ev-chip:hover { filter: brightness(1.15); } + +.cal-ev-icon { flex-shrink: 0; } +.cal-ev-title { flex: 1; overflow: hidden; text-overflow: ellipsis; } + .cal-ep-bell { font-size: 0.6rem; margin-left: 2px; diff --git a/src/CalendarPage.tsx b/src/CalendarPage.tsx index 474ed16..35364b1 100644 --- a/src/CalendarPage.tsx +++ b/src/CalendarPage.tsx @@ -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([]) + 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(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() + 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() { + + ✉ Email ← Admin @@ -391,6 +526,47 @@ function CalendarClient() { )} + {newEvOpen && ( +
+
+

New Event

+
+ + + + +
+
+ +
+
+ +
+
+
+ )} +
{loading ? ( @@ -434,6 +610,22 @@ function CalendarClient() { {(ep.reminderDays ?? 0) > 0 && {ep.reminderSentAt ? '✓' : '🔔'}} ))} + {(eventsByDate.get(dk) ?? []).map(ev => { + const opt = EVENT_TYPE_OPTIONS.find(o => o.value === ev.type) + return ( + + ) + })}
) @@ -561,6 +753,69 @@ function CalendarClient() { )} + + {/* Event edit popover */} + {editEv && ( +
setEditEv(null)}> +
e.stopPropagation()}> +
+

Edit Event

+ +
+
+ + + + + + {editEv.type === 'task' && ( + + )} +
+
+ + + +
+
+
+ )} ) } diff --git a/src/ContactsPage.tsx b/src/ContactsPage.tsx index 08ee17b..722b574 100644 --- a/src/ContactsPage.tsx +++ b/src/ContactsPage.tsx @@ -303,7 +303,8 @@ function ContactsClient() { > {addOpen ? 'Cancel' : '+ Add Contact'} - Email + ✉ Email + Calendar ← Admin