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';