From 0ee5dc8e682f3e1a720cb904c99c4589afb9ef99 Mon Sep 17 00:00:00 2001 From: Fred Marecesche Date: Wed, 4 Dec 2024 16:22:29 +0000 Subject: [PATCH 1/3] Separate sections for occupancy view --- .../pages/match/occupancyViewPage.ts | 6 +++++ server/utils/nunjucksSetup.ts | 3 ++- .../placementRequests/occupancyView/view.njk | 26 +++++++++++++------ 3 files changed, 26 insertions(+), 9 deletions(-) diff --git a/integration_tests/pages/match/occupancyViewPage.ts b/integration_tests/pages/match/occupancyViewPage.ts index c85d3b211..174202139 100644 --- a/integration_tests/pages/match/occupancyViewPage.ts +++ b/integration_tests/pages/match/occupancyViewPage.ts @@ -14,6 +14,7 @@ import { } from '../../../server/utils/match' import { createQueryString } from '../../../server/utils/utils' import paths from '../../../server/paths/match' +import { DateFormats, daysToWeeksAndDays } from '../../../server/utils/dateUtils' export default class OccupancyViewPage extends Page { constructor(premisesName: string) { @@ -47,6 +48,11 @@ export default class OccupancyViewPage extends Page { occupancyViewSummaryListForMatchingDetails(totalCapacity, dates, placementRequest, essentialCharacteristics), ) }) + cy.get('.govuk-heading-l') + .contains( + `View availability and book your placement for ${DateFormats.formatDuration(daysToWeeksAndDays(durationDays))} from ${DateFormats.isoDateToUIDate(startDate, { format: 'short' })}`, + ) + .should('exist') } shouldShowOccupancySummary(premiseCapacity: Cas1PremiseCapacity) { diff --git a/server/utils/nunjucksSetup.ts b/server/utils/nunjucksSetup.ts index 7df798840..9f22fb3e4 100644 --- a/server/utils/nunjucksSetup.ts +++ b/server/utils/nunjucksSetup.ts @@ -33,7 +33,7 @@ import { navigationItems } from './navigationItems' import { StatusTagOptions } from './statusTag' import { ApplicationStatusTag } from './applications/statusTag' -import { DateFormats, monthOptions, uiDateOrDateEmptyMessage, yearOptions } from './dateUtils' +import { DateFormats, daysToWeeksAndDays, monthOptions, uiDateOrDateEmptyMessage, yearOptions } from './dateUtils' import { pagination } from './pagination' import { sortHeader } from './sortHeader' import { SumbmittedApplicationSummaryCards } from './applications/submittedApplicationSummaryCards' @@ -121,6 +121,7 @@ export default function nunjucksSetup(app: express.Express, path: pathModule.Pla njkEnv.addGlobal('formatDate', (date: string, options: { format: 'short' | 'long' } = { format: 'long' }) => DateFormats.isoDateToUIDate(date, options), ) + njkEnv.addGlobal('formatDuration', (days: number) => DateFormats.formatDuration(daysToWeeksAndDays(days))) njkEnv.addGlobal('formatDateTime', (date: string) => DateFormats.isoDateTimeToUIDateTime(date)) njkEnv.addGlobal('dateObjToUIDate', (date: Date) => DateFormats.dateObjtoUIDate(date)) njkEnv.addGlobal( diff --git a/server/views/match/placementRequests/occupancyView/view.njk b/server/views/match/placementRequests/occupancyView/view.njk index 9fcda8f3c..97a01d89b 100644 --- a/server/views/match/placementRequests/occupancyView/view.njk +++ b/server/views/match/placementRequests/occupancyView/view.njk @@ -30,13 +30,23 @@ html: detailsHTML }) }} - {{ govukNotificationBanner({ - html: occupancySummaryHtml, - classes: 'govuk-notification-banner--full-width-content' - }) }} +
+

View availability and book your placement + for {{ formatDuration(durationDays) }} + from {{ formatDate(startDate, { format: 'short' }) }}

+ + {{ govukNotificationBanner({ + html: occupancySummaryHtml, + classes: 'govuk-notification-banner--full-width-content' + }) }} +
+ +
+

Book your placement

- - Continue - + + Continue + +
{% endblock %} From 455a4645e586e0292022f6d2c614b43ef64f33cb Mon Sep 17 00:00:00 2001 From: Fred Marecesche Date: Thu, 5 Dec 2024 09:26:43 +0000 Subject: [PATCH 2/3] Implement basic calendar view This lays out the calendar format and displays each date on the page. --- assets/sass/components/_calendar.scss | 137 ++++-------------- .../occupancyViewController.ts | 3 + .../factories/cas1PremiseCapacity.ts | 8 +- server/utils/dateUtils.test.ts | 16 ++ server/utils/dateUtils.ts | 22 ++- server/utils/match/occupancyCalendar.test.ts | 24 +++ server/utils/match/occupancyCalendar.ts | 35 +++++ .../partials/_occupancyCalendar.njk | 14 ++ .../placementRequests/occupancyView/view.njk | 10 ++ 9 files changed, 150 insertions(+), 119 deletions(-) create mode 100644 server/utils/match/occupancyCalendar.test.ts create mode 100644 server/utils/match/occupancyCalendar.ts create mode 100644 server/views/match/placementRequests/occupancyView/partials/_occupancyCalendar.njk diff --git a/assets/sass/components/_calendar.scss b/assets/sass/components/_calendar.scss index c1ed90abc..0d3124e1b 100644 --- a/assets/sass/components/_calendar.scss +++ b/assets/sass/components/_calendar.scss @@ -1,123 +1,44 @@ -$colWidth: 22px; -$colPadding: 2px; +.calendar__month { + border-top: 1px solid $govuk-border-colour; -$roomHeaderWidth: 100px; - -$calendarWidth: $roomHeaderWidth + (30 * ($colWidth + ($colPadding * 2))); - -.govuk-link { - &--booking { - &:link, &:visited { - color: govuk-colour("black"); - font-weight: bold; - } + .calendar__day { + padding: govuk-spacing(2) 0; + border-bottom: 1px solid $govuk-border-colour; } - &--overbooking { - &:link, &:visited { - color: govuk-colour("white"); - font-weight: bold; - } - } -} - -.govuk-pagination { - &--calendar { - padding-bottom: govuk-spacing(6); + @include govuk-media-query($from: tablet) { + display: grid; + grid-template-columns: repeat(7, 1fr); + grid-gap: 2px; + border-top: none; - .govuk-pagination__prev { - float: left; + .calendar__day { + margin: 0; + padding: govuk-spacing(2); + border: 1px solid $govuk-text-colour; + @include govuk-font-size(16); } - .govuk-pagination__next { - float: right; - border-top: none; + .calendar__day--mon { + grid-column-start: 1; } - - .govuk-pagination__prev + .govuk-pagination__next { - border-top: none; + .calendar__day--tue { + grid-column-start: 2; } - } -} - -.govuk-table { - &--calendar { - font-size: 0.9em; - width: $calendarWidth; - table-layout: fixed; - margin: 0 auto; - - @for $i from 1 through 31 { - th[colspan="#{$i}"], td[colspan="#{$i}"] { - width: ($i * ($colWidth + ($colPadding * 2))); - } + .calendar__day--wed { + grid-column-start: 3; } - } - - &__header { - &--calendar { - border: 1px solid $govuk-border-colour; - text-align: center; - width: $colWidth; - padding: $colPadding; + .calendar__day--thu { + grid-column-start: 4; } - - &--calendar-room-header { - text-align: center; - width: 100px; - vertical-align: middle; + .calendar__day--fri { + grid-column-start: 5; } - } - - - &__cell { - &--calendar { - position: relative; - border: 1px solid $govuk-border-colour; - padding: $colPadding; - - span { - white-space: nowrap; - overflow:hidden; - text-overflow: ellipsis; - display: block; - - &.tooltip { - &:hover::before { - position: absolute; - bottom: -120%; - left: 10px; - z-index: 999; - content: attr(title); - opacity: 0.75; - background-color: govuk-colour("black"); - color: govuk-colour("white"); - padding: $colPadding; - border-radius: 3px; - display: block; - font-weight: normal; - } - } - } + .calendar__day--sat { + grid-column-start: 6; } - - &--booking { - background-color: govuk-colour("light-blue"); - } - - &--lost_bed { - background-color: govuk-colour("light-grey"); - font-weight: bold; - } - - &--month { - text-align: center; - } - - &--overbooking { - background-color: govuk-colour("black"); - color: govuk-colour("white"); - font-weight: bold; + .calendar__day--sun { + grid-column-start: 7; } } } diff --git a/server/controllers/match/placementRequests/occupancyViewController.ts b/server/controllers/match/placementRequests/occupancyViewController.ts index 2caef3973..5b669f167 100644 --- a/server/controllers/match/placementRequests/occupancyViewController.ts +++ b/server/controllers/match/placementRequests/occupancyViewController.ts @@ -7,6 +7,7 @@ import { occupancyViewSummaryListForMatchingDetails, placementDates, } from '../../../utils/match' +import { occupancyCalendar } from '../../../utils/match/occupancyCalendar' interface NewRequest extends Request { params: { id: string } @@ -35,6 +36,7 @@ export default class { essentialCharacteristics, ) const occupancySummaryHtml = occupancySummary(capacity) + const calendar = occupancyCalendar(capacity) res.render('match/placementRequests/occupancyView/view', { pageHeading: `View spaces in ${premisesName}`, @@ -46,6 +48,7 @@ export default class { durationDays, matchingDetailsSummaryList, occupancySummaryHtml, + calendar, }) } } diff --git a/server/testutils/factories/cas1PremiseCapacity.ts b/server/testutils/factories/cas1PremiseCapacity.ts index 03ada14d6..a1b2faaba 100644 --- a/server/testutils/factories/cas1PremiseCapacity.ts +++ b/server/testutils/factories/cas1PremiseCapacity.ts @@ -11,10 +11,10 @@ import cas1PremisesSummaryFactory from './cas1PremisesSummary' import { DateFormats } from '../../utils/dateUtils' import { offenceAndRiskCriteria } from '../../utils/placementCriteriaUtils' -export default Factory.define(() => { - const startDate = faker.date.anytime() - const endDate = faker.date.soon({ days: 365, refDate: startDate }) - const days = differenceInDays(endDate, startDate) +export default Factory.define(({ params }) => { + const startDate = DateFormats.isoToDateObj(params.startDate) || faker.date.anytime() + const endDate = DateFormats.isoToDateObj(params.endDate) || faker.date.soon({ days: 365, refDate: startDate }) + const days = differenceInDays(endDate, startDate) + 1 const capacity = Array.from(Array(days).keys()).map(index => cas1PremiseCapacityForDayFactory.build({ diff --git a/server/utils/dateUtils.test.ts b/server/utils/dateUtils.test.ts index 4b6e3a73d..f27d88510 100644 --- a/server/utils/dateUtils.test.ts +++ b/server/utils/dateUtils.test.ts @@ -70,6 +70,13 @@ describe('DateFormats', () => { expect(DateFormats.isoDateToUIDate(date, { format: 'short' })).toEqual(expectedUiDate) }) + it.each([ + ['2022-11-09T00:00:00.000Z', 'Wed 9 Nov'], + ['2022-11-11T00:00:00.000Z', 'Fri 11 Nov'], + ])('converts ISO8601 date %s to a long format date with no year', (date, expectedUiDate) => { + expect(DateFormats.isoDateToUIDate(date, { format: 'longNoYear' })).toEqual(expectedUiDate) + }) + it('raises an error if the date is not a valid ISO8601 date string', () => { const date = '23/11/2022' @@ -281,6 +288,15 @@ describe('DateFormats', () => { expect(DateFormats.formatDuration({ days: '4', weeks: '7' })).toEqual('7 weeks, 4 days') }) }) + + describe('isoDateToMonthAndYear', () => { + it.each([ + ['2024-12-04', 'December 2024'], + ['2025-01-01', 'January 2025'], + ])('returns the month and year for the date %s', (date, expected) => { + expect(DateFormats.isoDateToMonthAndYear(date)).toEqual(expected) + }) + }) }) describe('uiDateOrDateEmptyMessage', () => { diff --git a/server/utils/dateUtils.ts b/server/utils/dateUtils.ts index b2ae28f2c..26e8a6502 100644 --- a/server/utils/dateUtils.ts +++ b/server/utils/dateUtils.ts @@ -33,6 +33,13 @@ type DurationWithNumberOrString = { seconds?: number | string } +const uiDateFormats = { + short: 'd MMM y', + long: 'ccc d MMM y', + longNoYear: 'ccc d MMM', +} +type UiDateFormat = keyof typeof uiDateFormats + export class DateFormats { /** * @param date JS Date object. @@ -64,11 +71,8 @@ export class DateFormats { * @param options.format - 'long' (default, e.g. "Thu 20 Dec 2012") or 'short' (e.g. "20 Dec 2012") * @returns the date in the to be shown in the UI. */ - static dateObjtoUIDate(date: Date, options: { format: 'short' | 'long' } = { format: 'long' }) { - if (options.format === 'long') { - return format(date, 'ccc d MMM y') - } - return format(date, 'd MMM y') + static dateObjtoUIDate(date: Date, options: { format: UiDateFormat } = { format: 'long' }) { + return format(date, uiDateFormats[options.format]) } /** @@ -99,7 +103,7 @@ export class DateFormats { * @param options.format - 'long' (default, e.g. "Thu 20 Dec 2012") or 'short' (e.g. "20 Dec 2012") * @returns the date in the to be shown in the UI. */ - static isoDateToUIDate(isoDate: string, options: { format: 'short' | 'long' } = { format: 'long' }) { + static isoDateToUIDate(isoDate: string, options: { format: UiDateFormat } = { format: 'long' }) { return DateFormats.dateObjtoUIDate(DateFormats.isoToDateObj(isoDate), options) } @@ -226,10 +230,14 @@ export class DateFormats { static formatDurationBetweenTwoDates( date1: string, date2: string, - options: { format: 'short' | 'long' } = { format: 'long' }, + options: { format: UiDateFormat } = { format: 'long' }, ): string { return `${DateFormats.isoDateToUIDate(date1, { format: options.format })} - ${DateFormats.isoDateToUIDate(date2, { format: options.format })}` } + + static isoDateToMonthAndYear(date: string) { + return format(date, 'MMMM yyyy') + } } export const addBusinessDays = (date: Date, days: number, holidays: Array = bankHolidays()): Date => { diff --git a/server/utils/match/occupancyCalendar.test.ts b/server/utils/match/occupancyCalendar.test.ts new file mode 100644 index 000000000..e099353b1 --- /dev/null +++ b/server/utils/match/occupancyCalendar.test.ts @@ -0,0 +1,24 @@ +import { occupancyCalendar } from './occupancyCalendar' +import { cas1PremiseCapacityFactory } from '../../testutils/factories' + +describe('occupancyCalendar', () => { + it('returns a calendar from the start date to the end date', () => { + const premisesCapacity = cas1PremiseCapacityFactory.build({ startDate: '2024-12-30', endDate: '2025-01-02' }) + expect(occupancyCalendar(premisesCapacity)).toEqual([ + { + name: 'December 2024', + days: [ + { name: 'Mon 30 Dec', ...premisesCapacity.capacity[0] }, + { name: 'Tue 31 Dec', ...premisesCapacity.capacity[1] }, + ], + }, + { + name: 'January 2025', + days: [ + { name: 'Wed 1 Jan', ...premisesCapacity.capacity[2] }, + { name: 'Thu 2 Jan', ...premisesCapacity.capacity[3] }, + ], + }, + ]) + }) +}) diff --git a/server/utils/match/occupancyCalendar.ts b/server/utils/match/occupancyCalendar.ts new file mode 100644 index 000000000..3ee73379d --- /dev/null +++ b/server/utils/match/occupancyCalendar.ts @@ -0,0 +1,35 @@ +import { Cas1PremiseCapacity, Cas1PremiseCapacityForDay } from '@approved-premises/api' +import { DateFormats } from '../dateUtils' + +type CalendarDay = Cas1PremiseCapacityForDay & { + name: string +} +type CalendarMonth = { + name: string + days: Array +} +type Calendar = Array + +export const occupancyCalendar = (capacity: Cas1PremiseCapacity) => { + const calendar: Calendar = [] + + capacity.capacity.forEach(day => { + const dayMonthAndYear = DateFormats.isoDateToMonthAndYear(day.date) + let currentMonth = calendar.find(month => month.name === dayMonthAndYear) + + if (!currentMonth) { + currentMonth = { + name: dayMonthAndYear, + days: [], + } + calendar.push(currentMonth) + } + + currentMonth.days.push({ + name: DateFormats.isoDateToUIDate(day.date, { format: 'longNoYear' }), + ...day, + }) + }) + + return calendar +} diff --git a/server/views/match/placementRequests/occupancyView/partials/_occupancyCalendar.njk b/server/views/match/placementRequests/occupancyView/partials/_occupancyCalendar.njk new file mode 100644 index 000000000..0f2406416 --- /dev/null +++ b/server/views/match/placementRequests/occupancyView/partials/_occupancyCalendar.njk @@ -0,0 +1,14 @@ +{% macro occupancyCalendar(calendar) %} +
+ {% for month in calendar %} +

{{ month.name }}

+
    + {% for day in month.days %} +
  • + +
  • + {% endfor %} +
+ {% endfor %} +
+{% endmacro %} diff --git a/server/views/match/placementRequests/occupancyView/view.njk b/server/views/match/placementRequests/occupancyView/view.njk index 97a01d89b..034699c8a 100644 --- a/server/views/match/placementRequests/occupancyView/view.njk +++ b/server/views/match/placementRequests/occupancyView/view.njk @@ -3,6 +3,7 @@ {% from "govuk/components/button/macro.njk" import govukButton %} {% from "govuk/components/back-link/macro.njk" import govukBackLink %} {% from "govuk/components/notification-banner/macro.njk" import govukNotificationBanner %} +{% from "./partials/_occupancyCalendar.njk" import occupancyCalendar %} {% extends "../../layout-with-details.njk" %} @@ -15,6 +16,13 @@ }) }} {% endblock %} +{% block extraScripts %} + {# TODO: useful for debugging, remove once page complete! #} + +{% endblock %} + {% block content %}

{{ pageHeading }}

@@ -39,6 +47,8 @@ html: occupancySummaryHtml, classes: 'govuk-notification-banner--full-width-content' }) }} + + {{ occupancyCalendar(calendar) }}
From 096b0475cbf0f2dc306389faf937fe0096dd3ceb Mon Sep 17 00:00:00 2001 From: Fred Marecesche Date: Thu, 5 Dec 2024 15:17:35 +0000 Subject: [PATCH 3/3] Show days as available or fully booked This fixes an incorrect calculation of availability for the occupancy summary panel -- availability is now calculated based on `availableBedCount - bookingCount` all around. --- assets/sass/components/_calendar.scss | 59 +++++++++++- .../pages/match/occupancyViewPage.ts | 24 ++++- integration_tests/tests/match/match.cy.ts | 3 + .../occupancyViewController.test.ts | 2 + .../factories/cas1PremiseCapacity.ts | 23 +++-- server/utils/match/occupancy.test.ts | 96 +++++++++++++++++++ server/utils/match/occupancy.ts | 17 ++++ server/utils/match/occupancyCalendar.test.ts | 9 +- server/utils/match/occupancyCalendar.ts | 8 +- server/utils/match/occupancySummary.test.ts | 20 ++-- server/utils/match/occupancySummary.ts | 21 ++-- .../partials/_occupancyCalendar.njk | 16 +++- .../placementRequests/occupancyView/view.njk | 18 +++- 13 files changed, 271 insertions(+), 45 deletions(-) create mode 100644 server/utils/match/occupancy.test.ts create mode 100644 server/utils/match/occupancy.ts diff --git a/assets/sass/components/_calendar.scss b/assets/sass/components/_calendar.scss index 0d3124e1b..f973f5fc5 100644 --- a/assets/sass/components/_calendar.scss +++ b/assets/sass/components/_calendar.scss @@ -2,8 +2,44 @@ border-top: 1px solid $govuk-border-colour; .calendar__day { - padding: govuk-spacing(2) 0; + margin: 0; border-bottom: 1px solid $govuk-border-colour; + + &:focus-within { + border-color: transparent; + } + + #calendar-key & { + padding: govuk-spacing(2) 0; + } + } + + .calendar__link { + display: flex; + flex-direction: row; + gap: govuk-spacing(4); + color: $govuk-text-colour; + padding: govuk-spacing(2) 0; + text-decoration: none; + + &:hover { + background: govuk-colour('blue'); + color: govuk-colour('white'); + text-decoration: underline; + text-decoration-thickness: 2px; + } + } + + .calendar__date { + min-width: 20%; + } + + .calendar__availability { + margin: 0; + + dd { + margin: 0; + } } @include govuk-media-query($from: tablet) { @@ -12,10 +48,23 @@ grid-gap: 2px; border-top: none; + #calendar-key & { + display: flex; + } + .calendar__day { - margin: 0; - padding: govuk-spacing(2); + display: flex; border: 1px solid $govuk-text-colour; + + #calendar-key & { + padding: govuk-spacing(2) govuk-spacing(6); + } + } + + .calendar__link { + flex: 1; + flex-direction: column; + padding: govuk-spacing(2); @include govuk-font-size(16); } @@ -40,5 +89,9 @@ .calendar__day--sun { grid-column-start: 7; } + + .calendar__date { + margin-bottom: auto; + } } } diff --git a/integration_tests/pages/match/occupancyViewPage.ts b/integration_tests/pages/match/occupancyViewPage.ts index 174202139..e3884e9c7 100644 --- a/integration_tests/pages/match/occupancyViewPage.ts +++ b/integration_tests/pages/match/occupancyViewPage.ts @@ -15,6 +15,7 @@ import { import { createQueryString } from '../../../server/utils/utils' import paths from '../../../server/paths/match' import { DateFormats, daysToWeeksAndDays } from '../../../server/utils/dateUtils' +import { dateRangeAvailability } from '../../../server/utils/match/occupancy' export default class OccupancyViewPage extends Page { constructor(premisesName: string) { @@ -56,13 +57,32 @@ export default class OccupancyViewPage extends Page { } shouldShowOccupancySummary(premiseCapacity: Cas1PremiseCapacity) { - if (premiseCapacity.capacity.every(day => day.availableBedCount > 0)) { + const availability = dateRangeAvailability(premiseCapacity) + + if (availability === 'available') { this.shouldShowBanner('The placement dates you have selected are available.') - } else if (premiseCapacity.capacity.every(day => day.availableBedCount <= 0)) { + } else if (availability === 'none') { this.shouldShowBanner('There are no spaces available for the dates you have selected.') } else { this.shouldShowBanner('Available on:') this.shouldShowBanner('Overbooked on:') } } + + shouldShowCalendarCell(copy: string | RegExp) { + cy.get('.calendar__availability').contains(copy).should('exist') + } + + shouldShowOccupancyCalendar(premiseCapacity: Cas1PremiseCapacity) { + const firstMonth = DateFormats.isoDateToMonthAndYear(premiseCapacity.startDate) + cy.get('.govuk-heading-m').contains(firstMonth).should('exist') + + const availability = dateRangeAvailability(premiseCapacity) + if (availability === 'available' || availability === 'partial') { + this.shouldShowCalendarCell('Available') + } + if (availability === 'none' || availability === 'partial') { + this.shouldShowCalendarCell(/-?\d+ total/) + } + } } diff --git a/integration_tests/tests/match/match.cy.ts b/integration_tests/tests/match/match.cy.ts index b179551da..e92d1fa17 100644 --- a/integration_tests/tests/match/match.cy.ts +++ b/integration_tests/tests/match/match.cy.ts @@ -140,6 +140,9 @@ context('Placement Requests', () => { // And I should see a summary of occupancy occupancyViewPage.shouldShowOccupancySummary(premiseCapacity) + + // And I should see an occupancy calendar + occupancyViewPage.shouldShowOccupancyCalendar(premiseCapacity) }) it('allows me to book a space', () => { diff --git a/server/controllers/match/placementRequests/occupancyViewController.test.ts b/server/controllers/match/placementRequests/occupancyViewController.test.ts index 1a11463a9..ac46e4dec 100644 --- a/server/controllers/match/placementRequests/occupancyViewController.test.ts +++ b/server/controllers/match/placementRequests/occupancyViewController.test.ts @@ -10,6 +10,7 @@ import { occupancyViewSummaryListForMatchingDetails, placementDates, } from '../../../utils/match' +import { occupancyCalendar } from '../../../utils/match/occupancyCalendar' describe('OccupancyViewController', () => { const token = 'SOME_TOKEN' @@ -72,6 +73,7 @@ describe('OccupancyViewController', () => { filterOutAPTypes(placementRequestDetail.essentialCriteria), ), occupancySummaryHtml: occupancySummary(premiseCapacity), + calendar: occupancyCalendar(premiseCapacity), }) expect(placementRequestService.getPlacementRequest).toHaveBeenCalledWith(token, placementRequestDetail.id) }) diff --git a/server/testutils/factories/cas1PremiseCapacity.ts b/server/testutils/factories/cas1PremiseCapacity.ts index a1b2faaba..b0bea9520 100644 --- a/server/testutils/factories/cas1PremiseCapacity.ts +++ b/server/testutils/factories/cas1PremiseCapacity.ts @@ -12,8 +12,13 @@ import { DateFormats } from '../../utils/dateUtils' import { offenceAndRiskCriteria } from '../../utils/placementCriteriaUtils' export default Factory.define(({ params }) => { - const startDate = DateFormats.isoToDateObj(params.startDate) || faker.date.anytime() - const endDate = DateFormats.isoToDateObj(params.endDate) || faker.date.soon({ days: 365, refDate: startDate }) + const startDate = params.startDate ? DateFormats.isoToDateObj(params.startDate) : faker.date.anytime() + const endDate = params.endDate + ? DateFormats.isoToDateObj(params.endDate) + : faker.date.soon({ + days: 365, + refDate: startDate, + }) const days = differenceInDays(endDate, startDate) + 1 const capacity = Array.from(Array(days).keys()).map(index => @@ -32,9 +37,9 @@ export default Factory.define(({ params }) => { class CapacityForDayFactory extends Factory { available() { - const totalBedCount = faker.number.int({ min: 1, max: 40 }) - const availableBedCount = faker.number.int({ min: 1, max: totalBedCount }) - const bookingCount = totalBedCount - availableBedCount + const totalBedCount = faker.number.int({ min: 6, max: 40 }) + const availableBedCount = faker.number.int({ min: totalBedCount - 5, max: totalBedCount }) + const bookingCount = faker.number.int({ min: 0, max: availableBedCount - 1 }) return this.params({ totalBedCount, @@ -44,12 +49,14 @@ class CapacityForDayFactory extends Factory { } overbooked() { - const totalBedCount = faker.number.int({ min: 1, max: 40 }) - const availableBedCount = 0 + const totalBedCount = faker.number.int({ min: 6, max: 40 }) + const availableBedCount = faker.number.int({ min: totalBedCount - 5, max: totalBedCount }) + const bookingCount = faker.number.int({ min: availableBedCount, max: totalBedCount + 5 }) + return this.params({ totalBedCount, availableBedCount, - bookingCount: faker.number.int({ min: totalBedCount, max: totalBedCount + 10 }), + bookingCount, }) } } diff --git a/server/utils/match/occupancy.test.ts b/server/utils/match/occupancy.test.ts new file mode 100644 index 000000000..92e7ee83c --- /dev/null +++ b/server/utils/match/occupancy.test.ts @@ -0,0 +1,96 @@ +import { faker } from '@faker-js/faker' +import { cas1PremiseCapacityFactory, cas1PremiseCapacityForDayFactory } from '../../testutils/factories' +import { dateRangeAvailability, dayAvailabilityCount, dayHasAvailability } from './occupancy' + +describe('dayAvailabilityCount', () => { + it('returns the count of available spaces for the day', () => { + const availableBedCount = faker.number.int({ min: 1, max: 20 }) + const bookingCount = faker.number.int({ min: 1, max: 30 }) + const dayCapacity = cas1PremiseCapacityForDayFactory.build({ + availableBedCount, + bookingCount, + }) + + expect(dayAvailabilityCount(dayCapacity)).toEqual(availableBedCount - bookingCount) + }) +}) + +describe('dayHasAvailability', () => { + it('returns true if the day has availability', () => { + const dayCapacity = cas1PremiseCapacityForDayFactory.build({ + availableBedCount: 15, + bookingCount: 10, + }) + + expect(dayHasAvailability(dayCapacity)).toBe(true) + }) + + it('returns false if the day is overbooked', () => { + const dayCapacity = cas1PremiseCapacityForDayFactory.build({ + availableBedCount: 15, + bookingCount: 20, + }) + + expect(dayHasAvailability(dayCapacity)).toBe(false) + }) + + it('returns false if the day is fully booked', () => { + const dayCapacity = cas1PremiseCapacityForDayFactory.build({ + availableBedCount: 15, + bookingCount: 15, + }) + + expect(dayHasAvailability(dayCapacity)).toBe(false) + }) +}) + +describe('dateRangeAvailability', () => { + it('returns "available" if all dates have availability', () => { + const premisesCapacity = cas1PremiseCapacityFactory.build({ + startDate: '2024-12-05', + endDate: '2024-12-05', + capacity: [ + cas1PremiseCapacityForDayFactory.build({ + availableBedCount: 15, + bookingCount: 0, + }), + ], + }) + + expect(dateRangeAvailability(premisesCapacity)).toEqual('available') + }) + + it('returns "partial" if only some of the dates have availability', () => { + const premisesCapacity = cas1PremiseCapacityFactory.build({ + startDate: '2024-12-05', + endDate: '2024-12-05', + capacity: [ + cas1PremiseCapacityForDayFactory.build({ + availableBedCount: 15, + bookingCount: 0, + }), + cas1PremiseCapacityForDayFactory.build({ + availableBedCount: 15, + bookingCount: 20, + }), + ], + }) + + expect(dateRangeAvailability(premisesCapacity)).toEqual('partial') + }) + + it('returns "none" if none of the dates have availability', () => { + const premisesCapacity = cas1PremiseCapacityFactory.build({ + startDate: '2024-12-05', + endDate: '2024-12-05', + capacity: [ + cas1PremiseCapacityForDayFactory.build({ + availableBedCount: 15, + bookingCount: 20, + }), + ], + }) + + expect(dateRangeAvailability(premisesCapacity)).toEqual('none') + }) +}) diff --git a/server/utils/match/occupancy.ts b/server/utils/match/occupancy.ts new file mode 100644 index 000000000..60559be47 --- /dev/null +++ b/server/utils/match/occupancy.ts @@ -0,0 +1,17 @@ +import { Cas1PremiseCapacity, Cas1PremiseCapacityForDay } from '@approved-premises/api' + +export const dayAvailabilityCount = (dayCapacity: Cas1PremiseCapacityForDay) => { + return dayCapacity.availableBedCount - dayCapacity.bookingCount +} + +export const dayHasAvailability = (dayCapacity: Cas1PremiseCapacityForDay) => { + return dayAvailabilityCount(dayCapacity) > 0 +} + +export const dateRangeAvailability = (capacity: Cas1PremiseCapacity) => { + const availableDays = capacity.capacity.filter(dayHasAvailability) + + if (availableDays.length === capacity.capacity.length) return 'available' + if (availableDays.length === 0) return 'none' + return 'partial' +} diff --git a/server/utils/match/occupancyCalendar.test.ts b/server/utils/match/occupancyCalendar.test.ts index e099353b1..9e846a2b3 100644 --- a/server/utils/match/occupancyCalendar.test.ts +++ b/server/utils/match/occupancyCalendar.test.ts @@ -1,5 +1,6 @@ import { occupancyCalendar } from './occupancyCalendar' import { cas1PremiseCapacityFactory } from '../../testutils/factories' +import { dayAvailabilityCount } from './occupancy' describe('occupancyCalendar', () => { it('returns a calendar from the start date to the end date', () => { @@ -8,15 +9,15 @@ describe('occupancyCalendar', () => { { name: 'December 2024', days: [ - { name: 'Mon 30 Dec', ...premisesCapacity.capacity[0] }, - { name: 'Tue 31 Dec', ...premisesCapacity.capacity[1] }, + { name: 'Mon 30 Dec', bookableCount: dayAvailabilityCount(premisesCapacity.capacity[0]) }, + { name: 'Tue 31 Dec', bookableCount: dayAvailabilityCount(premisesCapacity.capacity[1]) }, ], }, { name: 'January 2025', days: [ - { name: 'Wed 1 Jan', ...premisesCapacity.capacity[2] }, - { name: 'Thu 2 Jan', ...premisesCapacity.capacity[3] }, + { name: 'Wed 1 Jan', bookableCount: dayAvailabilityCount(premisesCapacity.capacity[2]) }, + { name: 'Thu 2 Jan', bookableCount: dayAvailabilityCount(premisesCapacity.capacity[3]) }, ], }, ]) diff --git a/server/utils/match/occupancyCalendar.ts b/server/utils/match/occupancyCalendar.ts index 3ee73379d..7eaf8c913 100644 --- a/server/utils/match/occupancyCalendar.ts +++ b/server/utils/match/occupancyCalendar.ts @@ -1,8 +1,10 @@ -import { Cas1PremiseCapacity, Cas1PremiseCapacityForDay } from '@approved-premises/api' +import { Cas1PremiseCapacity } from '@approved-premises/api' import { DateFormats } from '../dateUtils' +import { dayAvailabilityCount } from './occupancy' -type CalendarDay = Cas1PremiseCapacityForDay & { +type CalendarDay = { name: string + bookableCount: number } type CalendarMonth = { name: string @@ -27,7 +29,7 @@ export const occupancyCalendar = (capacity: Cas1PremiseCapacity) => { currentMonth.days.push({ name: DateFormats.isoDateToUIDate(day.date, { format: 'longNoYear' }), - ...day, + bookableCount: dayAvailabilityCount(day), }) }) diff --git a/server/utils/match/occupancySummary.test.ts b/server/utils/match/occupancySummary.test.ts index 88a22b063..f1f905c0a 100644 --- a/server/utils/match/occupancySummary.test.ts +++ b/server/utils/match/occupancySummary.test.ts @@ -16,17 +16,15 @@ describe('occupancySummary', () => { const result = occupancySummary(capacity) expect(result).toMatchStringIgnoringWhitespace(` -
-

Available on:

-
    -
  • Wed 12 Feb 2025 to Thu 13 Feb 2025 (2 days)
  • -
  • Mon 17 Feb 2025 (1 day)
  • -
-

Overbooked on:

-
    -
  • Fri 14 Feb 2025 to Sun 16 Feb 2025 (3 days)
  • -
-
+

Available on:

+
    +
  • Wed 12 Feb 2025 to Thu 13 Feb 2025 (2 days)
  • +
  • Mon 17 Feb 2025 (1 day)
  • +
+

Overbooked on:

+
    +
  • Fri 14 Feb 2025 to Sun 16 Feb 2025 (3 days)
  • +
`) }) diff --git a/server/utils/match/occupancySummary.ts b/server/utils/match/occupancySummary.ts index 9b7453d40..8ffa4ef8f 100644 --- a/server/utils/match/occupancySummary.ts +++ b/server/utils/match/occupancySummary.ts @@ -1,6 +1,7 @@ import type { Cas1PremiseCapacity, Cas1PremiseCapacityForDay } from '@approved-premises/api' import { differenceInDays } from 'date-fns' import { DateFormats, daysToWeeksAndDays } from '../dateUtils' +import { dayHasAvailability } from './occupancy' export type DateRange = { start: string @@ -42,7 +43,7 @@ export const occupancySummary = (premiseCapacity: Cas1PremiseCapacity): string = const overbookedDays: Array = [] premiseCapacity.capacity.forEach(capacityForDay => { - if (capacityForDay.availableBedCount > 0) { + if (dayHasAvailability(capacityForDay)) { availableDays.push(capacityForDay) } else { overbookedDays.push(capacityForDay) @@ -60,15 +61,13 @@ export const occupancySummary = (premiseCapacity: Cas1PremiseCapacity): string = const overbookedRanges = daysToRanges(overbookedDays).map(renderDateRange) return ` -
-

Available on:

-
    - ${availableRanges.map(range => `
  • ${range}
  • `).join('')} -
-

Overbooked on:

-
    - ${overbookedRanges.map(range => `
  • ${range}
  • `).join('')} -
-
+

Available on:

+
    + ${availableRanges.map(range => `
  • ${range}
  • `).join('')} +
+

Overbooked on:

+
    + ${overbookedRanges.map(range => `
  • ${range}
  • `).join('')} +
` } diff --git a/server/views/match/placementRequests/occupancyView/partials/_occupancyCalendar.njk b/server/views/match/placementRequests/occupancyView/partials/_occupancyCalendar.njk index 0f2406416..8c3e7e0e9 100644 --- a/server/views/match/placementRequests/occupancyView/partials/_occupancyCalendar.njk +++ b/server/views/match/placementRequests/occupancyView/partials/_occupancyCalendar.njk @@ -4,8 +4,20 @@

{{ month.name }}

diff --git a/server/views/match/placementRequests/occupancyView/view.njk b/server/views/match/placementRequests/occupancyView/view.njk index 034699c8a..e1c739267 100644 --- a/server/views/match/placementRequests/occupancyView/view.njk +++ b/server/views/match/placementRequests/occupancyView/view.njk @@ -48,7 +48,23 @@ classes: 'govuk-notification-banner--full-width-content' }) }} - {{ occupancyCalendar(calendar) }} +
+

Key

+ +
    +
  • + Available +
  • +
  • + Full or overbooked +
  • +
+
+ + +
+ {{ occupancyCalendar(calendar) }} +