From de201ff35634e9babaa0f57e961675c33a2da49a Mon Sep 17 00:00:00 2001 From: nmemmert Date: Tue, 28 Jul 2026 11:12:17 -0400 Subject: [PATCH] Add email reminders to calendar episodes; v1.1.15 - Per-episode reminder select (1d/2d/3d/1wk/2wk before publish date) - Server checks hourly; fires Resend email to admin on reminder day - reminderSentAt persisted so reminders don't re-fire; cleared if date or setting changes - Bell indicator on calendar chips; sent confirmation in edit popover Co-Authored-By: Claude Sonnet 4.6 --- package.json | 2 +- server.js | 3 ++ server/reminder.js | 88 ++++++++++++++++++++++++++++++++++++++++++++ src/App.css | 14 +++++++ src/CalendarPage.tsx | 48 ++++++++++++++++++++++-- 5 files changed, 150 insertions(+), 5 deletions(-) create mode 100644 server/reminder.js diff --git a/package.json b/package.json index 2ead829..b72145c 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "siteforge", "private": true, - "version": "1.1.14", + "version": "1.1.15", "type": "module", "scripts": { "dev": "vite", diff --git a/server.js b/server.js index 0ebf78e..957fd68 100644 --- a/server.js +++ b/server.js @@ -50,6 +50,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 { startReminderScheduler } from './server/reminder.js' import { register as registerInboundEmail } from './server/routes/inbound-email.js' import { register as registerQuestions } from './server/routes/questions.js' import { register as registerAnalytics } from './server/routes/analytics.js' @@ -156,6 +157,8 @@ Promise.all([ createBackupSnapshot('scheduled').catch(() => {}) }, BACKUP_INTERVAL_MS) + startReminderScheduler() + // Purge expired study sessions every hour setInterval(() => { const now = Date.now() diff --git a/server/reminder.js b/server/reminder.js new file mode 100644 index 0000000..8ed93b0 --- /dev/null +++ b/server/reminder.js @@ -0,0 +1,88 @@ +import { Resend } from 'resend' +import { state } from './state.js' +import { queuePodcastChecklistWrite } from './data.js' +import { DEFAULT_RESEND_FROM, DEFAULT_RESEND_TO } from './config.js' + +function toDateKey(date) { + return date.toISOString().slice(0, 10) +} + +export function startReminderScheduler() { + checkReminders() + setInterval(checkReminders, 60 * 60 * 1000) +} + +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 + } + } + } + + if (changed) queuePodcastChecklistWrite() +} + +async function sendReminderEmail(ep) { + if (!process.env.RESEND_API_KEY) return false + + try { + const resend = new Resend(process.env.RESEND_API_KEY) + 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 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 daysText = daysLeft === 0 ? 'today' : daysLeft === 1 ? 'in 1 day' : `in ${daysLeft} days` + const subject = `Reminder: "${label}: ${title}" publishes ${daysText}` + + const html = ` +
+

📅 Release Reminder

+

+ ${label}: ${title}${series}
+ Publishes ${ep.datePublished} — ${daysText} +

+
+

+ This reminder was set ${ep.reminderDays} day${ep.reminderDays === 1 ? '' : 's'} before the publish 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})`) + return true + } catch (err) { + console.error('[reminder] send exception:', err) + return false + } +} diff --git a/src/App.css b/src/App.css index 431575d..9499936 100644 --- a/src/App.css +++ b/src/App.css @@ -10575,6 +10575,20 @@ .cal-ep-chip-title { color: #4a8a58; } +.cal-ep-bell { + font-size: 0.6rem; + margin-left: 2px; + opacity: 0.75; + vertical-align: middle; +} + +.cal-reminder-sent { + font-size: 0.72rem; + color: #4ade80; + margin-top: 0.25rem; + display: block; +} + .cal-sidebar { width: 220px; flex-shrink: 0; diff --git a/src/CalendarPage.tsx b/src/CalendarPage.tsx index b1929ad..474ed16 100644 --- a/src/CalendarPage.tsx +++ b/src/CalendarPage.tsx @@ -17,8 +17,19 @@ interface PodcastChecklistEpisode { datePublished: string expanded: boolean tasks: Record + reminderDays?: number // 0 = none; 1/2/3/7/14 = days before publish + reminderSentAt?: string // ISO timestamp set after reminder fires } +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' }, +] + interface PodcastChecklistData { tasks: PodcastChecklistTask[] episodes: PodcastChecklistEpisode[] @@ -149,9 +160,9 @@ function CalendarClient() { const [viewMonth, setViewMonth] = useState(today.getMonth()) const [selectedEpisodeId, setSelectedEpisodeId] = useState(null) const [editEp, setEditEp] = useState(null) - const [editForm, setEditForm] = useState({ series: '', episodeNumber: '', title: '', datePublished: '' }) + const [editForm, setEditForm] = useState({ series: '', episodeNumber: '', title: '', datePublished: '', reminderDays: '0' }) const [newEpOpen, setNewEpOpen] = useState(false) - const [newForm, setNewForm] = useState({ series: '', episodeNumber: '', title: '', datePublished: '' }) + const [newForm, setNewForm] = useState({ series: '', episodeNumber: '', title: '', datePublished: '', reminderDays: '0' }) const [newBusy, setNewBusy] = useState(false) const load = useCallback(async () => { @@ -247,11 +258,16 @@ function CalendarClient() { episodeNumber: ep.episodeNumber != null ? String(ep.episodeNumber) : '', title: ep.title, datePublished: ep.datePublished?.trim() ?? '', + 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 ? { @@ -259,7 +275,10 @@ function CalendarClient() { series: editForm.series.trim(), episodeNumber: editForm.episodeNumber.trim() ? Number(editForm.episodeNumber) : null, title: editForm.title.trim(), - datePublished: editForm.datePublished.trim(), + datePublished: newDate, + reminderDays: newReminder, + // Re-arm if date or reminder setting changed + reminderSentAt: dateChanged || reminderChanged ? undefined : ep.reminderSentAt, } : ep ) @@ -289,12 +308,13 @@ function CalendarClient() { episodeNumber: newForm.episodeNumber.trim() ? Number(newForm.episodeNumber) : null, title: newForm.title.trim(), datePublished: newForm.datePublished.trim(), + reminderDays: Number(newForm.reminderDays), expanded: false, tasks: {}, } const updated = [...checklist.episodes, ep] await save(updated) - setNewForm({ series: '', episodeNumber: '', title: '', datePublished: '' }) + setNewForm({ series: '', episodeNumber: '', title: '', datePublished: '', reminderDays: '0' }) setNewEpOpen(false) setNewBusy(false) } @@ -355,6 +375,12 @@ function CalendarClient() { Date setNewForm(f => ({ ...f, datePublished: e.target.value }))} /> +
))}
@@ -506,6 +533,19 @@ function CalendarClient() { onChange={e => setEditForm(f => ({ ...f, datePublished: e.target.value }))} /> +