All articles
July 22, 20264 min readTypeScript · Node.js · MongoDB · System Design · Domain Modeling

One Shift, Many Workers: Modeling Aggregate Status in Support24

How Support24's scheduling engine derives a single shift status from several independent worker assignment statuses, and why recurring shifts propagate data but not status.

Share

Support24 connects support workers with participants and their guardians or coordinators for care shifts. Most shifts have one worker. Higher-need care sometimes needs several — and that one branching decision, single worker vs. multiple, ends up shaping almost every status transition in the scheduling engine.

Whose status is the shift's status?

For a single-worker shift, status is a single field on the document, moving through a fairly ordinary lifecycle:

export enum ShiftStatus {
  OPEN = 'open',
  PENDING = 'pending',
  CONFIRMED = 'confirmed',
  IN_PROGRESS = 'inProgress',
  COMPLETED = 'completed',
  CANCELLED = 'cancelled',
  NO_SHOW = 'noShow',
  DECLINED = 'declined',
}

A multi-worker shift doesn't have one status to begin with — it has an array of workerAssignments, and each assignment carries its own status from that same enum. Worker A might have accepted while Worker B hasn't responded yet. The shift itself still needs one status, because everything downstream — client-facing UI, notifications, invoicing — expects a single value to key off. So something has to reduce N independent statuses down to one.

Deriving one status from many

That reduction happens in a single function, run any time a worker assignment changes:

function updateMultiWorkerShiftStatus(shift: IShift): void {
  const statusCounts: Record<string, number> = { /* one counter per ShiftStatus */ };

  for (const assignment of shift.workerAssignments) {
    statusCounts[assignment.status]++;
  }

  // If every worker shares the same status, that's the shift's status
  const total = shift.workerAssignments.length;
  for (const status of Object.keys(statusCounts)) {
    if (statusCounts[status] === total) {
      shift.status = status as ShiftStatus;
      return;
    }
  }

  if (statusCounts[ShiftStatus.IN_PROGRESS] > 0) return void (shift.status = ShiftStatus.IN_PROGRESS);
  if (statusCounts[ShiftStatus.CONFIRMED] > 0) return void (shift.status = ShiftStatus.CONFIRMED);
  if (statusCounts[ShiftStatus.PENDING] > 0) return void (shift.status = ShiftStatus.PENDING);

  if (statusCounts[ShiftStatus.DECLINED] + statusCounts[ShiftStatus.CANCELLED] === total) {
    shift.status = ShiftStatus.OPEN; // everyone dropped out — reopen it
    return;
  }

  shift.status = ShiftStatus.PENDING; // fallback
}

The logic is a priority ladder, not a lookup table: check for unanimous agreement first, then let the "most active" status win — a shift with one worker in progress and two still pending is IN_PROGRESS, not some average of the three. The one deliberately non-obvious rule is at the bottom: if every worker declined or was cancelled, the shift doesn't die, it reopens (OPEN) so it can be re-staffed. That business rule — a fully-declined shift is a staffing gap, not a dead record — would be easy to lose if this logic were spread across several places instead of one function.

Recurring shifts: shared data, independent status

Shifts can recur (daily, weekly, biweekly, monthly), which creates a parent shift and a chain of child occurrences. When you edit a recurring shift and choose to apply the change going forward, the update needs to reach every future sibling — but status is explicitly carved out of that propagation:

// For single-worker shifts
// Exclude status updates for recurring instances (each must be confirmed separately)
const { ...updateWithoutStatus } = updateData;
await Shift.findByIdAndUpdate(siblingShift._id, updateWithoutStatus);

A time change, a new worker, updated instructions — those apply to every future occurrence at once. Whether a given occurrence is confirmed does not, because a worker agreeing to next Tuesday says nothing about whether they're free the Tuesday after. The one exception is an admin force-updating status directly, which bypasses this rule on purpose. For multi-worker recurring shifts, the same split applies, except the worker assignment list itself is copied across siblings and then run back through updateMultiWorkerShiftStatus so each occurrence's aggregate status stays internally consistent rather than being copied wholesale.

Where role decides what's legal

The same update path behaves differently depending on who's calling it. A worker can only move their own assignment between PENDING/CONFIRMED/DECLINED. A client cancelling a shift cascades that cancellation down to every worker assignment on it. An admin can force a status directly, skipping the derivation logic entirely. None of this is enforced by the type system — it's a sequence of if (user.role === ...) checks inside the service method — which is a pragmatic choice for a domain with three roles and a handful of transitions, but it's also the part of this code most likely to need a real state-machine library if a fourth role or a ninth status ever gets added.

Takeaway

"Sometimes one, sometimes many" relationships show up constantly in scheduling and marketplace systems, and the tempting shortcut is to bolt an array onto the single-item model and patch the status logic to cope. That's exactly what happened here, and it works — but it means the aggregation function is quietly holding onto business rules (what a fully-declined shift means, what counts as "in progress enough") that a more formal state machine would make explicit and testable on their own. Worth knowing which trade you made, so it's a deliberate one the next time the status list grows.

—

Comments