Add calendar app — shared events calendar with departments, staff tagging, bank holidays and two-way phone sync

Fastify/Postgres backend + React frontend matching the stack's app conventions, plus
a hand-rolled RFC 4791 CalDAV server (caldav-adapter turned out Koa-only in practice)
so calendars subscribe as genuine two-way sync in Apple/Google/Outlook. v1 scope:
multiple colour-coded calendars, department/staff event tagging via live Workforce
lookups, month/week/day/list views, dashboard, file attachments, activity log, and
an auto-synced UK bank holidays calendar. Verified end-to-end locally against real
Postgres: REST CRUD, CalDAV discovery/PROPFIND/REPORT/PUT/sync-collection, all-day
date handling, system-calendar write protection, and activity logging across both
the web and CalDAV write paths.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
jtricerolph 2026-07-24 16:36:37 +00:00
commit bf5557d277
53 changed files with 12529 additions and 0 deletions

View file

@ -0,0 +1,165 @@
import { useEffect, useState, useCallback } from 'react'
import { ChevronLeft, ChevronRight, Plus } from 'lucide-react'
import type { Calendar, EventSummary } from '../types'
import { can } from '../types'
import { useAuth } from '../components/AuthGate'
import { fetchCalendars, fetchEvents } from '../api'
import CalendarToggleList from '../components/CalendarToggleList'
import ViewSwitcher, { type CalendarViewKey } from '../components/ViewSwitcher'
import EventForm from '../components/EventForm'
import MonthGrid from '../components/views/MonthGrid'
import WeekGrid from '../components/views/WeekGrid'
import DayGrid from '../components/views/DayGrid'
import AgendaList from '../components/views/AgendaList'
import {
addDays, addMonths, monthGridDays, startOfWeek, toISODate,
formatMonthLabel, formatWeekLabel, formatDateLabel,
} from '../dateUtils'
function rangeFor(view: CalendarViewKey, date: Date): { from: Date; to: Date } {
if (view === 'month') {
const days = monthGridDays(date)
return { from: days[0], to: days[days.length - 1] }
}
if (view === 'week') {
const start = startOfWeek(date)
return { from: start, to: addDays(start, 6) }
}
if (view === 'day') {
return { from: date, to: date }
}
// list/agenda — rolling 30-day window from the current date
return { from: date, to: addDays(date, 30) }
}
function labelFor(view: CalendarViewKey, date: Date): string {
if (view === 'month') return formatMonthLabel(date)
if (view === 'week') return formatWeekLabel(date)
if (view === 'day') return formatDateLabel(date)
return `Next 30 days from ${formatDateLabel(date)}`
}
function shiftDate(view: CalendarViewKey, date: Date, dir: 1 | -1): Date {
if (view === 'month') return addMonths(date, dir)
if (view === 'week') return addDays(date, 7 * dir)
return addDays(date, dir)
}
export default function CalendarView() {
const { user } = useAuth()
const [view, setView] = useState<CalendarViewKey>('month')
const [currentDate, setCurrentDate] = useState(new Date())
const [calendars, setCalendars] = useState<Calendar[]>([])
const [visibleIds, setVisibleIds] = useState<Set<number>>(new Set())
const [events, setEvents] = useState<EventSummary[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [formState, setFormState] = useState<{ open: boolean; eventId?: number; initialDate?: Date }>({ open: false })
useEffect(() => {
fetchCalendars().then(cals => {
setCalendars(cals)
setVisibleIds(new Set(cals.map(c => c.id)))
}).catch(err => setError(err.message))
}, [])
const reload = useCallback(() => {
const { from, to } = rangeFor(view, currentDate)
setLoading(true)
setError(null)
fetchEvents({ from: toISODate(from), to: toISODate(to) })
.then(setEvents)
.catch(err => setError(err.message))
.finally(() => setLoading(false))
}, [view, currentDate])
useEffect(() => { reload() }, [reload])
function toggleCalendar(id: number) {
setVisibleIds(prev => {
const next = new Set(prev)
if (next.has(id)) next.delete(id)
else next.add(id)
return next
})
}
const visibleEvents = events.filter(ev => visibleIds.has(ev.calendar_id))
function openCreate(date: Date) {
if (!can(user, 'create')) return
setFormState({ open: true, initialDate: date })
}
function openEdit(id: number) {
setFormState({ open: true, eventId: id })
}
function closeForm() {
setFormState({ open: false })
}
const viewProps = {
events: visibleEvents,
date: currentDate,
onSelectDate: openCreate,
onSelectEvent: openEdit,
}
return (
<div className="page" style={{ maxWidth: 1300 }}>
<div className="page-header">
<h1>Calendar</h1>
{can(user, 'create') && (
<button className="btn btn-primary" onClick={() => openCreate(currentDate)}>
<Plus size={14} strokeWidth={1.75} />
New event
</button>
)}
</div>
{error && <div className="error-banner">{error}</div>}
<div style={{ display: 'flex', gap: 16, alignItems: 'flex-start', flexWrap: 'wrap' }}>
<div className="card" style={{ width: 220, flexShrink: 0 }}>
<div className="section-title" style={{ marginTop: 0 }}>Calendars</div>
<CalendarToggleList calendars={calendars} visibleIds={visibleIds} onToggle={toggleCalendar} />
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<div className="filter-row" style={{ marginBottom: 12, alignItems: 'center' }}>
<button className="btn btn-sm" onClick={() => setCurrentDate(shiftDate(view, currentDate, -1))}>
<ChevronLeft size={14} strokeWidth={1.75} />
</button>
<button className="btn btn-sm" onClick={() => setCurrentDate(new Date())}>Today</button>
<button className="btn btn-sm" onClick={() => setCurrentDate(shiftDate(view, currentDate, 1))}>
<ChevronRight size={14} strokeWidth={1.75} />
</button>
<div style={{ fontWeight: 600, fontSize: 14, flex: 1 }}>{labelFor(view, currentDate)}</div>
<ViewSwitcher view={view} onChange={setView} />
</div>
{loading ? (
<div className="empty-state">Loading</div>
) : view === 'month' ? (
<MonthGrid {...viewProps} />
) : view === 'week' ? (
<WeekGrid {...viewProps} />
) : view === 'day' ? (
<DayGrid {...viewProps} />
) : (
<AgendaList {...viewProps} />
)}
</div>
</div>
{formState.open && (
<EventForm
eventId={formState.eventId}
initialDate={formState.initialDate}
onClose={closeForm}
onSaved={reload}
/>
)}
</div>
)
}