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:
@@ -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')
|
||||
|
||||
@@ -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() {
|
||||
|
||||
+60
-37
@@ -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 = `
|
||||
<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>
|
||||
<h2 style="color:#c8860a;margin-bottom:4px">${icon} Calendar 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>
|
||||
<strong>${label}: ${title}</strong>${seriesStr}<br>
|
||||
<span style="color:#555">Scheduled for <strong>${date}</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.
|
||||
This reminder was set ${reminderDays} day${reminderDays === 1 ? '' : 's'} before the 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})`)
|
||||
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)
|
||||
|
||||
@@ -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 })
|
||||
})
|
||||
}
|
||||
@@ -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 },
|
||||
|
||||
Reference in New Issue
Block a user