diff --git a/server/@types/bapv.d.ts b/server/@types/bapv.d.ts index 8269c0e0..ec2ef943 100644 --- a/server/@types/bapv.d.ts +++ b/server/@types/bapv.d.ts @@ -25,3 +25,15 @@ export type FlashData = { errors?: FieldValidationError[] formValues?: Record } + +// available visit sessions formatted in months/days for calendar component +export type VisitSessionsCalendar = { + selectedDate: string // TODO consider removing this from here and defaulting/validating in route? + months: Record< + string, + { + startDayColumn: number // the day column first date should start on (1 = Monday) + dates: Record + } + > +} diff --git a/server/routes/bookingJourney/index.ts b/server/routes/bookingJourney/index.ts index c94c3f35..eef562bd 100644 --- a/server/routes/bookingJourney/index.ts +++ b/server/routes/bookingJourney/index.ts @@ -28,5 +28,7 @@ export default function routes(services: Services): Router { postWithValidation('/select-visitors', selectVisitorsController.validate(), selectVisitorsController.submit()) get('/select-date-and-time', selectVisitDateTimeController.view()) + post('/select-date-and-time', selectVisitDateTimeController.submit()) + return router } diff --git a/server/routes/bookingJourney/selectVisitDateTimeController.ts b/server/routes/bookingJourney/selectVisitDateTimeController.ts index 799de7ec..4157a7f3 100644 --- a/server/routes/bookingJourney/selectVisitDateTimeController.ts +++ b/server/routes/bookingJourney/selectVisitDateTimeController.ts @@ -1,108 +1,40 @@ import type { RequestHandler } from 'express' import { VisitSessionsService } from '../../services' -type CalendarData = { - selectedDate: string - months: Record< - string, - { - startDayColumn: number - dates: Record - } - > -} - -const calendarData: CalendarData = { - selectedDate: '2024-02-24', - months: { - 'February 2024': { - startDayColumn: 4, // 1 = first date is a Monday - dates: { - '2024-02-22': [], - '2024-02-23': [], - '2024-02-24': [ - { reference: 'a', time: '10am to 11:30am', duration: '1 hour and 30 minutes' }, - { reference: 'b', time: '4:30pm to 6:30am', duration: '2 hours' }, - ], - '2024-02-25': [], - '2024-02-26': [{ reference: 'c', time: '11am to 12:30am', duration: '1 hour and 30 minutes' }], - '2024-02-27': [ - { reference: 'd', time: '9:30am to 10:30am', duration: '1 hour' }, - { reference: 'e', time: '1pm to 3pm', duration: '2 hours' }, - ], - '2024-02-28': [], - '2024-02-29': [ - { reference: 'f', time: '10am to 11:30am', duration: '1 hour and 30 minutes' }, - { reference: 'g', time: '4:30pm to 6:30am', duration: '2 hours' }, - ], - }, - }, - - 'March 2024': { - startDayColumn: 5, - dates: { - '2024-03-01': [], - '2024-03-02': [{ reference: 'h', time: '11am to 12:30am', duration: '1 hour and 30 minutes' }], - '2024-03-03': [ - { reference: 'i', time: '9:30am to 10:30am', duration: '1 hour' }, - { reference: 'j', time: '1pm to 3pm', duration: '2 hours' }, - ], - '2024-03-04': [], - '2024-03-05': [], - '2024-03-06': [ - { reference: 'k', time: '10am to 11:30am', duration: '1 hour and 30 minutes' }, - { reference: 'l', time: '4:30pm to 6:30am', duration: '2 hours' }, - ], - '2024-03-07': [], - '2024-03-08': [], - '2024-03-09': [{ reference: 'm', time: '11am to 12:30am', duration: '1 hour and 30 minutes' }], - '2024-03-10': [ - { reference: 'n', time: '9:30am to 10:30am', duration: '1 hour' }, - { reference: 'o', time: '1pm to 3pm', duration: '2 hours' }, - ], - '2024-03-11': [], - '2024-03-12': [], - '2024-03-13': [ - { reference: 'p', time: '10am to 11:30am', duration: '1 hour and 30 minutes' }, - { reference: 'q', time: '4:30pm to 6:30am', duration: '2 hours' }, - ], - '2024-03-14': [{ reference: 'r', time: '11am to 12:30am', duration: '1 hour and 30 minutes' }], - '2024-03-15': [], - '2024-03-16': [ - { reference: 'd', time: '9:30am to 10:30am', duration: '1 hour' }, - { reference: 't', time: '1pm to 3pm', duration: '2 hours' }, - ], - '2024-03-17': [ - { reference: 'u', time: '10am to 11:30am', duration: '1 hour and 30 minutes' }, - { reference: 'v', time: '4:30pm to 6:30am', duration: '2 hours' }, - ], - '2024-03-18': [{ reference: 'w', time: '11am to 12:30am', duration: '1 hour and 30 minutes' }], - }, - }, - }, -} - export default class SelectVisitDateTimeController { public constructor(private readonly visitSessionsService: VisitSessionsService) {} public view(): RequestHandler { return async (req, res) => { - const { prisoner, selectedVisitors } = req.session.bookingJourney + const { prison, prisoner, selectedVisitors } = req.session.bookingJourney const selectedVisitorIds = selectedVisitors.map(visitor => visitor.personId) - const visitSessions = await this.visitSessionsService.getVisitSessions( + const visitSessionsCalendar = await this.visitSessionsService.getVisitSessionsCalendar( prisoner.prisonCode, prisoner.prisonerNumber, selectedVisitorIds, + prison.policyNoticeDaysMax, ) res.render('pages/bookingJourney/selectVisitDateTime', { booker: req.session.booker, bookingJourney: req.session.bookingJourney, - visitSessions, - calendarData, + visitSessionsCalendar, }) } } + + public submit(): RequestHandler { + return async (req, res) => { + const { visitSlot } = req.body + + const sessionDate = visitSlot.split('_')[0] + const sessionTemplateReference = visitSlot.split('_')[1] + + return res.send( + `
Visit slot selected:\n\nsessionDate: ${sessionDate}\n\nsessionTemplateReference: ${sessionTemplateReference}
`, + ) + } + } } diff --git a/server/services/visitSessionsService.test.ts b/server/services/visitSessionsService.test.ts index 4b42a39d..ba0cf5a7 100644 --- a/server/services/visitSessionsService.test.ts +++ b/server/services/visitSessionsService.test.ts @@ -2,6 +2,7 @@ import TestData from '../routes/testutils/testData' import { createMockHmppsAuthClient, createMockOrchestrationApiClient } from '../data/testutils/mocks' import VisitSessionsService from './visitSessionsService' import { AvailableVisitSessionDto } from '../data/orchestrationApiTypes' +import { VisitSessionsCalendar } from '../@types/bapv' const token = 'some token' @@ -24,17 +25,110 @@ describe('Visit sessions service', () => { jest.resetAllMocks() }) - describe('getVisitSessions', () => { - it('should return available visit sessions for prison / prisoner / visitors', async () => { + describe('getVisitSessionsCalendar', () => { + const fakeDate = new Date('2024-05-25') + + beforeEach(() => { + jest.useFakeTimers({ advanceTimers: true, now: fakeDate }) + }) + + afterEach(() => { + jest.useRealTimers() + }) + + it('should return a VisitSessionsCalendar with visit sessions for prison / prisoner / visitors', async () => { + const prisoner = TestData.prisonerInfoDto() + const visitorIds = [1, 2] + const daysAhead = 10 // the booking window 'policyNoticeDaysMax' for the prison + const visitSessions: AvailableVisitSessionDto[] = [ + // first session after the fake start date to check we get empty dates at start + TestData.availableVisitSessionDto({ + sessionDate: '2024-05-30', + sessionTemplateReference: 'a', + sessionTimeSlot: { startTime: '10:00', endTime: '11:30' }, + }), + TestData.availableVisitSessionDto({ + sessionDate: '2024-05-31', + sessionTemplateReference: 'b', + sessionTimeSlot: { startTime: '09:00', endTime: '09:45' }, + }), + TestData.availableVisitSessionDto({ + sessionDate: '2024-05-31', // second session on same day + sessionTemplateReference: 'c', + sessionTimeSlot: { startTime: '14:00', endTime: '15:00' }, + }), + // next month - testing multiple months + TestData.availableVisitSessionDto({ + sessionDate: '2024-06-03', + sessionTemplateReference: 'd', + sessionTimeSlot: { startTime: '09:00', endTime: '11:00' }, + }), + ] + orchestrationApiClient.getVisitSessions.mockResolvedValue(visitSessions) + + const expectedVisitSessionsCalendar: VisitSessionsCalendar = { + selectedDate: '2024-05-30', + months: { + 'May 2024': { + startDayColumn: 6, // first day is a Saturday; sixth calendar day column + dates: { + '2024-05-25': [], + '2024-05-26': [], + '2024-05-27': [], + '2024-05-28': [], + '2024-05-29': [], + '2024-05-30': [{ reference: 'a', time: '10am to 11:30am', duration: '1 hour and 30 minutes' }], + '2024-05-31': [ + { reference: 'b', time: '9am to 9:45am', duration: '45 minutes' }, + { reference: 'c', time: '2pm to 3pm', duration: '1 hour' }, + ], + }, + }, + 'June 2024': { + startDayColumn: 6, + dates: { + '2024-06-01': [], + '2024-06-02': [], + '2024-06-03': [{ reference: 'd', time: '9am to 11am', duration: '2 hours' }], + '2024-06-04': [], + }, + }, + }, + } + + const results = await visitSessionsService.getVisitSessionsCalendar( + prisoner.prisonCode, + prisoner.prisonerNumber, + visitorIds, + daysAhead, + ) + + expect(orchestrationApiClient.getVisitSessions).toHaveBeenCalledWith( + prisoner.prisonCode, + prisoner.prisonerNumber, + visitorIds, + ) + expect(results).toStrictEqual(expectedVisitSessionsCalendar) + }) + + // TODO add tests to cover different startDayColumn values (mon / sun) and year boundary + + it('should return an empty VisitSessionsCalendar if no available visit sessions', async () => { const prisoner = TestData.prisonerInfoDto() const visitorIds = [1, 2] - const visitSessions: AvailableVisitSessionDto[] = [TestData.availableVisitSessionDto()] + const visitSessions: AvailableVisitSessionDto[] = [] orchestrationApiClient.getVisitSessions.mockResolvedValue(visitSessions) - const results = await visitSessionsService.getVisitSessions( + const expectedVisitSessionsCalendar: VisitSessionsCalendar = { + selectedDate: '', + months: {}, + } + + const results = await visitSessionsService.getVisitSessionsCalendar( prisoner.prisonCode, prisoner.prisonerNumber, visitorIds, + 28, ) expect(orchestrationApiClient.getVisitSessions).toHaveBeenCalledWith( @@ -42,7 +136,7 @@ describe('Visit sessions service', () => { prisoner.prisonerNumber, visitorIds, ) - expect(results).toStrictEqual(visitSessions) + expect(results).toStrictEqual(expectedVisitSessionsCalendar) }) }) }) diff --git a/server/services/visitSessionsService.ts b/server/services/visitSessionsService.ts index 372ce412..f7d96faa 100644 --- a/server/services/visitSessionsService.ts +++ b/server/services/visitSessionsService.ts @@ -1,3 +1,5 @@ +import { addDays, eachDayOfInterval, format, formatDuration, getISODay, intervalToDuration, parse } from 'date-fns' +import { VisitSessionsCalendar } from '../@types/bapv' import { HmppsAuthClient, OrchestrationApiClient, RestClientBuilder } from '../data' import { AvailableVisitSessionDto } from '../data/orchestrationApiTypes' @@ -7,7 +9,61 @@ export default class VisitSessionsService { private readonly hmppsAuthClient: HmppsAuthClient, ) {} - async getVisitSessions( + private readonly dateFormat = 'yyyy-MM-dd' + + async getVisitSessionsCalendar( + prisonId: string, + prisonerId: string, + visitorIds: number[], + daysAhead: number, + ): Promise { + const visitSessions = await this.getVisitSessions(prisonId, prisonerId, visitorIds) + + if (visitSessions.length === 0) { + return { selectedDate: '', months: {} } + } + + const calendar: VisitSessionsCalendar = { + selectedDate: visitSessions[0].sessionDate, // TODO consider if this should be worked out in route? + months: {}, + } + + // generate all calendar dates: first is today; last is max booking window + const today = new Date() + const allCalendarDates = eachDayOfInterval({ + start: format(today, this.dateFormat), + end: format(addDays(today, daysAhead), this.dateFormat), + }) + + allCalendarDates.forEach(date => { + const currentMonth = format(date, 'MMMM yyyy') + if (!calendar.months[currentMonth]) { + calendar.months[currentMonth] = { startDayColumn: getISODay(date), dates: {} } + } + + const { dates } = calendar.months[currentMonth] + const thisDate = format(date, this.dateFormat) + const thisDateVisitSessions = visitSessions.filter(session => session.sessionDate === thisDate) + + dates[thisDate] = thisDateVisitSessions.map(session => { + const startTime = parse(session.sessionTimeSlot.startTime, 'HH:mm', date) + const endTime = parse(session.sessionTimeSlot.endTime, 'HH:mm', date) + + const startTimeFormatted = format(startTime, 'h:mmaaa').replace(':00', '') + const endTimeFormatted = format(endTime, 'h:mmaaa').replace(':00', '') + + const duration = formatDuration(intervalToDuration({ start: startTime, end: endTime }), { delimiter: ' and ' }) + return { + reference: session.sessionTemplateReference, + time: `${startTimeFormatted} to ${endTimeFormatted}`, + duration, + } + }) + }) + return calendar + } + + private async getVisitSessions( prisonId: string, prisonerId: string, visitorIds: number[], diff --git a/server/views/pages/bookingJourney/selectVisitDateTime.njk b/server/views/pages/bookingJourney/selectVisitDateTime.njk index 58a50721..2f12b91c 100644 --- a/server/views/pages/bookingJourney/selectVisitDateTime.njk +++ b/server/views/pages/bookingJourney/selectVisitDateTime.njk @@ -18,7 +18,7 @@ -
+ {# Build radio visit slot items from months/dates/slots data #} - {% for monthName, monthData in calendarData.months %} + {% for monthName, monthData in visitSessionsCalendar.months %} {% for date, slots in monthData.dates %} {% if slots | length %} {% set visitSlotItems = [] %} {% for slot in slots %} {% set visitSlotItems = (visitSlotItems.push({ - value: slot.reference, + value: date + "_" +slot.reference, html: slot.time + ' (' + slot.duration + ')' }), visitSlotItems) %} {% endfor %} @@ -113,21 +113,6 @@ - - {# TODO remove below #} -
-
-    

Select date and time

- - BOOKER - {{ booker | dump(2) }} - - BOOKING JOURNEY - {{ bookingJourney | dump(2) }} - - AVAILABLE VISIT SESSIONS - {{ visitSessions | dump(2) }} -
{% endblock %} {% block pageScripts %}