How to Build an On-Call Rotation Calendar in JavaScript
By SimpleCalendarJS Team
Every engineering team that runs production services needs an on-call rotation calendar. Someone has to carry the pager, and the schedule has to be visible, predictable, and fair. The default path is a SaaS platform like PagerDuty at $21–$41 per user per month — but with OpsGenie shutting down in April 2027 and Grafana OnCall OSS archived in March 2026, teams are rethinking what they actually need. For most, it's a calendar with automated rotations — not a $5,000/year platform.
What on-call SaaS platforms actually cost
Before writing code, understand the landscape. On-call management tools fall into two categories: full-stack SaaS platforms (PagerDuty, incident.io, FireHydrant) and open-source tools you self-host (Grafana OnCall, GoAlert). The SaaS route means per-user recurring fees and your incident data on someone else's servers. The self-host route means ops overhead and, increasingly, abandoned projects.
| Platform | Cost (per user/month) | On-Call Scheduling | Alerting | Status |
|---|---|---|---|---|
| PagerDuty Professional | $21/user/mo | Yes | Yes | Active |
| PagerDuty Business | $41/user/mo | Yes | Yes + AIOps | Active |
| incident.io (with on-call) | $25–$45/user/mo | Yes | Yes | Active |
| FireHydrant | $20–$44/user/mo | Yes | Yes | Active |
| OpsGenie | N/A | Yes | Yes | Shutting down Apr 2027 |
| Grafana OnCall OSS | Free (self-host) | Yes | Limited | Archived Mar 2026 |
| Custom + SimpleCalendarJS | Free personal / $49/yr or $199 lifetime commercial | Your logic | Your infra | You own it |
A 10-person engineering team on PagerDuty Business pays $4,920 per year — before add-ons like AIOps ($699+/month) or status pages ($89/month). That's the baseline. The on-call calendar is one feature among many, and most teams only use the schedule and the alert routing.
If your team already has alerting infrastructure (Prometheus + Alertmanager, Datadog, or even Slack webhooks), the missing piece is the rotation calendar itself — not a full incident management platform.
Building the on-call rotation calendar
An on-call rotation calendar has four requirements: display who's on call, rotate automatically on a schedule, support overrides and swaps, and notify on handoff. Here's how to build each one.
Step 1: Set up the calendar
npm install simple-calendar-js
import SimpleCalendarJs from 'simple-calendar-js'; import 'simple-calendar-js/dist/simple-calendar-js.min.css'; const oncallCalendar = new SimpleCalendarJs('#oncall-calendar', { defaultView: 'month', locale: 'en-US', enabledViews: ['week', 'month'], fetchEvents: async (start, end) => { const res = await fetch( `/api/oncall/schedule?from=${start.toISOString()}&to=${end.toISOString()}` ); const shifts = await res.json(); return shifts.map((shift) => ({ id: shift.id, title: `🔔 ${shift.engineerName}`, start: new Date(shift.startDate), end: new Date(shift.endDate), color: shift.isPrimary ? '#2563eb' : '#64748b', })); }, onEventClick: (event) => { openOnCallDetailModal(event); }, });
This renders a month-view on-call calendar at ~14 KB gzipped. Primary on-call shifts appear in blue, secondary/backup in grey. Each block spans the full rotation period (typically a week) so managers see coverage at a glance.
Step 2: Generate rotation schedules automatically
The core of any on-call calendar is the rotation algorithm. Given a list of engineers and a start date, it assigns each person a consecutive period and cycles through the roster:
function generateRotation(engineers, startDate, weeksAhead = 12) { const shifts = []; const current = new Date(startDate); for (let i = 0; i < weeksAhead; i++) { const engineer = engineers[i % engineers.length]; const shiftStart = new Date(current); const shiftEnd = new Date(current); shiftEnd.setDate(shiftEnd.getDate() + 7); shifts.push({ id: `oncall-${i}`, engineerId: engineer.id, engineerName: engineer.name, startDate: shiftStart.toISOString(), endDate: shiftEnd.toISOString(), isPrimary: true, color: engineer.color, }); current.setDate(current.getDate() + 7); } return shifts; } const team = [ { id: 'eng-1', name: 'Alice', color: '#2563eb' }, { id: 'eng-2', name: 'Bob', color: '#16a34a' }, { id: 'eng-3', name: 'Carol', color: '#9333ea' }, { id: 'eng-4', name: 'Dave', color: '#ea580c' }, ]; const schedule = generateRotation(team, new Date('2026-09-14'), 12);
With four engineers on a weekly rotation, each person is on call once every four weeks. The schedule is deterministic — anyone can calculate who's on call for any future date without querying a database.
Step 3: Support overrides and swaps
Base rotations break the moment someone takes PTO or swaps a week. Store overrides separately and apply them at render time:
async function getEffectiveSchedule(start, end) { const [baseShifts, overrides] = await Promise.all([ fetch(`/api/oncall/schedule?from=${start}&to=${end}`).then((r) => r.json()), fetch(`/api/oncall/overrides?from=${start}&to=${end}`).then((r) => r.json()), ]); return baseShifts.map((shift) => { const override = overrides.find( (o) => o.originalShiftId === shift.id ); if (override) { return { ...shift, engineerId: override.replacementId, engineerName: override.replacementName, isOverride: true, originalEngineer: shift.engineerName, }; } return shift; }); } async function requestSwap(shiftId, replacementId) { const res = await fetch('/api/oncall/overrides', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ originalShiftId: shiftId, replacementId }), }); if (res.ok) { oncallCalendar.refetchEvents(); showSuccess('Override saved. Calendar updated.'); } }
Override shifts render with a dashed border or a distinct colour so managers can see at a glance which weeks deviate from the base rotation. The originalEngineer field preserves audit history.
Step 4: Notify on handoff
The most common on-call failure isn't a missed alert — it's a missed handoff. Automate notifications when the rotation transitions:
async function processHandoff(outgoingId, incomingId, handoffDate) { const [outgoing, incoming, openIncidents] = await Promise.all([ getEngineer(outgoingId), getEngineer(incomingId), getOpenIncidents(), ]); const handoffSummary = { date: handoffDate, from: outgoing.name, to: incoming.name, openIncidents: openIncidents.length, notes: openIncidents.map((i) => `• ${i.title} (${i.severity})`).join('\n'), }; await sendSlackMessage(incoming.slackId, formatHandoffMessage(handoffSummary)); await sendSlackMessage('#oncall-channel', formatHandoffAnnouncement(handoffSummary)); if (openIncidents.length > 0) { await sendSlackMessage( incoming.slackId, `⚠️ ${openIncidents.length} open incident(s) require your attention.` ); } }
The handoff message includes open incidents, recent deploys, and anything the outgoing engineer flagged. This runs as a cron job at the rotation boundary — typically Monday morning.
Colour-coding on-call status
A glance at the calendar should answer "who's on call this week?" instantly. Assign each engineer a persistent colour:
#oncall-calendar { --uc-primary: #2563eb; --uc-bg: #ffffff; --uc-border: #e5e7eb; --uc-text: #111827; } .oncall-primary { background: #dbeafe; border-left: 3px solid #2563eb; } .oncall-secondary { background: #f1f5f9; border-left: 3px solid #64748b; } .oncall-override { background: #fef3c7; border-left: 3px dashed #d97706; } .oncall-gap { background: #fee2e2; border-left: 3px solid #ef4444; } @media (prefers-color-scheme: dark) { #oncall-calendar { --uc-primary: #60a5fa; --uc-bg: #1f2937; --uc-border: #374151; --uc-text: #f9fafb; } .oncall-primary { background: #1e3a5f; } .oncall-secondary { background: #1e293b; } .oncall-override { background: #451a03; } .oncall-gap { background: #7f1d1d; } }
Red highlights for coverage gaps make scheduling errors impossible to miss. Yellow dashed borders for overrides distinguish swapped weeks from the base rotation.
Feature comparison: SaaS vs custom build
| Feature | PagerDuty Business | Grafana OnCall (Cloud IRM) | Custom + SimpleCalendarJS |
|---|---|---|---|
| Annual cost (10 users) | $4,920/yr | ~$2,400/yr | $49/yr or $199 lifetime |
| Bundle size | N/A (hosted) | N/A (hosted) | ~14 KB |
| Weekly rotation | Built-in | Built-in | Your logic |
| Follow-the-sun | Built-in | Built-in | Your logic |
| Override/swap management | Built-in | Built-in | Custom (API + UI) |
| Handoff notifications | Built-in | Built-in | Custom (Slack/email) |
| Escalation policies | Built-in | Built-in | Custom |
| Alert routing | Built-in | Built-in | BYO (Alertmanager, etc.) |
| Vendor lock-in | Yes | Yes (Cloud) | None |
| Data ownership | Vendor servers | Vendor servers | Your infrastructure |
SaaS platforms win when you need integrated alert routing, built-in phone/SMS escalation, and don't want to manage infrastructure. The custom build wins when your team already has alerting in place (Prometheus, Datadog, Slack) and what you actually need is a visible, predictable rotation calendar — not a $5,000/year platform for what is fundamentally a round-robin schedule.
Extending the calendar
Follow-the-sun rotations
For distributed teams, split coverage by time zone instead of assigning one person 24/7:
function generateFollowTheSun(regions, startDate, weeksAhead = 12) { const shifts = []; const current = new Date(startDate); for (let i = 0; i < weeksAhead; i++) { regions.forEach((region) => { const engineer = region.engineers[i % region.engineers.length]; const shiftStart = new Date(current); const shiftEnd = new Date(current); shiftEnd.setDate(shiftEnd.getDate() + 7); shifts.push({ id: `oncall-${region.name}-${i}`, engineerId: engineer.id, engineerName: `${engineer.name} (${region.label})`, startDate: shiftStart.toISOString(), endDate: shiftEnd.toISOString(), coverageHours: region.hours, color: region.color, }); }); current.setDate(current.getDate() + 7); } return shifts; } const regions = [ { name: 'us-west', label: 'US', hours: '08:00–16:00 PST', color: '#2563eb', engineers: [{ id: 'eng-1', name: 'Alice' }, { id: 'eng-2', name: 'Bob' }], }, { name: 'eu', label: 'EU', hours: '08:00–16:00 CET', color: '#16a34a', engineers: [{ id: 'eng-3', name: 'Carol' }, { id: 'eng-4', name: 'Dave' }], }, { name: 'apac', label: 'APAC', hours: '08:00–16:00 SGT', color: '#9333ea', engineers: [{ id: 'eng-5', name: 'Eve' }, { id: 'eng-6', name: 'Frank' }], }, ];
Three regions with two engineers each means no one takes overnight pages, and each person is on call every other week during their local business hours.
Coverage gap detection
Automatically flag gaps in the schedule before they become incidents:
function findCoverageGaps(shifts, rangeStart, rangeEnd) { const gaps = []; const sorted = [...shifts].sort( (a, b) => new Date(a.startDate) - new Date(b.startDate) ); let coveredUntil = new Date(rangeStart); for (const shift of sorted) { const shiftStart = new Date(shift.startDate); if (shiftStart > coveredUntil) { gaps.push({ start: new Date(coveredUntil), end: new Date(shiftStart), durationHours: (shiftStart - coveredUntil) / (1000 * 60 * 60), }); } const shiftEnd = new Date(shift.endDate); if (shiftEnd > coveredUntil) { coveredUntil = shiftEnd; } } if (coveredUntil < new Date(rangeEnd)) { gaps.push({ start: new Date(coveredUntil), end: new Date(rangeEnd), durationHours: (new Date(rangeEnd) - coveredUntil) / (1000 * 60 * 60), }); } return gaps; }
Render gaps as red blocks on the calendar. Run this check whenever the schedule changes — especially after overrides — and post a Slack alert if any gap exceeds your coverage SLA.
Summary
- On-call SaaS platforms (PagerDuty, incident.io, FireHydrant) cost $21–$45 per user per month — a 10-person team pays $2,520–$5,400+ per year for what is fundamentally a round-robin schedule with notifications
- OpsGenie shuts down April 2027 and Grafana OnCall OSS was archived in March 2026 — two major options are gone, making custom builds more appealing
- A custom on-call rotation calendar built with SimpleCalendarJS ships at ~14 KB with full control over rotation logic, override management, and handoff automation
- Weekly rotation is the default — cycle through engineers with
i % engineers.lengthand generate deterministic schedules for any future date range - Overrides must be separate from the base schedule — store them as exceptions linked to the original shift ID, and apply them at render time to keep the rotation clean
- Follow-the-sun eliminates overnight pages by splitting coverage across time zones, with each region maintaining its own sub-rotation
- Coverage gap detection should run on every schedule change and alert the team before a gap becomes an unmonitored incident
Sources & Further Reading
Research & References
- On-Call Rotations and Schedules: A Guide for 2026 — Xurrent
- On-Call Rotation Best Practices for Engineering Teams — DEV Community
- Best Practices for Creating On-Call Rotations and Schedules — FireHydrant
- Incident Management Pricing Comparison 2026 — incident.io
- PagerDuty Pricing Breakdown 2026 — Spike.sh
- Opsgenie Alternatives in 2026: Where to Migrate Before the Shutdown — DEV Community
- Grafana OnCall Alternative After the 2026 Archival — Arvo
- Best Open-Source PagerDuty Alternatives — incident.io
- LinkedIn OnCall — GitHub
Image Credits
- Cover: Person Using Desktop Computer in a Dark Room — Pexels
All images free to use under the Pexels License.
Frequently Asked Questions
What is an on-call rotation calendar?
An on-call rotation calendar is a schedule that assigns team members to be available for incident response during specific time periods. It rotates responsibility across the team — typically on a weekly basis — so no single engineer carries the pager permanently. The calendar shows who is currently on call, who is next, and highlights any coverage gaps.
How much does PagerDuty cost for on-call management?
PagerDuty's Professional plan starts at $21 per user per month ($252/year). The Business plan costs $41 per user per month ($492/year). Add-ons like AIOps ($699+/month) and status pages ($89/month) increase the total significantly. A 10-person engineering team on the Business plan pays at least $4,920 per year before add-ons.
Is OpsGenie still available for on-call scheduling?
No. Atlassian ended new OpsGenie sales on June 4, 2025, and the platform shuts down entirely on April 5, 2027. Existing users must migrate to Jira Service Management, PagerDuty, or another alternative before that date. All data — schedules, escalation policies, and alert history — will be deleted after shutdown.
Can I build an on-call rotation calendar without a SaaS platform?
Yes. An on-call rotation calendar is fundamentally a weekly schedule with colour-coded assignments and automated handoffs. You can build one with a lightweight calendar library like SimpleCalendarJS (~14 KB), a rotation algorithm in plain JavaScript, and your existing notification infrastructure (Slack webhooks, email, or SMS via Twilio).
What is a follow-the-sun on-call rotation?
Follow-the-sun distributes on-call coverage across time zones so no engineer takes overnight pages. A team with members in San Francisco, London, and Singapore can cover 24 hours with each person only handling their local business hours. The rotation calendar shows handoff times aligned to each region's working day.
How do I handle on-call swaps and overrides?
Store overrides as exceptions linked to the base rotation schedule. When an engineer requests a swap, create an override record with the replacement engineer's ID and the date range. When rendering the calendar, check for overrides before falling back to the computed rotation. This keeps the base schedule clean while supporting ad-hoc changes.
Add a calendar to your app today
Free for personal projects. $49/year or $199 lifetime per commercial project.
