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 <noreply@anthropic.com>
This commit is contained in:
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "siteforge",
|
"name": "siteforge",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.1.14",
|
"version": "1.1.15",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
@@ -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 registerStudyCertificate } from './server/routes/study-certificate.js'
|
||||||
import { register as registerEpisodeScripts } from './server/routes/episode-scripts.js'
|
import { register as registerEpisodeScripts } from './server/routes/episode-scripts.js'
|
||||||
import { register as registerContact } from './server/routes/contact.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 registerInboundEmail } from './server/routes/inbound-email.js'
|
||||||
import { register as registerQuestions } from './server/routes/questions.js'
|
import { register as registerQuestions } from './server/routes/questions.js'
|
||||||
import { register as registerAnalytics } from './server/routes/analytics.js'
|
import { register as registerAnalytics } from './server/routes/analytics.js'
|
||||||
@@ -156,6 +157,8 @@ Promise.all([
|
|||||||
createBackupSnapshot('scheduled').catch(() => {})
|
createBackupSnapshot('scheduled').catch(() => {})
|
||||||
}, BACKUP_INTERVAL_MS)
|
}, BACKUP_INTERVAL_MS)
|
||||||
|
|
||||||
|
startReminderScheduler()
|
||||||
|
|
||||||
// Purge expired study sessions every hour
|
// Purge expired study sessions every hour
|
||||||
setInterval(() => {
|
setInterval(() => {
|
||||||
const now = Date.now()
|
const now = Date.now()
|
||||||
|
|||||||
@@ -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 = `
|
||||||
|
<div style="font-family:system-ui,sans-serif;max-width:540px;margin:0 auto;color:#222">
|
||||||
|
<h2 style="color:#c8860a;margin-bottom:4px">📅 Release Reminder</h2>
|
||||||
|
<p style="font-size:1.1rem;margin-bottom:16px">
|
||||||
|
<strong>${label}: ${title}</strong>${series}<br>
|
||||||
|
<span style="color:#555">Publishes <strong>${ep.datePublished}</strong> — ${daysText}</span>
|
||||||
|
</p>
|
||||||
|
<hr style="border:none;border-top:1px solid #eee;margin:16px 0">
|
||||||
|
<p style="color:#777;font-size:0.85rem">
|
||||||
|
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.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
+14
@@ -10575,6 +10575,20 @@
|
|||||||
|
|
||||||
.cal-ep-chip-title { color: #4a8a58; }
|
.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 {
|
.cal-sidebar {
|
||||||
width: 220px;
|
width: 220px;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
|
|||||||
+44
-4
@@ -17,8 +17,19 @@ interface PodcastChecklistEpisode {
|
|||||||
datePublished: string
|
datePublished: string
|
||||||
expanded: boolean
|
expanded: boolean
|
||||||
tasks: Record<string, boolean>
|
tasks: Record<string, boolean>
|
||||||
|
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 {
|
interface PodcastChecklistData {
|
||||||
tasks: PodcastChecklistTask[]
|
tasks: PodcastChecklistTask[]
|
||||||
episodes: PodcastChecklistEpisode[]
|
episodes: PodcastChecklistEpisode[]
|
||||||
@@ -149,9 +160,9 @@ function CalendarClient() {
|
|||||||
const [viewMonth, setViewMonth] = useState(today.getMonth())
|
const [viewMonth, setViewMonth] = useState(today.getMonth())
|
||||||
const [selectedEpisodeId, setSelectedEpisodeId] = useState<string | null>(null)
|
const [selectedEpisodeId, setSelectedEpisodeId] = useState<string | null>(null)
|
||||||
const [editEp, setEditEp] = useState<PodcastChecklistEpisode | null>(null)
|
const [editEp, setEditEp] = useState<PodcastChecklistEpisode | null>(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 [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 [newBusy, setNewBusy] = useState(false)
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
const load = useCallback(async () => {
|
||||||
@@ -247,11 +258,16 @@ function CalendarClient() {
|
|||||||
episodeNumber: ep.episodeNumber != null ? String(ep.episodeNumber) : '',
|
episodeNumber: ep.episodeNumber != null ? String(ep.episodeNumber) : '',
|
||||||
title: ep.title,
|
title: ep.title,
|
||||||
datePublished: ep.datePublished?.trim() ?? '',
|
datePublished: ep.datePublished?.trim() ?? '',
|
||||||
|
reminderDays: String(ep.reminderDays ?? 0),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
function saveEdit() {
|
function saveEdit() {
|
||||||
if (!editEp || !checklist) return
|
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 =>
|
const updated = checklist.episodes.map(ep =>
|
||||||
ep.id === editEp.id
|
ep.id === editEp.id
|
||||||
? {
|
? {
|
||||||
@@ -259,7 +275,10 @@ function CalendarClient() {
|
|||||||
series: editForm.series.trim(),
|
series: editForm.series.trim(),
|
||||||
episodeNumber: editForm.episodeNumber.trim() ? Number(editForm.episodeNumber) : null,
|
episodeNumber: editForm.episodeNumber.trim() ? Number(editForm.episodeNumber) : null,
|
||||||
title: editForm.title.trim(),
|
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
|
: ep
|
||||||
)
|
)
|
||||||
@@ -289,12 +308,13 @@ function CalendarClient() {
|
|||||||
episodeNumber: newForm.episodeNumber.trim() ? Number(newForm.episodeNumber) : null,
|
episodeNumber: newForm.episodeNumber.trim() ? Number(newForm.episodeNumber) : null,
|
||||||
title: newForm.title.trim(),
|
title: newForm.title.trim(),
|
||||||
datePublished: newForm.datePublished.trim(),
|
datePublished: newForm.datePublished.trim(),
|
||||||
|
reminderDays: Number(newForm.reminderDays),
|
||||||
expanded: false,
|
expanded: false,
|
||||||
tasks: {},
|
tasks: {},
|
||||||
}
|
}
|
||||||
const updated = [...checklist.episodes, ep]
|
const updated = [...checklist.episodes, ep]
|
||||||
await save(updated)
|
await save(updated)
|
||||||
setNewForm({ series: '', episodeNumber: '', title: '', datePublished: '' })
|
setNewForm({ series: '', episodeNumber: '', title: '', datePublished: '', reminderDays: '0' })
|
||||||
setNewEpOpen(false)
|
setNewEpOpen(false)
|
||||||
setNewBusy(false)
|
setNewBusy(false)
|
||||||
}
|
}
|
||||||
@@ -355,6 +375,12 @@ function CalendarClient() {
|
|||||||
Date
|
Date
|
||||||
<input className="ct-input" type="date" value={newForm.datePublished} onChange={e => setNewForm(f => ({ ...f, datePublished: e.target.value }))} />
|
<input className="ct-input" type="date" value={newForm.datePublished} onChange={e => setNewForm(f => ({ ...f, datePublished: e.target.value }))} />
|
||||||
</label>
|
</label>
|
||||||
|
<label className="cal-new-ep-label">
|
||||||
|
Email Reminder
|
||||||
|
<select className="ct-input" value={newForm.reminderDays} onChange={e => setNewForm(f => ({ ...f, reminderDays: e.target.value }))}>
|
||||||
|
{REMINDER_OPTIONS.map(o => <option key={o.value} value={o.value}>{o.label}</option>)}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<div className="cal-new-ep-actions">
|
<div className="cal-new-ep-actions">
|
||||||
<button type="submit" className="em-btn em-btn--primary em-btn--sm" disabled={newBusy}>
|
<button type="submit" className="em-btn em-btn--primary em-btn--sm" disabled={newBusy}>
|
||||||
@@ -405,6 +431,7 @@ function CalendarClient() {
|
|||||||
>
|
>
|
||||||
{ep.episodeNumber ? `Ep ${ep.episodeNumber}` : ep.series?.slice(0, 6) ?? '—'}
|
{ep.episodeNumber ? `Ep ${ep.episodeNumber}` : ep.series?.slice(0, 6) ?? '—'}
|
||||||
{ep.title && <span className="cal-ep-chip-title"> {ep.title.slice(0, 18)}{ep.title.length > 18 ? '…' : ''}</span>}
|
{ep.title && <span className="cal-ep-chip-title"> {ep.title.slice(0, 18)}{ep.title.length > 18 ? '…' : ''}</span>}
|
||||||
|
{(ep.reminderDays ?? 0) > 0 && <span className="cal-ep-bell" title={`Reminder: ${ep.reminderDays}d before${ep.reminderSentAt ? ' (sent)' : ''}`}>{ep.reminderSentAt ? '✓' : '🔔'}</span>}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -506,6 +533,19 @@ function CalendarClient() {
|
|||||||
onChange={e => setEditForm(f => ({ ...f, datePublished: e.target.value }))}
|
onChange={e => setEditForm(f => ({ ...f, datePublished: e.target.value }))}
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
|
<label className="cal-popover-label">
|
||||||
|
Email Reminder
|
||||||
|
<select
|
||||||
|
className="ct-input"
|
||||||
|
value={editForm.reminderDays}
|
||||||
|
onChange={e => setEditForm(f => ({ ...f, reminderDays: e.target.value }))}
|
||||||
|
>
|
||||||
|
{REMINDER_OPTIONS.map(o => <option key={o.value} value={o.value}>{o.label}</option>)}
|
||||||
|
</select>
|
||||||
|
{editEp?.reminderSentAt && (
|
||||||
|
<span className="cal-reminder-sent">Reminder sent {new Date(editEp.reminderSentAt).toLocaleDateString()}</span>
|
||||||
|
)}
|
||||||
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<div className="cal-popover-actions">
|
<div className="cal-popover-actions">
|
||||||
<button type="button" className="em-btn em-btn--primary em-btn--sm" onClick={saveEdit}>
|
<button type="button" className="em-btn em-btn--primary em-btn--sm" onClick={saveEdit}>
|
||||||
|
|||||||
Reference in New Issue
Block a user