Call Scheduling Domain Model Proposal

Design Philosophy

This model captures the core structures needed for residency call scheduling without prematurely encoding compliance rules, eligibility logic, or fairness tracking. The guiding principles:


Entities

programs

Already exists in your schema. Call scheduling entities are scoped to a program.


sites

A physical location where call happens. Programs may train residents across multiple hospitals, each with different call structures. A site belongs to a single program.

sites
├── id              TEXT PRIMARY KEY
├── program_id      TEXT NOT NULL → programs(id)
├── name            TEXT NOT NULL        -- "University Hospital", "VA Medical Center"
├── code            TEXT NOT NULL        -- "UH", "VA" (short identifier)
├── created_at      TIMESTAMPTZ
└── updated_at      TIMESTAMPTZ

UNIQUE(program_id, name)
UNIQUE(program_id, code)

Why it exists: Different sites have different call structures. University Hospital might have 24/7 in-house psychiatry call while the VA has home call only. Templates are site-scoped, so the same program can have completely different shift patterns at each site.

How it’s used: When an admin creates call shift templates, they select a site. When viewing the call schedule, shifts can be filtered by site.


call_types

A program-defined label describing the format or structure of a call shift. These are the categories that are meaningful within a specific program’s culture and workflow.

call_types
├── id              TEXT PRIMARY KEY
├── program_id      TEXT NOT NULL → programs(id)
├── name            TEXT NOT NULL        -- "Night Float", "Long Call", "Short Call"
├── description     TEXT
├── color           TEXT DEFAULT '#6366f1'
├── created_at      TIMESTAMPTZ
└── updated_at      TIMESTAMPTZ

UNIQUE(program_id, name)

Why it exists: Programs use different call structures and need their own vocabulary. A psychiatry program might have “Short Call” (5pm–10pm) and “Long Call” (Saturday 8am–8pm) while an internal medicine program has “Night Float” and “Jeopardy.” These labels are used for display, filtering, and eventually for equity tracking (“how many Night Float shifts has each resident worked?”).

What it is NOT: This is not where time windows are defined (that’s on the template). This is not where in-house vs. home-call is determined (deferred). Multiple templates can share the same call type — e.g., “Night Float at University Hospital” and “Night Float at VA” are different templates with the same call type.

Example values:

Program Call Types
Psychiatry Short Call, Long Call, Weekend Call
Internal Medicine Night Float, Jeopardy, ICU Night
Surgery In-House Call, Trauma Call, Backup

call_schedules

A versionable workspace representing a scheduling period — the place where an admin defines call structure, generates shifts, and manages assignments. Analogous to your existing rotation_schedules for rotations.

call_schedules
├── id              TEXT PRIMARY KEY
├── program_id      TEXT NOT NULL → programs(id)
├── name            TEXT NOT NULL        -- "2025-2026 Academic Year", "July-Dec 2025"
├── start_date      DATE NOT NULL
├── end_date        DATE NOT NULL
├── created_at      TIMESTAMPTZ
└── updated_at      TIMESTAMPTZ

CHECK(start_date <= end_date)
UNIQUE(program_id, name)

Why it exists: Programs build call schedules for defined periods (academic year, quarter, month). This container groups all the templates, shifts, and assignments for that period. An admin might have a draft schedule for next quarter while the current quarter is still active.

Scope: A call schedule is program-wide, not site-specific. A single schedule can contain templates and shifts across multiple sites. This mirrors how a program director thinks: “I need to build the call schedule for next year” — which includes all sites.

Copy-forward workflow: Call structures tend to be similar year over year, with minor adjustments. Rather than rebuilding from scratch, an admin copies a previous schedule’s templates and relationships into a new schedule, tweaks what changed, and regenerates shifts. Specifically:

This gives the admin a starting point that reflects last year’s patterns while keeping each schedule fully independent. Modifying a copied template doesn’t affect the source schedule, and last year’s schedule is preserved as a historical record.


call_shift_templates

A recurring pattern that describes a type of shift that happens repeatedly. Templates capture the “shape” of a shift — when it starts, when it ends, which days it applies to, and at which site.

call_shift_templates
├── id                  TEXT PRIMARY KEY
├── call_schedule_id    TEXT NOT NULL → call_schedules(id)
├── site_id             TEXT NOT NULL → sites(id)
├── call_type_id        TEXT NOT NULL → call_types(id)
├── name                TEXT NOT NULL        -- "Weeknight Primary", "Weekend Senior"
├── start_time          TIME NOT NULL        -- 18:00
├── end_time            TIME NOT NULL        -- 07:00
├── spans_midnight      BOOLEAN DEFAULT FALSE
├── applicable_days     INT[]                -- ISO weekdays: {1,2,3,4,5} = Mon-Fri
├── is_active           BOOLEAN DEFAULT TRUE
├── created_at          TIMESTAMPTZ
└── updated_at          TIMESTAMPTZ

UNIQUE(call_schedule_id, name)

Why it exists: Without templates, an admin would need to manually create hundreds of individual shifts for a year-long schedule. Templates let them define the pattern once and generate shifts in bulk.

Key fields explained:

Example: Psychiatry program at a single site

Template Name Type Start End Spans Midnight Days
Weeknight Short Call Short Call 17:00 22:00 No Mon–Fri
Weekend Day Call Long Call 08:00 20:00 No Sat, Sun
Weekend Night Call Long Call 20:00 08:00 Yes Sat, Sun

Example: Internal medicine program, multi-site

Template Name Site Type Start End Days
Ward Night Float University Hospital Night Float 19:00 07:00 Mon–Fri
ICU Night University Hospital ICU Night 19:00 07:00 Every day
Ward Night Float VA Night Float 19:00 07:00 Mon–Fri
Weekend Jeopardy University Hospital Jeopardy 07:00 07:00 Sat, Sun

call_shift_template_relationships

Defines structural relationships between two templates. This is how programs encode that one shift role supervises another, that one shift serves as backup for another, or that two shifts work as a pair.

call_shift_template_relationships
├── id                  TEXT PRIMARY KEY
├── template_a_id       TEXT NOT NULL → call_shift_templates(id)
├── template_b_id       TEXT NOT NULL → call_shift_templates(id)
├── relationship_type   TEXT NOT NULL
├── created_at          TIMESTAMPTZ

UNIQUE(template_a_id, template_b_id, relationship_type)
CHECK(template_a_id != template_b_id)

Why it exists: Many call structures involve multiple residents working together with defined roles. Rather than encoding these roles into the shift itself (which is inflexible), relationships between shifts let programs define whatever structure they need.

Relationship semantics: The relationship is directional — “A [relationship_type] B” — with a convention that A is the actor:

relationship_type Meaning Example
supervises A oversees B Senior resident supervises intern on call
backs_up A is backup for B Jeopardy resident backs up night float
relieves A takes over from B Night float relieves day team
pairs_with A and B work together, no hierarchy Two interns splitting weekend coverage

Example: Psychiatry tandem call

The psychiatry program has PGY-2+ seniors supervising PGY-1 interns during weekend call:

Template: "Weekend Primary" (PGY-1 level)
Template: "Weekend Senior" (PGY-2+ level)
Relationship: Weekend Senior → supervises → Weekend Primary

Example: Internal medicine jeopardy

Template: "Ward Night Float"
Template: "Weekend Jeopardy"
Relationship: Weekend Jeopardy → backs_up → Ward Night Float

When instances are generated, these template relationships are propagated to the corresponding shift instances via call_shift_relationships.


call_shifts

A concrete shift on a specific date and time that needs one resident assigned. Generated from templates or created manually for one-off situations.

call_shifts
├── id                  TEXT PRIMARY KEY
├── call_schedule_id    TEXT NOT NULL → call_schedules(id)
├── template_id         TEXT → call_shift_templates(id)    -- nullable
├── site_id             TEXT NOT NULL → sites(id)
├── call_type_id        TEXT NOT NULL → call_types(id)
├── starts_at           TIMESTAMPTZ NOT NULL
├── ends_at             TIMESTAMPTZ NOT NULL
├── notes               TEXT
├── created_at          TIMESTAMPTZ
└── updated_at          TIMESTAMPTZ

CHECK(starts_at < ends_at)
UNIQUE(template_id, starts_at)  -- prevents duplicate generation

Why it exists: This is the core operational entity — the thing that gets assigned to a resident and shows up on the calendar.

Template-generated vs. manual:

Why site_id and call_type_id are denormalized from the template: Shifts need to be queryable by site and type without joining through templates. A manually-created shift (no template) still needs these fields. And if a template is deactivated or modified, existing shifts should retain their original values.

Time representation: starts_at and ends_at are stored as TIMESTAMPTZ. The application converts from the program’s timezone (stored on programs.timezone) when generating from templates. This handles DST transitions correctly and allows cross-timezone queries.


call_shift_relationships

Instance-level relationships between specific shifts on specific dates. Typically auto-generated from template relationships when shifts are created, but can also be created manually.

call_shift_relationships
├── id                              TEXT PRIMARY KEY
├── shift_a_id                      TEXT NOT NULL → call_shifts(id)
├── shift_b_id                      TEXT NOT NULL → call_shifts(id)
├── relationship_type               TEXT NOT NULL
├── source_template_relationship_id TEXT → call_shift_template_relationships(id)  -- nullable
├── created_at                      TIMESTAMPTZ

UNIQUE(shift_a_id, shift_b_id, relationship_type)
CHECK(shift_a_id != shift_b_id)

Why it exists separately from template relationships: Template relationships say “these patterns are related.” Instance relationships say “these specific shifts on January 15th are related.” This is needed because:

  1. Not every template-generated shift will have a corresponding partner (one might be cancelled).
  2. Manual shifts can participate in relationships too.
  3. The relationship is the thing that connects “Dr. Smith is supervising Dr. Jones tonight” — you need the concrete instances.

source_template_relationship_id: Traces back to the template relationship that caused this instance relationship to be created. NULL for manually-created relationships. Set to NULL (via ON DELETE SET NULL) if the source template relationship is deleted — the instance relationship survives independently.


call_assignments

The assignment of one resident to one shift.

call_assignments
├── id              TEXT PRIMARY KEY
├── call_shift_id   TEXT NOT NULL → call_shifts(id)
├── resident_id     TEXT NOT NULL → residents(id)
├── assigned_at     TIMESTAMPTZ DEFAULT NOW()
├── created_at      TIMESTAMPTZ
└── updated_at      TIMESTAMPTZ

UNIQUE(call_shift_id)  -- one person per shift

Why it exists as a separate table (not a column on call_shifts): Separating assignments from shifts makes it easy to:

The UNIQUE(call_shift_id) constraint enforces the one-shift-one-person invariant at the database level.


Deferred Concepts

These were discussed but intentionally excluded from v1. The schema is designed so they can be added without restructuring existing tables.

Concept What it would add How to add later
Call services Service-level granularity (ICU vs. Wards) for eligibility Add call_services table, optional FK on templates and shifts
PGY eligibility pgy_min/pgy_max on templates and shifts Add columns to call_shift_templates and call_shifts
ACGME compliance Duty hour tracking, violation detection Add compliance rule tables, duty hour logs
Fairness tracking Equity ledgers, weekend/holiday counts Add equity tracking tables, reporting views
Call model (in-house vs. home) Affects ACGME hour calculations Add model enum to call_types
Shift status open/filled/cancelled lifecycle Add status column to call_shifts (or derive from assignments)
Required/optional shifts Whether a shift must be filled Add is_required to templates and shifts
Assigned-by audit Who made the assignment Add assigned_by FK to call_assignments
Holidays Holiday flagging, equity weighting Add holidays table, is_holiday/holiday_id on shifts
Unavailability Vacation, leave, conference blocking Add unavailability table, check during assignment

Generation Flow

The expected workflow for building a call schedule:

1. Admin creates a call_schedule for a date range
2. Admin creates call_shift_templates defining recurring patterns
3. Admin creates call_shift_template_relationships between templates
4. System generates call_shifts from templates for each applicable date
5. System generates call_shift_relationships from template relationships
6. Admin (or solver) creates call_assignments for each shift

Generation logic (step 4):

For each template where applicable_days is not null: - Iterate through each date in the call_schedule’s date range - If the date’s ISO weekday is in applicable_days - Compute starts_at = date + template.start_time (in program timezone), converted to UTC - Compute ends_at = date + template.end_time (+ 1 day if spans_midnight), converted to UTC - Insert a call_shift linked to this template

Relationship propagation (step 5):

For each template relationship (A relates-to B): - Find pairs of generated shifts where shift_a.template_id = A and shift_b.template_id = B - Match by date (or overlapping time window) - Insert a call_shift_relationship with the same type, referencing the template relationship as source