@shiftwise/dates
@shiftwise/dates
A shared date utility package used by both the backend and frontend. It wraps
date-fns v4, @date-fns/utc, and @date-fns/tz behind an Adapter + Facade
pattern so the underlying library is never imported directly outside this package.
Design principles
- UTC everywhere except the display layer. All dates are stored, exchanged,
and calculated as UTC ISO strings (
"2026-03-18T09:00:00.000Z"). Timezone conversion happens only at the point of display in the UI. UTCDateinstead ofnew Date(). All internal operations useUTCDatefrom@date-fns/utcto prevent local timezone bleed during calculations.formatwith{ in: tz() }. Display formatting uses the date-fns v4{ in }option rather than deprecated helpers liketoZonedTime.- Swappable internals.
utc.tsanddisplay.tsare the only files that know about the underlying libraries. Swappingdate-fnsfor another library means editing one file — nothing else changes.
Package structure
packages/dates/src/
├── types.ts # Shared types: DateInput, ISOString, ShiftWindow, Timezone
├── utc.ts # UTC operations — safe on backend and frontend
├── display.ts # Localisation — frontend presentational layer only
└── index.ts # Public facade — the only import consumers use
What lives where
| Module | Imports | Used by |
|---|---|---|
utc.ts |
@date-fns/utc, date-fns |
BE + FE |
display.ts |
@date-fns/tz, date-fns { in } |
FE only |
index.ts |
re-exports both | BE + FE |
Usage
Backend — scheduler and routes
import { buildShiftWindow, doShiftsOverlap, hasMinRest, getWeekDays, toISO } from '@shiftwise/dates'
// Build shift windows from UTC date + time strings
const shiftA = buildShiftWindow('2026-03-18T00:00:00.000Z', '09:00', '17:00')
const shiftB = buildShiftWindow('2026-03-18T00:00:00.000Z', '14:00', '22:00')
doShiftsOverlap(shiftA, shiftB) // true — 14:00-17:00 overlap
hasMinRest(shiftA.end, shiftB.start) // false — only 5hr gap, need 8
hasMinRest(shiftA.end, shiftB.start, 4) // true — 5hr gap > 4hr minimum
// Get all 7 days in a week (Monday-first)
getWeekDays('2026-03-18T00:00:00.000Z').map(toISO)
// ["2026-03-16T00:00:00.000Z", "2026-03-17T00:00:00.000Z", ...]
Frontend — calendar display
import { formatShiftTime, formatShiftDate, formatShiftDateTime, localTimeToUTC } from '@shiftwise/dates'
// Display UTC dates in the user's timezone
formatShiftTime('2026-03-17T22:00:00.000Z', 'Australia/Sydney') // "9:00 AM"
formatShiftDate('2026-03-17T22:00:00.000Z', 'Australia/Sydney') // "Tue 18 Mar"
formatShiftDateTime('2026-03-17T22:00:00.000Z', 'Australia/Sydney') // "Tue 18 Mar, 9:00 AM"
// Convert manager's UI input to UTC before sending to API
localTimeToUTC('2026-03-18', '09:00', 'Australia/Sydney')
// → "2026-03-17T22:00:00.000Z"
Timezone model
Each user and workspace stores an IANA timezone string. User timezone takes priority over workspace timezone — this handles cases where a manager in one city is scheduling staff in another.
users.timezone "Australia/Sydney" (user-level override)
workspaces.timezone "Pacific/Auckland" (workspace default)
The resolution logic (to be implemented in the scheduler):
const displayTZ = user.timezone !== 'UTC' ? user.timezone : workspace.timezone
End-to-end data flow
Manager picks "9:00 AM" on 18 Mar in the UI (Sydney)
→ localTimeToUTC('2026-03-18', '09:00', 'Australia/Sydney')
→ "2026-03-17T22:00:00.000Z" ← sent to API, stored in DB
Scheduler checks constraints
→ buildShiftWindow("2026-03-17T22:00:00.000Z", ...)
→ utcAdapter.diffMinutes(...) ← pure UTC arithmetic, no timezone
Auckland employee views their schedule
→ formatShiftTime("2026-03-17T22:00:00.000Z", "Pacific/Auckland")
→ "11:00 AM NZDT"
Smoke test
Run this from the repo root to verify the package is working:
node --input-type=module << 'EOF'
import { toUTC, nowUTC, toISO, getWeekDays, buildShiftWindow, doShiftsOverlap, hasMinRest } from '/workspaces/shiftwise/packages/dates/src/index.ts'
const now = nowUTC()
console.log('nowUTC: ', toISO(now))
console.log('weekDays: ', getWeekDays(now).map(toISO))
const shiftA = buildShiftWindow('2026-03-18T00:00:00.000Z', '09:00', '17:00')
const shiftB = buildShiftWindow('2026-03-18T00:00:00.000Z', '14:00', '22:00')
const shiftC = buildShiftWindow('2026-03-18T00:00:00.000Z', '18:00', '23:00')
console.log('A overlaps B: ', doShiftsOverlap(shiftA, shiftB)) // true
console.log('A overlaps C: ', doShiftsOverlap(shiftA, shiftC)) // false
console.log('A→C 8hr rest: ', hasMinRest(shiftA.end, shiftC.start)) // false
console.log('A→C 1hr rest: ', hasMinRest(shiftA.end, shiftC.start, 1)) // true
EOF
Updating the package
Since the package points directly at .ts source files (no build step),
changes are picked up automatically:
| Change | What to do |
|---|---|
Edit a .ts file |
Just save — hot-reloads in tsx watch and Vite |
| Add a new export | Just save — available immediately |
| Add a dependency | npm install from the monorepo root |
| Typecheck all packages | npm run typecheck from the monorepo root |
Highlights
This is an application of two patterns working together — the
Adapter hides the third-party library, and the Facade exposes a
domain-specific API tailored to shift scheduling. If date-fns ships a
breaking change or we need timezone-aware dates via Luxon, we swap one file
and the rest of the codebase is untouched. The UTCDate constraint means
timezone bleed is structurally impossible in the calculation layer — it can
only happen intentionally in display.ts.