From 4d2ec215a2f2eb8d5b7c0e6175709f1a1f2f648c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Padez?= Date: Tue, 4 Aug 2026 10:50:53 +0000 Subject: [PATCH] add /calendar and /contacts screens over the caldav json door MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit two workspace screens backed by one app folder: the collection list on the left, the selected calendar's agenda or address book on the right. both read the officer-caldav sidecar through the /api/caldav auth proxy, which holds no DAV credentials of its own. the selected collection is a DAV path with slashes in it, so it lives in ?collection= on the same route rather than as a path segment — still in the URL, still bookmarkable, no selection channel. an absent or unknown value resolves to the first collection inside the panels instead of redirecting, because until the list has loaded there is no canonical URL to redirect to. the agenda is a grouped list, not a month grid: the sidecar returns RRULE unexpanded, so a grid would have to invent occurrences the server never claimed existed. a repeating event gets a badge instead. Co-Authored-By: Claude Opus 5 --- src/apps/officer-web/App.tsx | 2 + .../Dashboard/Calendar/CalendarScreen.tsx | 50 ++++++++++ .../Dashboard/Calendar/defaultLayout.ts | 11 +++ .../Screens/Dashboard/Calendar/index.tsx | 1 + .../Dashboard/Contacts/ContactsScreen.tsx | 44 +++++++++ .../Dashboard/Contacts/defaultLayout.ts | 11 +++ .../Screens/Dashboard/Contacts/index.tsx | 1 + .../Screens/Dashboard/Layout/Dock.tsx | 4 + .../officer-web/Screens/Dashboard/index.tsx | 2 + src/apps/officer-web/state/usePageTitle.ts | 2 + .../src/AppRegistry/AppRegistry.tsx | 2 + .../officerdev/src/apps/Dav/CalendarView.tsx | 99 +++++++++++++++++++ .../officerdev/src/apps/Dav/CollectionNav.tsx | 87 ++++++++++++++++ .../officerdev/src/apps/Dav/ContactsView.tsx | 79 +++++++++++++++ .../officerdev/src/apps/Dav/index.ts | 17 ++++ .../officerdev/src/apps/Dav/shared.ts | 47 +++++++++ .../officerdev/src/apps/Dav/useDavData.ts | 76 ++++++++++++++ src/workspaces/officerdev/src/index.ts | 5 + 18 files changed, 540 insertions(+) create mode 100644 src/apps/officer-web/Screens/Dashboard/Calendar/CalendarScreen.tsx create mode 100644 src/apps/officer-web/Screens/Dashboard/Calendar/defaultLayout.ts create mode 100644 src/apps/officer-web/Screens/Dashboard/Calendar/index.tsx create mode 100644 src/apps/officer-web/Screens/Dashboard/Contacts/ContactsScreen.tsx create mode 100644 src/apps/officer-web/Screens/Dashboard/Contacts/defaultLayout.ts create mode 100644 src/apps/officer-web/Screens/Dashboard/Contacts/index.tsx create mode 100644 src/workspaces/officerdev/src/apps/Dav/CalendarView.tsx create mode 100644 src/workspaces/officerdev/src/apps/Dav/CollectionNav.tsx create mode 100644 src/workspaces/officerdev/src/apps/Dav/ContactsView.tsx create mode 100644 src/workspaces/officerdev/src/apps/Dav/index.ts create mode 100644 src/workspaces/officerdev/src/apps/Dav/shared.ts create mode 100644 src/workspaces/officerdev/src/apps/Dav/useDavData.ts diff --git a/src/apps/officer-web/App.tsx b/src/apps/officer-web/App.tsx index 11909e70..0cc28c2f 100644 --- a/src/apps/officer-web/App.tsx +++ b/src/apps/officer-web/App.tsx @@ -39,6 +39,8 @@ export function App() { } /> } /> } /> + } /> + } /> } /> } /> } /> diff --git a/src/apps/officer-web/Screens/Dashboard/Calendar/CalendarScreen.tsx b/src/apps/officer-web/Screens/Dashboard/Calendar/CalendarScreen.tsx new file mode 100644 index 00000000..eeaa9827 --- /dev/null +++ b/src/apps/officer-web/Screens/Dashboard/Calendar/CalendarScreen.tsx @@ -0,0 +1,50 @@ +import { useEffect, useMemo } from 'react'; +import type { LayoutNode } from 'officerdev'; +import { WorkspaceView } from 'officerdev'; +import { useDashboardState } from 'state/useDashboardState'; +import { defaultLayout } from './defaultLayout'; + +// /calendar uses the Workspace/Panel system (like /transmission): the calendar list on the left, the +// selected calendar's agenda on the right. Both panels read the officer-caldav sidecar through the +// /api/caldav auth proxy, which holds no DAV credentials of its own. +// +// There is no :param route pair here and so no guard: a collection id is a DAV path with +// slashes in it, so the selection lives in ?collection= on this same route. An absent or unknown value +// resolves to the first collection inside the panels rather than by redirecting, because until the +// collection list has loaded there is no canonical URL to redirect to. + +const ALLOWED_APP_TYPES = new Set(['calendar-nav', 'calendar-view', null]); + +function normalizeLayout(node: LayoutNode): LayoutNode { + if (node.type === 'panel') { + return ALLOWED_APP_TYPES.has(node.appType) ? node : { ...node, appType: 'calendar-view' }; + } + const children = node.children.map((c) => { + const fixed = normalizeLayout(c.node); + return fixed === c.node ? c : { ...c, node: fixed }; + }); + const changed = children.some((c, i) => c !== node.children[i]); + return changed ? { ...node, children } : node; +} + +export const CalendarScreen = () => { + const rawWorkspace = useDashboardState('screens/calendar', defaultLayout); + + const workspace = useMemo(() => { + const fixed = normalizeLayout(rawWorkspace.value); + if (fixed === rawWorkspace.value) return rawWorkspace; + return { ...rawWorkspace, value: fixed }; + }, [rawWorkspace]); + + useEffect(() => { + if (rawWorkspace.isLoaded && workspace.value !== rawWorkspace.value) { + rawWorkspace.setValue(workspace.value); + } + }, [rawWorkspace.isLoaded, workspace.value, rawWorkspace.value]); + + return ( +
+ +
+ ); +}; diff --git a/src/apps/officer-web/Screens/Dashboard/Calendar/defaultLayout.ts b/src/apps/officer-web/Screens/Dashboard/Calendar/defaultLayout.ts new file mode 100644 index 00000000..f40ca8c2 --- /dev/null +++ b/src/apps/officer-web/Screens/Dashboard/Calendar/defaultLayout.ts @@ -0,0 +1,11 @@ +import type { LayoutNode } from 'officerdev'; + +export const defaultLayout: LayoutNode = { + type: 'group', + id: 'calendar-root', + direction: 'horizontal', + children: [ + { node: { type: 'panel', id: 'calendar-nav', appType: 'calendar-nav' }, size: 22 }, + { node: { type: 'panel', id: 'calendar-view', appType: 'calendar-view' }, size: 78 }, + ], +}; diff --git a/src/apps/officer-web/Screens/Dashboard/Calendar/index.tsx b/src/apps/officer-web/Screens/Dashboard/Calendar/index.tsx new file mode 100644 index 00000000..775c169e --- /dev/null +++ b/src/apps/officer-web/Screens/Dashboard/Calendar/index.tsx @@ -0,0 +1 @@ +export * from './CalendarScreen'; diff --git a/src/apps/officer-web/Screens/Dashboard/Contacts/ContactsScreen.tsx b/src/apps/officer-web/Screens/Dashboard/Contacts/ContactsScreen.tsx new file mode 100644 index 00000000..393519ab --- /dev/null +++ b/src/apps/officer-web/Screens/Dashboard/Contacts/ContactsScreen.tsx @@ -0,0 +1,44 @@ +import { useEffect, useMemo } from 'react'; +import type { LayoutNode } from 'officerdev'; +import { WorkspaceView } from 'officerdev'; +import { useDashboardState } from 'state/useDashboardState'; +import { defaultLayout } from './defaultLayout'; + +// /contacts — the CardDAV half of the same sidecar as /calendar; see CalendarScreen for why the selected +// collection is a query param rather than a route segment. + +const ALLOWED_APP_TYPES = new Set(['contacts-nav', 'contacts-view', null]); + +function normalizeLayout(node: LayoutNode): LayoutNode { + if (node.type === 'panel') { + return ALLOWED_APP_TYPES.has(node.appType) ? node : { ...node, appType: 'contacts-view' }; + } + const children = node.children.map((c) => { + const fixed = normalizeLayout(c.node); + return fixed === c.node ? c : { ...c, node: fixed }; + }); + const changed = children.some((c, i) => c !== node.children[i]); + return changed ? { ...node, children } : node; +} + +export const ContactsScreen = () => { + const rawWorkspace = useDashboardState('screens/contacts', defaultLayout); + + const workspace = useMemo(() => { + const fixed = normalizeLayout(rawWorkspace.value); + if (fixed === rawWorkspace.value) return rawWorkspace; + return { ...rawWorkspace, value: fixed }; + }, [rawWorkspace]); + + useEffect(() => { + if (rawWorkspace.isLoaded && workspace.value !== rawWorkspace.value) { + rawWorkspace.setValue(workspace.value); + } + }, [rawWorkspace.isLoaded, workspace.value, rawWorkspace.value]); + + return ( +
+ +
+ ); +}; diff --git a/src/apps/officer-web/Screens/Dashboard/Contacts/defaultLayout.ts b/src/apps/officer-web/Screens/Dashboard/Contacts/defaultLayout.ts new file mode 100644 index 00000000..a41f5280 --- /dev/null +++ b/src/apps/officer-web/Screens/Dashboard/Contacts/defaultLayout.ts @@ -0,0 +1,11 @@ +import type { LayoutNode } from 'officerdev'; + +export const defaultLayout: LayoutNode = { + type: 'group', + id: 'contacts-root', + direction: 'horizontal', + children: [ + { node: { type: 'panel', id: 'contacts-nav', appType: 'contacts-nav' }, size: 22 }, + { node: { type: 'panel', id: 'contacts-view', appType: 'contacts-view' }, size: 78 }, + ], +}; diff --git a/src/apps/officer-web/Screens/Dashboard/Contacts/index.tsx b/src/apps/officer-web/Screens/Dashboard/Contacts/index.tsx new file mode 100644 index 00000000..91317219 --- /dev/null +++ b/src/apps/officer-web/Screens/Dashboard/Contacts/index.tsx @@ -0,0 +1 @@ +export * from './ContactsScreen'; diff --git a/src/apps/officer-web/Screens/Dashboard/Layout/Dock.tsx b/src/apps/officer-web/Screens/Dashboard/Layout/Dock.tsx index 4f15702a..6c834c74 100644 --- a/src/apps/officer-web/Screens/Dashboard/Layout/Dock.tsx +++ b/src/apps/officer-web/Screens/Dashboard/Layout/Dock.tsx @@ -139,12 +139,16 @@ import { Bitcoin, Receipt, Images, + CalendarDays, + Contact, } from 'lucide-react'; export const ALL_DOCK_ITEMS: DockItem[] = [ { label: 'Home', to: '/', icon: Home, color: '#f59e0b' }, { label: 'Files', to: '/files', icon: FolderOpen, color: '#fbbf24' }, { label: 'Email', to: '/email', icon: Mail, color: '#ef4444' }, + { label: 'Calendar', to: '/calendar', icon: CalendarDays, color: '#3b82f6' }, + { label: 'Contacts', to: '/contacts', icon: Contact, color: '#0ea5e9' }, { label: 'Chat', to: '/chat', icon: MessageCircle, color: '#60a5fa' }, { label: 'Music', to: '/music', icon: Music, color: '#22c55e' }, { label: 'Photos', to: '/photos', icon: Images, color: '#10b981' }, diff --git a/src/apps/officer-web/Screens/Dashboard/index.tsx b/src/apps/officer-web/Screens/Dashboard/index.tsx index 040aaad5..80f3a349 100644 --- a/src/apps/officer-web/Screens/Dashboard/index.tsx +++ b/src/apps/officer-web/Screens/Dashboard/index.tsx @@ -10,6 +10,8 @@ export * from './TaskLogs'; export * from './Tasks'; export * from './Files'; +export * from './Calendar'; +export * from './Contacts'; export * from './Music'; export * from './Soulseek'; export * from './Headscale'; diff --git a/src/apps/officer-web/state/usePageTitle.ts b/src/apps/officer-web/state/usePageTitle.ts index 1870bc83..0e82de3a 100644 --- a/src/apps/officer-web/state/usePageTitle.ts +++ b/src/apps/officer-web/state/usePageTitle.ts @@ -14,6 +14,8 @@ const RULES: TitleRule[] = [ { match: (p) => p.startsWith('/chat'), title: 'Chat' }, { match: (p) => p.startsWith('/email'), title: 'Email' }, { match: (p) => p.startsWith('/files'), title: 'Files' }, + { match: (p) => p.startsWith('/calendar'), title: 'Calendar' }, + { match: (p) => p.startsWith('/contacts'), title: 'Contacts' }, { match: (p) => p.startsWith('/music'), title: 'Music' }, { match: (p) => p.startsWith('/photos'), title: 'Photos' }, { match: (p) => p.startsWith('/soulseek'), title: 'Soulseek' }, diff --git a/src/workspaces/officerdev/src/AppRegistry/AppRegistry.tsx b/src/workspaces/officerdev/src/AppRegistry/AppRegistry.tsx index 3ee3be16..beacce9c 100644 --- a/src/workspaces/officerdev/src/AppRegistry/AppRegistry.tsx +++ b/src/workspaces/officerdev/src/AppRegistry/AppRegistry.tsx @@ -16,6 +16,7 @@ import { appRegistryMetas as invoicesMetas } from '../apps/Invoices'; import { appRegistryMetas as walletMetas } from '../apps/Wallet'; import { appRegistryMetas as monitorMetas } from '../apps/SystemMonitor'; import { appRegistryMetas as qrTransferMetas } from '../apps/QrTransfer'; +import { appRegistryMetas as davMetas } from '../apps/Dav'; import { useAppRegistry } from './useAppRegistry'; const apps = [ @@ -37,6 +38,7 @@ const apps = [ ...walletMetas, ...monitorMetas, ...qrTransferMetas, + ...davMetas, ]; export const AppRegistry = () => { diff --git a/src/workspaces/officerdev/src/apps/Dav/CalendarView.tsx b/src/workspaces/officerdev/src/apps/Dav/CalendarView.tsx new file mode 100644 index 00000000..517e76cc --- /dev/null +++ b/src/workspaces/officerdev/src/apps/Dav/CalendarView.tsx @@ -0,0 +1,99 @@ +import type { CalendarEvent } from './shared'; +import { CalendarDays, MapPin, Repeat } from 'lucide-react'; +import { useCalendarEvents, useSelectedCollection } from './useDavData'; + +// 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 { selected, isLoading: loadingCollections } = useSelectedCollection('calendar'); + const { events, isLoading, error } = useCalendarEvents(selected?.path ?? null); + + if (loadingCollections) return ; + if (!selected) return ; + if (error) return ; + if (isLoading) return ; + if (events.length === 0) return ; + + 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 ( +
+
+ {days.map((day) => ( +
+

+ {formatDay(day.key)} +

+ {day.events.map((ev) => ( +
+ +
+
+ {ev.summary || '(no title)'} + {ev.rrule && ( + + + repeats + + )} +
+
{formatTime(ev)}
+ {ev.location && ( +
+ + {ev.location} +
+ )} + {ev.description && ( +

{ev.description}

+ )} +
+
+ ))} +
+ ))} +
+
+ ); +}; + +const Placeholder = ({ text }: { text: string }) => ( +
+ + {text} +
+); diff --git a/src/workspaces/officerdev/src/apps/Dav/CollectionNav.tsx b/src/workspaces/officerdev/src/apps/Dav/CollectionNav.tsx new file mode 100644 index 00000000..72a10566 --- /dev/null +++ b/src/workspaces/officerdev/src/apps/Dav/CollectionNav.tsx @@ -0,0 +1,87 @@ +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'; + +// Left panel for both /calendar and /contacts: the list of collections belonging to this user. +// +// Rows are real links carrying ?collection=, 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 [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 ( +
+
+
+ +
+
+
{title}
+
+ {isLoading + ? 'loading…' + : `${collections.length} ${collections.length === 1 ? 'collection' : 'collections'}`} +
+
+
+ + +
+ ); +}; + +export const CalendarNav = () => ( + +); + +export const ContactsNav = () => ( + +); diff --git a/src/workspaces/officerdev/src/apps/Dav/ContactsView.tsx b/src/workspaces/officerdev/src/apps/Dav/ContactsView.tsx new file mode 100644 index 00000000..b5a64deb --- /dev/null +++ b/src/workspaces/officerdev/src/apps/Dav/ContactsView.tsx @@ -0,0 +1,79 @@ +import { Building2, Mail, Phone, User } from 'lucide-react'; +import { useDavContacts, useSelectedCollection } from './useDavData'; + +// 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 { selected, isLoading: loadingCollections } = useSelectedCollection('addressbook'); + const { contacts, isLoading, error } = useDavContacts(selected?.path ?? null); + + if (loadingCollections) return ; + if (!selected) return ; + if (error) return ; + if (isLoading) return ; + if (contacts.length === 0) return ; + + const sorted = [...contacts].sort((a, b) => a.fullName.localeCompare(b.fullName)); + + return ( +
+
+ {sorted.map((contact) => ( +
+
+ {initials(contact.fullName)} +
+
+
{contact.fullName || '(no name)'}
+ {(contact.title || contact.org) && ( +
+ + {[contact.title, contact.org].filter(Boolean).join(' · ')} +
+ )} + {contact.emails.map((email) => ( + + + {email} + + ))} + {contact.phones.map((phone) => ( + + + {phone} + + ))} +
+
+ ))} +
+
+ ); +}; + +const Placeholder = ({ text }: { text: string }) => ( +
+ + {text} +
+); diff --git a/src/workspaces/officerdev/src/apps/Dav/index.ts b/src/workspaces/officerdev/src/apps/Dav/index.ts new file mode 100644 index 00000000..727b07c4 --- /dev/null +++ b/src/workspaces/officerdev/src/apps/Dav/index.ts @@ -0,0 +1,17 @@ +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'; + +// 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: 'Calendar', icon: PanelLeft, component: CalendarNav, availableOnPanel: false }, + { key: 'calendar-view', name: 'Calendar', icon: CalendarDays, component: CalendarView, availableOnPanel: false }, + { key: 'contacts-nav', name: 'Contacts', icon: PanelLeft, component: ContactsNav, availableOnPanel: false }, + { key: 'contacts-view', name: 'Contacts', icon: ContactIcon, component: ContactsView, availableOnPanel: false }, +]; diff --git a/src/workspaces/officerdev/src/apps/Dav/shared.ts b/src/workspaces/officerdev/src/apps/Dav/shared.ts new file mode 100644 index 00000000..8684f4bb --- /dev/null +++ b/src/workspaces/officerdev/src/apps/Dav/shared.ts @@ -0,0 +1,47 @@ +// 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///) 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)}`; diff --git a/src/workspaces/officerdev/src/apps/Dav/useDavData.ts b/src/workspaces/officerdev/src/apps/Dav/useDavData.ts new file mode 100644 index 00000000..e2978c88 --- /dev/null +++ b/src/workspaces/officerdev/src/apps/Dav/useDavData.ts @@ -0,0 +1,76 @@ +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 }; +} diff --git a/src/workspaces/officerdev/src/index.ts b/src/workspaces/officerdev/src/index.ts index 57a0698b..d3707463 100644 --- a/src/workspaces/officerdev/src/index.ts +++ b/src/workspaces/officerdev/src/index.ts @@ -43,6 +43,11 @@ export { } from './apps/Transmission/shared'; export type { TransmissionSectionId } from './apps/Transmission/shared'; +// /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'; + // Same for /invoices. export { DEFAULT_INVOICES_SECTION, invoicesSectionPath, isInvoicesSection } from './apps/Invoices/shared'; export type { InvoicesSectionId } from './apps/Invoices/shared';