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>
165 lines
5.8 KiB
TypeScript
165 lines
5.8 KiB
TypeScript
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>
|
|
)
|
|
}
|