Scheduling

Fair Rota Scheduler.

Every publishing day needs a writer and a reviewer. This assigns them fairly, respects availability, explains every decision, and shows what happens when someone misses a deadline.

  • Daily devotion
  • Music on Wednesdays
  • Real story on Saturdays
  • 28-day window
Today—
Reserve pieces—

Team

Changing the team restarts the simulation.

Workload

L = A / K + 0.5 × M over the 28-day window. Lowest gets the next slot.

What happened

    Simulation: a draft is treated as submitted and approved on its deadline unless you mark the writer as missing it. Logins, the full review workflow and notifications belong to the production version.

    Documentation

    How I solved it, step by step.

    Designed for CP Devotional's publishing team, then built and proven here first. Each step shows the question, the decision and the code.

    1. 01

      Turn the calendar into slots

      Question: what exactly needs an owner?

      Not "days" but slots: one piece of content due on one date. A daily devotion makes 28 slots in four weeks; music and stories add one a week each.

      Decision: describe each content type by the weekday it runs on, and generate slots oldest first, so earlier days are filled before later ones.

      CADENCE = {'devotion': None, 'music': 2, 'story': 5}  # Mon = 0
      
      for offset in range(self.window):            # 28 days
          day = self.today + timedelta(days=offset)
          for ctype, weekday in CADENCE.items():
              if weekday is None or day.weekday() == weekday:
                  ...  # create the slot, then assign it
    2. 02

      Rule people out before ranking them

      Question: who can take this slot at all?

      Fairness only matters among people who are actually free. Five hard rules come first: not away, not already writing that day, not over three slots that week, not back-to-back, and not someone who already missed this slot.

      Decision: every rule returns a reason, so the page can say exactly why someone was skipped.

      def eligibility(self, pid, slot, strict=True):
          if pid in slot.missed_by:
              return False, 'already missed this slot'
          if slot.day in person.unavailable:
              return False, 'unavailable that day'
          ...
          if strict and any(abs((s.day - slot.day).days) == 1
                            for s in mine):
              return False, 'back-to-back with another slot'
          return True, None
    3. 03

      Measure fairness relative to capacity

      Question: is giving everyone the same number of slots fair?

      No. Someone with time for ten pieces a month isn't overworked by four, but someone with time for five is nearly full at four. So load is slots held divided by capacity, plus a penalty for missed deadlines so reliability counts.

      Decision: L = A/K + 0.5 × M; the lowest wins.

      def load(self, pid):
          """L = A / K + 0.5 * M"""
          person = self.writers[pid]
          return self.held(pid) / person.capacity + 0.5 * person.missed
      
      # Ada: 4 slots of 8  -> 0.50
      # Ben: 3 slots of 4  -> 0.75   => Ada gets the slot
    4. 04

      Make ties predictable

      Question: two people have the same load. Who goes?

      Random choice would make the rota impossible to test and hard to trust. The person who has gone longest without a slot goes first, and team order settles anything left.

      Decision: sort by (load, latest slot, team order). The same inputs always give the same rota.

      candidates.sort(key=lambda c: (
          c['load'],              # fairest first
          c['last'],              # longest since their last slot
          self.order.index(c['id']),  # then team order
      ))
    5. 05

      Bend one rule rather than leave a gap

      Question: what if nobody passes every rule?

      An empty publishing day is worse than one person writing two days in a row. So the search runs twice: strict first, then with only the back-to-back rule relaxed. The other rules never bend.

      for strict in (True, False):
          candidates = [...]          # eligible under this pass
          if candidates:
              return {'winner': ..., 'relaxed': not strict, ...}
    6. 06

      Pick a reviewer who isn't the writer

      Question: who checks the work?

      The reviewer with the fewest unpublished reviews, and never the person who wrote it. Nobody approves their own work.

      options = [rid for rid in self.reviewers if rid != slot.writer]
      return min(options, key=lambda rid: (open_reviews(rid), ...))
    7. 07

      Plan for missed deadlines

      Question: a writer misses the draft deadline. Now what?

      • 3 or more days left: reassign to the next fairest writer, with 24 hours to draft.
      • Less time: use a ready-made reserve piece.
      • Reserve empty: mark it missed so the reviewer steps in.

      Decision: the missed writer's M goes up, so their load rises and they're never offered that slot again.

      writer.missed += 1
      slot.missed_by.append(writer.id)
      if (slot.day - self.today).days >= 3:
          ...  # reassign, draft due tomorrow
      else:
          self._use_reserve_or_miss(slot, reason)
    8. 08

      Keep the server stateless by replaying

      Question: how does "advance a day" work without storing anything?

      Because the rota is deterministic, the browser only keeps the setup and a list of actions. Each click sends them, and the server rebuilds the whole rota from scratch. Same inputs, same result, every time.

      def replay(writers, reviewers, start, actions, reserve=5):
          rota = Rota(writers, reviewers, start, reserve).generate()
          for action in actions:
              if action['type'] == 'advance':
                  rota.advance()
              elif action['type'] == 'miss':
                  rota.mark_will_miss(action['slot'])
          return rota
    9. 09

      Prove it with the design's own cases

      Question: how do I know it behaves as designed?

      The tests use the examples written into the design before any code existed, plus invariants checked over a whole generated month: everyone gets a writer, nobody writes twice in a day, the weekly limit holds and nobody reviews their own work.

      def test_lowest_load_wins(self):
          # Ada 4/8 -> 0.5, Ben 3/4 -> 0.75
          self.assertEqual(rota.load('ada'), 0.5)
          self.assertEqual(rota.load('ben'), 0.75)
          self.assertEqual(rota.pick_writer(slot)['winner'], 'ada')
    Full source: rota.pyShow
    """Fair Rota Scheduler: who writes and who reviews each day's content.
    
    Ported from the CP Devotional role-scheduler prototype (OzoneFL/dailydevotional,
    cp-scheduler-handoff/docs/scheduler). Pure Python, no database: a `Rota` holds the
    team, the slots and an injectable clock, so a simulation (and the tests) can
    advance one day at a time.
    
    Rules
    -----
    A *slot* is one piece of content due on one date: (day, content type).
    
    A writer is eligible for a slot only if they
      * have not already missed this slot,
      * are not unavailable that day,
      * have no other slot that day,
      * have fewer than `max_per_week` slots that ISO week,
      * have no slot the day before or after (no back-to-back days).
    If nobody qualifies, the back-to-back rule is relaxed and the search repeats.
    
    Among eligible writers the lowest load score wins:
    
        L = A / K + 0.5 * M
    
    A = slots held in the 28-day planning window (reserve slots excluded),
    K = capacity (slots per 28 days), M = missed deadlines.
    Ties go to the writer whose latest slot is earliest, then to team order.
    
    The reviewer is the one with the fewest unpublished reviews, never the writer.
    
    When a draft deadline passes without a draft, the writer's M goes up and
      * with 3 or more days left, the slot is reassigned with 24 hours to draft,
      * otherwise a reserve piece is used, or, if the reserve is empty, the slot
        is marked missed.
    """
    from dataclasses import dataclass, field
    from datetime import date, timedelta
    
    WINDOW = 28
    HISTORY = 14          # past days kept for display
    DRAFT_LEAD = 5        # draft due 5 days before publishing
    REVIEW_LEAD = 2       # review due 2 days before publishing
    MAX_PER_WEEK = 3
    REASSIGN_MIN_DAYS = 3
    
    # content type -> weekday it runs on (None = every day); Monday is 0
    CADENCE = {'devotion': None, 'music': 2, 'story': 5}
    TYPE_LABEL = {'devotion': 'Devotion', 'music': 'Music', 'story': 'Real story'}
    
    
    @dataclass
    class Person:
        id: str
        name: str
        capacity: int = 8                     # slots per 28 days
        unavailable: set = field(default_factory=set)
        missed: int = 0
    
    
    @dataclass
    class Slot:
        day: date
        type: str
        writer: str = None
        reviewer: str = None
        status: str = 'open'      # open, assigned, approved, published, missed
        draft_due: date = None
        review_due: date = None
        missed_by: list = field(default_factory=list)
        source: str = None        # 'reserve' when filled from the reserve bank
        will_miss: bool = False   # simulation flag: this writer won't deliver
        explanation: dict = None
    
        @property
        def key(self):
            return f'{self.day.isoformat()}:{self.type}'
    
    
    class Rota:
        def __init__(self, writers, reviewers, today, reserve=5, owner='Owner',
                     window=WINDOW, max_per_week=MAX_PER_WEEK):
            self.writers = {p.id: p for p in writers}
            self.reviewers = {p.id: p for p in reviewers}
            self.order = [p.id for p in writers]          # stable tie-break order
            self.today = today
            self.reserve = reserve
            self.owner = owner
            self.window = window
            self.max_per_week = max_per_week
            self.slots = []
            self.log = []
    
        # ----------------------------------------------------------- generation
    
        def generate(self):
            """Create and assign every slot in the planning window, oldest first."""
            for offset in range(self.window):
                self._make_slots_for(self.today + timedelta(days=offset))
            return self
    
        def _make_slots_for(self, day):
            for ctype, weekday in CADENCE.items():
                if weekday is not None and day.weekday() != weekday:
                    continue
                slot = Slot(day=day, type=ctype,
                            draft_due=day - timedelta(days=DRAFT_LEAD),
                            review_due=day - timedelta(days=REVIEW_LEAD))
                self.slots.append(slot)
                decision = self.pick_writer(slot)
                slot.explanation = decision
                if decision['winner']:
                    slot.writer, slot.status = decision['winner'], 'assigned'
                    if slot.draft_due < self.today:
                        slot.status = 'approved'   # too close to start from scratch: treat as already in hand
                slot.reviewer = self.pick_reviewer(slot)
    
        # ------------------------------------------------------------- the rules
    
        def eligibility(self, pid, slot, strict=True):
            """(eligible, reason) for one writer and one slot."""
            person = self.writers[pid]
            if pid in slot.missed_by:
                return False, 'already missed this slot'
            if slot.day in person.unavailable:
                return False, 'unavailable that day'
            mine = [s for s in self.slots if s is not slot and s.writer == pid]
            if any(s.day == slot.day for s in mine):
                return False, 'already writing that day'
            week = slot.day.isocalendar()[:2]
            if sum(1 for s in mine if s.day.isocalendar()[:2] == week) >= self.max_per_week:
                return False, f'already has {self.max_per_week} slots that week'
            if strict and any(abs((s.day - slot.day).days) == 1 for s in mine):
                return False, 'back-to-back with another slot'
            return True, None
    
        def held(self, pid):
            """A: non-reserve slots in the planning window."""
            end = self.today + timedelta(days=self.window)
            return sum(1 for s in self.slots
                       if s.writer == pid and s.source != 'reserve' and self.today <= s.day < end)
    
        def load(self, pid):
            """L = A / K + 0.5 * M"""
            person = self.writers[pid]
            return self.held(pid) / person.capacity + 0.5 * person.missed
    
        def last_day(self, pid):
            days = [s.day for s in self.slots if s.writer == pid]
            return max(days) if days else date.min
    
        def pick_writer(self, slot):
            """Strict pass, then relaxed; lowest load wins; explain everything."""
            for strict in (True, False):
                candidates, excluded = [], []
                for pid in self.order:
                    ok, reason = self.eligibility(pid, slot, strict)
                    if ok:
                        candidates.append({'id': pid, 'load': round(self.load(pid), 4),
                                           'held': self.held(pid), 'capacity': self.writers[pid].capacity,
                                           'missed': self.writers[pid].missed, 'last': self.last_day(pid)})
                    else:
                        excluded.append({'id': pid, 'reason': reason})
                if candidates:
                    candidates.sort(key=lambda c: (c['load'], c['last'], self.order.index(c['id'])))
                    tie = len(candidates) > 1 and candidates[0]['load'] == candidates[1]['load']
                    for c in candidates:
                        c['last'] = None if c['last'] == date.min else c['last'].isoformat()
                    return {'winner': candidates[0]['id'], 'relaxed': not strict, 'tie_break': tie,
                            'candidates': candidates, 'excluded': excluded}
            return {'winner': None, 'relaxed': True, 'tie_break': False, 'candidates': [], 'excluded': excluded}
    
        def pick_reviewer(self, slot):
            """Fewest unpublished reviews; never the slot's writer; team order breaks ties."""
            options = [rid for rid in self.reviewers if rid != slot.writer]
            if not options:
                return self.owner
    
            def open_reviews(rid):
                return sum(1 for s in self.slots if s.reviewer == rid and s.status != 'published')
            return min(options, key=lambda rid: (open_reviews(rid), list(self.reviewers).index(rid)))
    
        # ------------------------------------------------------------ simulation
    
        def mark_will_miss(self, key):
            slot = self.find(key)
            if slot and slot.status == 'assigned':
                slot.will_miss = True
                self._log('info', f'{self.name(slot.writer)} will miss the {self.label(slot)} draft (simulated).')
    
        def advance(self):
            """Move the clock one day: publish, sweep deadlines, roll the window."""
            self.today += timedelta(days=1)
    
            for slot in self.slots:                                  # 1. publish
                if slot.day <= self.today and slot.status == 'approved':
                    slot.status = 'published'
                    note = ' from the reserve' if slot.source == 'reserve' else ''
                    self._log('ok', f'Published {self.label(slot)}{note}.')
    
            for slot in sorted(self.slots, key=lambda s: s.day):     # 2. deadline sweep
                if slot.status != 'assigned' or self.today <= slot.draft_due:
                    continue
                if slot.will_miss:
                    writer = self.writers[slot.writer]
                    writer.missed += 1
                    slot.missed_by.append(writer.id)
                    self._reassign(slot, f'{writer.name} missed the draft deadline')
                else:
                    slot.status = 'approved'   # simulation: drafted on time and reviewed
    
            horizon_day = self.today + timedelta(days=self.window - 1)   # 3. roll the window
            self.slots = [s for s in self.slots if s.day >= self.today - timedelta(days=HISTORY)]
            self._make_slots_for(horizon_day)
            return self
    
        def _reassign(self, slot, reason):
            left = (slot.day - self.today).days
            if left >= REASSIGN_MIN_DAYS:
                slot.writer = None
                decision = self.pick_writer(slot)
                if decision['winner']:
                    slot.explanation = decision
                    slot.writer, slot.will_miss, slot.status = decision['winner'], False, 'assigned'
                    slot.draft_due = self.today + timedelta(days=1)
                    slot.review_due = min(slot.day - timedelta(days=1),
                                          max(slot.draft_due + timedelta(days=1),
                                              slot.day - timedelta(days=REVIEW_LEAD)))
                    slot.reviewer = self.pick_reviewer(slot)
                    self._log('warn', f'{self.label(slot)}: {reason}. Reassigned to '
                                      f'{self.name(slot.writer)} with 24 hours to draft.')
                    return
            self._use_reserve_or_miss(slot, reason)
    
        def _use_reserve_or_miss(self, slot, reason):
            slot.writer, slot.will_miss = None, False
            if self.reserve > 0:
                self.reserve -= 1
                slot.source, slot.status = 'reserve', 'approved'
                self._log('warn', f'{self.label(slot)}: {reason}. Too late to reassign, so a reserve '
                                  f'piece was used ({self.reserve} left).')
            else:
                slot.status = 'missed'
                self._log('warn', f'{self.label(slot)}: {reason}. The reserve is empty; '
                                  f'the reviewer must write it.')
    
        # --------------------------------------------------------------- helpers
    
        def find(self, key):
            return next((s for s in self.slots if s.key == key), None)
    
        def name(self, pid):
            person = self.writers.get(pid) or self.reviewers.get(pid)
            return person.name if person else (self.owner if pid == self.owner else 'Reserve')
    
        def label(self, slot):
            return f"{TYPE_LABEL[slot.type]} on {slot.day.strftime('%a %d %b')}"
    
        def _log(self, kind, text):
            self.log.append({'day': self.today.isoformat(), 'kind': kind, 'text': text})
    
        def to_dict(self):
            return {
                'today': self.today.isoformat(),
                'reserve': self.reserve,
                'people': [{'id': p.id, 'name': p.name, 'capacity': p.capacity, 'missed': p.missed,
                            'held': self.held(p.id), 'load': round(self.load(p.id), 2)}
                           for p in self.writers.values()],
                'reviewers': [{'id': p.id, 'name': p.name} for p in self.reviewers.values()],
                'slots': [{'key': s.key, 'day': s.day.isoformat(), 'type': s.type, 'writer': s.writer,
                           'reviewer': s.reviewer, 'status': s.status, 'source': s.source,
                           'draft_due': s.draft_due.isoformat(), 'review_due': s.review_due.isoformat(),
                           'missed_by': s.missed_by, 'will_miss': s.will_miss, 'explanation': s.explanation}
                          for s in sorted(self.slots, key=lambda s: (s.day, list(CADENCE).index(s.type)))],
                'log': list(reversed(self.log)),
            }
    
    
    def replay(writers, reviewers, start, actions, reserve=5):
        """Rebuild a rota from its inputs and the visitor's actions, deterministically."""
        rota = Rota(writers, reviewers, start, reserve=reserve).generate()
        rota._log('info', 'Rota generated: each slot went to the eligible writer with the lowest load score.')
        for action in actions:
            if action.get('type') == 'advance':
                rota.advance()
            elif action.get('type') == 'miss':
                rota.mark_will_miss(str(action.get('slot', '')))
        return rota