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:
commit
bf5557d277
53 changed files with 12529 additions and 0 deletions
174
frontend/src/pages/CalDavSetup.tsx
Normal file
174
frontend/src/pages/CalDavSetup.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue