changemaker.lite/SOCIAL_CALENDAR_PLAN.md

570 lines
25 KiB
Markdown

# Social Calendar Feature Plan
**Created:** 2026-03-06
**Status:** Planning Complete — Ready for Phase A Implementation
**Branch:** v2
**Feature Flag:** `enableSocialCalendar` (new, under SiteSettings)
---
## Overview
A layered personal and social calendar system. Each user gets their own calendar with multiple layers (system-populated, user-created, external feeds). Calendars can be shared between users at the item, category (layer), or whole-calendar level. Shared views allow multiple users' events to appear on a merged, color-coded calendar. Admin shared views can auto-include users by role.
### Design Principles
- **Layers are the core abstraction** — every event belongs to a layer, layers control visibility and sharing
- **System layers are virtual** — shifts, tickets, polls are queried live from source tables, not duplicated
- **Recurrence uses materialization** — consistent with existing ShiftSeries pattern (generate DB rows, allow exceptions)
- **Social-first** — friend relationships gate sharing; admin views are separate and only expose system data
- **Privacy by default** — layers default to PRIVATE; users explicitly opt into sharing
---
## Data Model
### CalendarLayer
Each user has multiple layers. System layers are auto-created on first calendar access.
| Field | Type | Notes |
|-------|------|-------|
| id | String (cuid) | PK |
| userId | String | FK to User |
| name | String | "Personal", "Gym", "Google Cal", etc. |
| layerType | Enum | SYSTEM, USER, EXTERNAL |
| systemType | Enum? | SHIFTS, TICKETS, POLLS, PUBLIC_EVENTS (for SYSTEM layers only) |
| color | String | Hex color (#1890ff) |
| visibility | Enum | PRIVATE, FRIENDS, PUBLIC |
| isEnabled | Boolean | User can toggle layers on/off for themselves |
| sortOrder | Int | Display ordering |
| createdAt | DateTime | |
| updatedAt | DateTime | |
**System layers (auto-created per user):**
- My Shifts — from ShiftSignup records
- My Tickets — from EventTicket records
- My Polls — from SchedulingPollVote records
- Public Events — the existing Gancio/platform feed (togglable)
System layers are **virtual** — no CalendarItem rows are created. The API queries source tables directly and maps to the CalendarItem shape at response time.
### CalendarItem
User-created events, time blocks, and cached .ics feed entries.
| Field | Type | Notes |
|-------|------|-------|
| id | String (cuid) | PK |
| userId | String | FK to User (owner) |
| layerId | String | FK to CalendarLayer |
| title | String | |
| description | String? | Text |
| date | DateTime | Date of this occurrence |
| startTime | String | HH:MM |
| endTime | String | HH:MM |
| isAllDay | Boolean | Default false |
| itemType | Enum | EVENT, TIME_BLOCK, REMINDER |
| location | String? | |
| color | String? | Override (null = inherit layer color) |
| visibility | Enum? | PRIVATE, FRIENDS, PUBLIC (null = inherit from layer) |
| busyStatus | Enum | BUSY, TENTATIVE, FREE (default BUSY) |
| showDetailsTo | Enum | NOBODY, FRIENDS, EVERYONE (default FRIENDS) |
| recurrenceRule | Json? | See Recurrence section |
| recurrenceEnd | DateTime? | When series stops |
| seriesId | String? | Groups recurring instances |
| isException | Boolean | Edited instance that broke from pattern |
| sourceType | Enum | MANUAL, ICS_FEED |
| sourceId | String? | External reference (ics UID, etc.) |
| createdAt | DateTime | |
| updatedAt | DateTime | |
**Notes:**
- System-layer items (shifts, tickets, polls) are NOT stored as CalendarItem rows — they're virtual
- .ics feed items ARE stored as CalendarItem rows (cached from external source, read-only to user)
- MANUAL items are user-created freeform events
### CalendarFeed (.ics import)
| Field | Type | Notes |
|-------|------|-------|
| id | String (cuid) | PK |
| userId | String | FK to User |
| name | String | "Google Calendar", "Work" |
| url | String | .ics URL |
| layerId | String | FK to auto-created CalendarLayer |
| refreshInterval | Enum | FIFTEEN_MIN, HOURLY, SIX_HOUR, DAILY |
| lastFetchedAt | DateTime? | |
| lastStatus | Enum | OK, ERROR, PENDING |
| lastError | String? | Error message if failed |
| itemCount | Int | How many items imported |
| createdAt | DateTime | |
| updatedAt | DateTime | |
### SharedCalendarView
| Field | Type | Notes |
|-------|------|-------|
| id | String (cuid) | PK |
| name | String | "Weekend Crew", "All Shift Admins" |
| description | String? | |
| ownerId | String | FK to User (creator) |
| viewType | Enum | MANUAL, ROLE_BASED |
| autoIncludeRoles | Json? | ["MAP_ADMIN", "USER"] (for ROLE_BASED) |
| includedLayerTypes | Json | ["shifts", "tickets", "personal-public"] |
| shareScope | Enum | MEMBERS, PUBLIC |
| shareToken | String? | Unique token for public share URL |
| createdAt | DateTime | |
| updatedAt | DateTime | |
**ROLE_BASED views:**
- Auto-include users matching specified roles
- Only pull system layers (shifts, tickets, polls) — never personal layers
- No notifications sent to included users (admin operational tool)
- Created/managed by SUPER_ADMIN or MAP_ADMIN
**MANUAL views:**
- Members are explicitly invited via notification system
- Can include personal layers (with member consent)
- Members can decline/leave
### SharedCalendarMember
| Field | Type | Notes |
|-------|------|-------|
| id | String (cuid) | PK |
| viewId | String | FK to SharedCalendarView |
| userId | String | FK to User |
| status | Enum | INVITED, ACCEPTED, DECLINED |
| color | String | Auto-assigned from palette |
| joinedAt | DateTime? | |
| @@unique | [viewId, userId] | |
**Auto-color palette:**
```
#1890ff (blue), #52c41a (green), #fa8c16 (orange), #722ed1 (purple),
#eb2f96 (pink), #13c2c2 (cyan), #faad14 (gold), #f5222d (red),
#2f54eb (geekblue), #a0d911 (lime)
```
Assigned sequentially as members join: `PALETTE[memberIndex % length]`.
Users can override their assigned color per shared view.
### SharedViewComment
| Field | Type | Notes |
|-------|------|-------|
| id | String (cuid) | PK |
| viewId | String | FK to SharedCalendarView |
| userId | String | FK to User |
| itemDate | String | YYYY-MM-DD (which date this comment is about) |
| itemId | String? | Optional: specific CalendarItem or source item ID |
| content | String | Text |
| createdAt | DateTime | |
### SharedViewReaction
| Field | Type | Notes |
|-------|------|-------|
| id | String (cuid) | PK |
| viewId | String | FK to SharedCalendarView |
| userId | String | FK to User |
| itemId | String | CalendarItem or source item ID (e.g., "shift-abc123") |
| emoji | String | Single emoji or shortcode |
| createdAt | DateTime | |
| @@unique | [viewId, userId, itemId, emoji] | One reaction type per user per item |
### CalendarExportToken (.ics export)
| Field | Type | Notes |
|-------|------|-------|
| id | String (cuid) | PK |
| userId | String | FK to User |
| token | String | Unique, random (for URL auth) |
| includePersonal | Boolean | Whether personal events are exported |
| includeLayers | Json? | Array of layer IDs (null = all enabled) |
| createdAt | DateTime | |
Export URL: `GET /api/calendar/feed/:userId/:token.ics`
---
## Recurrence Model
Uses **materialization** (consistent with existing ShiftSeries pattern):
1. User creates a recurring event with a recurrence rule
2. System generates CalendarItem rows for the next 3 months
3. Background job (BullMQ, daily) extends series forward by 1 month
4. Individual instances can be edited (becomes `isException: true`) or deleted
5. Editing the series template updates all non-exception future instances
### Recurrence Rule JSON
```json
{
"frequency": "DAILY | WEEKLY | BIWEEKLY | MONTHLY",
"daysOfWeek": [1, 3, 5],
"dayOfMonth": 15,
"interval": 1
}
```
- `WEEKLY` + `daysOfWeek: [1,3,5]` = every Mon/Wed/Fri
- `MONTHLY` + `dayOfMonth: 15` = 15th of every month
- `BIWEEKLY` + `daysOfWeek: [2,4]` = every other Tue/Thu
- `interval` for skip patterns (every 2 weeks, every 3 months)
### Recurrence Edit Options (UI)
When editing a recurring event instance:
- "This event only" — marks as exception, edits the single instance
- "This and future events" — updates template + regenerates future non-exception instances
- "All events in series" — updates template + all instances (including past, excluding exceptions)
When deleting:
- "This event only" — soft-delete the single instance
- "This and future events" — delete future instances, set recurrenceEnd on template
- "All events" — delete entire series
---
## Time Block Visibility (Configurable per item)
| `showDetailsTo` | Friends see | Public sees |
|-----------------|-------------|-------------|
| NOBODY | "Busy 2-4pm" | "Busy 2-4pm" |
| FRIENDS | "Dentist 2-4pm" | "Busy 2-4pm" |
| EVERYONE | "Dentist 2-4pm" | "Dentist 2-4pm" |
Combined with `busyStatus`:
- **BUSY** — solid color block
- **TENTATIVE** — dashed/lighter block
- **FREE** — no block shown (informational only, e.g., "Available for meetings")
---
## Notification Types (reusing existing system)
| Type | Message | Trigger |
|------|---------|---------|
| SHARED_VIEW_INVITE | "Alice invited you to 'Weekend Crew' calendar" | Manual shared view invite |
| SHARED_VIEW_ACCEPTED | "Bob accepted your invite to 'Weekend Crew'" | Member accepts |
| CALENDAR_EVENT_INVITE | "Alice added you to 'Planning Meeting' on Mar 10" | Phase B: event-level sharing |
| CALENDAR_REMINDER | "Reminder: Team standup in 15 minutes" | Future: optional reminders |
Role-based admin views do NOT trigger notifications (admin operational tool using only system data).
---
## Availability Finder (Phase B)
A dedicated mode within shared calendar views:
1. Toggle "Find Available Time" on a shared view
2. System overlays all members' BUSY/TENTATIVE time blocks
3. Highlights gaps where ALL members are free
4. Optional: filter by time range ("only show weekday 9am-5pm slots")
5. Click a free slot to create an event and auto-invite all members
Visual: green highlight on free slots, red/orange on conflicts, member avatars on busy blocks.
---
## API Routes
### Phase A (Personal Calendar)
```
# Layers
GET /api/calendar/layers — list user's layers
POST /api/calendar/layers — create custom layer
PATCH /api/calendar/layers/:id — update layer (name, color, visibility, enabled)
DELETE /api/calendar/layers/:id — delete custom layer (+ its items)
# Calendar Items
GET /api/calendar/items — list items in date range (all enabled layers merged)
POST /api/calendar/items — create item (event, time block, reminder)
PATCH /api/calendar/items/:id — update item
DELETE /api/calendar/items/:id — delete item
# Recurrence
POST /api/calendar/items/:id/series — edit series (this-only, this-and-future, all)
DELETE /api/calendar/items/:id/series — delete series (this-only, this-and-future, all)
# Unified personal view (merges system layers + user items)
GET /api/calendar/my — personal calendar (date range, layer filters)
```
### Phase B (Sharing + Social)
```
# Shared Views
GET /api/calendar/shared — list shared views I own or am a member of
POST /api/calendar/shared — create shared view
PATCH /api/calendar/shared/:id — update shared view
DELETE /api/calendar/shared/:id — delete shared view (owner only)
# Members
POST /api/calendar/shared/:id/invite — invite user(s) to shared view
PATCH /api/calendar/shared/:id/respond — accept/decline invite
DELETE /api/calendar/shared/:id/leave — leave a shared view
GET /api/calendar/shared/:id/members — list members + colors
# Merged calendar data
GET /api/calendar/shared/:id/items — merged items from all members
# Event-level sharing
POST /api/calendar/items/:id/share — share specific item with friend(s)
# Comments & Reactions (on shared views)
GET /api/calendar/shared/:id/comments?date=YYYY-MM-DD
POST /api/calendar/shared/:id/comments
DELETE /api/calendar/shared/:id/comments/:commentId
POST /api/calendar/shared/:id/reactions
DELETE /api/calendar/shared/:id/reactions/:reactionId
# Availability finder
GET /api/calendar/shared/:id/availability?start=&end=&dayStart=09:00&dayEnd=17:00
# Friend's public calendar
GET /api/calendar/user/:userId — view a friend's public items
```
### Phase C (.ics Integration)
```
# Feeds (import)
GET /api/calendar/feeds — list user's subscribed feeds
POST /api/calendar/feeds — subscribe to .ics URL
PATCH /api/calendar/feeds/:id — update feed settings
DELETE /api/calendar/feeds/:id — unsubscribe (deletes layer + cached items)
POST /api/calendar/feeds/:id/refresh — force refresh now
# Export
GET /api/calendar/export/token — get or create export token
DELETE /api/calendar/export/token — revoke export token
GET /api/calendar/feed/:userId/:token.ics — public .ics feed (no auth, token in URL)
```
### Phase D (Admin Shared Views)
```
# Admin role-based views (requireRole: SUPER_ADMIN, MAP_ADMIN)
POST /api/admin/calendar/shared — create role-based shared view
PATCH /api/admin/calendar/shared/:id — update
DELETE /api/admin/calendar/shared/:id — delete
GET /api/admin/calendar/shared/:id/items — merged system-layer data for matching users
```
---
## Frontend Pages & Components
### Phase A
| Component | Location | Description |
|-----------|----------|-------------|
| MyCalendarPage | `volunteer/MyCalendarPage.tsx` | Personal calendar (main view) |
| CalendarLayerPanel | `components/calendar/CalendarLayerPanel.tsx` | Sidebar: layer list with toggles, colors, visibility |
| CalendarItemModal | `components/calendar/CalendarItemModal.tsx` | Create/edit event, time block, or reminder |
| RecurrenceEditor | `components/calendar/RecurrenceEditor.tsx` | Recurrence rule builder (frequency, days, end date) |
| PersonalCalendarView | `components/calendar/PersonalCalendarView.tsx` | Month/week/day calendar with layer color-coding |
| MobileDayView | `components/calendar/MobileDayView.tsx` | Day/3-day swipeable view for mobile |
**Mobile UX:** Day or 3-day swipeable view (not full month grid). Swipe left/right to navigate days. Layer toggles in a collapsible bottom sheet.
### Phase B
| Component | Location | Description |
|-----------|----------|-------------|
| SharedCalendarsPage | `volunteer/SharedCalendarsPage.tsx` | List of shared views I'm in |
| SharedCalendarView | `components/calendar/SharedCalendarView.tsx` | Merged multi-user calendar with member colors |
| SharedViewMembersPanel | `components/calendar/SharedViewMembersPanel.tsx` | Member list, color overrides, invite button |
| AvailabilityFinder | `components/calendar/AvailabilityFinder.tsx` | Free/busy overlay with slot highlighting |
| CalendarComments | `components/calendar/CalendarComments.tsx` | Comment thread for a date in shared view |
| CalendarReactions | `components/calendar/CalendarReactions.tsx` | Emoji reactions on items |
| FriendCalendarPage | `volunteer/FriendCalendarPage.tsx` | View a friend's public calendar |
### Phase C
| Component | Location | Description |
|-----------|----------|-------------|
| CalendarFeedsPanel | `components/calendar/CalendarFeedsPanel.tsx` | Manage .ics subscriptions |
| CalendarExportPanel | `components/calendar/CalendarExportPanel.tsx` | Export token management, copy URL |
### Phase D
| Component | Location | Description |
|-----------|----------|-------------|
| AdminSharedViewsPage | `pages/AdminSharedViewsPage.tsx` | Admin: create/manage role-based views |
| AdminCalendarOverview | `components/calendar/AdminCalendarOverview.tsx` | Big shift/event overview for admins |
---
## Navigation & Routing
### Volunteer Portal
- Footer nav: add "Calendar" tab (CalendarOutlined icon)
- `/volunteer/calendar` — MyCalendarPage
- `/volunteer/calendar/shared` — SharedCalendarsPage
- `/volunteer/calendar/shared/:id` — SharedCalendarView
- `/volunteer/calendar/friend/:userId` — FriendCalendarPage
### Admin
- Sidebar under existing section: "Calendar Overview"
- `/app/calendar/shared` — AdminSharedViewsPage
---
## Phase Breakdown
### Phase A: Personal Calendar + Layers + Freeform Events
**Scope:**
- [x] Prisma models: CalendarLayer, CalendarItem, CalendarFeed, SharedCalendarView, SharedCalendarMember, SharedViewComment, SharedViewReaction, CalendarExportToken (+ 12 enums)
- [x] Auto-create system layers on first calendar access (ensureSystemLayers)
- [x] CalendarItem CRUD (create, read, update, delete)
- [x] Recurrence: create series (materialize 3 months), edit/delete with scope options (THIS_ONLY/THIS_AND_FUTURE/ALL)
- [ ] BullMQ job: extend recurring series daily (add 1 month of future instances)
- [x] Personal calendar API: GET /api/calendar/my (merge system layers + user items)
- [x] System layer queries: shifts (from ShiftSignup), tickets (from Ticket), polls (from SchedulingPollVote)
- [x] Layer CRUD: create custom layers, toggle on/off, set color
- [x] Layer visibility settings (PRIVATE/FRIENDS/PUBLIC) — stored but not enforced until Phase B
- [x] MyCalendarPage: month view (desktop), day/3-day view (mobile)
- [x] CalendarLayerPanel: sidebar with layer toggles, color pickers, inline editing, grouped by type
- [x] CalendarItemModal: create/edit form with item type, recurrence, time block settings, scope selector
- [x] RecurrenceEditor: frequency/days/interval/end-date with preview text
- [x] PersonalCalendarView: desktop month view with layer-colored items
- [x] MobileDayView: day view with time grid, current time indicator, floating add button
- [x] Volunteer footer nav: "Calendar" tab (gated behind enableSocialCalendar)
- [x] Feature flag: enableSocialCalendar in SiteSettings, Zod schema, frontend types, FeatureGate
- [x] Settings page toggle added ("Social Calendar" in People & Engagement section)
### Phase B: Sharing + Social
**Scope:**
- [ ] Prisma models: SharedCalendarView, SharedCalendarMember, SharedViewComment, SharedViewReaction
- [ ] SharedCalendarView CRUD
- [ ] Invite flow: send invite via notification system, accept/decline/leave
- [ ] Merged calendar API: query all members' items with layer type filtering
- [ ] Auto-color assignment for members
- [ ] Layer visibility enforcement (PRIVATE/FRIENDS/PUBLIC filtering based on relationship)
- [ ] Event-level sharing: share a specific item with friend(s) via notification
- [ ] Comments on shared view dates/items
- [ ] Emoji reactions on shared view items
- [ ] Availability finder: free/busy overlay, slot highlighting, time range filter
- [ ] Friend's public calendar view
- [ ] SharedCalendarsPage, SharedCalendarView components
- [ ] AvailabilityFinder component
- [ ] CalendarComments, CalendarReactions components
- [ ] Public share URL (shareToken for unauthenticated view)
### Phase C: .ics Integration
**Scope:**
- [x] Prisma models: CalendarFeed, CalendarExportToken (already existed from Phase A migration)
- [x] .ics feed parser (node-ical v0.25.5)
- [x] BullMQ job: refresh feeds every 15 minutes (calendar-feed-refresh queue)
- [x] Feed CRUD: subscribe, update, delete, force refresh
- [x] Auto-create EXTERNAL layer per feed, cache items as CalendarItem rows (sourceType: ICS_FEED)
- [x] .ics export: generate feed from user's calendar via ical-generator v10, token-authenticated URL
- [x] Export token management (create, list, revoke)
- [x] CalendarFeedsPanel, CalendarExportPanel components
- [x] MyCalendarPage settings Drawer integration (gear icon)
### Phase D: Admin Shared Views
**Scope:**
- [ ] Role-based SharedCalendarView (viewType: ROLE_BASED)
- [ ] Auto-include users by role(s) — query live, no member rows needed
- [ ] Only expose system layers (shifts, tickets, polls) — no personal data
- [ ] No notifications to included users
- [ ] Admin routes (requireRole: SUPER_ADMIN, MAP_ADMIN)
- [ ] AdminSharedViewsPage
- [ ] AdminCalendarOverview (big shift/event dashboard)
---
## Implementation Notes
### Extending UnifiedCalendar
The existing `UnifiedCalendar` component and `unified-calendar.service.ts` remain as the **public** calendar. The new personal calendar service (`calendar.service.ts`) reuses the same source queries (shifts, Gancio, polls, ticketed events) but filters to the user's own records and merges with their CalendarItem rows.
### Recurrence Background Job
```typescript
// jobs/calendar-recurrence.job.ts
// Runs daily via BullMQ repeatable job
// 1. Find all CalendarItems with recurrenceRule where latest materialized date < now + 3 months
// 2. Generate new instances up to 3 months ahead
// 3. Skip dates that already have an instance (idempotent)
```
### .ics Feed Refresh Job
```typescript
// jobs/calendar-feed-refresh.job.ts
// Runs every 15 minutes via BullMQ repeatable job
// 1. Find feeds where lastFetchedAt + refreshInterval < now
// 2. Fetch .ics URL, parse events
// 3. Upsert CalendarItem rows (match on sourceId = ics UID)
// 4. Delete items no longer in feed
// 5. Update feed status
```
### Privacy Boundaries
| Scenario | What's visible |
|----------|---------------|
| Viewing own calendar | Everything (all layers, all items) |
| Friend views your calendar | Items on FRIENDS or PUBLIC visibility layers, plus items with individual FRIENDS/PUBLIC override |
| Public profile calendar | Only PUBLIC visibility layers and PUBLIC override items |
| Admin role-based view | Only system layers (shifts, tickets, polls) for users matching role filter |
| Shared view (MANUAL) | Items from includedLayerTypes on layers with appropriate visibility for the viewer |
| Time blocks (BUSY) | Title shown per showDetailsTo setting, always shows busy bar |
### Performance Considerations
- CalendarItem table will grow with materialized recurrence — add indexes on (userId, date), (layerId, date), (seriesId)
- System layers query source tables directly — leverage existing indexes on ShiftSignup, EventTicket, etc.
- .ics feed items are cached — only re-parsed on refresh interval
- Shared view queries can be expensive (N members x M layers) — cache merged results in Redis (2min TTL, bust on member change)
- Availability finder operates on time blocks only — narrow query scope
---
## Tracking Log
### 2026-03-06 — Planning Complete
- Brainstormed feature across 3 rounds of refinement
- Decided on layer-based architecture (system, user, external layers)
- Recurrence uses materialization (consistent with ShiftSeries pattern)
- Time block visibility is configurable per item (showDetailsTo: NOBODY/FRIENDS/EVERYONE)
- Shared views support manual (invite-based) and role-based (admin, system data only)
- Availability finder included in Phase B
- Comments and reactions on shared view items included in Phase B
- .ics import and export in Phase C
- Admin role-based views in Phase D (no personal data, no notifications)
- Reuse existing notification system for invites
- Auto-color assignment for shared view members with user override option
- Mobile UX: day/3-day swipeable view instead of month grid
### 2026-03-06 — Phase A Implementation Complete
- Schema: 8 models, 12 enums, migration `20260306203326_social_calendar_layers_items` applied
- Fixed pre-existing migration ordering issue (ticketed_events create must come before alter)
- Backend: calendar.service.ts (layer mgmt, item CRUD, recurrence materialization, personal calendar merge), calendar.routes.ts (9 endpoints), calendar.schemas.ts (Zod validation)
- Frontend: 5 new components (CalendarLayerPanel, CalendarItemModal, RecurrenceEditor, PersonalCalendarView, MobileDayView), MyCalendarPage
- Navigation: VolunteerFooterNav Calendar tab, App.tsx route, SettingsPage toggle
- Smoke tested: layers auto-create, item CRUD works, recurring events materialize correctly (Weekly Mon/Wed/Fri generated 11 instances through June)
- Both API and Admin compile with zero TypeScript errors
- Remaining Phase A item: BullMQ job for extending recurring series (not critical for launch, series materializes 3 months on creation)
### 2026-03-07 — Phase C Implementation Complete
- Backend: feed.schemas.ts (3 Zod schemas), feed.service.ts (feed CRUD, ICS parsing, RRULE materialization, export generation), feed.routes.ts (1 public + 8 auth routes), calendar-feed-queue.service.ts (BullMQ 15min repeatable job)
- Dependencies: node-ical v0.25.5 (ICS parsing), ical-generator v10.0.0 (ICS output)
- Feed import: streaming body read with 5MB limit, 1000 event cap, RRULE materialization via rrule.between(), stale event cleanup, status tracking (OK/ERROR/PENDING)
- Feed export: 32-byte random token, configurable layer/personal inclusion, past 1 month + future 3 months, standard iCalendar output with Content-Type: text/calendar
- Frontend: CalendarFeedsPanel (add/edit/delete/refresh with status badges), CalendarExportPanel (create/copy/revoke tokens), settings Drawer in MyCalendarPage (gear icon)
- Types: CalendarFeed, CalendarExportToken, CalendarFeedStatus, CalendarFeedInterval added to admin/src/types/api.ts
- server.ts: feedRoutes mounted before calendarRoutes (public .ics route needs no auth), queue worker started on bootstrap, graceful shutdown
- Smoke tested: Google US Holidays feed → 317 events imported with status OK; export token → valid .ics with VEVENT entries; revoke → 404
- Docker gotcha: anonymous volume `/app/node_modules` caches old dependencies — must `docker compose rm -sf api` to clear when adding new npm packages
- Both API and Admin compile with zero TypeScript errors