Skip to content

Commit

Permalink
Merge pull request #2237 from ministryofjustice/feature/APS-1602-occu…
Browse files Browse the repository at this point in the history
…pancy-calendar

APS-1602: calendar view
  • Loading branch information
froddd authored Dec 9, 2024
2 parents e5e7aa5 + 096b047 commit c4d5e08
Show file tree
Hide file tree
Showing 17 changed files with 417 additions and 143 deletions.
160 changes: 67 additions & 93 deletions assets/sass/components/_calendar.scss
Original file line number Diff line number Diff line change
@@ -1,123 +1,97 @@
$colWidth: 22px;
$colPadding: 2px;
.calendar__month {
border-top: 1px solid $govuk-border-colour;

$roomHeaderWidth: 100px;
.calendar__day {
margin: 0;
border-bottom: 1px solid $govuk-border-colour;

$calendarWidth: $roomHeaderWidth + (30 * ($colWidth + ($colPadding * 2)));

.govuk-link {
&--booking {
&:link, &:visited {
color: govuk-colour("black");
font-weight: bold;
&:focus-within {
border-color: transparent;
}
}

&--overbooking {
&:link, &:visited {
color: govuk-colour("white");
font-weight: bold;
#calendar-key & {
padding: govuk-spacing(2) 0;
}
}
}

.govuk-pagination {
&--calendar {
padding-bottom: govuk-spacing(6);

.govuk-pagination__prev {
float: left;
}

.govuk-pagination__next {
float: right;
border-top: none;
}

.govuk-pagination__prev + .govuk-pagination__next {
border-top: none;
.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;
}
}
}

.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__date {
min-width: 20%;
}

&__header {
&--calendar {
border: 1px solid $govuk-border-colour;
text-align: center;
width: $colWidth;
padding: $colPadding;
}
.calendar__availability {
margin: 0;

&--calendar-room-header {
text-align: center;
width: 100px;
vertical-align: middle;
dd {
margin: 0;
}
}

@include govuk-media-query($from: tablet) {
display: grid;
grid-template-columns: repeat(7, 1fr);
grid-gap: 2px;
border-top: none;

&__cell {
&--calendar {
position: relative;
border: 1px solid $govuk-border-colour;
padding: $colPadding;
#calendar-key & {
display: flex;
}

span {
white-space: nowrap;
overflow:hidden;
text-overflow: ellipsis;
display: block;
.calendar__day {
display: flex;
border: 1px solid $govuk-text-colour;

&.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-key & {
padding: govuk-spacing(2) govuk-spacing(6);
}
}

&--booking {
background-color: govuk-colour("light-blue");
.calendar__link {
flex: 1;
flex-direction: column;
padding: govuk-spacing(2);
@include govuk-font-size(16);
}

&--lost_bed {
background-color: govuk-colour("light-grey");
font-weight: bold;
.calendar__day--mon {
grid-column-start: 1;
}

&--month {
text-align: center;
.calendar__day--tue {
grid-column-start: 2;
}
.calendar__day--wed {
grid-column-start: 3;
}
.calendar__day--thu {
grid-column-start: 4;
}
.calendar__day--fri {
grid-column-start: 5;
}
.calendar__day--sat {
grid-column-start: 6;
}
.calendar__day--sun {
grid-column-start: 7;
}

&--overbooking {
background-color: govuk-colour("black");
color: govuk-colour("white");
font-weight: bold;
.calendar__date {
margin-bottom: auto;
}
}
}
30 changes: 28 additions & 2 deletions integration_tests/pages/match/occupancyViewPage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ import {
} from '../../../server/utils/match'
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) {
Expand Down Expand Up @@ -47,16 +49,40 @@ 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) {
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/)
}
}
}
3 changes: 3 additions & 0 deletions integration_tests/tests/match/match.cy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
occupancyViewSummaryListForMatchingDetails,
placementDates,
} from '../../../utils/match'
import { occupancyCalendar } from '../../../utils/match/occupancyCalendar'

describe('OccupancyViewController', () => {
const token = 'SOME_TOKEN'
Expand Down Expand Up @@ -72,6 +73,7 @@ describe('OccupancyViewController', () => {
filterOutAPTypes(placementRequestDetail.essentialCriteria),
),
occupancySummaryHtml: occupancySummary(premiseCapacity),
calendar: occupancyCalendar(premiseCapacity),
})
expect(placementRequestService.getPlacementRequest).toHaveBeenCalledWith(token, placementRequestDetail.id)
})
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
occupancyViewSummaryListForMatchingDetails,
placementDates,
} from '../../../utils/match'
import { occupancyCalendar } from '../../../utils/match/occupancyCalendar'

interface NewRequest extends Request {
params: { id: string }
Expand Down Expand Up @@ -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}`,
Expand All @@ -46,6 +48,7 @@ export default class {
durationDays,
matchingDetailsSummaryList,
occupancySummaryHtml,
calendar,
})
}
}
Expand Down
27 changes: 17 additions & 10 deletions server/testutils/factories/cas1PremiseCapacity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,15 @@ import cas1PremisesSummaryFactory from './cas1PremisesSummary'
import { DateFormats } from '../../utils/dateUtils'
import { offenceAndRiskCriteria } from '../../utils/placementCriteriaUtils'

export default Factory.define<Cas1PremiseCapacity>(() => {
const startDate = faker.date.anytime()
const endDate = faker.date.soon({ days: 365, refDate: startDate })
const days = differenceInDays(endDate, startDate)
export default Factory.define<Cas1PremiseCapacity>(({ params }) => {
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 =>
cas1PremiseCapacityForDayFactory.build({
Expand All @@ -32,9 +37,9 @@ export default Factory.define<Cas1PremiseCapacity>(() => {

class CapacityForDayFactory extends Factory<Cas1PremiseCapacityForDay> {
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,
Expand All @@ -44,12 +49,14 @@ class CapacityForDayFactory extends Factory<Cas1PremiseCapacityForDay> {
}

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,
})
}
}
Expand Down
16 changes: 16 additions & 0 deletions server/utils/dateUtils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down Expand Up @@ -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', () => {
Expand Down
Loading

0 comments on commit c4d5e08

Please sign in to comment.