# 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