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
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "siteforge",
"private": true,
"version": "1.1.21",
"version": "1.1.22",
"type": "module",
"scripts": {
"dev": "vite",
+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
+101
View File
@@ -10698,10 +10698,104 @@
.cal-ev-chip--task { background: #2a2010; border-color: #3a3010; color: #fbbf24; }
.cal-ev-chip--done { opacity: 0.45; text-decoration: line-through; }
.cal-ev-chip:hover { filter: brightness(1.15); }
.cal-ev-chip--recurring { opacity: 0.85; }
.cal-ev-icon { flex-shrink: 0; }
.cal-ev-title { flex: 1; overflow: hidden; text-overflow: ellipsis; }
/* Time of day */
.cal-chip-time {
color: rgba(255,255,255,0.45);
font-size: 0.63rem;
font-family: system-ui, sans-serif;
margin-left: 2px;
white-space: nowrap;
}
/* Recurrence indicator */
.cal-recur-icon {
font-size: 0.6rem;
margin-left: 2px;
opacity: 0.65;
}
/* View toggle */
.cal-header-center {
display: flex;
align-items: center;
}
.cal-view-toggle {
display: flex;
background: #1e1e1e;
border: 1px solid #333;
border-radius: 6px;
overflow: hidden;
}
.cal-view-btn {
background: none;
border: none;
border-right: 1px solid #333;
color: #a09880;
cursor: pointer;
font-size: 0.78rem;
font-weight: 500;
padding: 0.3rem 0.65rem;
transition: background 0.12s, color 0.12s;
}
.cal-view-btn:last-child { border-right: none; }
.cal-view-btn:hover { background: rgba(255,255,255,0.06); color: #e0d8c8; }
.cal-view-btn--active { background: rgba(201,168,76,0.15); color: #c9a84c; }
/* Week / day grid variants */
.cal-grid--week {
grid-template-rows: minmax(200px, auto);
}
.cal-grid--week .cal-day {
min-height: 200px;
}
.cal-grid--day {
grid-template-columns: 1fr;
padding: 0;
}
.cal-grid--day .cal-day {
min-height: 300px;
border: none;
border-radius: 0;
}
.cal-day--week .cal-day-head { padding: 0.35rem 0.5rem 0; }
.cal-day--day-view { padding: 1rem 1.25rem; }
/* Day header (number + click-to-drill) */
.cal-day-head {
display: flex;
align-items: center;
justify-content: space-between;
}
.cal-day-num--today {
background: #c8860a;
border-radius: 50%;
color: #fff;
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 1.5rem;
height: 1.5rem;
padding: 0 0.2rem;
}
/* Drag-over day highlight */
.cal-day--drag-over {
background: rgba(201,168,76,0.1) !important;
border-color: rgba(201,168,76,0.4) !important;
}
.cal-ep-bell {
font-size: 0.6rem;
margin-left: 2px;
@@ -10883,6 +10977,13 @@
color: #8a8070;
}
.cal-popover-label--row {
flex-direction: row;
align-items: center;
gap: 0.5rem;
cursor: pointer;
}
.cal-popover-actions {
display: flex;
gap: 0.5rem;
+518 -358
View File
File diff suppressed because it is too large Load Diff