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