Skip to content

Commit

Permalink
APS-1677 AP occupancy view
Browse files Browse the repository at this point in the history
  • Loading branch information
Bob Meredith committed Dec 20, 2024
1 parent 220b106 commit 5e0bcce
Show file tree
Hide file tree
Showing 13 changed files with 467 additions and 40 deletions.
4 changes: 4 additions & 0 deletions server/controllers/manage/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import DateChangesController from './dateChangesController'

import type { Services } from '../../services'
import PremisesController from './premises/premisesController'
import ApOccupancyViewController from './premises/apOccupancyViewController'
import PlacementController from './placementController'
import BedsController from './premises/bedsController'
import OutOfServiceBedsController from './outOfServiceBedsController'
Expand Down Expand Up @@ -45,6 +46,7 @@ export const controllers = (services: Services) => {
const nonArrivalsController = new NonArrivalsController(services.premisesService, services.placementService)
const keyworkerController = new KeyworkerController(services.premisesService, services.placementService)
const departuresController = new DeparturesController(services.premisesService, services.placementService)
const apOccupancyViewController = new ApOccupancyViewController(services.premisesService)

return {
premisesController,
Expand All @@ -60,6 +62,7 @@ export const controllers = (services: Services) => {
cancellationsController,
placementController,
keyworkerController,
apOccupancyViewController,
}
}

Expand All @@ -75,4 +78,5 @@ export {
UpdateOutOfServiceBedsController,
BookingsController,
BookingExtensionsController,
ApOccupancyViewController,
}
113 changes: 113 additions & 0 deletions server/controllers/manage/premises/apOccupancyViewController.test.ts
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 server/controllers/manage/premises/apOccupancyViewController.ts
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,
})
}
}
}
4 changes: 4 additions & 0 deletions server/paths/manage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,10 @@ const paths = {
create: placementCancellationsPath.path('create'),
},
},
occupancy: {
view: singlePremisesPath.path('occupancy'),
day: singlePremisesPath.path('occupancy/day'),
},
},

bookings: {
Expand Down
3 changes: 3 additions & 0 deletions server/routes/manage.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { Router } from 'express'
import { DeepMocked, createMock } from '@golevelup/ts-jest'
import {
ApOccupancyViewController,
ArrivalsController,
BedsController,
BookingExtensionsController,
Expand Down Expand Up @@ -44,6 +45,7 @@ describe('manage routes', () => {
const cancellationsController: DeepMocked<CancellationsController> = createMock<CancellationsController>({})
const redirectController: DeepMocked<RedirectController> = createMock<RedirectController>({})
const keyworkerController: DeepMocked<KeyworkerController> = createMock<KeyworkerController>({})
const apOccupancyViewController: DeepMocked<ApOccupancyViewController> = createMock<ApOccupancyViewController>({})

const controllers: DeepMocked<Controllers> = createMock<Controllers>({
bookingExtensionsController,
Expand All @@ -60,6 +62,7 @@ describe('manage routes', () => {
redirectController,
placementController,
keyworkerController,
apOccupancyViewController,
})
const services: DeepMocked<Services> = createMock<Services>({})

Expand Down
4 changes: 4 additions & 0 deletions server/routes/manage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ export default function routes(controllers: Controllers, router: Router, service
cancellationsController,
redirectController,
keyworkerController,
apOccupancyViewController,
} = controllers

// Deprecated paths, redirect to v2 equivalent
Expand Down Expand Up @@ -239,6 +240,9 @@ export default function routes(controllers: Controllers, router: Router, service
allowedPermissions: ['cas1_space_booking_withdraw'],
})

// Occupancy
get(paths.premises.occupancy.view.pattern, apOccupancyViewController.view())

// Bookings
get(paths.bookings.show.pattern, bookingsController.show(), {
auditEvent: 'SHOW_BOOKING',
Expand Down
51 changes: 51 additions & 0 deletions server/utils/premises/occupancy.test.ts
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)
})
})
})
67 changes: 67 additions & 0 deletions server/utils/premises/occupancy.ts
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,
}))
5 changes: 5 additions & 0 deletions server/utils/premises/premisesActions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,11 @@ export const premisesActions = (user: UserDetails, premises: Premises) => {
classes: 'govuk-button--secondary',
href: paths.outOfServiceBeds.premisesIndex({ premisesId: premises.id, temporality: 'current' }),
})
actions.push({
text: 'View spaces',
classes: 'govuk-button--secondary',
href: paths.premises.occupancy.view({ premisesId: premises.id }),
})
}

return actions
Expand Down
Loading

0 comments on commit 5e0bcce

Please sign in to comment.