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

33
frontend/src/App.tsx Normal file
View file

@ -0,0 +1,33 @@
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'
import AuthGate from './components/AuthGate'
import { UpdateBanner } from './components/UpdateBanner'
import { useVersionCheck } from './hooks/useVersionCheck'
import Layout from './components/Layout'
import CalendarView from './pages/CalendarView'
import Dashboard from './pages/Dashboard'
import CalendarSettings from './pages/CalendarSettings'
import ActivityLog from './pages/ActivityLog'
import CalDavSetup from './pages/CalDavSetup'
export default function App() {
const updateAvailable = useVersionCheck('/calendar/health')
return (
<>
<BrowserRouter basename="/calendar">
<AuthGate>
<Layout>
<Routes>
<Route path="/" element={<CalendarView />} />
<Route path="/dashboard" element={<Dashboard />} />
<Route path="/settings" element={<CalendarSettings />} />
<Route path="/activity" element={<ActivityLog />} />
<Route path="/caldav-setup" element={<CalDavSetup />} />
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</Layout>
</AuthGate>
</BrowserRouter>
<UpdateBanner visible={updateAvailable} />
</>
)
}

143
frontend/src/api.ts Normal file
View file

@ -0,0 +1,143 @@
import type {
Calendar, EventSummary, EventDetail, EventAttachment,
ActivityLogEntry, Department, AuthUser, CaldavCredential, CaldavCredentialCreated,
} from './types'
const BASE = '/calendar/api'
async function request<T>(path: string, opts: RequestInit = {}): Promise<T> {
const res = await fetch(`${BASE}${path}`, {
credentials: 'include',
headers: { 'Content-Type': 'application/json', ...opts.headers },
...opts,
})
if (res.status === 401) {
;(window.top ?? window).location.href = '/login'
throw new Error('Unauthenticated')
}
if (!res.ok) {
const err = await res.json().catch(() => ({ error: res.statusText }))
throw new Error(err.error || `Request failed: ${res.status}`)
}
return res.json()
}
// Calendars
export function fetchCalendars(): Promise<Calendar[]> {
return request('/calendars')
}
export function createCalendar(body: { name: string; color: string }): Promise<Calendar> {
return request('/calendars', { method: 'POST', body: JSON.stringify(body) })
}
export function updateCalendar(id: number, body: { name?: string; color?: string }): Promise<Calendar> {
return request(`/calendars/${id}`, { method: 'PATCH', body: JSON.stringify(body) })
}
export function deleteCalendar(id: number): Promise<{ ok: boolean }> {
return request(`/calendars/${id}`, { method: 'DELETE' })
}
// Events
export interface EventFilters {
from?: string
to?: string
calendar_id?: number
department_id?: string
mine?: boolean
}
export function fetchEvents(filters: EventFilters = {}): Promise<EventSummary[]> {
const params = new URLSearchParams()
for (const [k, v] of Object.entries(filters)) {
if (v !== undefined && v !== null && v !== '' && v !== false) params.set(k, String(v))
}
const qs = params.toString()
return request(`/events${qs ? `?${qs}` : ''}`)
}
export function fetchEvent(id: number): Promise<EventDetail> {
return request(`/events/${id}`)
}
export interface EventBody {
calendar_id: number
title: string
description?: string | null
location?: string | null
start_at: string
end_at: string
all_day: boolean
departments?: { id: string; name: string }[]
assignees?: { email: string; name: string }[]
}
export function createEvent(body: EventBody): Promise<EventDetail> {
return request('/events', { method: 'POST', body: JSON.stringify(body) })
}
export function updateEvent(id: number, body: Partial<EventBody>): Promise<EventDetail> {
return request(`/events/${id}`, { method: 'PATCH', body: JSON.stringify(body) })
}
export function deleteEvent(id: number): Promise<{ ok: boolean }> {
return request(`/events/${id}`, { method: 'DELETE' })
}
// Attachments — multipart, so no JSON content-type header
export async function uploadAttachment(eventId: number, file: File): Promise<EventAttachment> {
const form = new FormData()
form.append('file', file)
const res = await fetch(`${BASE}/events/${eventId}/attachments`, { method: 'POST', credentials: 'include', body: form })
if (!res.ok) {
const err = await res.json().catch(() => ({ error: res.statusText }))
throw new Error(err.error || `Upload failed: ${res.status}`)
}
return res.json()
}
export function deleteAttachment(id: number): Promise<{ ok: boolean }> {
return request(`/attachments/${id}`, { method: 'DELETE' })
}
// Activity log
export interface ActivityFilters {
calendar_id?: number
event_id?: number
actor_email?: string
from?: string
to?: string
limit?: number
offset?: number
}
export function fetchActivity(filters: ActivityFilters = {}): Promise<ActivityLogEntry[]> {
const params = new URLSearchParams()
for (const [k, v] of Object.entries(filters)) {
if (v !== undefined && v !== null && v !== '' && v !== false) params.set(k, String(v))
}
const qs = params.toString()
return request(`/activity${qs ? `?${qs}` : ''}`)
}
// Departments
export function fetchDepartments(): Promise<Department[]> {
return request('/departments')
}
export function fetchMyDepartments(): Promise<Department[]> {
return request('/me/departments')
}
// Dashboard
export function fetchMyUpcoming(days = 7): Promise<EventSummary[]> {
return request(`/me/upcoming?days=${days}`)
}
// CalDAV credentials
export function fetchCaldavCredentials(): Promise<CaldavCredential[]> {
return request('/caldav-credentials')
}
export function createCaldavCredential(label?: string): Promise<CaldavCredentialCreated> {
return request('/caldav-credentials', { method: 'POST', body: JSON.stringify({ label }) })
}
export function deleteCaldavCredential(id: number): Promise<{ ok: boolean }> {
return request(`/caldav-credentials/${id}`, { method: 'DELETE' })
}
// Assignable staff users — served by the central auth service through the nginx auth proxy
export async function fetchAssignableUsers(): Promise<AuthUser[]> {
const res = await fetch('/calendar/api/auth/users?app=calendar', { credentials: 'include' })
if (!res.ok) throw new Error(`Failed to load users: ${res.status}`)
return res.json()
}

View file

@ -0,0 +1,76 @@
import { useRef, useState } from 'react'
import { Paperclip, Trash2, Upload, Loader2 } from 'lucide-react'
import type { EventAttachment } from '../types'
import { uploadAttachment, deleteAttachment } from '../api'
function formatSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
}
export default function AttachmentList({ eventId, attachments, canEdit, onChange }: {
eventId: number
attachments: EventAttachment[]
canEdit: boolean
onChange: (attachments: EventAttachment[]) => void
}) {
const fileRef = useRef<HTMLInputElement>(null)
const [uploading, setUploading] = useState(false)
const [error, setError] = useState<string | null>(null)
async function handleFile(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0]
if (!file) return
setUploading(true)
setError(null)
try {
const att = await uploadAttachment(eventId, file)
onChange([...attachments, att])
} catch (err) {
setError(err instanceof Error ? err.message : 'Upload failed')
} finally {
setUploading(false)
if (fileRef.current) fileRef.current.value = ''
}
}
async function handleDelete(id: number) {
if (!confirm('Remove this attachment?')) return
await deleteAttachment(id)
onChange(attachments.filter(a => a.id !== id))
}
return (
<div className="field">
<label>Attachments</label>
{attachments.length === 0 && <div className="field-hint">No attachments yet.</div>}
<div className="timeline">
{attachments.map(att => (
<div key={att.id} className="timeline-item">
<Paperclip size={14} strokeWidth={1.75} className="timeline-icon" />
<div className="timeline-body">
<a href={att.url} target="_blank" rel="noreferrer" className="timeline-photo-link">{att.filename}</a>
<div className="timeline-meta">{formatSize(att.size_bytes)}</div>
</div>
{canEdit && (
<button type="button" className="btn-ghost-sm btn-ghost-danger" onClick={() => handleDelete(att.id)}>
<Trash2 size={13} strokeWidth={1.75} />
</button>
)}
</div>
))}
</div>
{canEdit && (
<>
<input ref={fileRef} type="file" style={{ display: 'none' }} onChange={handleFile} />
<button type="button" className="btn btn-sm" onClick={() => fileRef.current?.click()} disabled={uploading}>
{uploading ? <Loader2 size={14} strokeWidth={1.75} className="spin" /> : <Upload size={14} strokeWidth={1.75} />}
{uploading ? 'Uploading…' : 'Add attachment'}
</button>
{error && <div className="field-hint" style={{ color: 'var(--danger)' }}>{error}</div>}
</>
)}
</div>
)
}

View file

@ -0,0 +1,45 @@
import { useEffect, useState, createContext, useContext } from 'react'
import type { User } from '../types'
interface AuthCtx { user: User }
const Ctx = createContext<AuthCtx | null>(null)
export function useAuth() {
const ctx = useContext(Ctx)
if (!ctx) throw new Error('useAuth must be used inside AuthGate')
return ctx
}
export default function AuthGate({ children }: { children: React.ReactNode }) {
const [user, setUser] = useState<User | null>(null)
useEffect(() => {
fetch('/api/auth/verify?app=calendar', { credentials: 'include' })
.then(r => {
if (!r.ok) {
;(window.top ?? window).location.href = '/login'
return null
}
return r.json()
})
.then(data => { if (data) setUser(data) })
.catch(() => { ;(window.top ?? window).location.href = '/login' })
}, [])
if (!user) {
return (
<div style={{
display: 'flex', alignItems: 'center', justifyContent: 'center',
height: '100vh', fontFamily: 'var(--font)', color: 'var(--text-mid)'
}}>
Loading
</div>
)
}
return (
<Ctx.Provider value={{ user }}>
{children}
</Ctx.Provider>
)
}

View file

@ -0,0 +1,28 @@
import type { Calendar } from '../types'
// Sidebar/panel widget: checkbox + colour dot + name per calendar. Controls a
// Set<number> of visible calendar ids lifted from the parent (CalendarView).
export default function CalendarToggleList({ calendars, visibleIds, onToggle }: {
calendars: Calendar[]
visibleIds: Set<number>
onToggle: (id: number) => void
}) {
if (calendars.length === 0) {
return <div className="field-hint">No calendars yet.</div>
}
return (
<div className="cal-sidebar-calendars">
{calendars.map(cal => (
<label key={cal.id} className="cal-toggle-row">
<input
type="checkbox"
checked={visibleIds.has(cal.id)}
onChange={() => onToggle(cal.id)}
/>
<span className="cal-dot" style={{ background: cal.color }} />
<span className="cal-toggle-name">{cal.name}</span>
</label>
))}
</div>
)
}

View file

@ -0,0 +1,344 @@
import { useEffect, useState } from 'react'
import { X, Trash2, ChevronDown, ChevronRight, Lock } from 'lucide-react'
import type { Calendar, Department, EventAttachment, AuthUser, ActivityLogEntry } from '../types'
import { can } from '../types'
import { useAuth } from './AuthGate'
import {
fetchCalendars, fetchDepartments, fetchAssignableUsers, fetchEvent, fetchActivity,
createEvent, updateEvent, deleteEvent,
} from '../api'
import type { EventBody } from '../api'
import AttachmentList from './AttachmentList'
import { toISODate, toTimeInput } from '../dateUtils'
interface Assignee { email: string; name: string }
interface EventFormProps {
eventId?: number
initialDate?: Date
onClose: () => void
onSaved: () => void
}
export default function EventForm({ eventId, initialDate, onClose, onSaved }: EventFormProps) {
const { user } = useAuth()
const editing = eventId != null
const permitted = editing ? can(user, 'edit') : can(user, 'create')
const [loading, setLoading] = useState(editing)
const [saving, setSaving] = useState(false)
const [error, setError] = useState<string | null>(null)
const [calendars, setCalendars] = useState<Calendar[]>([])
const [departments, setDepartments] = useState<Department[]>([])
const [users, setUsers] = useState<AuthUser[]>([])
const baseStart = initialDate ?? new Date()
const baseEnd = new Date(baseStart.getTime() + 60 * 60 * 1000)
const [calendarId, setCalendarId] = useState<number | null>(null)
const [title, setTitle] = useState('')
const [description, setDescription] = useState('')
const [location, setLocation] = useState('')
const [allDay, setAllDay] = useState(false)
const [startDate, setStartDate] = useState(toISODate(baseStart))
const [startTime, setStartTime] = useState(toTimeInput(baseStart))
const [endDate, setEndDate] = useState(toISODate(baseEnd))
const [endTime, setEndTime] = useState(toTimeInput(baseEnd))
const [selectedDepts, setSelectedDepts] = useState<Department[]>([])
const [selectedAssignees, setSelectedAssignees] = useState<Assignee[]>([])
const [attachments, setAttachments] = useState<EventAttachment[]>([])
const [isSystem, setIsSystem] = useState(false)
const [historyOpen, setHistoryOpen] = useState(false)
const [history, setHistory] = useState<ActivityLogEntry[] | null>(null)
useEffect(() => {
fetchCalendars().then(setCalendars).catch(() => {})
fetchDepartments().then(setDepartments).catch(() => {})
fetchAssignableUsers().then(setUsers).catch(() => {})
}, [])
// Default the calendar select to the first user-editable calendar once loaded (create mode only).
useEffect(() => {
if (!editing && calendarId === null && calendars.length > 0) {
const first = calendars.find(c => !c.is_system)
if (first) setCalendarId(first.id)
}
}, [calendars, editing, calendarId])
useEffect(() => {
if (!editing || !eventId) return
setLoading(true)
fetchEvent(eventId)
.then(ev => {
setCalendarId(ev.calendar_id)
setTitle(ev.title)
setDescription(ev.description ?? '')
setLocation(ev.location ?? '')
setAllDay(ev.all_day)
const s = new Date(ev.start_at)
const e = new Date(ev.end_at)
setStartDate(toISODate(s))
setStartTime(toTimeInput(s))
setEndDate(toISODate(e))
setEndTime(toTimeInput(e))
setSelectedDepts(ev.departments)
setSelectedAssignees(ev.assignees)
setAttachments(ev.attachments)
setIsSystem(ev.calendar.is_system)
})
.catch(err => setError(err instanceof Error ? err.message : 'Failed to load event'))
.finally(() => setLoading(false))
}, [eventId, editing])
function toggleDept(dept: Department) {
setSelectedDepts(prev =>
prev.some(d => d.id === dept.id) ? prev.filter(d => d.id !== dept.id) : [...prev, dept]
)
}
function toggleAssignee(u: AuthUser) {
setSelectedAssignees(prev =>
prev.some(a => a.email === u.email) ? prev.filter(a => a.email !== u.email) : [...prev, { email: u.email, name: u.name }]
)
}
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
if (!calendarId) { setError('Choose a calendar'); return }
if (!title.trim()) { setError('Title is required'); return }
const start = allDay ? new Date(`${startDate}T00:00:00`) : new Date(`${startDate}T${startTime}:00`)
const end = allDay ? new Date(`${endDate}T23:59:00`) : new Date(`${endDate}T${endTime}:00`)
if (end.getTime() < start.getTime()) { setError('End must be after start'); return }
const body: EventBody = {
calendar_id: calendarId,
title: title.trim(),
description: description.trim() || null,
location: location.trim() || null,
start_at: start.toISOString(),
end_at: end.toISOString(),
all_day: allDay,
departments: selectedDepts,
assignees: selectedAssignees,
}
setSaving(true)
setError(null)
try {
if (editing && eventId) await updateEvent(eventId, body)
else await createEvent(body)
onSaved()
onClose()
} catch (err) {
setError(err instanceof Error ? err.message : 'Save failed')
} finally {
setSaving(false)
}
}
async function handleDelete() {
if (!eventId) return
if (!confirm('Delete this event? This cannot be undone.')) return
setSaving(true)
setError(null)
try {
await deleteEvent(eventId)
onSaved()
onClose()
} catch (err) {
setError(err instanceof Error ? err.message : 'Delete failed')
} finally {
setSaving(false)
}
}
function toggleHistory() {
const next = !historyOpen
setHistoryOpen(next)
if (next && history === null && eventId) {
fetchActivity({ event_id: eventId }).then(setHistory).catch(() => setHistory([]))
}
}
const editableCalendars = calendars.filter(c => !c.is_system)
return (
<div className="modal-overlay" onClick={onClose}>
<div className="modal" onClick={e => e.stopPropagation()}>
<div className="modal-header">
<h2>{editing ? 'Edit event' : 'New event'}</h2>
<button className="modal-close" onClick={onClose}><X size={18} strokeWidth={1.75} /></button>
</div>
{error && <div className="error-banner">{error}</div>}
{loading ? (
<div className="empty-state">Loading</div>
) : isSystem ? (
<div>
<div className="field-hint" style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 12 }}>
<Lock size={13} strokeWidth={1.75} />
This event belongs to a read-only system calendar and cannot be edited.
</div>
<div className="field"><label>Title</label><div>{title}</div></div>
{location && <div className="field"><label>Location</label><div>{location}</div></div>}
{description && <div className="field"><label>Description</label><div style={{ whiteSpace: 'pre-wrap' }}>{description}</div></div>}
<div className="modal-actions">
<button className="btn" onClick={onClose}>Close</button>
</div>
</div>
) : (
<form onSubmit={handleSubmit}>
<div className="field">
<label>Title</label>
<input type="text" value={title} onChange={e => setTitle(e.target.value)} disabled={!permitted} required />
</div>
<div className="field">
<label>Calendar</label>
<select value={calendarId ?? ''} onChange={e => setCalendarId(Number(e.target.value))} disabled={!permitted}>
<option value="" disabled>Choose a calendar</option>
{editableCalendars.map(c => (
<option key={c.id} value={c.id}>{c.name}</option>
))}
</select>
</div>
<div className="field">
<label>Description</label>
<textarea value={description} onChange={e => setDescription(e.target.value)} disabled={!permitted} />
</div>
<div className="field">
<label>Location</label>
<input type="text" value={location} onChange={e => setLocation(e.target.value)} disabled={!permitted} />
</div>
<label className="field-check" style={{ marginBottom: 12 }}>
<input type="checkbox" checked={allDay} onChange={e => setAllDay(e.target.checked)} disabled={!permitted} />
All day
</label>
<div className="field-row">
<div className="field">
<label>Start date</label>
<input type="date" value={startDate} onChange={e => setStartDate(e.target.value)} disabled={!permitted} required />
</div>
{!allDay && (
<div className="field">
<label>Start time</label>
<input type="time" value={startTime} onChange={e => setStartTime(e.target.value)} disabled={!permitted} required />
</div>
)}
</div>
<div className="field-row">
<div className="field">
<label>End date</label>
<input type="date" value={endDate} onChange={e => setEndDate(e.target.value)} disabled={!permitted} required />
</div>
{!allDay && (
<div className="field">
<label>End time</label>
<input type="time" value={endTime} onChange={e => setEndTime(e.target.value)} disabled={!permitted} required />
</div>
)}
</div>
<div className="field">
<label>Departments</label>
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
{departments.map(dept => (
<label key={dept.id} className="field-check">
<input
type="checkbox"
checked={selectedDepts.some(d => d.id === dept.id)}
onChange={() => toggleDept(dept)}
disabled={!permitted}
/>
{dept.name}
</label>
))}
{departments.length === 0 && <span className="field-hint">No departments configured.</span>}
</div>
</div>
<div className="field">
<label>Assignees</label>
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
{users.map(u => (
<label key={u.email} className="field-check">
<input
type="checkbox"
checked={selectedAssignees.some(a => a.email === u.email)}
onChange={() => toggleAssignee(u)}
disabled={!permitted}
/>
{u.name}
</label>
))}
{users.length === 0 && <span className="field-hint">No staff available.</span>}
</div>
</div>
{editing && eventId && (
<AttachmentList
eventId={eventId}
attachments={attachments}
canEdit={permitted}
onChange={setAttachments}
/>
)}
{!editing && (
<div className="field-hint" style={{ marginBottom: 12 }}>Save the event first to add attachments.</div>
)}
{editing && (
<div>
<div
className="filter-section-header"
onClick={toggleHistory}
style={{ marginBottom: historyOpen ? 8 : 0 }}
>
{historyOpen ? <ChevronDown size={14} strokeWidth={1.75} /> : <ChevronRight size={14} strokeWidth={1.75} />}
<span className="filter-section-label">History</span>
</div>
{historyOpen && (
<div className="timeline">
{history === null && <div className="field-hint">Loading</div>}
{history && history.length === 0 && <div className="field-hint">No activity recorded.</div>}
{history && history.map(h => (
<div key={h.id} className="timeline-item">
<div className="timeline-body">
<div className="timeline-note">{h.summary}</div>
<div className="timeline-meta">{h.actor_name} · {new Date(h.created_at).toLocaleString()}</div>
</div>
</div>
))}
</div>
)}
</div>
)}
<div className="modal-footer-actions">
{editing && permitted && (
<button type="button" className="btn btn-danger" onClick={handleDelete} disabled={saving} style={{ marginRight: 'auto' }}>
<Trash2 size={14} strokeWidth={1.75} />
Delete
</button>
)}
<button type="button" className="btn" onClick={onClose}>Cancel</button>
{permitted && (
<button type="submit" className="btn btn-primary" disabled={saving}>
{saving ? 'Saving…' : 'Save'}
</button>
)}
</div>
</form>
)}
</div>
</div>
)
}

View file

@ -0,0 +1,74 @@
import { useState, useEffect } from 'react'
import { NavLink, useLocation } from 'react-router-dom'
import { CalendarDays, LayoutDashboard, Settings, History, Smartphone, Menu, LogOut } from 'lucide-react'
import { useAuth } from './AuthGate'
import { can } from '../types'
const ICON_PROPS = { size: 16, strokeWidth: 1.75 }
const NAV = [
{ to: '/', label: 'Calendar', icon: CalendarDays, cap: 'view' },
{ to: '/dashboard', label: 'Dashboard', icon: LayoutDashboard, cap: 'view' },
{ to: '/settings', label: 'Settings', icon: Settings, cap: 'manage_calendars' },
{ to: '/activity', label: 'Activity Log', icon: History, cap: 'admin' },
{ to: '/caldav-setup', label: 'Phone Sync', icon: Smartphone, cap: 'view' },
]
export default function Layout({ children }: { children: React.ReactNode }) {
const { user } = useAuth()
const items = NAV.filter(n => can(user, n.cap))
const location = useLocation()
const [menuOpen, setMenuOpen] = useState(false)
async function logout() {
await fetch('/calendar/api/auth/logout', { method: 'POST', credentials: 'include' })
window.location.reload()
}
useEffect(() => { setMenuOpen(false) }, [location.pathname])
return (
<div className={`app-shell${menuOpen ? ' menu-open' : ''}`}>
<aside className="sidebar">
<div className="sidebar-logo">
<CalendarDays size={18} strokeWidth={1.75} />
Calendar
</div>
<nav className="sidebar-nav">
{items.map(({ to, label, icon: Icon }) => (
<NavLink key={to} to={to} end={to === '/'} className={({ isActive }) => isActive ? 'active' : ''}>
<Icon {...ICON_PROPS} />
{label}
</NavLink>
))}
</nav>
<div className="sidebar-user" style={{ whiteSpace: 'normal' }}>
<div style={{ fontWeight: 600, color: 'var(--text)', fontSize: '12px', marginBottom: '2px' }}>{user.name}</div>
<div style={{ fontSize: '11px', marginBottom: '8px' }}>{user.email}</div>
<button onClick={logout} style={{
display: 'flex', alignItems: 'center', gap: '6px',
background: 'none', border: 'none', color: 'inherit',
fontSize: '12px', padding: 0, cursor: 'pointer',
}}>
<LogOut size={13} strokeWidth={1.75} />
Sign out
</button>
</div>
</aside>
{menuOpen && <div className="menu-backdrop" onClick={() => setMenuOpen(false)} />}
<header className="top-bar">
<button className="top-bar-burger" onClick={() => setMenuOpen(o => !o)}>
<Menu size={20} strokeWidth={1.75} />
</button>
<CalendarDays size={18} strokeWidth={1.75} color="var(--gold)" />
<span className="top-bar-title">Calendar</span>
</header>
<main className="page-content">
{children}
</main>
</div>
)
}

View file

@ -0,0 +1,44 @@
import { RefreshCw } from 'lucide-react'
export function UpdateBanner({ visible }: { visible: boolean }) {
if (!visible) return null
return (
<div style={{
position: 'fixed',
bottom: 0,
left: 0,
right: 0,
zIndex: 9999,
background: 'var(--sidebar)',
color: 'var(--text-light)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: '12px',
padding: '10px 16px',
fontSize: '14px',
boxShadow: '0 -2px 8px rgba(0,0,0,0.3)',
}}>
<span>A new version is available.</span>
<button
onClick={() => window.location.reload()}
style={{
display: 'flex',
alignItems: 'center',
gap: '6px',
background: 'var(--accent)',
color: 'var(--sidebar)',
border: 'none',
borderRadius: '4px',
padding: '6px 14px',
fontWeight: 600,
cursor: 'pointer',
fontSize: '13px',
}}
>
<RefreshCw size={14} strokeWidth={1.75} />
Reload
</button>
</div>
)
}

View file

@ -0,0 +1,28 @@
export type CalendarViewKey = 'month' | 'week' | 'day' | 'list'
const VIEWS: { key: CalendarViewKey; label: string }[] = [
{ key: 'month', label: 'Month' },
{ key: 'week', label: 'Week' },
{ key: 'day', label: 'Day' },
{ key: 'list', label: 'List' },
]
export default function ViewSwitcher({ view, onChange }: {
view: CalendarViewKey
onChange: (v: CalendarViewKey) => void
}) {
return (
<div className="cal-view-switcher chip-bar">
{VIEWS.map(v => (
<button
key={v.key}
type="button"
className={`chip ${view === v.key ? 'active' : ''}`}
onClick={() => onChange(v.key)}
>
{v.label}
</button>
))}
</div>
)
}

View file

@ -0,0 +1,75 @@
import { useMemo, useState } from 'react'
import { Search } from 'lucide-react'
import type { EventSummary } from '../../types'
import { toISODate, formatDayHeader, formatTime } from '../../dateUtils'
// Doubles as a simple client-side search box (filters by title/location —
// EventSummary carries no description field, only EventDetail does).
export default function AgendaList({ events, onSelectEvent }: {
events: EventSummary[]
date: Date
onSelectDate?: (d: Date) => void
onSelectEvent: (id: number) => void
}) {
const [q, setQ] = useState('')
const filtered = useMemo(() => {
const needle = q.trim().toLowerCase()
const list = needle
? events.filter(ev => ev.title.toLowerCase().includes(needle) || (ev.location ?? '').toLowerCase().includes(needle))
: events
return [...list].sort((a, b) => a.start_at.localeCompare(b.start_at))
}, [events, q])
const groups = useMemo(() => {
const map = new Map<string, EventSummary[]>()
for (const ev of filtered) {
const key = toISODate(new Date(ev.start_at))
if (!map.has(key)) map.set(key, [])
map.get(key)!.push(ev)
}
return [...map.entries()]
}, [filtered])
return (
<div>
<div className="field" style={{ maxWidth: 320 }}>
<label>Search</label>
<div style={{ position: 'relative' }}>
<Search size={14} strokeWidth={1.75} style={{ position: 'absolute', left: 10, top: 10, color: 'var(--text-mid)' }} />
<input
type="text"
value={q}
onChange={e => setQ(e.target.value)}
placeholder="Filter by title or location…"
style={{ paddingLeft: 30 }}
/>
</div>
</div>
{groups.length === 0 && <div className="empty-state">No events found.</div>}
{groups.map(([dayKey, dayEvents]) => (
<div key={dayKey}>
<div className="section-title">{formatDayHeader(new Date(dayKey))}</div>
{dayEvents.map(ev => (
<div key={ev.id} className="cal-agenda-item" onClick={() => onSelectEvent(ev.id)}>
<div className="cal-agenda-date">{ev.all_day ? 'All day' : formatTime(ev.start_at)}</div>
<div className="cal-agenda-main">
<div className="cal-agenda-title">
<span className="cal-dot" style={{ background: ev.calendar_color }} />
{ev.title}
</div>
<div className="cal-agenda-meta">
{ev.location && <span>{ev.location}</span>}
{ev.department_names.length > 0 && <span>{ev.department_names.join(', ')}</span>}
{ev.assignee_names.length > 0 && <span>{ev.assignee_names.join(', ')}</span>}
</div>
</div>
</div>
))}
</div>
))}
</div>
)
}

View file

@ -0,0 +1,66 @@
import type { EventSummary } from '../../types'
import { eventOccursOnDay, formatTime, formatDateLabel, HOURS, HOUR_PX, timedLayout } from '../../dateUtils'
export default function DayGrid({ events, date, onSelectDate, onSelectEvent }: {
events: EventSummary[]
date: Date
onSelectDate?: (d: Date) => void
onSelectEvent: (id: number) => void
}) {
const allDayEvents = events.filter(ev => ev.all_day && eventOccursOnDay(ev, date))
const timedEvents = events.filter(ev => !ev.all_day && eventOccursOnDay(ev, date))
return (
<div className="cal-week-grid cal-day-view">
<div className="cal-week-head-cell" />
<div
className="cal-week-head-cell today"
onClick={() => onSelectDate?.(date)}
style={{ cursor: 'pointer' }}
>
{formatDateLabel(date)}
</div>
<div />
<div style={{ borderLeft: '1px solid var(--card-border)', padding: '3px' }}>
{allDayEvents.map(ev => (
<span
key={ev.id}
className="cal-event-chip"
style={{ background: ev.calendar_color, color: '#fff', marginBottom: 2 }}
onClick={() => onSelectEvent(ev.id)}
>
{ev.title}
</span>
))}
</div>
<div className="cal-time-gutter">
{HOURS.map(h => (
<div key={h} className="cal-time-row">{h === 0 ? '' : `${h}:00`}</div>
))}
</div>
<div
className="cal-day-col"
style={{ height: HOURS.length * HOUR_PX }}
onClick={() => onSelectDate?.(date)}
>
{HOURS.map(h => <div key={h} className="cal-day-col-slot" />)}
{timedEvents.map(ev => {
const { top, height } = timedLayout(ev, date)
return (
<div
key={ev.id}
className="cal-week-event"
style={{ top, height, background: ev.calendar_color, color: '#fff' }}
onClick={e => { e.stopPropagation(); onSelectEvent(ev.id) }}
title={ev.title}
>
{formatTime(ev.start_at)} {ev.title}
</div>
)
})}
</div>
</div>
)
}

View file

@ -0,0 +1,51 @@
import type { EventSummary } from '../../types'
import { monthGridDays, isSameDay, eventOccursOnDay, formatTime, WEEKDAY_LABELS } from '../../dateUtils'
const MAX_VISIBLE = 3
export default function MonthGrid({ events, date, onSelectDate, onSelectEvent }: {
events: EventSummary[]
date: Date
onSelectDate?: (d: Date) => void
onSelectEvent: (id: number) => void
}) {
const days = monthGridDays(date)
const today = new Date()
return (
<div className="cal-grid">
{WEEKDAY_LABELS.map(d => (
<div key={d} className="cal-day-header">{d}</div>
))}
{days.map(day => {
const dayEvents = events
.filter(ev => eventOccursOnDay(ev, day))
.sort((a, b) => a.start_at.localeCompare(b.start_at))
const cls = [
'cal-day',
day.getMonth() !== date.getMonth() ? 'other-month' : '',
isSameDay(day, today) ? 'today' : '',
].filter(Boolean).join(' ')
return (
<div key={day.toISOString()} className={cls} onClick={() => onSelectDate?.(day)}>
<div className="cal-day-num">{day.getDate()}</div>
{dayEvents.slice(0, MAX_VISIBLE).map(ev => (
<span
key={ev.id}
className="cal-event-chip"
style={{ background: ev.calendar_color, color: '#fff' }}
title={ev.title}
onClick={e => { e.stopPropagation(); onSelectEvent(ev.id) }}
>
{!ev.all_day && `${formatTime(ev.start_at)} `}{ev.title}
</span>
))}
{dayEvents.length > MAX_VISIBLE && (
<span className="cal-event-more">+{dayEvents.length - MAX_VISIBLE} more</span>
)}
</div>
)
})}
</div>
)
}

View file

@ -0,0 +1,81 @@
import type { EventSummary } from '../../types'
import {
startOfWeek, addDays, isSameDay, eventOccursOnDay,
formatDayHeader, formatTime, HOURS, HOUR_PX, timedLayout,
} from '../../dateUtils'
export default function WeekGrid({ events, date, onSelectDate, onSelectEvent }: {
events: EventSummary[]
date: Date
onSelectDate?: (d: Date) => void
onSelectEvent: (id: number) => void
}) {
const weekStart = startOfWeek(date)
const days = Array.from({ length: 7 }, (_, i) => addDays(weekStart, i))
const today = new Date()
const allDayByDay = days.map(day => events.filter(ev => ev.all_day && eventOccursOnDay(ev, day)))
const timedByDay = days.map(day => events.filter(ev => !ev.all_day && eventOccursOnDay(ev, day)))
return (
<div className="cal-week-grid">
<div className="cal-week-head-cell" />
{days.map(day => (
<div
key={day.toISOString()}
className={`cal-week-head-cell${isSameDay(day, today) ? ' today' : ''}`}
onClick={() => onSelectDate?.(day)}
style={{ cursor: 'pointer' }}
>
{formatDayHeader(day)}
</div>
))}
<div />
{days.map((day, i) => (
<div key={`allday-${day.toISOString()}`} style={{ borderLeft: '1px solid var(--card-border)', padding: '3px' }}>
{allDayByDay[i].map(ev => (
<span
key={ev.id}
className="cal-event-chip"
style={{ background: ev.calendar_color, color: '#fff', marginBottom: 2 }}
onClick={() => onSelectEvent(ev.id)}
>
{ev.title}
</span>
))}
</div>
))}
<div className="cal-time-gutter">
{HOURS.map(h => (
<div key={h} className="cal-time-row">{h === 0 ? '' : `${h}:00`}</div>
))}
</div>
{days.map((day, i) => (
<div
key={`col-${day.toISOString()}`}
className="cal-day-col"
style={{ height: HOURS.length * HOUR_PX }}
onClick={() => onSelectDate?.(day)}
>
{HOURS.map(h => <div key={h} className="cal-day-col-slot" />)}
{timedByDay[i].map(ev => {
const { top, height } = timedLayout(ev, day)
return (
<div
key={ev.id}
className="cal-week-event"
style={{ top, height, background: ev.calendar_color, color: '#fff' }}
onClick={e => { e.stopPropagation(); onSelectEvent(ev.id) }}
title={ev.title}
>
{formatTime(ev.start_at)} {ev.title}
</div>
)
})}
</div>
))}
</div>
)
}

114
frontend/src/dateUtils.ts Normal file
View file

@ -0,0 +1,114 @@
// Plain-JS date arithmetic shared by the calendar grid views — no external
// calendar library. Weeks start Monday to match UK hotel-ops convention.
export const WEEKDAY_LABELS = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']
export const HOURS = Array.from({ length: 24 }, (_, i) => i)
export const HOUR_PX = 48
export function startOfDay(d: Date): Date {
const r = new Date(d)
r.setHours(0, 0, 0, 0)
return r
}
export function endOfDay(d: Date): Date {
const r = new Date(d)
r.setHours(23, 59, 59, 999)
return r
}
export function addDays(d: Date, n: number): Date {
const r = new Date(d)
r.setDate(r.getDate() + n)
return r
}
export function addMonths(d: Date, n: number): Date {
const r = new Date(d)
r.setMonth(r.getMonth() + n)
return r
}
export function isSameDay(a: Date, b: Date): boolean {
return a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate()
}
export function startOfWeek(d: Date): Date {
const r = startOfDay(d)
const day = r.getDay() // 0=Sun..6=Sat
const diff = day === 0 ? -6 : 1 - day
r.setDate(r.getDate() + diff)
return r
}
export function toISODate(d: Date): string {
const y = d.getFullYear()
const m = String(d.getMonth() + 1).padStart(2, '0')
const day = String(d.getDate()).padStart(2, '0')
return `${y}-${m}-${day}`
}
export function toTimeInput(d: Date): string {
const h = String(d.getHours()).padStart(2, '0')
const m = String(d.getMinutes()).padStart(2, '0')
return `${h}:${m}`
}
export function monthGridDays(date: Date): Date[] {
const firstOfMonth = new Date(date.getFullYear(), date.getMonth(), 1)
const gridStart = startOfWeek(firstOfMonth)
return Array.from({ length: 42 }, (_, i) => addDays(gridStart, i))
}
interface Span { start_at: string; end_at: string }
export function eventSpan(ev: Span): { start: Date; end: Date } {
return { start: new Date(ev.start_at), end: new Date(ev.end_at) }
}
export function eventOccursOnDay(ev: Span, day: Date): boolean {
const { start, end } = eventSpan(ev)
const dayStart = startOfDay(day).getTime()
const dayEnd = endOfDay(day).getTime()
return start.getTime() <= dayEnd && end.getTime() >= dayStart
}
export function minutesSinceMidnight(d: Date): number {
return d.getHours() * 60 + d.getMinutes()
}
// Vertical position/height (in px) for a timed event rendered within a single
// day column, clamped to that day's bounds (handles events that span midnight).
export function timedLayout(ev: Span, day: Date): { top: number; height: number } {
const dayStart = startOfDay(day)
const dayEnd = endOfDay(day)
const { start, end } = eventSpan(ev)
const clampedStart = start < dayStart ? dayStart : start
const clampedEnd = end > dayEnd ? dayEnd : end
const top = (minutesSinceMidnight(clampedStart) / 60) * HOUR_PX
const durationMin = Math.max(20, (clampedEnd.getTime() - clampedStart.getTime()) / 60000)
const height = (durationMin / 60) * HOUR_PX
return { top, height }
}
export function formatTime(iso: string): string {
return new Date(iso).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
}
export function formatDayHeader(d: Date): string {
return d.toLocaleDateString([], { weekday: 'short', day: 'numeric', month: 'short' })
}
export function formatMonthLabel(d: Date): string {
return d.toLocaleDateString([], { month: 'long', year: 'numeric' })
}
export function formatWeekLabel(d: Date): string {
const start = startOfWeek(d)
const end = addDays(start, 6)
return `${start.toLocaleDateString([], { day: 'numeric', month: 'short' })} ${end.toLocaleDateString([], { day: 'numeric', month: 'short', year: 'numeric' })}`
}
export function formatDateLabel(d: Date): string {
return d.toLocaleDateString([], { weekday: 'long', day: 'numeric', month: 'long', year: 'numeric' })
}

View file

@ -0,0 +1,43 @@
import { useEffect, useState } from 'react'
const POLL_MS = 2 * 60 * 1000
export function useVersionCheck(healthUrl: string) {
const [updateAvailable, setUpdateAvailable] = useState(false)
useEffect(() => {
let seenVersion: string | null = null
async function check() {
try {
const res = await fetch(healthUrl, { cache: 'no-store' })
if (!res.ok) return
const data = await res.json()
const v: string | undefined = data.version
if (!v) return
if (seenVersion === null) {
seenVersion = v
} else if (v !== seenVersion) {
setUpdateAvailable(true)
}
} catch {
// network error — skip silently
}
}
check()
const interval = setInterval(check, POLL_MS)
function onVisible() {
if (document.visibilityState === 'visible') check()
}
document.addEventListener('visibilitychange', onVisible)
return () => {
clearInterval(interval)
document.removeEventListener('visibilitychange', onVisible)
}
}, [healthUrl])
return updateAvailable
}

601
frontend/src/index.css Normal file
View file

@ -0,0 +1,601 @@
/* Stack design system tokens — include verbatim in every app */
:root {
--navy: #1a1a2e;
--navy-dark: #0f0f20;
--gold: #c9a84c;
--gold-light: #e8c96d;
--surface: rgba(255,255,255,0.07);
--surface-2: rgba(255,255,255,0.08);
--text: rgba(255,255,255,0.88);
--text-muted: rgba(255,255,255,0.48);
--body-bg: #f4f5f7;
--card-bg: #ffffff;
--card-border: #e4e8ee;
--text-dark: #1e293b;
--text-mid: #64748b;
--shadow-sm: 0 1px 3px rgba(0,0,0,0.07), 0 1px 2px rgba(0,0,0,0.04);
--shadow-md: 0 4px 12px rgba(0,0,0,0.08);
--danger: #dc2626;
--radius: 10px;
--font: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
}
body { background: var(--body-bg); color: var(--text-dark); font-family: var(--font); }
/* Layout tokens — calendar has no app-specific accent, it uses --gold/--gold-light directly */
:root {
--danger-bg: #fef2f2;
--warn-bg: #fffbeb;
--ok-bg: #f0fdf4;
--sidebar-w: 240px;
--topbar-h: 56px;
}
*, *::before, *::after { box-sizing: border-box; }
html, body, #root { height: 100%; margin: 0; font-size: 14px; }
::-webkit-scrollbar { width: 4px; height: 4px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: var(--card-border); border-radius: 2px; }
/* ── App shell ─────────────────────────────────────────────── */
.app-shell { display: flex; height: 100vh; overflow: hidden; }
.sidebar {
width: var(--sidebar-w);
background: var(--navy);
display: flex;
flex-direction: column;
flex-shrink: 0;
overflow-y: auto;
}
.sidebar-logo {
padding: 20px 16px 12px;
color: var(--gold);
font-size: 13px;
font-weight: 600;
letter-spacing: .05em;
text-transform: uppercase;
display: flex;
align-items: center;
gap: 8px;
}
.sidebar-logo svg { opacity: .8; }
.sidebar-nav { flex: 1; padding: 8px 0; }
.sidebar-nav a {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 16px;
color: var(--text-muted);
text-decoration: none;
font-size: 13.5px;
transition: background .15s, color .15s;
}
.sidebar-nav a:hover { background: var(--surface); color: var(--text); }
.sidebar-nav a.active { background: rgba(201,168,76,.1); color: var(--gold); }
.sidebar-user {
padding: 12px 16px;
border-top: 1px solid var(--surface-2);
color: var(--text-muted);
font-size: 12px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.top-bar {
display: none;
height: var(--topbar-h);
background: var(--navy);
color: var(--text);
align-items: center;
padding: 0 12px;
gap: 10px;
flex-shrink: 0;
}
.top-bar-title { flex: 1; font-size: 15px; font-weight: 600; color: var(--gold); }
.top-bar-nav { display: flex; gap: 2px; overflow-x: auto; scrollbar-width: none; }
.top-bar-nav::-webkit-scrollbar { display: none; }
.top-bar-nav a {
color: var(--text-muted);
padding: 6px 8px;
border-radius: 6px;
text-decoration: none;
font-size: 12px;
white-space: nowrap;
}
.top-bar-nav a.active { color: var(--gold); }
.page-content { flex: 1; overflow-y: auto; display: flex; flex-direction: column; }
@media (max-width: 768px) {
.sidebar {
position: fixed;
top: 0; left: 0; bottom: 0;
z-index: 200;
transform: translateX(calc(-1 * var(--sidebar-w)));
transition: transform 0.25s ease;
}
.app-shell.menu-open .sidebar { transform: translateX(0); }
.top-bar { display: flex; }
.app-shell { flex-direction: column; }
.field-row { flex-direction: column; }
}
.menu-backdrop {
position: fixed;
inset: 0;
background: rgba(0,0,0,0.5);
z-index: 199;
}
.top-bar-burger {
background: none;
border: none;
color: var(--text);
cursor: pointer;
display: flex;
align-items: center;
padding: 4px;
flex-shrink: 0;
}
/* ── Page chrome ───────────────────────────────────────────── */
.page { padding: 20px; max-width: 1100px; width: 100%; margin: 0 auto; }
.page-header { display: flex; align-items: center; gap: 12px; margin-bottom: 16px; flex-wrap: wrap; }
.page-header h1 { font-size: 18px; margin: 0; flex: 1; }
/* ── Buttons ───────────────────────────────────────────────── */
.btn {
display: inline-flex;
align-items: center;
gap: 6px;
border: 1px solid var(--card-border);
background: var(--card-bg);
color: var(--text-dark);
border-radius: var(--radius);
padding: 7px 14px;
font-size: 13px;
cursor: pointer;
font-family: var(--font);
transition: background .12s, border-color .12s;
}
.btn:hover { border-color: var(--text-mid); }
.btn:disabled { opacity: .5; cursor: default; }
.btn-primary { background: var(--gold); border-color: var(--gold); color: var(--navy); font-weight: 600; }
.btn-primary:hover { background: var(--gold-light); border-color: var(--gold-light); }
.btn-danger { background: var(--danger); border-color: var(--danger); color: #fff; }
.btn-sm { padding: 4px 10px; font-size: 12px; border-radius: 8px; }
/* ── Forms ─────────────────────────────────────────────────── */
.field { margin-bottom: 12px; }
.field label { display: block; font-size: 12px; font-weight: 600; color: var(--text-mid); margin-bottom: 4px; }
.field input[type="text"], .field input[type="email"], .field input[type="date"],
.field input[type="time"], .field input[type="datetime-local"],
.field input[type="number"], .field select, .field textarea {
width: 100%;
border: 1px solid var(--card-border);
border-radius: 8px;
padding: 8px 10px;
font-size: 13.5px;
font-family: var(--font);
color: var(--text-dark);
background: var(--card-bg);
}
.field textarea { min-height: 72px; resize: vertical; }
.field-row { display: flex; gap: 12px; }
.field-row > .field { flex: 1; }
.field-check { display: flex; align-items: center; gap: 8px; font-size: 13.5px; cursor: pointer; }
.field-check input { width: 16px; height: 16px; accent-color: var(--gold); }
.field-hint { font-size: 11.5px; color: var(--text-mid); margin-top: 3px; }
/* ── Cards & lists ─────────────────────────────────────────── */
.card {
background: var(--card-bg);
border: 1px solid var(--card-border);
border-radius: var(--radius);
box-shadow: var(--shadow-sm);
padding: 14px 16px;
margin-bottom: 10px;
}
.task-card {
display: flex;
align-items: flex-start;
gap: 12px;
cursor: pointer;
transition: box-shadow .12s;
}
.task-card:hover { box-shadow: var(--shadow-md); }
.task-card-main { flex: 1; min-width: 0; }
.task-card-title { font-weight: 600; font-size: 14px; margin-bottom: 2px; display: flex; align-items: center; gap: 6px; flex-wrap: wrap; }
.task-card-meta { font-size: 12px; color: var(--text-mid); display: flex; gap: 10px; flex-wrap: wrap; align-items: center; }
.task-card-side { display: flex; flex-direction: column; align-items: flex-end; gap: 6px; flex-shrink: 0; }
/* ── Badges ────────────────────────────────────────────────── */
.badge {
display: inline-flex;
align-items: center;
gap: 4px;
border-radius: 20px;
padding: 2px 9px;
font-size: 11px;
font-weight: 600;
color: #fff;
white-space: nowrap;
}
.badge-outline {
background: transparent;
border: 1px solid var(--card-border);
color: var(--text-mid);
font-weight: 500;
}
/* ── Filter chips ──────────────────────────────────────────── */
.filter-row { display: flex; align-items: flex-start; gap: 8px; margin-bottom: 4px; }
.filter-row .filter-section { flex: 1; min-width: 0; margin-bottom: 0; }
.filter-row .sort-chip { flex-shrink: 0; align-self: center; white-space: nowrap; }
.filter-section { margin-bottom: 10px; }
.filter-section-header {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 10px;
border-radius: 8px;
background: var(--card-bg);
border: 1px solid var(--card-border);
cursor: pointer;
user-select: none;
font-size: 12px;
color: var(--text-mid);
transition: border-color .12s;
}
.filter-section-header:hover { border-color: var(--gold); }
.filter-section-label { font-weight: 600; color: var(--text-dark); white-space: nowrap; }
.filter-section-summary { flex: 1; color: var(--gold); font-weight: 500; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.filter-section-summary.muted { color: var(--text-mid); font-weight: 400; }
.filter-section-chevron { flex-shrink: 0; transition: transform .18s; }
.filter-section-chevron.open { transform: rotate(180deg); }
.chip-bar { display: flex; gap: 6px; flex-wrap: wrap; margin: 8px 0 4px; align-items: center; }
.chip {
display: inline-flex;
align-items: center;
gap: 5px;
padding: 5px 12px;
border-radius: 20px;
border: 1px solid var(--card-border);
background: var(--card-bg);
cursor: pointer;
font-size: 12px;
color: var(--text-mid);
user-select: none;
font-family: var(--font);
transition: all .12s;
}
.chip:hover { border-color: var(--gold); color: var(--text-dark); }
.chip.active { background: var(--gold); border-color: var(--gold); color: var(--navy); font-weight: 600; }
/* ── Modal ─────────────────────────────────────────────────── */
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(15,15,32,.55);
display: flex;
align-items: flex-start;
justify-content: center;
padding: 24px 12px;
z-index: 100;
overflow-y: auto;
}
.modal {
background: var(--card-bg);
border-radius: var(--radius);
box-shadow: var(--shadow-md);
width: 100%;
max-width: 680px;
padding: 20px;
margin: auto 0;
}
.modal-header { display: flex; align-items: flex-start; gap: 10px; margin-bottom: 14px; }
.modal-header h2 { font-size: 16px; margin: 0; flex: 1; }
.modal-close {
background: none;
border: none;
cursor: pointer;
color: var(--text-mid);
padding: 2px;
display: flex;
}
.modal-actions { display: flex; gap: 8px; justify-content: flex-end; margin-top: 16px; flex-wrap: wrap; }
.modal-footer-actions { display: flex; gap: 12px; justify-content: flex-end; margin-top: 20px; padding-top: 12px; border-top: 1px solid var(--card-border); }
.btn-ghost-sm {
display: inline-flex; align-items: center; gap: 5px;
padding: 4px 8px; border-radius: 6px; border: none; background: none;
font-size: 12px; color: var(--text-mid); cursor: pointer; font-family: var(--font);
transition: color .12s, background .12s;
}
.btn-ghost-sm:hover { color: var(--text-dark); background: var(--card-border); }
.btn-ghost-danger { color: var(--danger); }
.btn-ghost-danger:hover { color: var(--danger); background: var(--danger-bg); }
/* ── Timeline / thread ─────────────────────────────────────── */
.timeline { margin: 8px 0; }
.timeline-item {
display: flex;
gap: 10px;
padding: 8px 0;
border-bottom: 1px solid var(--card-border);
font-size: 13px;
}
.timeline-item:last-child { border-bottom: none; }
.timeline-icon { color: var(--text-mid); flex-shrink: 0; margin-top: 1px; }
.timeline-body { flex: 1; min-width: 0; }
.timeline-note { white-space: pre-wrap; }
.timeline-meta { font-size: 11.5px; color: var(--text-mid); margin-top: 2px; }
.timeline-photo-link { color: var(--gold); cursor: pointer; font-size: 12px; font-weight: 500; }
.timeline-photo-link:hover { text-decoration: underline; }
/* ── Tables ────────────────────────────────────────────────── */
.table-wrap { overflow-x: auto; background: var(--card-bg); border: 1px solid var(--card-border); border-radius: var(--radius); box-shadow: var(--shadow-sm); }
table.data { width: 100%; border-collapse: collapse; font-size: 13px; }
table.data th {
text-align: left;
padding: 9px 12px;
font-size: 11.5px;
text-transform: uppercase;
letter-spacing: .04em;
color: var(--text-mid);
border-bottom: 1px solid var(--card-border);
white-space: nowrap;
}
table.data td { padding: 9px 12px; border-bottom: 1px solid var(--card-border); vertical-align: top; }
table.data tr:last-child td { border-bottom: none; }
table.data tr.clickable { cursor: pointer; }
table.data tr.clickable:hover td { background: var(--body-bg); }
/* ── Stats strip ───────────────────────────────────────────── */
.stats-strip { display: flex; gap: 10px; margin-bottom: 14px; flex-wrap: wrap; }
.stat-box {
background: var(--card-bg);
border: 1px solid var(--card-border);
border-radius: var(--radius);
box-shadow: var(--shadow-sm);
padding: 10px 16px;
min-width: 110px;
}
.stat-box .stat-value { font-size: 18px; font-weight: 700; }
.stat-box .stat-label { font-size: 11px; color: var(--text-mid); text-transform: uppercase; letter-spacing: .04em; }
/* ── Misc ──────────────────────────────────────────────────── */
.empty-state { text-align: center; color: var(--text-mid); padding: 40px 16px; font-size: 13.5px; }
.error-banner {
background: var(--danger-bg);
border: 1px solid var(--danger);
color: var(--danger);
border-radius: var(--radius);
padding: 10px 14px;
margin-bottom: 12px;
font-size: 13px;
}
.section-title { font-size: 13px; font-weight: 700; text-transform: uppercase; letter-spacing: .04em; color: var(--text-mid); margin: 18px 0 8px; }
.muted { color: var(--text-mid); }
.overdue { color: var(--danger); font-weight: 600; }
/* Sidebar scrollbar */
.nav-scroll::-webkit-scrollbar,
.sidebar::-webkit-scrollbar,
.sidebar-nav::-webkit-scrollbar { width: 4px; }
.nav-scroll::-webkit-scrollbar-track,
.sidebar::-webkit-scrollbar-track,
.sidebar-nav::-webkit-scrollbar-track { background: transparent; }
.nav-scroll::-webkit-scrollbar-thumb,
.sidebar::-webkit-scrollbar-thumb,
.sidebar-nav::-webkit-scrollbar-thumb { background: rgba(201,168,76,0.35); border-radius: 2px; }
.nav-scroll::-webkit-scrollbar-thumb:hover,
.sidebar::-webkit-scrollbar-thumb:hover,
.sidebar-nav::-webkit-scrollbar-thumb:hover { background: rgba(201,168,76,0.65); }
.nav-scroll, .sidebar, .sidebar-nav { scrollbar-width: thin; scrollbar-color: rgba(201,168,76,0.35) transparent; }
/*
Calendar-specific components
*/
/* ── View switcher (reuses .chip/.chip.active) ────────────────── */
.cal-view-switcher { display: flex; gap: 6px; flex-wrap: wrap; align-items: center; }
/* ── Sidebar calendar toggle list ─────────────────────────────── */
.cal-sidebar-calendars { display: flex; flex-direction: column; gap: 2px; }
.cal-toggle-row {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 10px;
border-radius: 8px;
cursor: pointer;
font-size: 13px;
color: var(--text-dark);
user-select: none;
}
.cal-toggle-row:hover { background: var(--body-bg); }
.cal-toggle-row input { width: 14px; height: 14px; accent-color: var(--gold); flex-shrink: 0; }
.cal-dot {
width: 10px;
height: 10px;
border-radius: 50%;
flex-shrink: 0;
display: inline-block;
}
.cal-toggle-name { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
/* ── Month grid ────────────────────────────────────────────────── */
.cal-grid {
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: 1px;
background: var(--card-border);
border: 1px solid var(--card-border);
border-radius: var(--radius);
overflow: hidden;
}
.cal-day-header {
background: var(--card-bg);
padding: 8px 6px;
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: .04em;
color: var(--text-mid);
text-align: center;
}
.cal-day {
background: var(--card-bg);
min-height: 96px;
padding: 6px;
display: flex;
flex-direction: column;
gap: 3px;
cursor: pointer;
transition: background .12s;
}
.cal-day:hover { background: var(--body-bg); }
.cal-day-num { font-size: 12px; font-weight: 600; color: var(--text-dark); margin-bottom: 2px; }
.cal-day.other-month { background: #fafafb; }
.cal-day.other-month .cal-day-num { color: var(--text-mid); opacity: .6; }
.cal-day.today { background: rgba(201,168,76,.08); }
.cal-day.today .cal-day-num {
display: inline-flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
border-radius: 50%;
background: var(--gold);
color: var(--navy);
}
.cal-event-chip {
display: block;
border-radius: 5px;
padding: 1px 6px;
font-size: 11px;
font-weight: 500;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
cursor: pointer;
}
.cal-event-more { font-size: 11px; color: var(--text-mid); padding: 1px 6px; cursor: pointer; }
/* ── Week / day grid ───────────────────────────────────────────── */
.cal-week-grid {
display: grid;
grid-template-columns: 56px repeat(7, 1fr);
border: 1px solid var(--card-border);
border-radius: var(--radius);
overflow: hidden;
background: var(--card-bg);
}
.cal-week-grid.cal-day-view { grid-template-columns: 56px 1fr; }
.cal-week-head { display: contents; }
.cal-week-head-cell {
padding: 8px 6px;
font-size: 11px;
font-weight: 700;
text-align: center;
color: var(--text-mid);
border-bottom: 1px solid var(--card-border);
border-left: 1px solid var(--card-border);
background: var(--card-bg);
}
.cal-week-head-cell.today { color: var(--gold); }
.cal-time-gutter { grid-column: 1; }
.cal-time-row {
height: 48px;
font-size: 10.5px;
color: var(--text-mid);
text-align: right;
padding: 2px 6px;
border-top: 1px solid var(--card-border);
box-sizing: border-box;
}
.cal-day-col {
position: relative;
border-left: 1px solid var(--card-border);
border-top: 1px solid var(--card-border);
min-height: 48px;
}
.cal-day-col-slot {
height: 48px;
border-top: 1px solid var(--card-border);
box-sizing: border-box;
}
.cal-day-col-slot:first-child { border-top: none; }
.cal-week-event {
position: absolute;
left: 2px;
right: 2px;
border-radius: 5px;
padding: 2px 5px;
font-size: 11px;
font-weight: 500;
overflow: hidden;
cursor: pointer;
box-shadow: var(--shadow-sm);
}
.cal-allday-row {
grid-column: 1 / -1;
display: flex;
gap: 4px;
padding: 4px 6px;
border-bottom: 1px solid var(--card-border);
flex-wrap: wrap;
}
/* ── Agenda / list view ───────────────────────────────────────── */
.cal-agenda-item {
display: flex;
align-items: flex-start;
gap: 10px;
padding: 10px 4px;
border-bottom: 1px solid var(--card-border);
cursor: pointer;
}
.cal-agenda-item:hover { background: var(--body-bg); }
.cal-agenda-item:last-child { border-bottom: none; }
.cal-agenda-date {
width: 64px;
flex-shrink: 0;
font-size: 11.5px;
color: var(--text-mid);
font-weight: 600;
padding-top: 1px;
}
.cal-agenda-main { flex: 1; min-width: 0; }
.cal-agenda-title { font-weight: 600; font-size: 13.5px; display: flex; align-items: center; gap: 6px; flex-wrap: wrap; }
.cal-agenda-meta { font-size: 12px; color: var(--text-mid); display: flex; gap: 10px; flex-wrap: wrap; margin-top: 2px; }
/* ── Colour swatch picker ─────────────────────────────────────── */
.cal-swatch-row { display: flex; gap: 8px; flex-wrap: wrap; margin: 8px 0 4px; }
.cal-swatch {
width: 28px;
height: 28px;
border-radius: 50%;
cursor: pointer;
border: 2px solid transparent;
transition: transform .1s, border-color .1s;
}
.cal-swatch:hover { transform: scale(1.08); }
.cal-swatch.active { border-color: var(--navy); }
/* ── Credential reveal box ────────────────────────────────────── */
.cal-credential-box {
background: var(--navy);
color: var(--text);
border-radius: var(--radius);
padding: 14px 16px;
font-family: monospace;
font-size: 13px;
margin: 10px 0;
}
.cal-credential-box .field-hint { color: var(--gold-light); }

18
frontend/src/main.tsx Normal file
View file

@ -0,0 +1,18 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App'
import './index.css'
if (new URLSearchParams(window.location.search).has('install')) {
window.addEventListener('beforeinstallprompt', e => {
e.preventDefault()
;(e as Event & { prompt: () => Promise<void> }).prompt()
}, { once: true })
}
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
</React.StrictMode>
)

View file

@ -0,0 +1,113 @@
import { useEffect, useState, useCallback } from 'react'
import type { ActivityLogEntry, Calendar } from '../types'
import { fetchActivity, fetchCalendars } from '../api'
const PAGE_SIZE = 50
export default function ActivityLog() {
const [calendars, setCalendars] = useState<Calendar[]>([])
const [entries, setEntries] = useState<ActivityLogEntry[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [offset, setOffset] = useState(0)
const [hasMore, setHasMore] = useState(true)
const [calendarId, setCalendarId] = useState('')
const [actorEmail, setActorEmail] = useState('')
const [from, setFrom] = useState('')
const [to, setTo] = useState('')
useEffect(() => {
fetchCalendars().then(setCalendars).catch(() => {})
}, [])
const load = useCallback((nextOffset: number, append: boolean) => {
setLoading(true)
setError(null)
fetchActivity({
calendar_id: calendarId ? Number(calendarId) : undefined,
actor_email: actorEmail || undefined,
from: from || undefined,
to: to || undefined,
limit: PAGE_SIZE,
offset: nextOffset,
})
.then(rows => {
setEntries(prev => append ? [...prev, ...rows] : rows)
setHasMore(rows.length === PAGE_SIZE)
setOffset(nextOffset)
})
.catch(err => setError(err.message))
.finally(() => setLoading(false))
}, [calendarId, actorEmail, from, to])
useEffect(() => { load(0, false) }, [load])
return (
<div className="page" style={{ maxWidth: 1100 }}>
<div className="page-header">
<h1>Activity Log</h1>
</div>
{error && <div className="error-banner">{error}</div>}
<div className="filter-row" style={{ flexWrap: 'wrap', marginBottom: 14 }}>
<div className="field" style={{ marginBottom: 0, minWidth: 160 }}>
<label>Calendar</label>
<select value={calendarId} onChange={e => setCalendarId(e.target.value)}>
<option value="">All calendars</option>
{calendars.map(c => <option key={c.id} value={c.id}>{c.name}</option>)}
</select>
</div>
<div className="field" style={{ marginBottom: 0, minWidth: 160 }}>
<label>Actor email</label>
<input type="text" value={actorEmail} onChange={e => setActorEmail(e.target.value)} placeholder="name@hotel..." />
</div>
<div className="field" style={{ marginBottom: 0 }}>
<label>From</label>
<input type="date" value={from} onChange={e => setFrom(e.target.value)} />
</div>
<div className="field" style={{ marginBottom: 0 }}>
<label>To</label>
<input type="date" value={to} onChange={e => setTo(e.target.value)} />
</div>
</div>
<div className="table-wrap">
<table className="data">
<thead>
<tr>
<th>Date</th>
<th>Actor</th>
<th>Action</th>
<th>Entity</th>
<th>Summary</th>
</tr>
</thead>
<tbody>
{entries.map(e => (
<tr key={e.id}>
<td style={{ whiteSpace: 'nowrap' }}>{new Date(e.created_at).toLocaleString()}</td>
<td>{e.actor_name || e.actor_email}</td>
<td>{e.action}</td>
<td>{e.entity_type} #{e.entity_id}</td>
<td>{e.summary}</td>
</tr>
))}
{entries.length === 0 && !loading && (
<tr><td colSpan={5} className="empty-state">No activity found.</td></tr>
)}
</tbody>
</table>
</div>
{loading && <div className="empty-state">Loading</div>}
{hasMore && !loading && entries.length > 0 && (
<div style={{ textAlign: 'center', marginTop: 12 }}>
<button className="btn" onClick={() => load(offset + PAGE_SIZE, true)}>Load more</button>
</div>
)}
</div>
)
}

View file

@ -0,0 +1,174 @@
import { useEffect, useState, useCallback } from 'react'
import { Copy, Plus, Smartphone, Trash2, TriangleAlert } from 'lucide-react'
import type { CaldavCredential, CaldavCredentialCreated } from '../types'
import { fetchCaldavCredentials, createCaldavCredential, deleteCaldavCredential } from '../api'
export default function CalDavSetup() {
const [credentials, setCredentials] = useState<CaldavCredential[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [label, setLabel] = useState('')
const [creating, setCreating] = useState(false)
const [revealed, setRevealed] = useState<CaldavCredentialCreated | null>(null)
const caldavUrl = `${window.location.origin}/calendar/caldav/`
const reload = useCallback(() => {
setLoading(true)
setError(null)
fetchCaldavCredentials().then(setCredentials).catch(err => setError(err.message)).finally(() => setLoading(false))
}, [])
useEffect(() => { reload() }, [reload])
async function handleCreate() {
setCreating(true)
setError(null)
try {
const created = await createCaldavCredential(label.trim() || undefined)
setRevealed(created)
setLabel('')
reload()
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to create credential')
} finally {
setCreating(false)
}
}
async function handleRevoke(id: number) {
if (!confirm('Revoke this device? It will stop syncing immediately.')) return
try {
await deleteCaldavCredential(id)
if (revealed?.id === id) setRevealed(null)
reload()
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to revoke credential')
}
}
function copy(text: string) {
navigator.clipboard?.writeText(text).catch(() => {})
}
return (
<div className="page" style={{ maxWidth: 760 }}>
<div className="page-header">
<h1>Phone Sync</h1>
</div>
<div className="card">
<p style={{ marginTop: 0 }}>
Subscribe to this calendar from your phone or computer's own calendar app (Apple Calendar,
Google Calendar, Outlook, ) using <strong>CalDAV</strong>. Once set up, events created here
show up on your device automatically, and new device-created events sync back no separate app
needed.
</p>
<p style={{ marginBottom: 0 }}>
Each device needs its own generated username and password below never share your normal
hotel login for this.
</p>
</div>
{error && <div className="error-banner">{error}</div>}
<div className="section-title">Your CalDAV devices</div>
{loading ? (
<div className="empty-state">Loading</div>
) : credentials.length === 0 ? (
<div className="empty-state">No devices set up yet.</div>
) : (
<div className="table-wrap">
<table className="data">
<thead>
<tr>
<th>Label</th>
<th>Username</th>
<th>Created</th>
<th>Last used</th>
<th></th>
</tr>
</thead>
<tbody>
{credentials.map(c => (
<tr key={c.id}>
<td>{c.label || '—'}</td>
<td>{c.username}</td>
<td>{new Date(c.created_at).toLocaleDateString()}</td>
<td>{c.last_used_at ? new Date(c.last_used_at).toLocaleString() : 'Never'}</td>
<td style={{ textAlign: 'right' }}>
<button className="btn-ghost-sm btn-ghost-danger" onClick={() => handleRevoke(c.id)}>
<Trash2 size={13} strokeWidth={1.75} /> Revoke
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
<div className="field-row" style={{ alignItems: 'flex-end', marginTop: 12 }}>
<div className="field" style={{ flex: 1 }}>
<label>Device label (optional)</label>
<input type="text" value={label} onChange={e => setLabel(e.target.value)} placeholder="e.g. Sarah's iPhone" />
</div>
<button className="btn btn-primary" onClick={handleCreate} disabled={creating} style={{ marginBottom: 12 }}>
<Plus size={14} strokeWidth={1.75} />
{creating ? 'Generating…' : 'Generate new'}
</button>
</div>
{revealed && (
<div className="card" style={{ borderColor: 'var(--gold)' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, color: 'var(--danger)', fontWeight: 600, marginBottom: 8 }}>
<TriangleAlert size={16} strokeWidth={1.75} />
Save this now the password won't be shown again.
</div>
<div className="cal-credential-box">
<div>Username: {revealed.username} <Copy size={13} strokeWidth={1.75} style={{ cursor: 'pointer', verticalAlign: 'middle' }} onClick={() => copy(revealed.username)} /></div>
<div>Password: {revealed.password} <Copy size={13} strokeWidth={1.75} style={{ cursor: 'pointer', verticalAlign: 'middle' }} onClick={() => copy(revealed.password)} /></div>
<div>Server URL: {caldavUrl} <Copy size={13} strokeWidth={1.75} style={{ cursor: 'pointer', verticalAlign: 'middle' }} onClick={() => copy(caldavUrl)} /></div>
</div>
<button className="btn btn-sm" onClick={() => setRevealed(null)}>I've saved it, hide this</button>
</div>
)}
<div className="section-title">Set-up instructions</div>
<div className="card">
<div style={{ fontWeight: 600, marginBottom: 6, display: 'flex', alignItems: 'center', gap: 6 }}>
<Smartphone size={15} strokeWidth={1.75} /> Apple Calendar (iPhone / Mac)
</div>
<ol style={{ marginTop: 0, paddingLeft: 20 }}>
<li>Settings Calendar Accounts Add Account Other Add CalDAV Account.</li>
<li>Server: <code>{caldavUrl}</code></li>
<li>User Name / Password: the credentials generated above.</li>
<li>Tap Next, then Save.</li>
</ol>
</div>
<div className="card">
<div style={{ fontWeight: 600, marginBottom: 6, display: 'flex', alignItems: 'center', gap: 6 }}>
<Smartphone size={15} strokeWidth={1.75} /> Google Calendar
</div>
<ol style={{ marginTop: 0, paddingLeft: 20 }}>
<li>Google Calendar doesn't support direct CalDAV subscriptions on the free tier easiest is to
use a CalDAV-sync app such as "CalDAV-Sync" (Android) with the server URL and credentials above.</li>
<li>Alternatively, on desktop, add it as a "secondary" calendar in a CalDAV-aware client and it
will appear alongside Google Calendar.</li>
</ol>
</div>
<div className="card">
<div style={{ fontWeight: 600, marginBottom: 6, display: 'flex', alignItems: 'center', gap: 6 }}>
<Smartphone size={15} strokeWidth={1.75} /> Outlook
</div>
<ol style={{ marginTop: 0, paddingLeft: 20 }}>
<li>Outlook (desktop): File Account Settings Internet Calendars New, then paste <code>{caldavUrl}</code>.</li>
<li>When prompted, enter the username and password generated above.</li>
</ol>
</div>
</div>
)
}

View file

@ -0,0 +1,200 @@
import { useEffect, useState, useCallback } from 'react'
import { Lock, Pencil, Plus, Trash2, X } from 'lucide-react'
import type { Calendar } from '../types'
import { fetchCalendars, createCalendar, updateCalendar, deleteCalendar } from '../api'
// Curated swatch — similarly saturated hues that read well against the navy/gold theme.
const SWATCHES = [
'#c9a84c', '#2563eb', '#16a34a', '#dc2626', '#7c3aed',
'#0d9488', '#d97706', '#db2777', '#4f46e5', '#64748b',
]
export default function CalendarSettings() {
const [calendars, setCalendars] = useState<Calendar[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [editingId, setEditingId] = useState<number | null>(null)
const [editName, setEditName] = useState('')
const [editColor, setEditColor] = useState(SWATCHES[0])
const [newOpen, setNewOpen] = useState(false)
const [newName, setNewName] = useState('')
const [newColor, setNewColor] = useState(SWATCHES[0])
const [saving, setSaving] = useState(false)
const reload = useCallback(() => {
setLoading(true)
setError(null)
fetchCalendars().then(setCalendars).catch(err => setError(err.message)).finally(() => setLoading(false))
}, [])
useEffect(() => { reload() }, [reload])
function startEdit(cal: Calendar) {
setEditingId(cal.id)
setEditName(cal.name)
setEditColor(cal.color)
}
async function saveEdit(id: number) {
setSaving(true)
setError(null)
try {
await updateCalendar(id, { name: editName.trim(), color: editColor })
setEditingId(null)
reload()
} catch (err) {
setError(err instanceof Error ? err.message : 'Update failed')
} finally {
setSaving(false)
}
}
async function handleDelete(cal: Calendar) {
if (!confirm(`Delete calendar "${cal.name}"? Events on it will also be removed.`)) return
try {
await deleteCalendar(cal.id)
reload()
} catch (err) {
setError(err instanceof Error ? err.message : 'Delete failed')
}
}
async function handleCreate(e: React.FormEvent) {
e.preventDefault()
if (!newName.trim()) return
setSaving(true)
setError(null)
try {
await createCalendar({ name: newName.trim(), color: newColor })
setNewName('')
setNewColor(SWATCHES[0])
setNewOpen(false)
reload()
} catch (err) {
setError(err instanceof Error ? err.message : 'Create failed')
} finally {
setSaving(false)
}
}
return (
<div className="page">
<div className="page-header">
<h1>Calendars</h1>
<button className="btn btn-primary" onClick={() => setNewOpen(o => !o)}>
{newOpen ? <X size={14} strokeWidth={1.75} /> : <Plus size={14} strokeWidth={1.75} />}
{newOpen ? 'Cancel' : 'New calendar'}
</button>
</div>
{error && <div className="error-banner">{error}</div>}
{newOpen && (
<div className="card">
<form onSubmit={handleCreate}>
<div className="field">
<label>Name</label>
<input type="text" value={newName} onChange={e => setNewName(e.target.value)} required />
</div>
<div className="field">
<label>Colour</label>
<div className="cal-swatch-row">
{SWATCHES.map(sw => (
<span
key={sw}
className={`cal-swatch ${newColor === sw ? 'active' : ''}`}
style={{ background: sw }}
onClick={() => setNewColor(sw)}
/>
))}
</div>
</div>
<div className="modal-actions">
<button type="submit" className="btn btn-primary" disabled={saving}>{saving ? 'Saving…' : 'Create'}</button>
</div>
</form>
</div>
)}
{loading ? (
<div className="empty-state">Loading</div>
) : (
<div className="table-wrap">
<table className="data">
<thead>
<tr>
<th></th>
<th>Name</th>
<th></th>
</tr>
</thead>
<tbody>
{calendars.map(cal => (
<tr key={cal.id}>
{editingId === cal.id ? (
<>
<td style={{ width: 40 }}>
<div className="cal-swatch-row" style={{ margin: 0 }}>
{SWATCHES.map(sw => (
<span
key={sw}
className={`cal-swatch ${editColor === sw ? 'active' : ''}`}
style={{ background: sw, width: 18, height: 18 }}
onClick={() => setEditColor(sw)}
/>
))}
</div>
</td>
<td>
<input type="text" value={editName} onChange={e => setEditName(e.target.value)} />
</td>
<td style={{ textAlign: 'right', whiteSpace: 'nowrap' }}>
<button className="btn btn-sm btn-primary" onClick={() => saveEdit(cal.id)} disabled={saving}>Save</button>{' '}
<button className="btn btn-sm" onClick={() => setEditingId(null)}>Cancel</button>
</td>
</>
) : (
<>
<td style={{ width: 40 }}>
<span className="cal-dot" style={{ background: cal.color, width: 14, height: 14 }} />
</td>
<td>
{cal.name}
{cal.is_system && (
<span className="badge badge-outline" style={{ marginLeft: 8 }}>
<Lock size={11} strokeWidth={1.75} /> system
</span>
)}
</td>
<td style={{ textAlign: 'right', whiteSpace: 'nowrap' }}>
<button
className="btn-ghost-sm"
onClick={() => startEdit(cal)}
disabled={cal.is_system}
>
<Pencil size={13} strokeWidth={1.75} />
</button>
<button
className="btn-ghost-sm btn-ghost-danger"
onClick={() => handleDelete(cal)}
disabled={cal.is_system}
>
<Trash2 size={13} strokeWidth={1.75} />
</button>
</td>
</>
)}
</tr>
))}
{calendars.length === 0 && (
<tr><td colSpan={3} className="empty-state">No calendars yet.</td></tr>
)}
</tbody>
</table>
</div>
)}
</div>
)
}

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>
)
}

View file

@ -0,0 +1,89 @@
import { useEffect, useState, useCallback } from 'react'
import { CalendarClock, Users2 } from 'lucide-react'
import type { Department, EventSummary } from '../types'
import { fetchMyUpcoming, fetchMyDepartments } from '../api'
import EventForm from '../components/EventForm'
import { formatDayHeader, formatTime } from '../dateUtils'
export default function Dashboard() {
const [upcoming, setUpcoming] = useState<EventSummary[]>([])
const [departments, setDepartments] = useState<Department[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [openEventId, setOpenEventId] = useState<number | null>(null)
const reload = useCallback(() => {
setLoading(true)
setError(null)
Promise.all([fetchMyUpcoming(7), fetchMyDepartments()])
.then(([ev, depts]) => { setUpcoming(ev); setDepartments(depts) })
.catch(err => setError(err.message))
.finally(() => setLoading(false))
}, [])
useEffect(() => { reload() }, [reload])
return (
<div className="page">
<div className="page-header">
<h1>Dashboard</h1>
</div>
{error && <div className="error-banner">{error}</div>}
<div className="stats-strip">
<div className="stat-box">
<div className="stat-value">{upcoming.length}</div>
<div className="stat-label">Upcoming (7 days)</div>
</div>
<div className="stat-box">
<div className="stat-value">{departments.length}</div>
<div className="stat-label">My departments</div>
</div>
</div>
<div className="section-title" style={{ marginTop: 0 }}>Your departments</div>
{departments.length === 0 ? (
<div className="empty-state">You're not assigned to any departments.</div>
) : (
<div className="chip-bar" style={{ marginBottom: 8 }}>
{departments.map(d => (
<span key={d.id} className="badge badge-outline">
<Users2 size={12} strokeWidth={1.75} />
{d.name}
</span>
))}
</div>
)}
<div className="section-title">Upcoming events</div>
{loading ? (
<div className="empty-state">Loading</div>
) : upcoming.length === 0 ? (
<div className="empty-state">Nothing on your calendar in the next 7 days.</div>
) : (
upcoming.map(ev => (
<div key={ev.id} className="card task-card" onClick={() => setOpenEventId(ev.id)}>
<CalendarClock size={16} strokeWidth={1.75} color={ev.calendar_color} style={{ marginTop: 2 }} />
<div className="task-card-main">
<div className="task-card-title">{ev.title}</div>
<div className="task-card-meta">
<span>{formatDayHeader(new Date(ev.start_at))}{!ev.all_day && ` · ${formatTime(ev.start_at)}`}</span>
{ev.location && <span>{ev.location}</span>}
<span className="badge-outline badge">{ev.calendar_name}</span>
</div>
</div>
</div>
))
)}
{openEventId !== null && (
<EventForm
eventId={openEventId}
onClose={() => setOpenEventId(null)}
onSaved={reload}
/>
)}
</div>
)
}

11
frontend/src/sw.js Normal file
View file

@ -0,0 +1,11 @@
import { precacheAndRoute, createHandlerBoundToURL } from 'workbox-precaching'
import { NavigationRoute, registerRoute } from 'workbox-routing'
precacheAndRoute(self.__WB_MANIFEST)
// SPA fallback: navigate requests that don't match a cached asset serve index.html
registerRoute(
new NavigationRoute(createHandlerBoundToURL('/calendar/index.html'), {
denylist: [/\/api\//],
})
)

109
frontend/src/types.ts Normal file
View file

@ -0,0 +1,109 @@
export interface Calendar {
id: number
slug: string
name: string
color: string
is_system: boolean
created_by: string | null
created_at: string
}
export interface EventSummary {
id: number
calendar_id: number
calendar_name: string
calendar_color: string
uid: string
title: string
location: string | null
start_at: string
end_at: string
all_day: boolean
department_names: string[]
assignee_names: string[]
attachment_count: number
}
export interface EventDepartment {
id: string
name: string
}
export interface EventAssignee {
email: string
name: string
}
export interface EventAttachment {
id: number
filename: string
mime_type: string
size_bytes: number
url: string
}
export interface EventDetail extends EventSummary {
description: string | null
departments: EventDepartment[]
assignees: EventAssignee[]
attachments: EventAttachment[]
calendar: {
id: number
name: string
color: string
is_system: boolean
}
}
export interface ActivityLogEntry {
id: number
actor_email: string
actor_name: string
action: string
entity_type: string
entity_id: number
calendar_id: number | null
summary: string
details: string | null
source: string
created_at: string
}
export interface Department {
id: string
name: string
}
export interface CaldavCredential {
id: number
username: string
label: string | null
created_at: string
last_used_at: string | null
}
// Returned once, immediately after creation — password is never retrievable again.
export interface CaldavCredentialCreated {
id: number
username: string
password: string
label: string | null
}
export interface AuthUser {
id: number
email: string
name: string
}
export interface User {
user_id: number
name: string
email: string
is_admin: boolean
caps: string[] // bare slugs — verify?app=calendar strips the prefix
}
export function can(user: User, cap: string): boolean {
return user.is_admin || user.caps.includes(cap)
}

1
frontend/src/vite-env.d.ts vendored Normal file
View file

@ -0,0 +1 @@
/// <reference types="vite/client" />