Layer
Calendar & Time
The time layer. Calendars are shared publication surfaces — independent of any single module — that all time-based content links into. Programs, Boards, Holidays, and Reservations each contribute entries; the calendar aggregates them into unified views.
| Status | In Progress |
| Last updated | — |
| Related |
Layer Location · Distribution & Publishing Platform Microsoft Platform Module Reservations · Programs & Registration |
What a Calendar Is
A Calendar is a named, configurable container for time-based entries. It is not the authoritative source for any record — it is a shared aggregation surface. Programs, Boards, Holidays, and Reservations all maintain their own modules; the Calendar is where those records become visible together in a time-based view.
This independence is the defining characteristic. The City Meetings calendar does not belong to the Boards module — it belongs to the Calendar layer. Boards push into it. If tomorrow a new module (Road Closures, Special Events) needs time-based visibility, it pushes into a Calendar too — no changes to the Calendar layer are required.
Calendars support a parent/child hierarchy. A parent calendar aggregates entries from its children without requiring staff to enter anything twice. A child belongs to at most one parent.
Calendar Types
Calendars serve several distinct purposes across the platform. The type is not a formal schema field — it is a description of the calendar's primary source and audience.
Parks and Library program sessions. Each program category maps to a calendar. A parent calendar (e.g. "Parks Programs") aggregates all child program calendars.
Committee and board meeting agendas. The Boards module publishes meeting dates and documents; meetings appear on the City Meetings calendar. Independent of Programs.
Official holidays and observed dates. Staff-authored native events. Future: changes here propagate as exception hours on affected Facility records.
One calendar per Facility or FacilitySpace showing all events and reservations at that location. Feeds the facility detail page and the Exchange resource mailbox for reservable spaces.
External webcal/ICS subscriptions polled on a schedule. Used for city council streams, county feeds, or partner organization events. MyGov caches the entries; the external source is authoritative.
One-off events authored directly in the Calendar module — visiting acts, community events, road closures. Not tied to any other module.
Future: Holiday → Facility exception hours
When a holiday is added to the City Holidays calendar, affected Facility records
should have their hours updated automatically (or with a confirmation prompt) to
reflect the closure. This creates a direct link between the Calendar layer and the
Location layer — Facility hours become a derived view of operating schedule plus
holiday exceptions, rather than a manually maintained field. This is a design target
that requires the FacilityContact.hours JSON to support a typed exception
model; the current free-form JSON blob would need to be formalised first.
Calendar Entry Data Model
All calendar entries share a common schema regardless of their source. CalendarEvent is an iCal-compatible core augmented with a small, deliberately capped set of optional structured fields for location linkage, source provenance, content categorisation, and web display.
| Field | Type | iCal mapped | Notes |
|---|---|---|---|
| uid | string | UID | Globally unique; stable across sync. Derived from sourceType-sourceId for pushed entries. |
| summary | string | SUMMARY | Event title. |
| dtstart / dtend | datetime | DTSTART / DTEND | Timezone-aware. |
| rrule | string | RRULE | Optional. iCal recurrence rule. |
| exdate | datetime[] | EXDATE | Optional. Exception dates excluded from a recurring series. |
| location | string | LOCATION | Optional free text. Auto-composed from FacilitySpace + Facility address when facilitySpaceId is set. |
| description | string | DESCRIPTION | Optional plain text. Included in iCal export and Outlook event body. |
| url | string | URL | Optional link to the source record or detail page. |
| categories | string[] | CATEGORIES | Optional. iCal CATEGORIES field — populated from categoryId label at export time. Clients that honour it (e.g. Apple Calendar) display the category; Outlook ignores it gracefully. |
| facilitySpaceId | int? | — | Optional FK to organization.FacilitySpace. Enables structured location display and facility-calendar views. Not in iCal export. |
| categoryId | int? | — | Optional FK to data.CalendarCategory. Used for filtering, display colour/icon, and populating the iCal CATEGORIES field. Not propagated to Outlook as a shared colour — see Category & Tagging decision below. |
| sourceType | string? | — | Optional. Identifies the pushing module: programSession | reservation | boardMeeting | native | icsFeed. Used for deduplication and reverse lookup. |
| sourceId | int? | — | Optional FK back to the source record. Paired with sourceType. |
| imageUrl | string? | — | Optional. Web display only — not in iCal export. Inherited from the source record at push time; re-pushed on source image update. |
Options considered
Option A (pure iCal fields only) was the current implementation.
It was rejected because: citizen-facing views were text-only with no images or
structured location links; source lookup required fragile uid-prefix parsing;
and deduplication on upsert was unreliable. Option B (iCal core + capped
extensions) was adopted. The iCal contract is fully preserved — extended
fields are invisible to Outlook and any standard calendar client. The scope of
extensions is capped to the fields above; adding more requires revisiting this
section. Migration from Option A adds sourceType,
sourceId, facilitySpaceId, categoryId, and
imageUrl columns and updates the program sync to populate them.
What Feeds a Calendar and How
Program Sessions Program Session
A Program has one or more Sessions. A Session is the time-bound record (start, end, recurrence rule, exceptions). Sessions — not the Program itself — are the unit that maps to a calendar entry.
Push model (design target): When a Program is published or a
Session is added/updated/deleted, a calendar entry is written immediately — not
via a scheduled job. Staff should be able to see the entry on the website within
seconds of publishing. sourceType = "programSession" and
sourceId = session.id allow reliable upsert and deletion.
Calendar mapping: All program sessions push to the single
Calendar that represents their audience (e.g. "Parks Programs", "Library Programs").
Sub-categorisation within that calendar uses the event-level categoryId
field — not separate child calendars. See the Category & Tagging section below.
Board Meetings Board Meeting
The Boards module manages committees, agendas, and meeting schedules. Scheduled
meetings push into the City Meetings calendar using the same push model as
Programs. sourceType = "boardMeeting". The Board module owns the
meeting record; the Calendar is the publication surface. This is a design target —
Boards integration with Calendar is not yet implemented.
City Holidays Holiday
Staff-authored native events on the City Holidays calendar.
sourceType = "native". Future enhancement: when a holiday entry is
saved, affected Facility records are offered an exception-hours update. See the
Holiday → Facility hours future design note above.
Reservations Reservation
A confirmed reservation blocks a FacilitySpace for a time window. It surfaces on the facility's calendar as an entry with a limited citizen-visible payload: space name, date/time, "Reserved" label. No identifying information about the reserving party is shown publicly. Staff-facing views (admin portal, Exchange) carry the full reservation record. See Reservations.
External ICS Feeds ICS Import
External webcal/ICS sources are polled on a schedule and cached as CalendarEvent
rows. sourceType = "icsFeed". MyGov caches the entries — the
external source remains authoritative. Staff cannot edit imported entries; changes
must be made at the source.
Category & Tagging
Decision: event-level tags; Calendar as subscription audience
The two models considered were calendar-as-category (each category
maps to its own Calendar, rollup via parent/child hierarchy) and
event-level tags (one Calendar per subscription audience;
a categoryId on the event for filtering and display).
Event-level tags is the adopted approach. The separation is:
- Calendar = subscription audience. Who would subscribe to this as a feed? "Parks Programs" and "Library Programs" are distinct audiences. "Parks Athletics" is not — it is a filter within Parks Programs.
- CategoryId = content filter within a subscription. Sub-categorisation, display colour/icon, and UI filtering use the event tag, not a separate calendar object.
Why not calendar-per-category
The Microsoft Ecosystem angle is decisive. Pushing category-level distinctions via separate Outlook calendar subscriptions requires a staff member to subscribe to N calendars (one per Parks program category, potentially 50+) to get full visibility. Managing Exchange-backed resource mailbox access across N calendars multiplies the administrative surface unnecessarily. Moving a program to a different category would require migrating its events to a different calendar.
iCal's CATEGORIES field (populated from categoryId label
at export time) provides category metadata to clients that honour it — Apple Calendar
renders it; Outlook ignores it gracefully. We do not try to push shared colour-coding
via ICS because no standard reliable mechanism exists for that. Staff who want to
filter their Outlook subscription by category use Outlook's own search/filter tools.
Implications for CalendarCategory
data.CalendarCategory is a simple lookup table: id, label, colour,
iconSlug. It is not a parent of Calendar — it is a property of CalendarEvent.
Programs' ProgramCategory provides the categoryId value
when pushing sessions; the Calendar layer owns the CalendarCategory
record and the display contract. See Programs & Registration
for how ProgramCategory maps to CalendarCategory.
Duplicate event risk
With event-level tags there is no parent/child calendar rollup — no event duplication risk. One event record; filtered at query/render time. The parent/child calendar hierarchy (see below) applies only to audience-level groupings (Parks Programs may have a parent "All Programs" for a combined city calendar view), not to category-level groupings.
Calendar Hierarchy and Rollup
Calendars support a two-level hierarchy at the audience level only — not at the category level (see Category & Tagging above). A parent aggregates entries from its children at display time; entries are not duplicated in the database. A child belongs to at most one parent.
Example hierarchy:
- All Programs (optional top-level parent)
- Parks Programs
- Library Programs
- Parks Programs — flat; events carry
categoryIdfor sub-filtering (Athletics, Aquatics, Camps, etc.) - Library Programs — flat; events carry
categoryId - City Meetings — flat; receives Board Meeting pushes
- City Holidays — flat; staff-authored; future facility hours linkage
- Auburn Recreation Center (parent, by facility)
- Main Gym (space calendar)
- Meeting Room A (space calendar)
data.Calendar has no
parentCalendarId field. The hierarchy is a design target — the schema
is currently flat (single-level). Parent/child rollup requires adding the FK and
updating the calendar UI component to union child entries at render time.
Sync Approach
The guiding principle: an employee who publishes a record should be able to verify it is live on the website immediately. Async sync to external systems (Outlook, webcal subscribers) is acceptable, but a manual trigger must always be available.
Calendar.exchangeCalendarAddress is set. For resource mailboxes,
the Exchange event is written at booking submission (blocking the room
immediately) regardless of approval state. There is no polling or scheduled
sync for the write direction.
Microsoft 365 and Outlook
Calendar as Exchange broker
The Calendar layer is the single integration point between MyGov and Exchange. Modules (Reservations, Programs) do not call Exchange directly — they push to a Calendar, and the Calendar controller decides whether that also means writing to Exchange. This is the broker model.
Two fields on data.Calendar implement this:
| Field | Type | Notes |
|---|---|---|
| exchangeCalendarAddress | string? | The SMTP address of the Exchange calendar or shared mailbox this Calendar syncs events to/from. Null = no Exchange sync. Used with Calendars.ReadWrite Graph API permissions. Distinct from FacilitySpace.exchangeResourceMailbox — see below. |
| isPublic | bool | Default true. When false, no ICS feed is published and the calendar is not surfaced on the public website. The calendar still exists as an internal availability source. Use for conference rooms and staff-only spaces. |
Note on two Exchange fields: FacilitySpace.exchangeResourceMailbox
and Calendar.exchangeCalendarAddress may reference the same SMTP
address for a reservable conference room, but they represent different Exchange
concepts and different Graph API operations. The space field tracks the room as
an Exchange Place (room finder, meeting invite resource — Place.ReadWrite.All).
The Calendar field tracks it as an Exchange calendar to sync events to
(Calendars.ReadWrite). A public programs calendar like "Parks Programs"
has a Calendar.exchangeCalendarAddress pointing to a shared mailbox
but no corresponding FacilitySpace.exchangeResourceMailbox. See
Microsoft Platform.
Why broker and not direct
Two alternatives were considered and rejected:
- Module-direct Exchange access — Reservations calls Exchange via MSGraphHandler independently of the Calendar layer. Rejected because it creates two parallel time-management tracks for a space: CalendarEvent rows (Programs) and Exchange events (Reservations). Availability queries must union both sources. Adding a new module that needs Exchange would require its own Exchange integration.
- No Calendar for reservations — Exchange-only for reservable spaces; Calendar layer not involved. Rejected because it splits the availability surface: Programs push to Calendar; Reservations go straight to Exchange. A conflict check (can we book this room at 2pm?) requires querying two separate systems and reconciling them. The broker model gives a single authoritative source.
Calendar schema for FacilitySpaces
Each FacilitySpace that participates in time-based activities (Programs, Reservations,
or both) has a corresponding data.Calendar record. The FK is
Calendar.facilitySpaceId (or via the parent Facility for facility-level
calendars). Configuration:
- Conference room (staff-booked via Outlook):
exchangeCalendarAddressset to the room mailbox address;isPublic = false. Staff subscribe in Outlook; no public ICS feed. The room also hasFacilitySpace.exchangeResourceMailboxset (the Place/resource identity). Both fields may reference the same mailbox — they serve different Graph API operations. - Reservable public space (pavilion, study room):
exchangeCalendarAddressnull;isPublic = true. Availability managed entirely within MyGov; public ICS feed shows upcoming bookings with a limited payload ("Reserved"). - Program space (gym, pool):
exchangeCalendarAddressoptionally set if staff also use Outlook to view the schedule;isPublic = true. Program sessions appear on the public calendar and optionally sync to the Exchange calendar.
Outlook calendar subscriptions (webcal / ICS)
When isPublic = true, MyGov publishes the calendar as an ICS feed.
Staff and citizens can subscribe in Outlook or any calendar client. MyGov is the
source of truth; the ICS feed is read-only.
Staff adding events directly to their Outlook subscription does not write
back to MyGov — this workflow should be explicitly discouraged in staff
documentation and onboarding. Changes must be made in the MyGov admin portal.
When isPublic = false, only Outlook access via the Exchange resource
mailbox is available. Staff subscribe to the mailbox as a shared calendar in Outlook
and see events with full detail. No ICS endpoint is exposed.
Coordinator attendee communication
When a Program coordinator needs to email all registrants with updates, the Exchange resource mailbox reply-all is not a reliable primary channel (depends on how invitations were issued). The designed pattern is a "notify attendees" action in the admin portal using eNotifier or a direct MSGraph SendMail — not the mailbox. See Distribution & Publishing.
Audit Findings
Codebase audited: api/Models/organization/Calendar.cs, api/Models/organization/CalendarEvent.cs, api/Models/CMS/ProgramCategory.cs, api/Models/CMS/ProgramSession.cs, api/Controllers/cityPrograms/CalendarController.cs, my/js/modules/events/
| Feature | Status | Notes |
|---|---|---|
| External ICS feed pull + cache | BUILT | Manual trigger; no scheduled refresh job wired yet. Upsert-only — stale entries not deleted. |
| Program session → CalendarEvent push | PARTIAL | Endpoint built and working (POST /programs/{id}/sync-calendar); manual trigger only — on-publish auto-trigger not implemented. |
| Calendar broker fields — exchangeCalendarAddress, isPublic | NOT STARTED | Neither field exists on data.Calendar today. exchangeCalendarAddress is a new field (distinct from FacilitySpace.exchangeResourceMailbox — see Microsoft Platform doc). isPublic migrates from cms.FacilitySpaceContact.isPublic for space calendars. |
| Program session → Exchange blocking (via Calendar broker) | NOT STARTED | Phase 2 of sync-calendar. Depends on Calendar broker fields above. Calendar controller writes Exchange event when Calendar.exchangeCalendarAddress is set. |
| ICS feed export (outbound) | BUILT | Per-facility feed at GET /calendars/facility/{id}/feed.ics with date filter. |
| Option B migration — extension fields | NOT STARTED | Schema is Option A today. Migration adds: sourceType, sourceId, facilitySpaceId, categoryId, imageUrl columns. Update program sync to populate all five on push. |
| Parent/child calendar hierarchy | NOT STARTED | No parentCalendarId on Calendar model. Single-level only. |
| On-publish auto-sync trigger | NOT STARTED | Program publish does not call sync-calendar automatically. |
| Admin UI for refresh / sync trigger | NOT STARTED | Events module has basic CRUD only — no refresh or sync buttons. |
| Boards → City Meetings push | NOT STARTED | Boards module not yet integrated with Calendar layer. Data model for the push (Meeting entity, FacilitySpaceId, agenda visibility) is a Boards module design question — does not affect the Calendar layer. |
| Holiday → Facility exception hours | NOT STARTED | Design target only. Requires typed exception model on FacilityContact.hours. Rollout scope (all facilities, opt-in, by type) and confirmation flow are implementation decisions for the Holidays/Calendar module build. |
| Stale event deletion from ICS pull | NOT STARTED | Refresh endpoint is upsert-only; events removed from source feed are never deleted. |