A shift-scheduling and swap-management platform for hourly workforces (retail, cafes, hospitals) - built to replace the "post it in a WhatsApp group and hope nobody double-books" workflow with an automated, rule-enforced swap system.
Live app: https://shift-swap-eta.vercel.app
Backend API: https://shiftswap-kwyn.onrender.com
Note: The backend is hosted on Render's free tier, which spins down after periods of inactivity. The first request after a period of inactivity may take 30–60 seconds while the server wakes up.
| Role | Password | |
|---|---|---|
| Manager | aarav.shah@cafe.com |
password123 |
| Employee | priya.nair@cafe.com |
password123 |
All seeded accounts use the same password:
password123.
Employees can post a shift they can't work as an open swap request. Other employees with the matching role can claim it - but before a claim is accepted, the system automatically checks it against real scheduling rules:
- No double-booking - the claiming employee can't already have an overlapping shift.
- Weekly hour limits - the claim can't push the employee over their personal weekly hour cap.
- Minimum rest period - there must be at least 8 hours between shifts.
A claim that fails any of these is rejected immediately, with the specific reason shown to the user - no manager has to manually cross-check a schedule to catch it. A claim that passes goes to a manager for one-click approval, which reassigns the shift.
Most "shift scheduling" side projects are CRUD wrappers around a calendar. The interesting engineering problem here isn't the calendar - it's the conflict-detection logic underneath it: interval-overlap SQL, aggregate hour calculations scoped to a rolling week, and rest-period checks that have to handle boundary cases correctly (exact 8-hour gaps, shifts spanning midnight, adjacent weeks).
That logic is validated by an automated test suite covering 29 constructed scenarios, including deliberately adversarial boundary cases (off-by-one-minute gaps, shifts landing exactly at a limit), run automatically in CI on every push.
- Node.js
- Express
- TypeScript
- PostgreSQL
- JWT Authentication
- bcrypt
- React
- TypeScript
- Vite
- FullCalendar
- GitHub Actions (CI)
- Render (API + PostgreSQL)
- Vercel (Frontend)
Employee / Manager (Browser)
│
▼
React + Vite Frontend
(Vercel)
│
│ JWT (Authorization Header)
▼
Express REST API
(Render)
│
├── requireAuth
├── requireManager
├── Conflict Validation Service
│ ├── Interval Overlap
│ ├── Weekly Hour Limits
│ └── Rest Period Checks
│
▼
PostgreSQL (Render Managed Database)
The conflict-detection logic lives in a single service module (conflictValidator.ts), called from the claim endpoint and reused-with an in-memory equivalent of the same three rules-by the seed script, so demo data is guaranteed to satisfy the same constraints the app enforces live.
- employees - name, role, weekly hour cap, manager flag, hashed password
- shifts - employee, role required, start/end time (
TIMESTAMPTZ), status - swap_requests - links a shift to the employee giving it up; a partial unique index ensures a shift can only have one open swap request at a time while still allowing a new request after a previous one was rejected or cancelled.
- swap_claims - records every claim attempt (including failed ones) with the validation result and reason; a second partial unique index prevents two simultaneous pending claims on the same request.
Early in development, conflict-check results showed shift times that were consistently 5.5 hours off from the actual shift.
Tracing it required ruling out:
- the database (
TIMESTAMPvsTIMESTAMPTZ) - the Node/
pgdriver layer - the seed script
The real culprit was that the seed script used Date.setHours() (local time) instead of setUTCHours() (explicit UTC), making the definition of "7 AM" ambiguous.
Fixing the column type alone wasn't enough. The final solution required ensuring every timestamp-producing code path in the project consistently used UTC.
The same class of bug resurfaced later in a frontend formatting function that relied on the browser's implicit timezone instead of UTC. It was caught manually by comparing displayed times against FullCalendar's correct rendering-a useful reminder that automated tests don't catch every category of bug.
Claiming a swap involves:
- Running conflict-detection queries
- Inserting a claim record
- Updating the swap request
These operations must either all succeed or all fail.
Every multi-step write (claim, approve, reject) runs inside an explicit PostgreSQL transaction (BEGIN / COMMIT / ROLLBACK) using a single database connection.
Rather than relying only on application logic, the database itself prevents duplicate pending claims.
A partial unique index:
UNIQUE (...) WHERE validated IS NULLmakes duplicate pending claims structurally impossible regardless of timing.
The conflict-detection engine is validated by 29 scenarios covering:
- Overlap detection
- containment
- boundary touches
- exact duplicates
- multi-shift weeks
- Weekly hour limits
- accumulated hours
- exact-boundary cases
- correct week scoping
- Rest period enforcement
- exact 8-hour boundary
- one-minute-under violations
- overnight shifts
Each scenario runs against a real disposable PostgreSQL transaction (rolled back after each test) rather than a mocked database.
Run locally with:
npm run test:conflictsTests also run automatically on every push through GitHub Actions.
- Passwords hashed with bcrypt (cost factor 10)
- JWT-based authentication
- Every mutating endpoint requires a valid token
- Manager-only endpoints enforced server-side via middleware
- Generic login errors prevent user enumeration
- Parameterized SQL queries throughout
- Node.js 20+
- PostgreSQL
cd backend
npm install
npm run migrate
npm run migrate:002
npm run migrate:003
npm run migrate:004
npm run migrate:005
npm run migrate:006
npm run seed
npm run devcd frontend
npm install
npm run devBackend runs on:
http://localhost:5000
Frontend runs on:
http://localhost:5173
Copy .env.example to .env inside backend/ and provide:
- PostgreSQL credentials
- JWT secret
before running migrations.
- Real-time updates (WebSockets/SSE) instead of 15-second polling.
- Audit log for approvals and rejections.
- Refresh tokens for improved authentication UX.
- Email and in-app notifications for swap events.
shiftswap/
├── backend/
│ └── src/
│ ├── config/ # Database connection pool
│ ├── db/ # Migrations and seed script
│ ├── middleware/ # Authentication middleware
│ ├── routes/ # Express route handlers
│ ├── services/ # Conflict-detection engine
│ ├── tests/ # Automated test suite
│ └── utils/ # JWT signing & verification
│
├── frontend/
│ └── src/
│ ├── api/ # Configured Axios client
│ ├── components/ # React components
│ ├── context/ # Authentication context
│ └── types/ # Shared TypeScript types
│
└── .github/
└── workflows/ # CI pipeline