Scheduling

Plan your worship services and staff them with volunteers.

How do I create a service?

Open Scheduling from the admin sidebar and select New service. Fill in:

  • Title — what the service is called, such as "Sunday Morning Worship" (required).
  • Date — the day the service takes place.
  • Notes — anything the team should know about this service (optional).

Select Create service to save. The new service appears in the service list on the left right away.

Each service in the list shows two small progress meters — Plan and Staffing — that fill in as you build the service's run-of-show and assign volunteers.

How do I edit or remove a service?

The service list has two tabs: Active (the default) and Trash. Open the menu on a service to act on it.

  • Edit opens the same form so you can change the title, date or notes.
  • Move to trash removes the service. A trashed service is hidden from your members and from the calendar. It is not gone — it moves to the Trash tab, where the count next to the tab shows how many trashed services you have.

Because moving to trash is reversible, you can always undo it.

How do I restore or permanently delete a service?

Switch to the Trash tab in the service list. Open the menu on a trashed service:

  • Restore brings it back to the Active tab, visible to members again.
  • Delete permanently removes the service for good, along with its plan and its scheduled positions and assignments. This cannot be undone, so we ask you to confirm. Use it to keep your Trash tidy once you're sure you won't need a service again.

How do I build a service plan?

Select a service from the list to open its plan editor — the order of service, top to bottom.

  • Add item — choose a type (Welcome, Song, Reading, Prayer, Sermon, Communion, Announcement or Custom). The new item appears at the bottom of the plan.
  • Reorder — drag an item by the handle on its left to move it. The new order is saved as you drop it.
  • Rename — edit an item's title directly on its row.
  • Delete — open the menu on an item and choose Delete. Plan items are removed permanently (unlike services, they are not kept in a trash).

Select an item to open the inspector on the right, where you can set:

  • Type — change what kind of item it is.
  • Duration — how many minutes the item runs. The plan's progress meter on the service list fills in as you add items and durations.
  • Song key and BPM — useful for songs.
  • Notes — cues, transitions or anything the team should know.
  • Attachments — upload files such as chord charts or readings.

How do I set up serving teams?

Switch to the Teams view at the top of the Scheduling page and choose New team. Give the team a name (such as Sound, Welcome or Kids), an optional description, and decide whether it requires volunteer screening (turn this on for any team serving with children — see below).

Open a team's menu and choose Manage to:

  • Add members — pick people from your church to put on the team.
  • Approve requests — when a member asks to join from the app, they appear as Interested; choose Approve to make them an active team member.
  • Remove anyone who should no longer be on the team.

Archiving a team from the menu removes it from the rota and hides it from members, but keeps it for later — useful for a team that runs seasonally.

How do I manage archived teams?

The Teams view has two tabs: Active and Archived (the count next to it shows how many teams are archived). Switch to Archived and open a team's menu:

  • Restore brings the team back to Active, ready to staff again.
  • Delete permanently removes the team and its roster for good. This cannot be undone. A team that's still used by service positions or position templates can't be permanently deleted — remove it from those first, and the app will tell you if something is still using it.

How do I staff a service (the rota)?

Switch to the Rota view. It's a month grid — each row is a service, each column is a serving team. Use Previous / Next to move between months.

In an empty cell, choose Assign… and pick an active member of that team. The volunteer is notified automatically that they've been scheduled. To change an assignment, remove it with the on the cell and assign someone else.

What is volunteer screening?

Volunteer screening is the record that someone is cleared to serve — for example a background check, a reference, or child-safety training. Screening is managed on a person's page (see People).

When a serving team has requires screening turned on, the rota's assign list flags anyone who does not have a cleared screening record as Not screened. The assignment is still allowed — the flag is there so you can make an informed decision before placing someone with children.

Schema changes (Scheduling Rework, S-1)

This iteration introduces five data-layer concepts that subsequent slices build on. Admin UI doesn't yet expose them; this section documents what's present in the database so feature work can compose against it.

  • Positions — a service no longer has at most one volunteer per role. Each service now has a list of positions (named slots — "Lead Vocals", "Backing Vocals #1", "Backing Vocals #2"); each position is occupied by zero or one volunteer. "Two vocalists, three sound techs" is now representable. Existing assignments were backfilled into auto-generated positions losslessly. The legacy assignment count is preserved.
  • Position templates — admins will be able to save a service's positions as a reusable template ("Sunday 9am" — drummer, lead vocals, two backing vocals, sound tech, two camera ops). Templates are independent from the services they're applied to — deleting a template doesn't touch past services.
  • Recurrence — events and services now carry an optional rrule (an iCal RRULE string) and a series_id. Recurring services and events are never materialised into per-occurrence rows; occurrences are expanded server-side at query time, respecting the church's timezone. When an occurrence is skipped or rescheduled, a row lands in recurrence_exceptions.
  • Person blockouts — members can mark date ranges they're unavailable to serve. Admins see these as warning chips on the forthcoming assign popover; the warning is overridable.
  • Per-occurrence RSVPs + check-ins — a member RSVPing to one date of a recurring event no longer auto-RSVPs to other dates; the same applies to check-ins. The unique-per-event constraints have been re-keyed (event, occurrence_date, user).

The forthcoming event registration wiring (S-9) also lands in this slice as data-layer plumbing only: events can carry a form_id and a requires_registration flag, and form submissions can record the event_id + occurrence_date they belong to. The form-builder UI and registration flow are deferred to S-9.

Conflict + blockout detection (Scheduling Rework, S-2)

When an admin assigns a volunteer, the server runs three checks before inserting the assignment:

  1. Blockout — does this volunteer have a person_blockouts row that overlaps the service's date?
  2. Service conflict — is this volunteer already on the rota for a different service that overlaps in time on the same day?
  3. Event conflict — does this volunteer have an event_rsvps row (state = going) on an overlapping event?

If any check fires, the assignment is not inserted. The admin sees a warning chip on the assign popover (e.g. "Blocked Aug 1–15", "Conflict: Kids 9am") and an "Assign anyway" button. Clicking re-runs the check with the warnings as acknowledged; the assignment then completes unless a new warning has appeared (e.g. the volunteer added a fresh blockout in the intervening seconds).

Race-safe: the check runs inside the same transaction as the insert, so two admins can't both "Assign anyway" their way past the same conflict.

Recurring services & events

Services and events both support an rrule (an iCal RRULE string — "every Sunday", "first Tuesday of the month", etc.). Recurrence is never materialised into the database: when the admin views the calendar, the data layer expands each parent into its individual occurrences on the fly, respecting the church's timezone. The authoritative implementation is expandRecurrence; the DST smoke test ensures that "Sunday 9am New York" stays at 9am local across the spring-forward boundary.

When an admin needs to edit a single occurrence (move it to a different time, change its title, or skip it entirely), a row lands in recurrence_exceptions keyed by (parent_type, parent_id, exception_date). When the admin chooses "this and all future occurrences", the data layer splits the series: the original parent gets UNTIL=<splitDate - 1> appended to its rrule, and a new parent row is inserted starting at the split point. Both fragments share a series_id so the calendar can surface "you're editing fragment 2 of 3".

Calendar view (Scheduling Rework, S-4)

The default scheduling landing — /c/[slug]/admin/scheduling — is now a month/week/day/agenda calendar. The legacy spreadsheet views still live underneath:

/c/[slug]/admin/scheduling           (calendar — default)
                       /services     (build the run-of-show per service;
                                      position templates live inline at
                                      the bottom of this page)
                       /teams        (serving teams admin)
                       /rota         (the legacy month grid)
                       /templates/:id (edit a single position template;
                                       reached from the Services page)
                       /people/:id   (person-centric calendar)

A top-bar nav inside the scheduling shell switches between Calendar, Services, Teams and Rota. Events are NOT a scheduling tab — they live on their own top-level admin route at /c/[slug]/admin/events. Position templates aren't a tab either; small churches touch them rarely, so they ride inline at the bottom of the Services page.

Slot-click on an empty day cell opens the Add to the calendar sheet. A Service / Event toggle at the top swaps the form in place — no separate picker step. The clicked day (and time, in time-grid views) pre-fills the new occurrence's date and start/end; clicking a day in the month view defaults to a 9:00 AM, one-hour slot you can adjust. Times are shown and saved in the church's configured timezone. The new occurrence appears on the calendar immediately on save.

Chip-click on a service or event navigates to the matching admin sub-route (services for a service, events for an event). S-6 layers in in-place sheets that open over the calendar without losing context.

Drag-to-reschedule opens a confirmation dialog:

  • For one-off occurrences: a single "Reschedule to <newTime>?" prompt.
  • For recurring occurrences: a three-way prompt — "Just this occurrence" (writes a recurrence_exceptions row), "This and all future" (calls splitSeries), or "The whole series" (shifts the parent's start/end). Identical semantics to the recurrence-edit dialog in S-6.

Filter chips:

  • All (default), Services, Events — toggles.
  • Group — combobox over the church's groups; scopes the calendar to group-attached events.
  • Person — combobox over the church's members; pivots the calendar to the member's schedule. When selected, a "View full schedule →" link drops in below the chips and routes to the dedicated person-centric view at /scheduling/people/<userId>.

Chips and Group/Person are mutually exclusive: setting Person clears Group and vice-versa.

Events live at the top-level admin route /c/[slug]/admin/events (their S3 home), not under Scheduling.

Positions (Scheduling Rework, S-5)

Each service has a positions pane that lists every slot on the rota — one row per role per person. The pane renders inline on /c/[slug]/admin/scheduling/services?service=<id> and shows:

  • The current assignee (with a status pill — Pending / Confirmed / Declined) or "— Open" with an [Assign] button.
  • A staffing summary at the top: "3 of 5 filled · 60%".
  • Three toolbar actions:
    • + Add position — opens a small dialog (team picker + role + optional label).
    • Apply template ▾ — appears when the church has at least one position template; drops in the template's items at the end of the position list.
    • Copy last week — calls copyPreviousServicePlan which finds the most recent prior service with the same title and clones its positions + plan items (NOT its assignments).
  • A row-level "⋯" menu with Remove position (soft-delete; hard- deletes any current assignee so the slot vanishes cleanly).

Clicking [Assign] opens the assign popover with three sections:

  1. This week's plan items (collapsible) — song titles, keys, BPM for context.
  2. Searchable team roster — pre-joined blockout / conflict / last-served metadata via the select_assignable_people RPC. Each row shows blockout chips (red) and conflict chips (amber) inline.
  3. Warnings panel — appears only when the assign mutation returns warnings. Lists each warning in plain language and offers an "Assign anyway" button that re-fires the mutation with the warnings acknowledged.

Position templates (Scheduling Rework, S-5)

Templates are reusable sets of positions — "Sunday 9am", "Christmas Eve", "Easter morning". Build them once in the Position templates section at the bottom of /c/[slug]/admin/scheduling/services, then apply them to any service with one click. Editing a template opens its own page at /c/[slug]/admin/scheduling/templates/<id>.

A template is independent of the services it's applied to after application. Each row in the template becomes a new service_positions entry — editing the template later does NOT affect already-applied services; deleting the template does NOT remove the positions it generated.

Service-create flow gains a "Start from" select at the top:

  • Blank (default) — empty service.
  • Template: <picker> — applies the chosen template's items to the new service on save.
  • Copy from previous — runs copyPreviousServicePlan against the new service, cloning positions + plan items from the most recent prior service with the same title.

Copy last week's plan

The copyPreviousServicePlan mutation finds the church's most recent prior service (before the target's date) whose title matches exactly, then clones:

  • Every service_positions row (preserving position_index order).
  • Every plan_items row (run-of-show items: titles, durations, songs, attachments).

Assignments are deliberately NOT cloned — the admin re-picks the volunteers. If no prior service matches, the mutation returns quietly with copiedFromServiceId: null and the admin sees a "No prior service with the same title yet" toast.

Recurring services & events (Scheduling Rework, S-6)

Both the service-create and event-create sheets ship a Recurrence field with four presets:

  • Doesn't repeat (default).
  • Weekly on [Sun] [Mon] … [Sat] — toggle the days; multiple days produce FREQ=WEEKLY;BYDAY=SU,WE.
  • Monthly on the [first ▾] [Sunday ▾] — two pickers; produces FREQ=MONTHLY;BYDAY=1SU (or -1SU for "last", etc.).
  • Custom (RRULE text) — type any iCal RRULE directly.

A "Next 5 occurrences" preview drops in below the picker, always labelled with the church's IANA timezone. The preview is driven by the canonical expandRecurrence helper (S-2) — so what the admin sees is exactly what the calendar will render. A "Sunday 9am America/New_York" recurrence shows 9am for every row across the March DST transition.

Recurring services need a precise start time + end time (added as optional datetime-local inputs on the service sheet). The form flags the missing time when the admin picks a recurrence but leaves the times blank — DST math is meaningless without a clock anchor.

The three-way edit dialog

Clicking a recurring chip on the admin calendar opens a small prompt:

This is a recurring [service / event].
What would you like to edit?
  (•) Just this occurrence
  ( ) This and all future occurrences
  ( ) The whole series
[Cancel]   [Continue]

The three options route to different mutations on save:

  • Just this occurrencesetRecurrenceException writes a row in recurrence_exceptions with the form's values as overrides. Only that single date changes.
  • This and all future occurrencessplitSeries caps the original parent with UNTIL=<date - 1>, inserts a new parent starting at the clicked occurrence with the form's RRULE and values, and links both fragments via series_id.
  • The whole seriesupdateService / updateEvent updates the parent row directly; every occurrence shifts.

Identical semantics + mutations as the drag-to-reschedule dialog (S-4) — the brief locks in these three scopes as the canonical recurrence-edit surface.

Per-occurrence RSVPs

RSVPs on a recurring event are per-occurrence: a member who RSVPs to one date is not auto-RSVPed to the others. The schema enforces this via the per-occurrence unique index from S-1 — event_rsvps is keyed on (event_id, occurrence_date, user_id).

Service reminders (48-hour notification)

Every volunteer gets a push + email reminder 48 hours before they're scheduled to serve. The dispatch is driven by a Postgres function, send_service_reminders(), that the church admin schedules via pg_cron to run hourly. Each assignment is reminded once — the function stamps service_assignments.reminder_sent_at after firing, and filters that column on subsequent runs.

A volunteer who declines an assignment after the reminder window opens still gets the reminder, but the existing notify_on_assignment trigger fires a separate decline notification to all admins of the church.

Subscribe to your schedule (ICS)

Every member can subscribe their personal calendar app (Google Calendar, Apple Calendar, Outlook) to a private ICS feed that always shows:

  • Every serving assignment they're on, expanded per occurrence (a Sunday-9am rota emits one entry per Sunday in the window).
  • Every event RSVP they've marked going, expanded per occurrence for recurring events.

Where to find it

  • Web: Account → "Subscribe to your schedule" card (available on the root portal account page AND the per-church /c/<slug>/account page).
  • Mobile: You tab → "Subscribe to your schedule" row.

How it works

The feed URL takes the shape https://psalmly.app/api/calendar/ics/<token>. The <token> is a per-user opaque 256-bit random string stored on the user_calendar_tokens table (one row per user). The route runs with the service-role key (calendar clients carry no auth cookie), resolves the token to a user_id, then iterates the user's church memberships and emits one VEVENT per occurrence inside a 90-day window (past 30 days + next 60 days).

Rotation

Tapping Regenerate in the UI swaps the token for a fresh value. The old URL immediately stops working — calendar clients holding the prior URL will 404 the next time they refresh. The button doubles as the "someone copied my URL" remediation path.

Refresh cadence

The ICS payload sets X-PUBLISHED-TTL to 1 hour. Most calendar clients respect that as a polling hint, so an admin who un-assigns someone sees the entry disappear within an hour without an explicit refresh.

What's not in scope

  • Two-way calendar sync (CalDAV / Google Calendar API) — out of scope for v1.
  • Per-event ICS feeds — only per-user feeds in v1.