caldav leaves the platform

calendar and contacts are a plugin now — plugins/caldav, its own repository like
every other one. a fresh machine runs five processes instead of seven and gets a
calendar by installing one plugin, which is the whole point: the platform stops
carrying a python calendar server for people who may not want one.

what left: the radicale sidecar, four routers, the panel apps, the
dav_app_passwords table, the two screens, the two dock tiles, the permission
registry entry, the settings section, and radicale from every package list.

three platform things had to change first, each committed on its own:
6b5c87da a plugin can be more than one screen; e0df28ff a first-party plugin can
claim a reserved root path. this commit adds the third, `personal` on a plugin
permission — a member granted READ must still mint and revoke their own app
password, because that password is how their own phone reaches their own
calendar. those rows are theirs and nobody else's.

two fixes the work surfaced rather than caused. the dock pinned mountPrefix, so
a multi-screen plugin pinned a tile pointing at /caldav — a route no plugin
serves. and the OPTIONS carve-out was a literal list of four DAV paths; it asks
the claim registry now, so it follows whoever owns the route.

/calendar stays in DEFAULT_DOCK_PATHS. it is installed by default and a fresh
dock that silently dropped it would read as a downgrade.

docs:check earned its keep twice here: it caught spotlight still offering
/settings/integrations/dav-app-passwords after the section was deleted, and the
permission count going 22 -> 21. neither is greppable and both were already wrong
when it ran.

verified against alpha, which is still on the pre-extraction code: /.well-known/
caldav 301s, /dav/ answers 401 with the same WWW-Authenticate realm byte for
byte, /api/caldav/passwords 401s, and OPTIONS reaches the DAV router rather than
being answered as a CORS preflight. installed here end to end — schema applied,
sidecar started, two tiles with their own artwork behind one permission.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
This commit is contained in:
2026-08-27 04:54:42 +00:00
co-authored by Claude Opus 5
parent e0df28ff5d
commit 52f209c69f
45 changed files with 108 additions and 2277 deletions
@@ -12,7 +12,6 @@ import { appRegistryMetas as widgetMetas } from '../apps/Widgets';
import { appRegistryMetas as monitorMetas } from '../apps/SystemMonitor';
import { appRegistryMetas as qrTransferMetas } from '../apps/QrTransfer';
import { appRegistryMetas as sendOffMetas } from '../apps/SendOff';
import { appRegistryMetas as davMetas } from '../apps/Dav';
import { appRegistryMetas as commandHqMetas } from '../apps/CommandHQ';
import { appRegistryMetas as todoBoardMetas } from '../apps/TodoBoard';
import type { QueryClient } from '@tanstack/react-query';
@@ -31,7 +30,6 @@ export const apps = [
...monitorMetas,
...qrTransferMetas,
...sendOffMetas,
...davMetas,
...commandHqMetas,
...todoBoardMetas,
...appStoreMetas,
@@ -1,101 +0,0 @@
import type { CalendarEvent } from './shared';
import { CalendarDays, MapPin, Repeat } from 'lucide-react';
import { useCalendarEvents, useSelectedCollection } from './useDavData';
import { useTranslation } from '../../locales';
// Right panel of /calendar. Deliberately a grouped agenda list, not a month grid: the sidecar returns
// events as they are stored, with RRULE unexpanded, so a grid would have to invent occurrences the server
// never claimed existed. A list can show a repeating event honestly, as one entry with a "repeats" badge.
const dayKey = (ev: CalendarEvent) => (ev.start ? ev.start.slice(0, 10) : '');
const formatDay = (iso: string) => {
if (!iso) return 'No date';
const date = new Date(`${iso}T12:00:00`);
if (Number.isNaN(date.getTime())) return iso;
return date.toLocaleDateString(undefined, { weekday: 'long', day: 'numeric', month: 'long', year: 'numeric' });
};
const formatTime = (ev: CalendarEvent) => {
if (ev.allDay || !ev.start) return 'All day';
const start = new Date(ev.start);
if (Number.isNaN(start.getTime())) return '';
const opts: Intl.DateTimeFormatOptions = { hour: '2-digit', minute: '2-digit' };
const end = ev.end ? new Date(ev.end) : null;
if (!end || Number.isNaN(end.getTime())) return start.toLocaleTimeString(undefined, opts);
return `${start.toLocaleTimeString(undefined, opts)} – ${end.toLocaleTimeString(undefined, opts)}`;
};
export const CalendarView = () => {
const { t } = useTranslation();
const { selected, isLoading: loadingCollections } = useSelectedCollection('calendar');
const { events, isLoading, error } = useCalendarEvents(selected?.path ?? null);
if (loadingCollections) return <Placeholder text="Loading calendars…" />;
if (!selected) return <Placeholder text="No calendars yet. Create one from a CalDAV client." />;
if (error) return <Placeholder text={t('dav.readFailed', { name: selected.displayName, reason: String(error) })} />;
if (isLoading) return <Placeholder text="Loading events…" />;
if (events.length === 0) return <Placeholder text={`${selected.displayName} has no events.`} />;
const sorted = [...events].sort((a, b) => (a.start ?? '').localeCompare(b.start ?? ''));
const days: Array<{ key: string; events: CalendarEvent[] }> = [];
for (const ev of sorted) {
const key = dayKey(ev);
const last = days[days.length - 1];
if (last && last.key === key) last.events.push(ev);
else days.push({ key, events: [ev] });
}
return (
<div className="h-full overflow-y-auto">
<div className="mx-auto flex max-w-3xl flex-col gap-6 p-6">
{days.map((day) => (
<section key={day.key} className="flex flex-col gap-2">
<h2 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
{formatDay(day.key)}
</h2>
{day.events.map((ev) => (
<article key={ev.uid} className="flex gap-3 rounded-xl border bg-card p-3">
<span
className="mt-1 h-2.5 w-2.5 shrink-0 rounded-full ring-1 ring-black/10"
style={{ backgroundColor: selected.color || '#94a3b8' }}
/>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="truncate text-sm font-medium">{ev.summary || '(no title)'}</span>
{ev.rrule && (
<span
title={ev.rrule}
className="flex items-center gap-1 rounded-full bg-muted px-2 py-0.5 text-[10px] text-muted-foreground"
>
<Repeat className="h-3 w-3" />
repeats
</span>
)}
</div>
<div className="mt-0.5 text-xs tabular-nums text-muted-foreground">{formatTime(ev)}</div>
{ev.location && (
<div className="mt-1 flex items-center gap-1 text-xs text-muted-foreground">
<MapPin className="h-3 w-3 shrink-0" />
<span className="truncate">{ev.location}</span>
</div>
)}
{ev.description && (
<p className="mt-1 whitespace-pre-wrap text-xs text-muted-foreground">{ev.description}</p>
)}
</div>
</article>
))}
</section>
))}
</div>
</div>
);
};
const Placeholder = ({ text }: { text: string }) => (
<div className="flex h-full flex-col items-center justify-center gap-2 p-6 text-center text-sm text-muted-foreground">
<CalendarDays className="h-6 w-6 opacity-40" />
<span>{text}</span>
</div>
);
@@ -1,89 +0,0 @@
import type { LucideIcon } from 'lucide-react';
import type { DavCollection } from './shared';
import { Link, useSearchParams } from 'react-router';
import { CalendarDays, Contact as ContactIcon } from 'lucide-react';
import { davCollectionPath, COLLECTION_PARAM } from './shared';
import { useDavCollections } from './useDavData';
import { useTranslation } from '../../locales';
// Left panel for both /calendar and /contacts: the list of collections belonging to this user.
//
// Rows are real links carrying ?collection=<dav path>, so a calendar is a bookmarkable address and the
// right-hand panel reads the same URL rather than being told over a channel. Active state is compared
// against the URL directly instead of NavLink's isActive, because every row shares one pathname and
// differs only in the query string.
type Props = {
kind: DavCollection['kind'];
base: '/calendar' | '/contacts';
title: string;
icon: LucideIcon;
};
const ROW = 'group relative flex items-center gap-3 rounded-lg px-3 py-2 text-left text-sm transition-colors';
const CollectionNav = ({ kind, base, title, icon: Icon }: Props) => {
const { t } = useTranslation();
const [params] = useSearchParams();
const { collections, isLoading } = useDavCollections(kind);
const fromUrl = params.get(COLLECTION_PARAM);
// Nothing in the URL means the view falls back to the first collection — highlight the same one.
const activePath = collections.some((c) => c.path === fromUrl) ? fromUrl : (collections[0]?.path ?? null);
return (
<div className="flex h-full flex-col overflow-y-auto bg-muted/30">
<div className="flex items-center gap-3 px-4 py-4">
<div className="flex h-9 w-9 items-center justify-center rounded-xl bg-sky-500/15 text-sky-500 ring-1 ring-black/5">
<Icon className="h-5 w-5" />
</div>
<div className="min-w-0">
<div className="truncate text-sm font-semibold leading-tight">{title}</div>
<div className="text-xs text-muted-foreground">
{isLoading
? 'loading…'
: `${collections.length} ${collections.length === 1 ? 'collection' : 'collections'}`}
</div>
</div>
</div>
<nav className="flex flex-col gap-0.5 px-2 pb-3">
{collections.map((collection) => {
const isActive = collection.path === activePath;
return (
<Link
key={collection.path}
to={davCollectionPath(base, collection.path)}
className={`${ROW} ${
isActive
? 'bg-primary/10 font-medium text-primary'
: 'text-muted-foreground hover:bg-muted hover:text-foreground'
}`}
>
{isActive && (
<span className="absolute left-0 top-1/2 h-5 w-1 -translate-y-1/2 rounded-r-full bg-primary" />
)}
<span
className="h-2.5 w-2.5 shrink-0 rounded-full ring-1 ring-black/10"
style={{ backgroundColor: collection.color || '#94a3b8' }}
/>
<span className="flex-1 truncate">{collection.displayName}</span>
</Link>
);
})}
{!isLoading && collections.length === 0 && (
<p className="px-3 py-2 text-xs text-muted-foreground">{t('dav.empty')}</p>
)}
</nav>
</div>
);
};
export const CalendarNav = () => {
const { t } = useTranslation();
return <CollectionNav kind="calendar" base="/calendar" title={t('dav.calendar')} icon={CalendarDays} />;
};
export const ContactsNav = () => {
const { t } = useTranslation();
return <CollectionNav kind="addressbook" base="/contacts" title={t('dav.contacts')} icon={ContactIcon} />;
};
@@ -1,81 +0,0 @@
import { Building2, Mail, Phone, User } from 'lucide-react';
import { useDavContacts, useSelectedCollection } from './useDavData';
import { useTranslation } from '../../locales';
// Right panel of /contacts: the selected address book as cards, sorted by name.
//
// `hasPhoto` is a flag, not the bytes — the sidecar keeps embedded photos out of the list response so a
// book with a hundred cards is not a hundred base64 blobs. Until there is an endpoint that serves one
// card's photo, an initial stands in.
const initials = (name: string) =>
name
.split(/\s+/)
.filter(Boolean)
.slice(0, 2)
.map((part) => part[0]?.toUpperCase() ?? '')
.join('') || '?';
export const ContactsView = () => {
const { t } = useTranslation();
const { selected, isLoading: loadingCollections } = useSelectedCollection('addressbook');
const { contacts, isLoading, error } = useDavContacts(selected?.path ?? null);
if (loadingCollections) return <Placeholder text="Loading address books…" />;
if (!selected) return <Placeholder text="No address books yet. Create one from a CardDAV client." />;
if (error) return <Placeholder text={t('dav.readFailed', { name: selected.displayName, reason: String(error) })} />;
if (isLoading) return <Placeholder text="Loading contacts…" />;
if (contacts.length === 0) return <Placeholder text={`${selected.displayName} has no contacts.`} />;
const sorted = [...contacts].sort((a, b) => a.fullName.localeCompare(b.fullName));
return (
<div className="h-full overflow-y-auto">
<div className="mx-auto grid max-w-4xl grid-cols-1 gap-3 p-6 sm:grid-cols-2">
{sorted.map((contact) => (
<article key={contact.uid} className="flex gap-3 rounded-xl border bg-card p-3">
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-sky-500/15 text-sm font-semibold text-sky-600">
{initials(contact.fullName)}
</div>
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-medium">{contact.fullName || '(no name)'}</div>
{(contact.title || contact.org) && (
<div className="mt-0.5 flex items-center gap-1 text-xs text-muted-foreground">
<Building2 className="h-3 w-3 shrink-0" />
<span className="truncate">{[contact.title, contact.org].filter(Boolean).join(' · ')}</span>
</div>
)}
{contact.emails.map((email) => (
<a
key={email}
href={`mailto:${email}`}
className="mt-1 flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground"
>
<Mail className="h-3 w-3 shrink-0" />
<span className="truncate">{email}</span>
</a>
))}
{contact.phones.map((phone) => (
<a
key={phone}
href={`tel:${phone}`}
className="mt-1 flex items-center gap-1 text-xs tabular-nums text-muted-foreground hover:text-foreground"
>
<Phone className="h-3 w-3 shrink-0" />
<span className="truncate">{phone}</span>
</a>
))}
</div>
</article>
))}
</div>
</div>
);
};
const Placeholder = ({ text }: { text: string }) => (
<div className="flex h-full flex-col items-center justify-center gap-2 p-6 text-center text-sm text-muted-foreground">
<User className="h-6 w-6 opacity-40" />
<span>{text}</span>
</div>
);
@@ -1,42 +0,0 @@
import type { AppRegistryMeta } from '../../AppRegistry';
import { CalendarDays, Contact as ContactIcon, PanelLeft } from 'lucide-react';
import { CalendarNav, ContactsNav } from './CollectionNav';
import { CalendarView } from './CalendarView';
import { ContactsView } from './ContactsView';
import { panelName } from '../../locales';
// One app folder for both /calendar and /contacts: they are the same DAV sidecar, the same collection
// list and the same selection rule, differing only in which kind of collection they show.
export { CalendarNav, ContactsNav, CalendarView, ContactsView };
export const appRegistryMetas: AppRegistryMeta[] = [
{
key: 'calendar-nav',
name: panelName('panelName.calendar'),
icon: PanelLeft,
component: CalendarNav,
availableOnPanel: false,
},
{
key: 'calendar-view',
name: panelName('panelName.calendar'),
icon: CalendarDays,
component: CalendarView,
availableOnPanel: false,
},
{
key: 'contacts-nav',
name: panelName('panelName.contacts'),
icon: PanelLeft,
component: ContactsNav,
availableOnPanel: false,
},
{
key: 'contacts-view',
name: panelName('panelName.contacts'),
icon: ContactIcon,
component: ContactsView,
availableOnPanel: false,
},
];
@@ -1,47 +0,0 @@
// Shared wire shapes for the /calendar and /contacts workspaces.
//
// These mirror what the officer-caldav sidecar returns from its JSON door under /api/caldav/_officer/* —
// see src/servers/sidecar/caldav/{collections,ical}.ts, which is where they are produced. The sidecar
// parses the iCalendar/vCard itself so nothing here ever sees DAV XML.
/** One calendar or address book. `path` is the DAV path (/dav/<userId>/<name>/) and doubles as the id. */
export type DavCollection = {
path: string;
displayName: string;
kind: 'calendar' | 'addressbook';
color?: string;
};
export type CalendarEvent = {
uid: string;
summary: string;
description?: string;
location?: string;
start?: string;
end?: string;
allDay: boolean;
/** Raw RRULE, unexpanded — the sidecar does not expand a series, so this is a badge, not occurrences. */
rrule?: string;
status?: string;
};
export type DavContact = {
uid: string;
fullName: string;
emails: string[];
phones: string[];
org?: string;
title?: string;
hasPhoto: boolean;
};
/**
* The query param both screens select with. A collection id contains slashes, so it cannot be a path
* segment without escaping — and per the navigation rules the selection still has to live in the URL,
* so it is a query param on the screen you are already on.
*/
export const COLLECTION_PARAM = 'collection';
/** The one place the two screens' URLs are spelled, so nav links and guards cannot drift. */
export const davCollectionPath = (base: '/calendar' | '/contacts', collection: string) =>
`${base}?${COLLECTION_PARAM}=${encodeURIComponent(collection)}`;
@@ -1,76 +0,0 @@
import type { CalendarEvent, DavCollection, DavContact } from './shared';
import { useQuery } from '@tanstack/react-query';
import { useSearchParams } from 'react-router';
import { useClient } from 'hooks/useClient';
import { COLLECTION_PARAM } from './shared';
// Data layer for /calendar and /contacts. Everything goes through the /api/caldav auth proxy to the
// officer-caldav sidecar, which holds the Radicale process and every DAV credential; officer forwards the
// authenticated user's id and nothing else.
//
// There is no push channel — Radicale is a plain DAV server — so these are ordinary queries with a
// generous staleTime rather than a poll. A calendar changes when someone edits it, not on a timer.
const STALE_MS = 30_000;
const EMPTY_COLLECTIONS: DavCollection[] = [];
const EMPTY_EVENTS: CalendarEvent[] = [];
const EMPTY_CONTACTS: DavContact[] = [];
export function useDavCollections(kind?: DavCollection['kind']) {
const { get } = useClient();
const query = useQuery({
queryKey: ['caldav', 'collections'],
queryFn: () => get<{ collections: DavCollection[] }>('/caldav/_officer/collections'),
staleTime: STALE_MS,
});
const all = query.data?.collections ?? EMPTY_COLLECTIONS;
return {
collections: kind ? all.filter((c) => c.kind === kind) : all,
isLoading: query.isLoading,
error: query.error,
};
}
export function useCalendarEvents(collection: string | null) {
const { get } = useClient();
const query = useQuery({
queryKey: ['caldav', 'events', collection],
queryFn: () =>
get<{ events: CalendarEvent[] }>(`/caldav/_officer/events?collection=${encodeURIComponent(collection ?? '')}`),
enabled: Boolean(collection),
staleTime: STALE_MS,
});
return { events: query.data?.events ?? EMPTY_EVENTS, isLoading: query.isLoading, error: query.error };
}
export function useDavContacts(collection: string | null) {
const { get } = useClient();
const query = useQuery({
queryKey: ['caldav', 'contacts', collection],
queryFn: () =>
get<{ contacts: DavContact[] }>(`/caldav/_officer/contacts?collection=${encodeURIComponent(collection ?? '')}`),
enabled: Boolean(collection),
staleTime: STALE_MS,
});
return { contacts: query.data?.contacts ?? EMPTY_CONTACTS, isLoading: query.isLoading, error: query.error };
}
/**
* The collection the URL selects, or the first one of that kind once the list arrives. Both panels of a
* screen call this and get the same answer, because the answer is derived from the URL and the query
* cache rather than passed between them.
*/
export function useSelectedCollection(kind: DavCollection['kind']) {
const [params] = useSearchParams();
const { collections, isLoading } = useDavCollections(kind);
const fromUrl = params.get(COLLECTION_PARAM);
const selected = collections.find((c) => c.path === fromUrl) ?? collections[0] ?? null;
return { selected, collections, isLoading };
}
+13 -5
View File
@@ -51,11 +51,6 @@ export type {
CommandHqCostSummary,
} from './apps/CommandHQ';
// /calendar and /contacts select with a query param rather than a path segment, so what the screens need
// from the app is the param name and the link builder.
export { COLLECTION_PARAM, davCollectionPath } from './apps/Dav/shared';
export type { DavCollection, CalendarEvent, DavContact } from './apps/Dav/shared';
export {
useFilesAPI,
useTasks,
@@ -131,3 +126,16 @@ export type {
// App store — install, enable and remove the sidecars this server runs.
export { AppStoreList, AppStoreDetail, useAppStore } from './apps/AppStore';
export type { StoreItem as AppStoreItem } from './apps/AppStore';
// Localisation, for a FIRST-PARTY plugin's panels.
//
// Plugin panels have no localisation mechanism of their own — `scripts/i18n/scan-hardcoded.ts` does not
// scan `plugins/`, and every third-party plugin's strings are plain English by convention. These two are
// exported because caldav left the platform carrying strings that were ALREADY translated into six
// locales (`panel.calendar`, `panel.contacts`), and dropping to English would have been a visible
// regression in six languages to make a refactor tidier.
//
// The catalogs stay here rather than moving into the plugin. A plugin shipping its own catalog is
// docs/localization.md's open question and is not answered by this.
export { panelName } from './locales/panelName';
export { useTranslation } from 'i18n';