Add recurring events, week/day view, drag-to-reschedule, iCal export, and start times to calendar; v1.1.22

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
nmemmert
2026-07-28 16:46:45 -04:00
parent ee7c783de8
commit 5e43c41b2b
5 changed files with 744 additions and 368 deletions
+4 -1
View File
@@ -1256,7 +1256,10 @@ export function sanitizePodcastChecklist(value) {
for (const taskId of taskIds) {
taskState[taskId] = sourceTasks[taskId] === true
}
episodes.push({ id, series, episodeNumber, title, datePublished, expanded: item.expanded === true, tasks: taskState })
const reminderDays = Number.isFinite(Number(item.reminderDays)) && Number(item.reminderDays) >= 0 ? Number(item.reminderDays) : 0
const reminderSentAt = typeof item.reminderSentAt === 'string' && item.reminderSentAt ? item.reminderSentAt : undefined
const startTime = typeof item.startTime === 'string' && /^\d{2}:\d{2}$/.test(item.startTime) ? item.startTime : undefined
episodes.push({ id, series, episodeNumber, title, datePublished, expanded: item.expanded === true, tasks: taskState, reminderDays, reminderSentAt, ...(startTime ? { startTime } : {}) })
}
if (episodes.length === 0) {
+113 -1
View File
@@ -4,29 +4,136 @@ import { queueCalendarEventsWrite } from '../data.js'
import { requireAdminAuth } from '../auth.js'
const VALID_TYPES = ['general', 'recording', 'social', 'task']
const VALID_FREQS = ['none', 'weekly', 'biweekly', 'monthly']
const DATE_RE = /^\d{4}-\d{2}-\d{2}$/
const TIME_RE = /^\d{2}:\d{2}$/
function sanitizeRecurrence(raw) {
if (!raw || typeof raw !== 'object') return null
const freq = VALID_FREQS.includes(raw.freq) ? raw.freq : 'none'
if (freq === 'none') return null
const until = typeof raw.until === 'string' && DATE_RE.test(raw.until) ? raw.until : null
return { freq, until }
}
function sanitize(ev) {
const recurrence = sanitizeRecurrence(ev.recurrence)
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 : '',
startTime: typeof ev.startTime === 'string' && TIME_RE.test(ev.startTime) ? ev.startTime : undefined,
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,
recurrence: recurrence ?? undefined,
createdAt: typeof ev.createdAt === 'string' ? ev.createdAt : new Date().toISOString(),
}
}
// ── iCal helpers ─────────────────────────────────────────────────────────────
function escapeIcs(s) {
return String(s ?? '').replace(/\\/g, '\\\\').replace(/;/g, '\\;').replace(/,/g, '\\,').replace(/\n/g, '\\n')
}
function foldIcsLine(line) {
const chars = [...line]
if (chars.length <= 75) return line
const parts = []
let current = ''
for (const ch of chars) {
if ((current + ch).length > 75) { parts.push(current); current = ' ' + ch }
else current += ch
}
if (current) parts.push(current)
return parts.join('\r\n')
}
function toIcsDatetime(dateStr, timeStr) {
const d = dateStr.replace(/-/g, '')
if (timeStr && TIME_RE.test(timeStr)) {
const t = timeStr.replace(':', '') + '00'
return `DTSTART:${d}T${t}`
}
return `DTSTART;VALUE=DATE:${d}`
}
function toIcsTimestamp(iso) {
try {
const d = new Date(iso)
return d.toISOString().replace(/[-:]/g, '').replace(/\.\d+/, '')
} catch { return '' }
}
function rruleFor(recurrence) {
if (!recurrence || recurrence.freq === 'none') return null
const freqMap = { weekly: 'WEEKLY', biweekly: 'WEEKLY', monthly: 'MONTHLY' }
const freq = freqMap[recurrence.freq]
if (!freq) return null
let rule = `RRULE:FREQ=${freq}`
if (recurrence.freq === 'biweekly') rule += ';INTERVAL=2'
if (recurrence.until) rule += `;UNTIL=${recurrence.until.replace(/-/g, '')}T235959Z`
return rule
}
function generateIcal() {
const lines = [
'BEGIN:VCALENDAR',
'VERSION:2.0',
'PRODID:-//Siteforge//CalendarExport//EN',
'CALSCALE:GREGORIAN',
'METHOD:PUBLISH',
'X-WR-CALNAME:Verse by Verse Calendar',
]
for (const ep of (state.podcastChecklist?.episodes ?? [])) {
if (!ep.datePublished) continue
const summary = [ep.series, ep.episodeNumber != null ? `Ep ${ep.episodeNumber}` : null, ep.title].filter(Boolean).join(' · ')
const dtstart = ep.startTime ? `DTSTART:${ep.datePublished.replace(/-/g, '')}T${ep.startTime.replace(':', '')}00` : `DTSTART;VALUE=DATE:${ep.datePublished.replace(/-/g, '')}`
lines.push('BEGIN:VEVENT')
lines.push(`UID:ep-${ep.id}@siteforge`)
lines.push(dtstart)
lines.push(`SUMMARY:${escapeIcs(summary || 'Episode')}`)
if (ep.title) lines.push(`DESCRIPTION:${escapeIcs(ep.title)}`)
lines.push('END:VEVENT')
}
for (const ev of (state.calendarEvents ?? [])) {
if (!ev.date) continue
lines.push('BEGIN:VEVENT')
lines.push(`UID:ev-${ev.id}@siteforge`)
lines.push(toIcsDatetime(ev.date, ev.startTime))
lines.push(`SUMMARY:${escapeIcs(ev.title || '(Event)')}`)
if (ev.notes) lines.push(`DESCRIPTION:${escapeIcs(ev.notes)}`)
const rrule = rruleFor(ev.recurrence)
if (rrule) lines.push(rrule)
if (ev.createdAt) lines.push(`CREATED:${toIcsTimestamp(ev.createdAt)}`)
lines.push('END:VEVENT')
}
lines.push('END:VCALENDAR')
return lines.map(foldIcsLine).join('\r\n') + '\r\n'
}
// ── Routes ───────────────────────────────────────────────────────────────────
export function register(app) {
app.get('/api/admin-calendar-events', requireAdminAuth, (_req, res) => {
res.json({ events: state.calendarEvents })
})
app.get('/api/admin-calendar.ics', requireAdminAuth, (_req, res) => {
const ical = generateIcal()
res.setHeader('Content-Type', 'text/calendar; charset=utf-8')
res.setHeader('Content-Disposition', 'attachment; filename="siteforge-calendar.ics"')
res.send(ical)
})
app.post('/api/admin-calendar-events', requireAdminAuth, (req, res) => {
const { type, title, date, notes, reminderDays } = req.body ?? {}
const { type, title, date, startTime, notes, reminderDays, recurrence } = 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 }
@@ -36,9 +143,11 @@ export function register(app) {
type: type ?? 'general',
title: String(title).trim(),
date,
startTime: startTime ?? undefined,
notes: notes ?? '',
completed: false,
reminderDays: reminderDays ?? 0,
recurrence: recurrence ?? null,
createdAt: new Date().toISOString(),
})
@@ -56,10 +165,13 @@ export function register(app) {
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?.startTime === 'string') patch.startTime = TIME_RE.test(req.body.startTime) ? req.body.startTime : undefined
if (req.body?.startTime === null) patch.startTime = undefined
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
if ('recurrence' in (req.body ?? {})) patch.recurrence = sanitizeRecurrence(req.body.recurrence) ?? undefined
const dateChanged = patch.date && patch.date !== ev.date
const reminderChanged = 'reminderDays' in patch && patch.reminderDays !== ev.reminderDays
if (dateChanged || reminderChanged) patch.reminderSentAt = undefined