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