availability.ts 5.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157
  1. /**
  2. * Whether a slot in the shop is free, and where the next free one is.
  3. *
  4. * A technician and a bay are each a resource that can hold one job at a time,
  5. * and the board lets both be set independently, so a booking can clash on
  6. * either without clashing on the other. Everything here is pure: the rules
  7. * are worth testing on their own, and the caller brings the bookings.
  8. *
  9. * Inspections occupy a bay and a person exactly as a job does, so they arrive
  10. * in the same list rather than being a second kind of thing to remember.
  11. */
  12. export interface Booking {
  13. id: string
  14. /** What it is, so a clash can name it. */
  15. kind: 'serviceRecord' | 'inspection'
  16. label: string
  17. start: Date
  18. end: Date
  19. technicianId?: string | null
  20. workBayId?: string | null
  21. }
  22. export interface Slot {
  23. start: Date
  24. end: Date
  25. technicianId?: string | null
  26. workBayId?: string | null
  27. }
  28. /** The shop's bookable hours, as "HH:mm" in the shop's own reckoning. */
  29. export interface WorkingHours {
  30. start: string
  31. end: string
  32. /**
  33. * Whether Saturday and Sunday can be booked. Off by default: a shop that
  34. * works weekends says so, and one that does not should never be handed a
  35. * Sunday morning as its next free slot.
  36. */
  37. includeWeekends?: boolean
  38. }
  39. /** Two half-open intervals overlap when each starts before the other ends. */
  40. export function overlaps(aStart: Date, aEnd: Date, bStart: Date, bEnd: Date): boolean {
  41. return aStart.getTime() < bEnd.getTime() && aEnd.getTime() > bStart.getTime()
  42. }
  43. /**
  44. * The bookings a slot would collide with.
  45. *
  46. * A booking only counts when it shares a resource. Two jobs at the same hour
  47. * in different bays with different people are not a clash, and a job with
  48. * neither set is not holding anything, so it cannot be clashed with.
  49. */
  50. export function findConflicts(slot: Slot, bookings: Booking[], excludeId?: string): Booking[] {
  51. return bookings.filter((booking) => {
  52. if (booking.id === excludeId) return false
  53. if (!overlaps(slot.start, slot.end, booking.start, booking.end)) return false
  54. const sameTech = !!slot.technicianId && booking.technicianId === slot.technicianId
  55. const sameBay = !!slot.workBayId && booking.workBayId === slot.workBayId
  56. return sameTech || sameBay
  57. })
  58. }
  59. function minutesOf(hhmm: string): number {
  60. const [h, m] = hhmm.split(':').map(Number)
  61. return (Number.isFinite(h) ? h : 0) * 60 + (Number.isFinite(m) ? m : 0)
  62. }
  63. const isWeekend = (d: Date) => d.getDay() === 0 || d.getDay() === 6
  64. /** The opening moment of the working day `date` falls in. */
  65. function dayStart(date: Date, hours: WorkingHours): Date {
  66. const d = new Date(date)
  67. d.setHours(0, minutesOf(hours.start), 0, 0)
  68. return d
  69. }
  70. function dayEnd(date: Date, hours: WorkingHours): Date {
  71. const d = new Date(date)
  72. d.setHours(0, minutesOf(hours.end), 0, 0)
  73. return d
  74. }
  75. /** Whether the whole slot sits inside one bookable day. */
  76. export function withinWorkingHours(slot: Slot, hours: WorkingHours): boolean {
  77. if (!hours.includeWeekends && isWeekend(slot.start)) return false
  78. const open = dayStart(slot.start, hours)
  79. const close = dayEnd(slot.start, hours)
  80. return slot.start >= open && slot.end <= close
  81. }
  82. /**
  83. * The earliest free slot of `durationMinutes`, at or after `from`.
  84. *
  85. * Walks the working days forward, and inside each one steps from the opening
  86. * time to the end of whatever is in the way, which lands exactly on the
  87. * moment a resource frees up rather than on an arbitrary grid. A shop booked
  88. * to the minute for a fortnight gets null instead of a slot in the far
  89. * future that nobody would want offered.
  90. */
  91. export function nextAvailableSlot({
  92. from,
  93. durationMinutes,
  94. bookings,
  95. hours,
  96. technicianId,
  97. workBayId,
  98. excludeId,
  99. searchDays = 30,
  100. }: {
  101. from: Date
  102. durationMinutes: number
  103. bookings: Booking[]
  104. hours: WorkingHours
  105. technicianId?: string | null
  106. workBayId?: string | null
  107. excludeId?: string
  108. searchDays?: number
  109. }): Slot | null {
  110. const durationMs = Math.max(1, durationMinutes) * 60_000
  111. const day = new Date(from)
  112. day.setHours(0, 0, 0, 0)
  113. for (let i = 0; i < searchDays; i++) {
  114. const cursorDay = new Date(day)
  115. cursorDay.setDate(day.getDate() + i)
  116. if (!hours.includeWeekends && isWeekend(cursorDay)) continue
  117. const close = dayEnd(cursorDay, hours)
  118. // Today starts from now rather than from opening time, so a search at
  119. // half past two is not offered nine in the morning.
  120. let cursor = dayStart(cursorDay, hours)
  121. if (from > cursor) cursor = new Date(from)
  122. // Whole minutes: a slot starting at 09:07:23 is noise on a booking sheet.
  123. cursor.setSeconds(0, 0)
  124. if (cursor.getTime() % 60_000 !== 0)
  125. cursor = new Date(Math.ceil(cursor.getTime() / 60_000) * 60_000)
  126. while (cursor.getTime() + durationMs <= close.getTime()) {
  127. const candidate: Slot = {
  128. start: new Date(cursor),
  129. end: new Date(cursor.getTime() + durationMs),
  130. technicianId,
  131. workBayId,
  132. }
  133. const clashes = findConflicts(candidate, bookings, excludeId)
  134. if (clashes.length === 0) return candidate
  135. // Jump to the moment the last thing in the way finishes; stepping by a
  136. // fixed interval would try the same occupied minutes over and over.
  137. const freeAt = Math.max(...clashes.map((c) => c.end.getTime()))
  138. cursor = new Date(Math.max(freeAt, cursor.getTime() + 60_000))
  139. }
  140. }
  141. return null
  142. }