generated from ministryofjustice/hmpps-template-typescript
-
Notifications
You must be signed in to change notification settings - Fork 1
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
Bob Meredith
committed
Dec 20, 2024
1 parent
220b106
commit 5e0bcce
Showing
13 changed files
with
467 additions
and
40 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
113 changes: 113 additions & 0 deletions
113
server/controllers/manage/premises/apOccupancyViewController.test.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,113 @@ | ||
import type { Cas1PremisesSummary } from '@approved-premises/api' | ||
import type { NextFunction, Request, Response } from 'express' | ||
import { DeepMocked, createMock } from '@golevelup/ts-jest' | ||
|
||
import { PremisesService } from 'server/services' | ||
import ApOccupancyViewController from './apOccupancyViewController' | ||
|
||
import { cas1PremiseCapacityFactory, cas1PremisesSummaryFactory } from '../../../testutils/factories' | ||
|
||
import paths from '../../../paths/manage' | ||
import { occupancyCalendar } from '../../../utils/premises/occupancy' | ||
import { DateFormats } from '../../../utils/dateUtils' | ||
|
||
describe('AP occupancyViewController', () => { | ||
const token = 'TEST_TOKEN' | ||
const premisesId = 'some-uuid' | ||
|
||
let request: DeepMocked<Request> | ||
let response: DeepMocked<Response> = createMock<Response>({}) | ||
const next: DeepMocked<NextFunction> = createMock<NextFunction>({}) | ||
|
||
const premisesService = createMock<PremisesService>({}) | ||
const occupancyViewController = new ApOccupancyViewController(premisesService) | ||
|
||
beforeEach(() => { | ||
jest.resetAllMocks() | ||
request = createMock<Request>({ user: { token }, params: { premisesId }, flash: jest.fn() }) | ||
response = createMock<Response>({ locals: { user: { permissions: ['cas1_space_booking_list'] } } }) | ||
|
||
jest.useFakeTimers() | ||
jest.setSystemTime(new Date('2024-01-01')) | ||
}) | ||
|
||
describe('view', () => { | ||
const mockPremises = async (startDate: string = DateFormats.dateObjToIsoDate(new Date())) => { | ||
const premisesSummary: Cas1PremisesSummary = cas1PremisesSummaryFactory.build({ id: premisesId }) | ||
const premisesCapacity = cas1PremiseCapacityFactory.build({ startDate }) | ||
premisesService.getCapacity.mockResolvedValue(premisesCapacity) | ||
premisesService.find.mockResolvedValue(premisesSummary) | ||
|
||
const requestHandler = occupancyViewController.view() | ||
await requestHandler(request, response, next) | ||
|
||
return { | ||
premisesSummary, | ||
premisesCapacity, | ||
} | ||
} | ||
|
||
it('should render the premises occupancy view with default date and duration', async () => { | ||
const startDate = '2024-01-01' | ||
const endDate = '2024-03-25' | ||
const { premisesSummary, premisesCapacity } = await mockPremises(startDate) | ||
|
||
expect(response.render).toHaveBeenCalledWith( | ||
'manage/premises/occupancy/view', | ||
expect.objectContaining({ | ||
calendarHeading: 'Showing 12 weeks from 1 Jan 2024', | ||
premises: premisesSummary, | ||
backLink: paths.premises.show({ premisesId }), | ||
calendar: occupancyCalendar(premisesCapacity.capacity, premisesId), | ||
}), | ||
) | ||
expect(premisesService.find).toHaveBeenCalledWith(token, premisesId) | ||
expect(premisesService.getCapacity).toHaveBeenCalledWith(token, premisesId, startDate, endDate) | ||
}) | ||
|
||
it('should render the premises occupancy view with specified valid date and duration', async () => { | ||
request = createMock<Request>({ | ||
user: { token }, | ||
params: { premisesId }, | ||
flash: jest.fn(), | ||
query: { 'startDate-year': '2024', 'startDate-month': '06', 'startDate-day': '20', durationDays: '7' }, | ||
}) | ||
const { premisesSummary, premisesCapacity } = await mockPremises('2024-06-20') | ||
|
||
expect(response.render).toHaveBeenCalledWith( | ||
'manage/premises/occupancy/view', | ||
expect.objectContaining({ | ||
calendarHeading: 'Showing 1 week from 20 Jun 2024', | ||
premises: premisesSummary, | ||
backLink: paths.premises.show({ premisesId }), | ||
calendar: occupancyCalendar(premisesCapacity.capacity, premisesId), | ||
errorSummary: [], | ||
}), | ||
) | ||
expect(premisesService.find).toHaveBeenCalledWith(token, premisesId) | ||
expect(premisesService.getCapacity).toHaveBeenCalledWith(token, premisesId, '2024-06-20', '2024-06-27') | ||
}) | ||
|
||
it('should render error if date is invalid', async () => { | ||
request = createMock<Request>({ | ||
user: { token }, | ||
params: { premisesId }, | ||
flash: jest.fn(), | ||
query: { 'startDate-year': '2023', 'startDate-month': '02', 'startDate-day': '29', durationDays: '7' }, | ||
}) | ||
const { premisesSummary } = await mockPremises('2024-06-20') | ||
|
||
expect(response.render).toHaveBeenCalledWith( | ||
'manage/premises/occupancy/view', | ||
expect.objectContaining({ | ||
premises: premisesSummary, | ||
backLink: paths.premises.show({ premisesId }), | ||
calendar: [], | ||
errorSummary: [{ text: 'Enter a valid date', href: '#startDate' }], | ||
}), | ||
) | ||
expect(premisesService.find).toHaveBeenCalledWith(token, premisesId) | ||
expect(premisesService.getCapacity).not.toHaveBeenCalled() | ||
}) | ||
}) | ||
}) |
64 changes: 64 additions & 0 deletions
64
server/controllers/manage/premises/apOccupancyViewController.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,64 @@ | ||
import type { Request, RequestHandler, Response } from 'express' | ||
|
||
import { ObjectWithDateParts } from '@approved-premises/ui' | ||
import { PremisesService } from '../../../services' | ||
|
||
import paths from '../../../paths/manage' | ||
import { Calendar, durationSelectOptions, occupancyCalendar } from '../../../utils/premises/occupancy' | ||
import { DateFormats, dateAndTimeInputsAreValidDates, daysToWeeksAndDays } from '../../../utils/dateUtils' | ||
import { placementDates } from '../../../utils/match' | ||
import { fetchErrorsAndUserInput, generateErrorMessages, generateErrorSummary } from '../../../utils/validation' | ||
|
||
export default class ApOccupancyViewController { | ||
constructor(private readonly premisesService: PremisesService) {} | ||
|
||
view(): RequestHandler { | ||
return async (req: Request, res: Response) => { | ||
const { token } = req.user | ||
const { premisesId } = req.params | ||
const { errors, errorSummary } = fetchErrorsAndUserInput(req) | ||
|
||
let startDate | ||
if (req.query.durationDays) { | ||
if (dateAndTimeInputsAreValidDates(req.query as ObjectWithDateParts<'startDate'>, 'startDate')) { | ||
startDate = DateFormats.dateAndTimeInputsToIsoString( | ||
req.query as ObjectWithDateParts<'startDate'>, | ||
'startDate', | ||
).startDate | ||
} else { | ||
const dateError = { startDate: 'Enter a valid date' } | ||
Object.assign(errors, generateErrorMessages(dateError)) | ||
errorSummary.push(generateErrorSummary(dateError)[0]) | ||
startDate = DateFormats.dateObjToIsoDate(new Date()) | ||
} | ||
} | ||
startDate = startDate || DateFormats.dateObjToIsoDate(new Date()) | ||
const { durationDays = '84', ...startDateParts } = req.query | ||
const premises = await this.premisesService.find(req.user.token, premisesId) | ||
let calendar: Calendar = [] | ||
if (!errorSummary.length) { | ||
const capacityDates = placementDates(String(startDate), String(durationDays)) | ||
const capacity = await this.premisesService.getCapacity( | ||
token, | ||
premisesId, | ||
capacityDates.startDate, | ||
capacityDates.endDate, | ||
) | ||
calendar = occupancyCalendar(capacity.capacity, premisesId) | ||
} | ||
const calendarHeading = `Showing ${DateFormats.formatDuration(daysToWeeksAndDays(String(durationDays)))} from ${DateFormats.isoDateToUIDate(startDate, { format: 'short' })}` | ||
return res.render('manage/premises/occupancy/view', { | ||
premises, | ||
calendar, | ||
backLink: paths.premises.show({ premisesId }), | ||
calendarHeading, | ||
...DateFormats.isoDateToDateInputs(startDate, 'startDate'), | ||
...startDateParts, | ||
selfPath: paths.premises.occupancy.view({ premisesId }), | ||
durationOptions: durationSelectOptions(String(durationDays)), | ||
errors, | ||
errorSummary, | ||
}) | ||
} | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,51 @@ | ||
import type { SelectOption } from '@approved-premises/ui' | ||
import { cas1PremiseCapacityFactory } from '../../testutils/factories' | ||
import { durationSelectOptions, occupancyCalendar } from './occupancy' | ||
import { DateFormats } from '../dateUtils' | ||
|
||
describe('apOccupancy utils', () => { | ||
describe('occupancyCalendar', () => { | ||
it('converts the premises capacity to a calendar', () => { | ||
const capacity = cas1PremiseCapacityFactory.build({ startDate: '2024-12-01', endDate: '2024-12-07' }) | ||
const premisesId = 'test-premises-id' | ||
const calendar = occupancyCalendar(capacity.capacity, premisesId) | ||
calendar.forEach(month => { | ||
expect(month.name).toEqual('December 2024') | ||
month.days.forEach((day, index) => { | ||
const { date, availableBedCount, bookingCount } = capacity.capacity[index] | ||
let expectedStatus = availableBedCount < bookingCount ? 'overbooked' : 'available' | ||
expectedStatus = availableBedCount === bookingCount ? 'full' : expectedStatus | ||
expect(day).toEqual({ | ||
link: `/manage/premises/test-premises-id/occupancy/day?date=${date}`, | ||
availability: availableBedCount - bookingCount, | ||
booked: bookingCount, | ||
name: DateFormats.isoDateToUIDate(date, { format: 'longNoYear' }), | ||
status: expectedStatus, | ||
}) | ||
}) | ||
}) | ||
}) | ||
}) | ||
const durationOptions: Array<SelectOption> = [ | ||
{ selected: undefined, text: '1 week', value: '7' }, | ||
{ selected: undefined, text: '6 weeks', value: '42' }, | ||
{ selected: undefined, text: '12 weeks', value: '84' }, | ||
{ selected: undefined, text: '26 weeks', value: '182' }, | ||
{ selected: undefined, text: '52 weeks', value: '364' }, | ||
] | ||
|
||
describe('durationSelectOptions', () => { | ||
it('should return the set of duration periods', () => { | ||
expect(durationSelectOptions()).toEqual(durationOptions) | ||
}) | ||
|
||
it('should select the option matching the supplied duration', () => { | ||
expect(durationSelectOptions('26')).toEqual( | ||
durationOptions.map(option => (option.value === '26' ? { ...option, selected: true } : option)), | ||
) | ||
}) | ||
it('should not select an option if there is no matching value', () => { | ||
expect(durationSelectOptions('27')).toEqual(durationOptions) | ||
}) | ||
}) | ||
}) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,67 @@ | ||
import type { Cas1PremiseCapacityForDay } from '@approved-premises/api' | ||
import { SelectOption } from '@approved-premises/ui' | ||
import { DateFormats } from '../dateUtils' | ||
import paths from '../../paths/manage' | ||
|
||
type CalendarDayStatus = 'available' | 'full' | 'overbooked' | ||
|
||
type CalendarDay = { | ||
name: string | ||
status: CalendarDayStatus | ||
availability: number | ||
booked: number | ||
criteriaBookableCount?: number | ||
link: string | ||
} | ||
type CalendarMonth = { | ||
name: string | ||
days: Array<CalendarDay> | ||
} | ||
export type Calendar = Array<CalendarMonth> | ||
|
||
export const occupancyCalendar = (capacity: Array<Cas1PremiseCapacityForDay>, premisesId: string): Calendar => { | ||
return capacity.reduce<Calendar>((calendar, { availableBedCount, bookingCount, date }) => { | ||
const monthAndYear = DateFormats.isoDateToMonthAndYear(date) | ||
let currentMonth = calendar.find(month => month.name === monthAndYear) | ||
|
||
if (!currentMonth) { | ||
currentMonth = { | ||
name: monthAndYear, | ||
days: [], | ||
} | ||
calendar.push(currentMonth) | ||
} | ||
|
||
const availability = availableBedCount - bookingCount | ||
|
||
let status: CalendarDayStatus = availability >= 0 ? 'available' : 'overbooked' | ||
if (availability === 0) status = 'full' | ||
|
||
const calendarDay: CalendarDay = { | ||
name: DateFormats.isoDateToUIDate(date, { format: 'longNoYear' }), | ||
status, | ||
availability, | ||
booked: bookingCount, | ||
link: `${paths.premises.occupancy.day({ premisesId })}?date=${date}`, | ||
} | ||
|
||
currentMonth.days.push(calendarDay) | ||
|
||
return calendar | ||
}, []) | ||
} | ||
|
||
const durationOptionsMap: Record<number, string> = { | ||
'7': '1 week', | ||
'42': '6 weeks', | ||
'84': '12 weeks', | ||
'182': '26 weeks', | ||
'364': '52 weeks', | ||
} | ||
|
||
export const durationSelectOptions = (durationDays?: string): Array<SelectOption> => | ||
Object.entries(durationOptionsMap).map(([value, label]) => ({ | ||
value, | ||
text: label, | ||
selected: value === durationDays || undefined, | ||
})) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.