# Scheduling recipes

[Wiki](../README.md) / [Graph data science](README.md) / [Native Scheduler](scheduling.md)

**On this page**

- [Home care visits](#home-care-visits)
- [Guaranteed hours and fair rotas](#guaranteed-hours-and-fair-rotas)
- [Dance competition](#dance-competition)
- [Hospital theatre list](#hospital-theatre-list)
- [Exam timetable](#exam-timetable)
- [Sports fixtures](#sports-fixtures)
- [Conference programme](#conference-programme)
- [Clinic appointments](#clinic-appointments)
- [Factory floor](#factory-floor)
- [Field service with overtime](#field-service-with-overtime)
- [Re-planning after a change](#re-planning-after-a-change)

Each recipe creates its own small dataset, solves it with
[`gdb.schedule.solve`](scheduling.md) and shows what to do with the result. Run each
one in an empty database, one statement at a time. Times are minutes from midnight
unless a recipe uses `datetime` values. The names are invented.

## Home care visits

Visits have a preferred time and a place, some need two people, and regular clients
prefer people who have visited them before. Nobody works more than three hours without
a 30-minute break.

```cypher
CREATE (ann:Staff {name:'Ann', start:420, end:900, location:point({x:0, y:0})}),
       (raj:Staff {name:'Raj', start:420, end:900, location:point({x:4000, y:0})}),
       (mei:Staff {name:'Mei', start:600, end:1080, location:point({x:0, y:4000})}),
       (c1:Client {name:'Client 1'}), (c2:Client {name:'Client 2'}),
       (ann)-[:VISITED]->(:Visit {name:'old visit'})-[:FOR]->(c1),
       (:Visit {name:'wash', target:480, duration:30, location:point({x:500, y:800})})-[:FOR]->(c1),
       (:Visit {name:'lunch', target:720, duration:30, location:point({x:500, y:800})})-[:FOR]->(c1),
       (:Visit {name:'hoist', target:540, duration:45, people:2, location:point({x:3000, y:1000})})-[:FOR]->(c2),
       (:Visit {name:'meds', target:600, duration:15, location:point({x:2000, y:3000})})-[:FOR]->(c2),
       (:Visit {name:'tea', target:1020, duration:30, location:point({x:1000, y:2500})})-[:FOR]->(c2);
```

```cypher
MATCH (v:Visit) WHERE v.duration IS NOT NULL WITH collect(v) AS visits
MATCH (s:Staff) WITH visits, collect(s) AS staff
CALL gdb.schedule.solve({
  tasks: visits, resources: staff, randomSeed: 1,
  task: {required: 'coalesce(t.people, 1)'},
  pair: {affinity: 'size([(r)-[:VISITED]->(:Visit)-[:FOR]->(c)<-[:FOR]-(t) | 1])'},
  constraints: [{type: 'maxContinuous', amount: 180, breakLength: 30}]
})
YIELD task, resources, start, end
UNWIND resources AS person
CREATE (person)-[:BOOKED {start: start, end: end}]->(task)
RETURN task.name, person.name, start ORDER BY start;
```

`wash` and `lunch` go to Ann because she has visited Client 1 before; `hoist` is
booked on two people at one start.

## Guaranteed hours and fair rotas

Each person is guaranteed at least four hours, nobody works more than eight in a
day, and long idle gaps cost something. The guarantee is a soft rule with a penalty,
so the plan comes as close as the work allows.

```cypher
UNWIND ['Ola', 'Pat', 'Rui'] AS name
CREATE (:Staff {name: name, start: 360, end: 1320});
```

```cypher
UNWIND range(0, 11) AS i
CREATE (:Shift {name: 'shift ' + i, duration: 120, target: 360 + (i % 6) * 150});
```

```cypher
MATCH (s:Shift) WITH collect(s) AS shifts
MATCH (p:Staff) WITH shifts, collect(p) AS staff
CALL gdb.schedule.solve.stats({
  tasks: shifts, resources: staff, randomSeed: 1,
  constraints: [
    {type: 'minWork', amount: 240, penalty: 10},
    {type: 'maxWork', amount: 480, period: 1440},
    {type: 'idle', penalty: 0.1}
  ],
  objective: ['unassigned', 'penalty', 'workload', 'drift']
})
YIELD tasksAssigned, objective
RETURN tasksAssigned, objective.penalty, objective.workload;
```

## Dance competition

Two stages with two sessions each. Dancers are participants, so the data decides who
is in each dance, and every dancer needs 15 minutes between dances. A stage needs 5
minutes to change style, the heat ends an hour before the final, and nobody dances
more than three times in a session.

```cypher
CREATE (main:Stage {name:'Main stage', windows:[[540, 720], [780, 1020]]}),
       (studio:Stage {name:'Studio', windows:[[540, 720], [780, 1020]]}),
       (ava:Dancer {name:'Ava'}), (ben:Dancer {name:'Ben'}), (cleo:Dancer {name:'Cleo'}),
       (heat:Dance {name:'Latin heat', minutes:15, style:'latin'}),
       (final:Dance {name:'Latin final', minutes:15, style:'latin'}),
       (waltz:Dance {name:'Waltz', minutes:10, style:'ballroom'}),
       (trio:Dance {name:'Trio', minutes:20, style:'street'}),
       (ava)-[:DANCES_IN]->(heat), (ben)-[:DANCES_IN]->(heat),
       (ava)-[:DANCES_IN]->(final), (ben)-[:DANCES_IN]->(final),
       (ava)-[:DANCES_IN]->(waltz), (cleo)-[:DANCES_IN]->(waltz),
       (ava)-[:DANCES_IN]->(trio), (ben)-[:DANCES_IN]->(trio), (cleo)-[:DANCES_IN]->(trio);
```

```cypher
MATCH (d:Dance) WITH collect(d) AS dances
MATCH (s:Stage) WITH dances, collect(s) AS stages
MATCH (heat:Dance {name:'Latin heat'}), (final:Dance {name:'Latin final'})
CALL gdb.schedule.solve({
  tasks: dances, resources: stages, randomSeed: 1,
  task: {duration: 't.minutes', kind: 't.style',
         participants: '[(p:Dancer)-[:DANCES_IN]->(t) | p]'},
  participant: {changeover: 15},
  setups: [{from: '*', to: '*', time: 5}],
  constraints: [
    {type: 'precedence', tasks: [heat, final], minGap: 60},
    {type: 'maxTasks', per: 'participant', count: 3, period: 240}
  ]
})
YIELD task, resource, start, end
RETURN task.name, resource.name, start, end ORDER BY start;
```

## Hospital theatre list

Every operation needs a surgeon of the right speciality, an anaesthetist and a
theatre at once. Theatres need 20 minutes of cleaning between cases. The emergency is
fixed in place and has the highest priority.

```cypher
CREATE (:Staff:Surgeon {name:'Dr Hale', speciality:'ortho', start:480, end:1080}),
       (:Staff:Surgeon {name:'Dr Ito', speciality:'general', start:480, end:1080}),
       (:Staff:Anaesthetist {name:'Dr Kerr', start:480, end:1080}),
       (:Staff:Theatre {name:'Theatre 1', start:480, end:1080, changeover:20}),
       (:Staff:Theatre {name:'Theatre 2', start:480, end:1080, changeover:20}),
       (:Op {name:'hip', speciality:'ortho', duration:90, target:480}),
       (:Op {name:'knee', speciality:'ortho', duration:60, target:480}),
       (:Op {name:'hernia', speciality:'general', duration:45, target:480}),
       (:Op {name:'appendix', speciality:'general', duration:60, fixedStart:780, priority:10});
```

```cypher
MATCH (o:Op) WITH collect(o) AS ops
MATCH (s:Staff) WITH ops, collect(s) AS staff
CALL gdb.schedule.solve({
  tasks: ops, resources: staff, randomSeed: 1,
  needs: [
    {count: 1, where: 'r:Surgeon AND r.speciality = t.speciality'},
    {count: 1, where: 'r:Anaesthetist'},
    {count: 1, where: 'r:Theatre'}
  ]
})
YIELD task, resources, start, end
RETURN task.name, [r IN resources | r.name] AS team, start, end ORDER BY start;
```

One anaesthetist means no two operations overlap, whichever theatre they use.

## Exam timetable

Exams use as many seats as they have students, so a hall holds several at once. No
cohort sits two exams at the same time, cohorts get 30 minutes between exams and at
most two a day, and starts snap to the half hour. The days are `datetime` values.

```cypher
CREATE (:Hall {name:'Great Hall', seats:120,
               windows:[{start: datetime('2026-06-01T09:00:00Z'), end: datetime('2026-06-01T17:00:00Z')},
                        {start: datetime('2026-06-02T09:00:00Z'), end: datetime('2026-06-02T17:00:00Z')}]}),
       (a:Cohort {name:'History'}), (b:Cohort {name:'Maths'}), (c:Cohort {name:'Physics'}),
       (e1:Exam {name:'Paper 1', students:80, duration: duration({hours: 2})}),
       (e2:Exam {name:'Paper 2', students:40, duration: duration({hours: 2})}),
       (e3:Exam {name:'Paper 3', students:60, duration: duration({minutes: 90})}),
       (e4:Exam {name:'Paper 4', students:60, duration: duration({hours: 3})}),
       (a)-[:SITS]->(e1), (b)-[:SITS]->(e2), (b)-[:SITS]->(e3), (c)-[:SITS]->(e3),
       (a)-[:SITS]->(e4), (c)-[:SITS]->(e4), (b)-[:SITS]->(e4);
```

```cypher
MATCH (e:Exam) WITH collect(e) AS exams
MATCH (h:Hall) WITH exams, collect(h) AS halls
CALL gdb.schedule.solve({
  tasks: exams, resources: halls, randomSeed: 1,
  task: {demand: 't.students', participants: '[(c:Cohort)-[:SITS]->(t) | c]'},
  resource: {units: 'r.seats'},
  participant: {changeover: duration({minutes: 30})},
  timeStep: duration({minutes: 30}),
  constraints: [{type: 'maxTasks', per: 'participant', count: 2, period: duration({days: 1})}]
})
YIELD task, start, end
RETURN task.name, toString(start), toString(end) ORDER BY start;
```

## Sports fixtures

Teams are participants that need three days' rest between matches. Pitches are
resources, each team plays at most once per round of a week, and every fixture aims
for the start of the season, so the rounds come as early as the rules allow.

```cypher
UNWIND ['Lions', 'Owls', 'Foxes', 'Hares'] AS name CREATE (:Team {name: name});
```

```cypher
CREATE (:Pitch {name:'North pitch'}), (:Pitch {name:'South pitch'});
```

```cypher
MATCH (a:Team), (b:Team) WHERE a.name < b.name
CREATE (m:Fixture {name: a.name + ' v ' + b.name, duration: duration({hours: 2})}),
       (a)-[:PLAYS]->(m), (b)-[:PLAYS]->(m);
```

```cypher
MATCH (m:Fixture) WITH collect(m) AS matches
MATCH (p:Pitch) WITH matches, collect(p) AS pitches
CALL gdb.schedule.solve({
  tasks: matches, resources: pitches, randomSeed: 1,
  horizon: {start: datetime('2026-09-05T00:00:00Z'), end: datetime('2026-10-03T00:00:00Z')},
  task: {participants: '[(team:Team)-[:PLAYS]->(t) | team]',
         target: datetime('2026-09-05T00:00:00Z')},
  participant: {changeover: duration({days: 3})},
  timeStep: duration({days: 1}),
  constraints: [{type: 'maxTasks', per: 'participant', count: 1, period: duration({days: 7})}]
})
YIELD task, resource, start
RETURN task.name, resource.name, toString(start) ORDER BY start;
```

## Conference programme

Talks go in rooms, speakers are never in two places at once, and the keynote needs
every room so nothing runs alongside it.

```cypher
CREATE (r1:Room {name:'Room A', start:540, end:1020}),
       (r2:Room {name:'Room B', start:540, end:1020}),
       (ana:Speaker {name:'Ana'}), (bo:Speaker {name:'Bo'}),
       (k:Talk {name:'Keynote', duration:60, target:540, rooms:2}),
       (t1:Talk {name:'Graphs at scale', duration:45}),
       (t2:Talk {name:'Rotas from Cypher', duration:45}),
       (t3:Talk {name:'Ask me anything', duration:30}),
       (ana)-[:GIVES]->(k), (ana)-[:GIVES]->(t1), (bo)-[:GIVES]->(t2), (ana)-[:GIVES]->(t3), (bo)-[:GIVES]->(t3);
```

```cypher
MATCH (t:Talk) WITH collect(t) AS talks
MATCH (r:Room) WITH talks, collect(r) AS rooms
CALL gdb.schedule.solve({
  tasks: talks, resources: rooms, randomSeed: 1,
  task: {required: 'coalesce(t.rooms, 1)', participants: '[(s:Speaker)-[:GIVES]->(t) | s]',
         earliest: 't.target'}
})
YIELD task, resources, start
RETURN task.name, size(resources) AS rooms, start ORDER BY start;
```

## Clinic appointments

The clinic has two bays, so at most two appointments run at once whichever clinician
takes them. Appointments that need the scanner go to clinicians trained on it, and the
follow-up comes 60 to 120 minutes after the first appointment.

```cypher
CREATE (bays:Clinic {name:'Clinic', units:2}),
       (scan:Skill {name:'scanner'}),
       (:Clinician {name:'Nurse Ola', start:480, end:720})-[:HAS]->(scan),
       (:Clinician {name:'Nurse Pim', start:480, end:720}),
       (:Clinician {name:'Dr Quist', start:480, end:720})-[:HAS]->(scan),
       (a:Appointment {name:'scan', duration:30, target:480})-[:NEEDS]->(scan),
       (b:Appointment {name:'review', duration:20, target:480}),
       (c:Appointment {name:'bloods', duration:15, target:480}),
       (f:Appointment {name:'follow-up', duration:20}),
       (a)-[:AT]->(bays), (b)-[:AT]->(bays), (c)-[:AT]->(bays), (f)-[:AT]->(bays);
```

```cypher
MATCH (a:Appointment) WITH collect(a) AS appts
MATCH (c:Clinician) WITH appts, collect(c) AS clinicians
MATCH (first:Appointment {name:'scan'}), (next:Appointment {name:'follow-up'})
CALL gdb.schedule.solve({
  tasks: appts, resources: clinicians, randomSeed: 1,
  task: {place: '[(t)-[:AT]->(c) | c][0]'},
  participant: {units: 'p.units'},
  pair: {eligible: 'all(s IN [(t)-[:NEEDS]->(s) | s] WHERE EXISTS { (r)-[:HAS]->(s) })'},
  constraints: [{type: 'precedence', tasks: [first, next], minGap: 60, maxGap: 120}]
})
YIELD task, resource, start
RETURN task.name, resource.name, start ORDER BY start;
```

## Factory floor

Jobs run on lines with sequence-dependent setup times: dark to light paint takes
longest. Each job has a due time, and lateness costs more the later it is, while
finishing early costs almost nothing.

```cypher
CREATE (:Line {name:'Line 1', start:360, end:1320}), (:Line {name:'Line 2', start:360, end:1320});
```

```cypher
UNWIND range(0, 9) AS i
CREATE (:Job {name: 'job ' + i, minutes: 45 + (i % 3) * 15, due: 480 + i * 60,
              colour: ['white', 'grey', 'black'][i % 3]});
```

```cypher
MATCH (j:Job) WITH collect(j) AS jobs
MATCH (l:Line) WITH jobs, collect(l) AS lines
CALL gdb.schedule.solve({
  tasks: jobs, resources: lines, randomSeed: 1,
  task: {duration: 't.minutes', target: 't.due - t.minutes', kind: 't.colour', earlyWeight: 0.02},
  lateCurve: {points: [[10, 2.5], [20, 15], [60, 100]], smooth: true},
  setups: [{from: 'white', to: 'grey', time: 20}, {from: 'grey', to: 'black', time: 15},
           {from: 'black', to: 'grey', time: 40}, {from: 'black', to: 'white', time: 60},
           {from: 'grey', to: 'white', time: 40}, {from: 'white', to: 'black', time: 25}]
})
YIELD task, resource, start, drift
RETURN task.name, resource.name, start, drift ORDER BY resource.name, start;
```

## Field service with overtime

Engineers hold certifications, and a senior certification covers the junior one.
Evening hours cost more, overtime past six hours is allowed at a rising price, and
jobs are reached by travel between their locations.

```cypher
CREATE (senior:Cert {name:'gas senior'})-[:IMPLIES]->(junior:Cert {name:'gas'}),
       (e1:Engineer {name:'Tia', location:point({x:0, y:0}),
                     windows:[{start:480, end:1020}, {start:1020, end:1200, costRate:1.5}]})-[:HAS]->(senior),
       (e2:Engineer {name:'Uri', location:point({x:8000, y:0}),
                     windows:[{start:480, end:1020}, {start:1020, end:1200, costRate:1.5}]})-[:HAS]->(junior),
       (:Job {name:'boiler service', duration:60, target:540, location:point({x:1000, y:500})})-[:REQUIRES]->(junior),
       (:Job {name:'gas leak', duration:90, target:600, priority:5, location:point({x:7000, y:800})})-[:REQUIRES]->(senior),
       (:Job {name:'meter swap', duration:45, target:840, location:point({x:4000, y:4000})})-[:REQUIRES]->(junior);
```

```cypher
MATCH (j:Job) WITH collect(j) AS jobs
MATCH (e:Engineer) WITH jobs, collect(e) AS engineers
CALL gdb.schedule.solve({
  tasks: jobs, resources: engineers, randomSeed: 1,
  pair: {eligible: 'all(c IN [(t)-[:REQUIRES]->(c) | c] WHERE EXISTS { (r)-[:HAS]->(:Cert)-[:IMPLIES*0..]->(c) })'},
  constraints: [{type: 'maxWork', amount: 360, curve: {slope: 2, after: 0}}],
  objective: ['unassigned', 'penalty', 'cost', 'drift', 'travel']
})
YIELD task, resource, start
RETURN task.name, resource.name, start ORDER BY start;
```

The gas leak can only go to Tia, whose senior certification implies the junior one.

## Re-planning after a change

Bookings already exist as relationships. When someone is unavailable, the baseline
keeps every booking that still works, so only the affected tasks move.

```cypher
CREATE (a:Staff {name:'Vic', start:480, end:1020}), (b:Staff {name:'Wen', start:480, end:1020, sick:true}),
       (c:Staff {name:'Xia', start:480, end:1020}),
       (a)-[:BOOKED {start:540}]->(:Task {name:'one', duration:60, target:540}),
       (b)-[:BOOKED {start:600}]->(:Task {name:'two', duration:60, target:600}),
       (c)-[:BOOKED {start:660}]->(:Task {name:'three', duration:60, target:660});
```

```cypher
MATCH (t:Task) WITH collect(t) AS tasks
MATCH (s:Staff) WITH tasks, collect(s) AS staff
CALL gdb.schedule.solve({
  tasks: tasks, resources: staff, randomSeed: 1,
  task: {baselineStart: '[(r)-[b:BOOKED]->(t) | b.start][0]',
         baselineResources: '[(r)-[:BOOKED]->(t) | r]'},
  resource: {windows: 'CASE WHEN r.sick THEN [] ELSE [{start: r.start, end: r.end}] END'}
})
YIELD task, resource, start
RETURN task.name, resource.name, start ORDER BY start;
```

Only `two` moves; `one` and `three` keep their people and times.

## Related articles

[Native Scheduler](scheduling.md) · [Graph data science](README.md)
