25 KiB
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):
- User creates a recurring event with a recurrence rule
- System generates CalendarItem rows for the next 3 months
- Background job (BullMQ, daily) extends series forward by 1 month
- Individual instances can be edited (becomes
isException: true) or deleted - Editing the series template updates all non-exception future instances
Recurrence Rule JSON
{
"frequency": "DAILY | WEEKLY | BIWEEKLY | MONTHLY",
"daysOfWeek": [1, 3, 5],
"dayOfMonth": 15,
"interval": 1
}
WEEKLY+daysOfWeek: [1,3,5]= every Mon/Wed/FriMONTHLY+dayOfMonth: 15= 15th of every monthBIWEEKLY+daysOfWeek: [2,4]= every other Tue/Thuintervalfor 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:
- Toggle "Find Available Time" on a shared view
- System overlays all members' BUSY/TENTATIVE time blocks
- Highlights gaps where ALL members are free
- Optional: filter by time range ("only show weekday 9am-5pm slots")
- 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:
- Prisma models: CalendarLayer, CalendarItem, CalendarFeed, SharedCalendarView, SharedCalendarMember, SharedViewComment, SharedViewReaction, CalendarExportToken (+ 12 enums)
- Auto-create system layers on first calendar access (ensureSystemLayers)
- CalendarItem CRUD (create, read, update, delete)
- 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)
- Personal calendar API: GET /api/calendar/my (merge system layers + user items)
- System layer queries: shifts (from ShiftSignup), tickets (from Ticket), polls (from SchedulingPollVote)
- Layer CRUD: create custom layers, toggle on/off, set color
- Layer visibility settings (PRIVATE/FRIENDS/PUBLIC) — stored but not enforced until Phase B
- MyCalendarPage: month view (desktop), day/3-day view (mobile)
- CalendarLayerPanel: sidebar with layer toggles, color pickers, inline editing, grouped by type
- CalendarItemModal: create/edit form with item type, recurrence, time block settings, scope selector
- RecurrenceEditor: frequency/days/interval/end-date with preview text
- PersonalCalendarView: desktop month view with layer-colored items
- MobileDayView: day view with time grid, current time indicator, floating add button
- Volunteer footer nav: "Calendar" tab (gated behind enableSocialCalendar)
- Feature flag: enableSocialCalendar in SiteSettings, Zod schema, frontend types, FeatureGate
- 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:
- Prisma models: CalendarFeed, CalendarExportToken (already existed from Phase A migration)
- .ics feed parser (node-ical v0.25.5)
- BullMQ job: refresh feeds every 15 minutes (calendar-feed-refresh queue)
- Feed CRUD: subscribe, update, delete, force refresh
- Auto-create EXTERNAL layer per feed, cache items as CalendarItem rows (sourceType: ICS_FEED)
- .ics export: generate feed from user's calendar via ical-generator v10, token-authenticated URL
- Export token management (create, list, revoke)
- CalendarFeedsPanel, CalendarExportPanel components
- 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
// 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
// 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_itemsapplied - 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_modulescaches old dependencies — mustdocker compose rm -sf apito clear when adding new npm packages - Both API and Admin compile with zero TypeScript errors