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 }))} />
+
+ Email Reminder
+ setNewForm(f => ({ ...f, reminderDays: e.target.value }))}>
+ {REMINDER_OPTIONS.map(o => {o.label} )}
+
+
@@ -405,6 +431,7 @@ function CalendarClient() {
>
{ep.episodeNumber ? `Ep ${ep.episodeNumber}` : ep.series?.slice(0, 6) ?? '—'}
{ep.title && {ep.title.slice(0, 18)}{ep.title.length > 18 ? '…' : ''} }
+ {(ep.reminderDays ?? 0) > 0 && {ep.reminderSentAt ? '✓' : '🔔'} }
))}
@@ -506,6 +533,19 @@ function CalendarClient() {
onChange={e => setEditForm(f => ({ ...f, datePublished: e.target.value }))}
/>
+
+ Email Reminder
+ setEditForm(f => ({ ...f, reminderDays: e.target.value }))}
+ >
+ {REMINDER_OPTIONS.map(o => {o.label} )}
+
+ {editEp?.reminderSentAt && (
+ Reminder sent {new Date(editEp.reminderSentAt).toLocaleDateString()}
+ )}
+