From 5e43c41b2b0bc952109d04dabcf745327befa2bd Mon Sep 17 00:00:00 2001 From: nmemmert Date: Tue, 28 Jul 2026 16:46:45 -0400 Subject: [PATCH] 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 --- package.json | 2 +- server/data.js | 5 +- server/routes/calendar-events.js | 114 +++- src/App.css | 101 ++++ src/CalendarPage.tsx | 890 ++++++++++++++++++------------- 5 files changed, 744 insertions(+), 368 deletions(-) diff --git a/package.json b/package.json index a374bd0..6767503 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "siteforge", "private": true, - "version": "1.1.21", + "version": "1.1.22", "type": "module", "scripts": { "dev": "vite", diff --git a/server/data.js b/server/data.js index 5e75ccb..810e847 100644 --- a/server/data.js +++ b/server/data.js @@ -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) { diff --git a/server/routes/calendar-events.js b/server/routes/calendar-events.js index 7013904..430043b 100644 --- a/server/routes/calendar-events.js +++ b/server/routes/calendar-events.js @@ -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 diff --git a/src/App.css b/src/App.css index 6350dce..7ce4f34 100644 --- a/src/App.css +++ b/src/App.css @@ -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; diff --git a/src/CalendarPage.tsx b/src/CalendarPage.tsx index 35364b1..acd6d99 100644 --- a/src/CalendarPage.tsx +++ b/src/CalendarPage.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useEffect, useMemo, useState } from 'react' +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { Link } from 'react-router-dom' // ── Types ──────────────────────────────────────────────────────────────────── @@ -15,12 +15,46 @@ interface PodcastChecklistEpisode { episodeNumber: number | null title: string datePublished: string + startTime?: 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 + reminderDays?: number + reminderSentAt?: string } +type RecurrenceFreq = 'none' | 'weekly' | 'biweekly' | 'monthly' + +interface Recurrence { + freq: RecurrenceFreq + until: string | null +} + +type CalendarEventType = 'general' | 'recording' | 'social' | 'task' + +interface CalendarEvent { + id: string + type: CalendarEventType + title: string + date: string + startTime?: string + notes: string + completed: boolean + reminderDays: number + reminderSentAt?: string + recurrence?: Recurrence + createdAt: string + recurrenceOf?: string // runtime only: id of base event if this is an expanded instance +} + +interface PodcastChecklistData { + tasks: PodcastChecklistTask[] + episodes: PodcastChecklistEpisode[] +} + +type ViewMode = 'month' | 'week' | 'day' + +// ── Constants ───────────────────────────────────────────────────────────────── + const REMINDER_OPTIONS = [ { value: '0', label: 'No reminder' }, { value: '1', label: '1 day before' }, @@ -30,20 +64,6 @@ const REMINDER_OPTIONS = [ { value: '14', label: '2 weeks before' }, ] -type CalendarEventType = 'general' | 'recording' | 'social' | 'task' - -interface CalendarEvent { - id: string - type: CalendarEventType - title: string - date: string - notes: string - completed: boolean - reminderDays: number - reminderSentAt?: string - createdAt: string -} - const EVENT_TYPE_OPTIONS: { value: CalendarEventType; label: string; icon: string }[] = [ { value: 'general', label: 'General', icon: '📌' }, { value: 'recording', label: 'Recording', icon: '🎙️' }, @@ -51,12 +71,86 @@ const EVENT_TYPE_OPTIONS: { value: CalendarEventType; label: string; icon: strin { value: 'task', label: 'Task', icon: '✅' }, ] -interface PodcastChecklistData { - tasks: PodcastChecklistTask[] - episodes: PodcastChecklistEpisode[] +const RECURRENCE_OPTIONS: { value: RecurrenceFreq; label: string }[] = [ + { value: 'none', label: 'Does not repeat' }, + { value: 'weekly', label: 'Weekly' }, + { value: 'biweekly', label: 'Every 2 weeks' }, + { value: 'monthly', label: 'Monthly' }, +] + +const MONTH_NAMES = [ + 'January', 'February', 'March', 'April', 'May', 'June', + 'July', 'August', 'September', 'October', 'November', 'December', +] + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +function toDateKey(date: Date) { + return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}` } -// ── Auth Shell ─────────────────────────────────────────────────────────────── +function getMondayOf(date: Date): Date { + const d = new Date(date) + const dow = d.getDay() + const diff = dow === 0 ? -6 : 1 - dow + d.setDate(d.getDate() + diff) + return d +} + +function addDays(date: Date, n: number): Date { + const d = new Date(date) + d.setDate(d.getDate() + n) + return d +} + +function addMonths(date: Date, n: number): Date { + const d = new Date(date) + d.setMonth(d.getMonth() + n) + return d +} + +function formatTimeDisplay(t: string | undefined): string { + if (!t) return '' + const [h, m] = t.split(':').map(Number) + if (isNaN(h)) return '' + const ampm = h >= 12 ? 'PM' : 'AM' + const h12 = h % 12 || 12 + return `${h12}:${String(m).padStart(2, '0')} ${ampm}` +} + +function expandRecurring(events: CalendarEvent[], firstKey: string, lastKey: string): CalendarEvent[] { + const result: CalendarEvent[] = [] + for (const ev of events) { + // always include base event if it falls in range + if (ev.date >= firstKey && ev.date <= lastKey) result.push(ev) + + if (!ev.recurrence || ev.recurrence.freq === 'none') continue + + const base = new Date(ev.date + 'T12:00:00') + const untilDate = ev.recurrence.until + ? new Date(ev.recurrence.until + 'T23:59:59') + : new Date(base.getFullYear() + 2, base.getMonth(), base.getDate()) // 2-year horizon + + let d = new Date(base) + for (let i = 0; i < 1000; i++) { + if (ev.recurrence.freq === 'weekly') d = addDays(d, 7) + else if (ev.recurrence.freq === 'biweekly') d = addDays(d, 14) + else d = new Date(d.getFullYear(), d.getMonth() + 1, d.getDate()) + + if (d > untilDate) break + const dk = toDateKey(d) + if (dk > lastKey) break // past visible range + if (dk === ev.date) continue // skip base date duplicate + if (dk < firstKey) continue // before visible range, keep iterating + + // Don't add if it's the same as a date that's already included as a non-recurring event + result.push({ ...ev, id: `${ev.id}:${dk}`, date: dk, recurrenceOf: ev.id }) + } + } + return result +} + +// ── Auth Shell ──────────────────────────────────────────────────────────────── export default function CalendarShell() { const [authState, setAuthState] = useState<'checking' | 'needs-password' | 'needs-totp' | 'ok'>('checking') @@ -64,6 +158,7 @@ export default function CalendarShell() { const [totp, setTotp] = useState('') const [authError, setAuthError] = useState('') const [authBusy, setAuthBusy] = useState(false) + const [pendingToken, setPendingToken] = useState('') useEffect(() => { fetch('/api/admin-auth/status', { credentials: 'include' }) @@ -85,9 +180,9 @@ export default function CalendarShell() { body: JSON.stringify({ password }), credentials: 'include', }) - const data = await res.json() as { ok?: boolean; requiresTOTP?: boolean; message?: string } + const data = await res.json() as { ok?: boolean; totpRequired?: boolean; pendingToken?: string; message?: string } if (!res.ok) { setAuthError(data.message ?? 'Invalid password.'); setAuthBusy(false); return } - if (data.requiresTOTP) { setAuthState('needs-totp'); setAuthBusy(false); return } + if (data.totpRequired && data.pendingToken) { setPendingToken(data.pendingToken); setAuthState('needs-totp'); setAuthBusy(false); return } setAuthState('ok') } catch { setAuthError('Login failed.') } setAuthBusy(false) @@ -98,10 +193,10 @@ export default function CalendarShell() { setAuthBusy(true) setAuthError('') try { - const res = await fetch('/api/admin-auth/totp', { + const res = await fetch('/api/admin-auth/totp-verify', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ token: totp }), + body: JSON.stringify({ pendingToken, code: totp }), credentials: 'include', }) const data = await res.json() as { ok?: boolean; message?: string } @@ -111,9 +206,7 @@ export default function CalendarShell() { setAuthBusy(false) } - if (authState === 'checking') { - return
Loading…
- } + if (authState === 'checking') return
Loading…
if (authState === 'needs-password') { return ( @@ -138,16 +231,7 @@ export default function CalendarShell() {

Two-factor code

{authError &&

{authError}

} + ) + } + + function renderEvChip(ev: CalendarEvent) { + const opt = EVENT_TYPE_OPTIONS.find(o => o.value === ev.type) + const isInstance = Boolean(ev.recurrenceOf) + return ( + + ) + } + + // ── Day cell renderer ── + + function renderDayCell({ date, inMonth }: { date: Date; inMonth: boolean }, i: number) { + const dk = toDateKey(date) + const eps = episodesByDate.get(dk) ?? [] + const evs = expandedEventsByDate.get(dk) ?? [] + const isToday = dk === todayKey + const isSelecting = Boolean(selectedEpisodeId) + const isDragOver = dragOverKey === dk + + return ( +
handleDayClick(date, inMonth)} + onDragOver={e => { if (inMonth || viewMode !== 'month') { e.preventDefault(); setDragOverKey(dk) } }} + onDragLeave={() => setDragOverKey(null)} + onDrop={e => onDayDrop(e, date, inMonth)} + > +
+ { e.stopPropagation(); setViewDate(date); setViewMode('day') }} + title="Go to day view" + > + {viewMode !== 'month' ? `${MONTH_NAMES[date.getMonth()].slice(0,3)} ${date.getDate()}` : date.getDate()} + +
+
+ {eps.map(ep => renderEpChip(ep, dk))} + {evs.map(ev => renderEvChip(ev))} +
+
+ ) + } + + // ── Main render ── + return (
+ {/* Header */}
- -

- {MONTH_NAMES[viewMonth]} {viewYear} -

- + +

{viewLabel}

+ + {saving && Saving…}
+
+
+ {(['month', 'week', 'day'] as ViewMode[]).map(mode => ( + + ))} +
+
{selectedEpisodeId && ( Click a date to schedule ·{' '} - + )} - - + + 📅 iCal + ✉ Email ← Admin
+ {/* New episode banner */} {newEpOpen && (

New Episode

- - - - - + + + + + +
- +
)} + {/* New event banner */} {newEvOpen && (

New Event

- - - - + + + + +
- + + {newEvForm.recurrenceFreq !== 'none' && ( + + )} +
- +
)} + {/* Calendar body */}
{loading ? (

Loading calendar…

) : ( <> -
- {['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'].map(d => ( -
{d}
- ))} -
-
- {calDays.map(({ date, inMonth }, i) => { - const dk = toDateKey(date) - const eps = episodesByDate.get(dk) ?? [] - const isToday = dk === todayKey - const isSelecting = Boolean(selectedEpisodeId) - return ( -
handleDayClick(date, inMonth)} - > - {date.getDate()} -
- {eps.map(ep => ( - - ))} - {(eventsByDate.get(dk) ?? []).map(ev => { - const opt = EVENT_TYPE_OPTIONS.find(o => o.value === ev.type) - return ( - - ) - })} -
-
- ) - })} + {/* Day-of-week headers */} + {viewMode !== 'day' && ( +
+ {['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'].map(d => ( +
{d}
+ ))} +
+ )} + {/* Grid */} +
+ {calDays.map((day, i) => renderDayCell(day, i))}
)}
+ {/* Sidebar — unscheduled */}
))}
@@ -680,7 +897,7 @@ function CalendarClient() {
- {/* Edit popover */} + {/* Episode edit popover */} {editEp && (
setEditEp(null)}>
e.stopPropagation()}> @@ -689,66 +906,23 @@ function CalendarClient() {
- - - - + + + + +
- - - + + +
@@ -763,55 +937,41 @@ function CalendarClient() {
- - - - + + + + + + + {editEvForm.recurrenceFreq !== 'none' && ( + + )} {editEv.type === 'task' && ( -
- - - + + +