This model captures the core structures needed for residency call scheduling without prematurely encoding compliance rules, eligibility logic, or fairness tracking. The guiding principles:
Already exists in your schema. Call scheduling entities are scoped to a program.
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.
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 |
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:
call_shift_templates and
call_shift_template_relationships (the structure)call_shifts and
call_assignments (the instances — these are generated
fresh for the new date range)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.
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:
start_time / end_time /
spans_midnight: Defines the time window in the
program’s local timezone (from programs.timezone). A
night float shift might be
start_time=19:00, end_time=07:00, spans_midnight=true. A
short call might be
start_time=17:00, end_time=22:00, spans_midnight=false.
applicable_days: ISO weekday
numbers (1=Monday through 7=Sunday). Controls which days of the week
this template generates shifts for. {1,2,3,4} means
Monday through Thursday. {6,7} means weekends.
NULL means the template exists but shifts are created
manually (useful for holidays or irregular patterns).
is_active: Soft toggle.
Deactivating a template stops future generation but doesn’t remove
existing shifts.
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 |
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.
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:
Template-generated: template_id
is set. Fields like site_id, call_type_id,
starts_at, ends_at are computed from the
template + target date. The
UNIQUE(template_id, starts_at) constraint prevents
generating the same shift twice.
Manual (one-off): template_id is
NULL. The admin creates a shift directly for an unusual situation —
holiday coverage, emergency staffing, a shift that doesn’t fit any
template pattern. All fields are set explicitly.
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.
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:
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.
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:
LEFT JOIN call_assignments WHERE assignment IS NULL)The UNIQUE(call_shift_id) constraint
enforces the one-shift-one-person invariant at the database level.
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 |
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