CalendAT — Decentralized ATProto Spaces Calendar MVP #
CalendAT is a collaborative calendar application built on AT Protocol (ATProto) Spaces for private and shared calendars.
The application runs on Nuxt 4, Vue 3, and Nuxt UI, using the MIT-licensed Nuxt calendar template. Its calendar views, navigation, glass styling, and layout utilities are adapted to CalendAT's existing AT Protocol records. Template attribution is retained in licenses/nuxt-calendar-template.txt.
app/ contains the production Vue UI and composables; src/services, src/utils, and src/types contain the shared domain code. The previous React components remain as regression fixtures for the existing tests, and are not imported by the Nuxt application. OAuth and private calendar data stay in the browser; Nitro serves the app and public metadata without a demo event store.
Key Features #
-
ATProto Spaces for Private Data:
- Each calendar is an isolated ATProto Space created with
com.atproto.simplespace.createSpace. - Access control enforced via
com.atproto.simplespace.defs#memberListPolicy(only invited members have read/write access to the space). - Compatible with
com.atproto.simplespace.defs#openapp access.
- Each calendar is an isolated ATProto Space created with
-
Calendar Sharing & Member Invitations:
- Invite collaborators by ATProto handle (e.g.
alice.spaces-alpha.bsky.network) or DID (did:plc:...). - Dynamic handle resolution using
agent.resolveHandle. - Member management via
com.atproto.simplespace.addMember,removeMember, andlistMembers. - Profile resolution with avatars and display names.
- Invite collaborators by ATProto handle (e.g.
-
Standard Lexicon Compliance:
- Event records conform to the community schema:
community.lexicon.calendar.event. - Supported event modes:
community.lexicon.calendar.event#virtual(Online)community.lexicon.calendar.event#inperson(In-Person)community.lexicon.calendar.event#hybrid(Hybrid)
- Supported event statuses:
community.lexicon.calendar.event#scheduledcommunity.lexicon.calendar.event#plannedcommunity.lexicon.calendar.event#rescheduledcommunity.lexicon.calendar.event#cancelled
- Event records conform to the community schema:
-
Decentralized Multi-User Event Sync:
- In ATProto Spaces, each collaborator writes events to their own repository within the space.
- CalendAT queries
com.atproto.space.listRecordsacross all active collaborators in the space, providing a unified, decentralized calendar view.
-
Rich Interactive UI & Polish:
- Month Grid View: Template month grid with continuous scrolling, connected multi-day bars, and quick event creation.
- Day View: Focused timeline using the template's day layout.
- Week Timeline View: 24-hour time-slot column view with duration-scaled event cards, auto-scroll to 8:00 AM, and real-time "Now" indicator.
- Agenda View: Events for the selected month, with one entry per event, meeting links, collaborator profiles, search, and upcoming/past filters.
- Dark Mode Support: Seamless Light / Dark / System theme toggle with persistence.
- iCalendar (.ics) Export: Export spaces or individual events to standard
.icsfor Google Calendar, Apple Calendar, and Outlook. - Mobile Drawer: Fully responsive navigation drawer on mobile and tablet devices.
- Toast Feedback: Non-intrusive notification toasts for actions.
Authentication & PDS Endpoint #
Space declaration #
CalendAT creates at.calend.calendar Spaces from the published declaration. OAuth uses the versioned oauth-client-metadata-v3.json URL and a wildcard Space type restricted to CalendAT's event, RSVP, and metadata collections. This avoids declaration lookup failures on the Spaces alpha provider while preserving collection-level access limits.
The declaration source is in lexicons/at/calend/calendar.json. The standalone Node 24 publisher covers the owned at.calend.* records:
# Dry run: validate local lexicons without network access or credentials.
npm run lexicons:publish
# Write records for calend.at using APP_PASSWORD from .env.
npm run lexicons:publish -- --write
The write flow loads .env automatically. It uses CALENDAT_PUBLISHER_APP_PASSWORD or the existing APP_PASSWORD, otherwise prompts without echoing. If the provider requires it, it also prompts for an email code. Set CALENDAT_PUBLISHER_APP_PASSWORD and CALENDAT_PUBLISHER_AUTH_FACTOR_TOKEN instead when supplying them through the environment. Add --update to allow differing existing records; identical records are skipped. Publication verifies the DNS TXT record _lexicon.calend.at as did=<publisher DID>, the handle, DID, and PDS, and reads records back after writing. The script does not change DNS or migrate app scopes. Once publication is complete, coordinate the OAuth spaceType change and migration of existing calendars together; changing a type string does not move existing data.
The publisher defaults to calend.at (did:plc:brpbmc7mlalm6fbtyaysk2m5). To change accounts, set both CALENDAT_PUBLISHER_IDENTIFIER and CALENDAT_PUBLISHER_DID. Configure DNS TXT _lexicon.calend.at with value did=did:plc:brpbmc7mlalm6fbtyaysk2m5 (name _lexicon in the calend.at DNS zone).
For initial publication before the DNS record exists, use npm run lexicons:publish -- --write --bootstrap. This permits missing DNS, but still rejects a conflicting DNS owner. Public Lexicon discovery requires the DNS record even after repository read-back verification succeeds.
The old community.lexicon.calendar namespace belongs to lexicon.community. The client recognizes existing spaces with that legacy type, while OAuth and newly created calendars use at.calend.calendar.
CalendAT uses AT Protocol OAuth. Enter a handle, DID, or PDS URL and complete sign-in with that provider. CalendAT never accepts or stores account passwords.
Production OAuth metadata is served at https://calend.at/oauth-client-metadata-v3.json, with the callback at https://calend.at/oauth/callback. Deploys must preserve that JSON file exactly and route /oauth/callback to the SPA. Set VITE_PUBLIC_URL only when hosting a separately registered metadata document whose client_id, client_uri, and callback URLs all use that same public origin. Local development uses the AT Protocol loopback client at 127.0.0.1; the browser OAuth SDK redirects localhost to that address and refresh tokens are intentionally short-lived.
The metadata requests atproto plus a space:* grant for calendar event, RSVP, and metadata records under any authority, with create, update, and delete management access. The permissioned-data proposal defines the Spaces OAuth consent boundary. OAuth requires a provider supporting this Spaces alpha grant. No client secret or separate registration is needed; publish the metadata JSON with the app, serve it as application/json, and serve the app at the callback path. A live sign-in must be verified after deployment.
Calendar dates and timezones #
- Timed events store UTC instants and an IANA
timeZonefor editing. The calendar views show the viewer's local time. The editor rejects nonexistent times during the spring daylight-saving transition and preserves the original instant when an unchanged time is ambiguous during the autumn transition. - All-day events use explicit
allDay: true,startDate, and exclusiveendDatefields. These are CalendAT extensions to the event record. TheirstartsAtandendsAtare UTC midnight timestamps for compatibility. The form shows an inclusive end date: September 11–12 is stored withendDate: "2026-09-13". - Month and week views use connected multi-day bars, wrapping at week boundaries. Agenda lists each event once. Events ending at midnight do not occupy the following day. The week view keeps all-day and multi-day events above its scrolling timeline.
- Existing records without explicit all-day metadata remain timed events. Their original intent cannot be recovered reliably from timestamps alone; edit and save them with the All-Day Event option when needed.
- All-day
.icsexports use date values and an exclusive end date.
Recurrence, collaboration, and calendar tools #
- Recurring events: daily, weekly, monthly, and yearly schedules with an interval, end date, or occurrence count. Edit or delete one occurrence or the whole series. Dragging moves one occurrence. Recurrence follows the event's IANA timezone across daylight-saving changes; timed values are still stored in UTC. Nonexistent local times are skipped without consuming the occurrence count. Imported custom RRULEs are preserved unless recurrence controls change. Expansion is limited to 1,000 occurrences per series and 5,000 overall per visible window, with a 20,000-candidate work limit; unsupported rules produce a visible error.
- Overlays: sidebar rows and right-hand eye buttons toggle visibility. Visible calendars have a shadow tinted in their own color. Hiding removes their events immediately. The header calendar menu chooses the target for new events and tools without changing visibility. Each calendar's settings button edits its name, description, color, and sharing. Timed overlaps have a
!marker. Loading appears in a floating toast. - Refresh: every 2 minutes while online, visible, and free of an open dialog or pending load. Failures back off to at most 15 minutes. Manual Refresh remains available. Collaborators' moved or cancelled events produce in-app notices after a successful refresh.
- Preloading: sign-in loads events and members for every calendar, including hidden calendars. Visibility changes show cached events immediately and refresh only the toggled calendar. The Spaces API paginates records without date filtering; the loader still reads every page, which remains a scaling limitation for large calendars. Adjacent months are prepared locally, with at most nine view ranges retained for scrolling. Refreshes invalidate expanded occurrences, and logout clears cached private data.
- Reminders: choose an event reminder and optionally enable desktop notifications with the bell button. Reminders require CalendAT to remain open; there is no background push delivery. All-day reminders use midnight in the viewer's local timezone. Reminder history is stored per account in the browser to avoid duplicate notices.
- Import: Calendar tools accepts local
.icsfiles up to 5 MB and 2,000 VEVENT components, previews the import, and skips previously imported UIDs. Retrying a partial import skips successful writes. Recurrence rules, exclusions, and individual overrides are supported; RDATE, multiple RRULEs, and THISANDFUTURE are rejected explicitly. Floating times use the importing user's timezone with a warning. Subscription feeds are deferred. - Lifecycle: owners can archive, restore, or permanently delete calendars. Archiving is a shared CalendAT visibility/editing setting, not a server access restriction. Deletion requires typing the calendar name. Collaborators can request to leave; servers that restrict member removal to owners show an error asking the owner to remove access.
- Keyboard and mobile: dialogs trap focus and restore it on close. Bare
Ncreates an event,Tgoes to today,1/2/3switches views, and[/]navigates dates. Shortcuts pause while typing or using a dialog and preserve Cmd/Ctrl/Alt combinations. Open an event and change its date/time to reschedule on touch devices.
Recurrence, reminders, archive status, and explicit all-day dates are CalendAT record extensions. Other clients may not understand them. The Spaces alpha write API has no conditional record update, so simultaneous edits to the same series can still overwrite each other; occurrence edits read the latest record before merging their changes.
Getting Started #
1. Install Dependencies #
npm install
2. Run Tests #
npm test
npm run typecheck
npm run test:timezones
Browser tests run the real UI and ATProto client against intercepted test responses, without accessing a live account:
npx playwright install chromium
npm run test:browser
The browser suite covers sharing and clipboard permission failures, failed saves, drag and mobile rescheduling, navigation, all-day editing, timezone conversion, daylight-saving times, overlays, recurrence scope, and modal focus. Service and hook tests cover session refresh, import retries, lifecycle permissions, reminder deduplication, and automatic refresh backoff. Date handling and recurrence tests run in UTC, New York, Madrid, and Tokyo. Export tests verify the included VTIMEZONE definitions independently through ical.js.
3. Development Server #
npm run dev
The application will launch on http://127.0.0.1:5180. Use the loopback IP for OAuth.
This is the development server: Nuxt serves hundreds of separate source modules for live editing. Use the bundled build below when running the app normally or measuring page-load requests.
4. Production Build #
npm run build
npm start
Open http://127.0.0.1:3000. This serves bundled assets without development module requests. Set PORT to change the port; rebuild after source changes. npm run preview also previews the same build.
5. Docker Deployment #
Build and run the production image:
docker build --platform linux/amd64 -t calendat .
docker run --rm --platform linux/amd64 -p 3000:3000 calendat
The multi-stage image uses Node 24 Alpine to run npm ci and npm run build, then starts Nuxt's Nitro server from .output/server/index.mjs on port 3000. The container includes the versioned public oauth-client-metadata-v3.json file and the /oauth/callback route. Use https://calend.at as the production origin; the hosting platform must provide HTTPS for OAuth because the container itself serves plain HTTP. A separately registered deployment can set NUXT_PUBLIC_URL at build time; VITE_PUBLIC_URL remains a compatibility fallback.
For Docker Compose, the included docker-compose.yml builds and runs linux/amd64, publishes ${PORT:-3000}:3000, and restarts it unless stopped:
docker compose up -d --build
docker compose down
Set a different host port when needed:
PORT=8080 docker compose up -d