changemaker.lite/SOCIAL_CALENDAR_PLAN.md

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):

  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

{
  "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:

  • 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_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