diff --git a/apps/workplace/e2e/local/desk-times.spec.ts b/apps/workplace/e2e/local/desk-times.spec.ts index cca7323cbaa..71d8b8c1c7c 100644 --- a/apps/workplace/e2e/local/desk-times.spec.ts +++ b/apps/workplace/e2e/local/desk-times.spec.ts @@ -111,6 +111,7 @@ test.describe('desk booking times', () => { test('a maximum length and bookable hours limit what can be chosen', async ({ staffPage, }) => { + const min = LIMITED_HOURS['app.bookings.min_duration']; const max = LIMITED_HOURS['app.bookings.max_duration']; const hours = LIMITED_HOURS['app.bookings.bookable_hours']; @@ -145,10 +146,12 @@ test.describe('desk booking times', () => { `no start time before ${hours.start}:00 may be offered, got ` + `${JSON.stringify(times)}`, ).toBeGreaterThanOrEqual(hours.start * 60); + // The last start must leave the minimum length before closing time. + // A start at closing time has no valid length (PPT-2872). expect( Math.max(...minutes), - `no start time after ${hours.end}:00 may be offered, got ` + - `${JSON.stringify(times)}`, - ).toBeLessThanOrEqual(hours.end * 60); + `no start time later than ${min} minutes before ${hours.end}:00 ` + + `may be offered, got ${JSON.stringify(times)}`, + ).toBeLessThanOrEqual(hours.end * 60 - min); }); }); diff --git a/apps/workplace/e2e/local/room-times.spec.ts b/apps/workplace/e2e/local/room-times.spec.ts index 30b739673da..c81ed3af79e 100644 --- a/apps/workplace/e2e/local/room-times.spec.ts +++ b/apps/workplace/e2e/local/room-times.spec.ts @@ -40,6 +40,7 @@ const DAY = 86_400; const window_from = () => Math.floor(Date.now() / 1000) - 2 * DAY; const window_to = () => Math.floor(Date.now() / 1000) + 7 * DAY; +const MIN_MINUTES = LIMITED_HOURS['app.events.min_duration']; const MAX_MINUTES = LIMITED_HOURS['app.events.max_duration']; const HOURS = LIMITED_HOURS['app.events.bookable_hours']; @@ -147,9 +148,9 @@ test.describe('room booking times', () => { ).toBeGreaterThanOrEqual(HOURS.start * 60); expect( Math.max(...times.map(minutesOfDay)), - `nor after ${HOURS.end}:00 — an option outside the window is one a user can ` + - 'pick and then be refused for', - ).toBeLessThanOrEqual(HOURS.end * 60); + `nor later than ${MIN_MINUTES} minutes before ${HOURS.end}:00 — a later ` + + 'start leaves no meeting length that ends by closing time (PPT-2872)', + ).toBeLessThanOrEqual(HOURS.end * 60 - MIN_MINUTES); }); /** diff --git a/apps/workplace/src/app/book/desk-flow-new/desk-flow-details.component.ts b/apps/workplace/src/app/book/desk-flow-new/desk-flow-details.component.ts index eb17665c218..440729902c0 100644 --- a/apps/workplace/src/app/book/desk-flow-new/desk-flow-details.component.ts +++ b/apps/workplace/src/app/book/desk-flow-new/desk-flow-details.component.ts @@ -206,6 +206,7 @@ export type DeskFormType = 'single' | 'group' | 'other'; " [ngModelOptions]="{ standalone: true }" [range]="bookable_hours()" + [min_duration]="effective_min_duration()" [use_24hr]="use_24hr()" [timezone]="timezone" /> @@ -449,6 +450,11 @@ export class DeskFlowDetailsComponent { settingSignal('desks.custom_duration_options')() || settingSignal('bookings.custom_duration_options', [] as number[])(), ); + /** Shortest length the duration field offers. The start-time field uses it + * so that the last start leaves room for a booking before closing time. */ + public readonly effective_min_duration = computed(() => + Math.min(this.min_duration(), ...this.custom_duration_options()), + ); public readonly can_book_lockers = settingSignal( 'desks.can_book_lockers', false, diff --git a/apps/workplace/src/app/book/desk-flow-new/desk-flow-select.component.ts b/apps/workplace/src/app/book/desk-flow-new/desk-flow-select.component.ts index 1bb4fd1cd54..0b48ab3f68f 100644 --- a/apps/workplace/src/app/book/desk-flow-new/desk-flow-select.component.ts +++ b/apps/workplace/src/app/book/desk-flow-new/desk-flow-select.component.ts @@ -199,6 +199,7 @@ import { DeskFlowSelectMapComponent } from './desk-flow-select-map.component'; " [ngModelOptions]="{ standalone: true }" [range]="bookable_hours()" + [min_duration]="effective_min_duration()" [use_24hr]="use_24hr()" [timezone]="timezone" /> @@ -489,6 +490,7 @@ import { DeskFlowSelectMapComponent } from './desk-flow-select-map.component'; " [ngModelOptions]="{ standalone: true }" [range]="bookable_hours()" + [min_duration]="effective_min_duration()" [use_24hr]="use_24hr()" [timezone]="timezone" /> @@ -639,6 +641,11 @@ export class DeskFlowSelectComponent extends AsyncHandler implements OnInit { settingSignal('desks.custom_duration_options')() || settingSignal('bookings.custom_duration_options', [] as number[])(), ); + /** Shortest length the duration field offers. The start-time field uses it + * so that the last start leaves room for a booking before closing time. */ + public readonly effective_min_duration = computed(() => + Math.min(this.min_duration(), ...this.custom_duration_options()), + ); public readonly bookable_hours = computed< { start: number; end: number } | undefined >( diff --git a/apps/workplace/src/app/book/meeting-flow-new/meeting-flow-details.component.ts b/apps/workplace/src/app/book/meeting-flow-new/meeting-flow-details.component.ts index e2556eb4e1d..a329cbeb1b4 100644 --- a/apps/workplace/src/app/book/meeting-flow-new/meeting-flow-details.component.ts +++ b/apps/workplace/src/app/book/meeting-flow-new/meeting-flow-details.component.ts @@ -104,6 +104,7 @@ import { ROOM_CAPACITY_OPTIONS, roomCapacityFilters } from './room-capacity'; " [ngModelOptions]="{ standalone: true }" [range]="bookable_hours()" + [min_duration]="effective_min_duration()" [use_24hr]="use_24hr()" [timezone]="timezone" [disabled]="start_time_disabled()" @@ -317,6 +318,11 @@ export class MeetingFlowDetailsComponent { 'events.custom_duration_options', [], ); + /** Shortest length the duration field offers. The start-time field uses it + * so that the last start leaves room for a booking before closing time. */ + public readonly effective_min_duration = computed(() => + Math.min(this.min_duration(), ...this.custom_duration_options()), + ); /** Number of days ahead that the user can book a meeting */ public readonly allowed_future_days = settingSignal( 'events.allowed_future_days', diff --git a/apps/workplace/src/app/book/meeting-flow-new/meeting-flow-space-select.component.ts b/apps/workplace/src/app/book/meeting-flow-new/meeting-flow-space-select.component.ts index 83f2b8b6278..a1a5d47cee7 100644 --- a/apps/workplace/src/app/book/meeting-flow-new/meeting-flow-space-select.component.ts +++ b/apps/workplace/src/app/book/meeting-flow-new/meeting-flow-space-select.component.ts @@ -215,6 +215,7 @@ import { ROOM_CAPACITY_OPTIONS, roomCapacityFilters } from './room-capacity'; [ngModelOptions]="{ standalone: true }" [disabled]="form.date().disabled()" [range]="bookable_hours()" + [min_duration]="effective_min_duration()" [use_24hr]="use_24hr()" [timezone]="timezone" /> @@ -586,6 +587,7 @@ import { ROOM_CAPACITY_OPTIONS, roomCapacityFilters } from './room-capacity'; [ngModelOptions]="{ standalone: true }" [disabled]="form.date().disabled()" [range]="bookable_hours()" + [min_duration]="effective_min_duration()" [use_24hr]="use_24hr()" [timezone]="timezone" /> @@ -776,6 +778,11 @@ export class MeetingFlowSpaceSelectComponent { 'events.custom_duration_options', [], ); + /** Shortest length the duration field offers. The start-time field uses it + * so that the last start leaves room for a booking before closing time. */ + public readonly effective_min_duration = computed(() => + Math.min(this.min_duration(), ...this.custom_duration_options()), + ); public readonly allow_all_day = settingSignal( 'events.allow_all_day', false, diff --git a/docs/settings/workplace.md b/docs/settings/workplace.md index 3789e87b352..4b4dcdf50e3 100644 --- a/docs/settings/workplace.md +++ b/docs/settings/workplace.md @@ -164,7 +164,7 @@ These apply to all resource booking flows (desks, parking, lockers). Most can be | `bookings.allowed_daily_parking_count` | number | – | Number of parking bookings a user can make for a single day. Use `0` for unlimited. Falls back to `1`. | | `bookings.allowed_daily_locker_count` | number | – | Number of locker bookings a user can make for a single day. Use `0` for unlimited. Falls back to `1`. | | `bookings.all_day_period` | object | – | Time window used for "all day" resource bookings, as hours of the day, e.g. `{ "start": 8, "end": 18 }`. Override per type under `desks.*`, `parking.*` or `lockers.*`. | -| `bookings.bookable_hours` | object | – | Time window in which bookings can be made, as hours of the day, e.g. `{ "start": 8, "end": 19 }`. | +| `bookings.bookable_hours` | object | – | Time window in which bookings can be made, as hours of the day, e.g. `{ "start": 8, "end": 19 }`. A booking must end by `end`, so the last start time is `end` minus the shortest duration option. | | `bookings.can_book_for_others` | boolean | – | Allow users to make bookings on behalf of other users. | | `bookings.can_book_for_anyone` | boolean | – | Allow users to make bookings on behalf of anyone, including people outside the organisation. | | `bookings.custom_duration_options` | number[] | – | Custom list of duration options, in minutes, offered when selecting a booking duration. | @@ -203,7 +203,7 @@ These settings override the matching `bookings.*` settings for visitor invites. | Setting | Type | Default | Description | |---------|------|---------|-------------| -| `events.bookable_hours` | object | – | Time window in which meetings can be booked, as hours of the day, e.g. `{ "start": 8, "end": 19 }`. | +| `events.bookable_hours` | object | – | Time window in which meetings can be booked, as hours of the day, e.g. `{ "start": 8, "end": 19 }`. A meeting must end by `end`, so the last start time is `end` minus the shortest duration option. | | `events.all_day_period` | object | – | Time window used for "all day" meetings, as hours of the day. | | `events.multiple_spaces` | boolean | `false` | Allow users to book multiple spaces in a single meeting. | | `events.allow_multiple_spaces` | boolean | – | Legacy alias for `events.multiple_spaces`. Either key set to `true` enables multiple room selection. Use `events.multiple_spaces` for new configuration. | diff --git a/e2e/support/desk/desk.settings.ts b/e2e/support/desk/desk.settings.ts index b362f45d7fb..1c002d0c74b 100644 --- a/e2e/support/desk/desk.settings.ts +++ b/e2e/support/desk/desk.settings.ts @@ -30,7 +30,8 @@ export async function useSettings( } /** - * Cap the booking length and confine it to office hours. + * Cap the booking length and confine it to office hours. The minimum length + * sets the last start time: a booking must end by closing time. * * Both unset by default, so nothing else in the suite sees them, and both fail * quietly in the same way: an out-of-range option is simply ABSENT from the @@ -38,6 +39,7 @@ export async function useSettings( * offered. */ export const LIMITED_HOURS = { + 'app.bookings.min_duration': 30, 'app.bookings.max_duration': 60, 'app.bookings.bookable_hours': { start: 9, end: 17 }, }; diff --git a/e2e/support/room/room.settings.ts b/e2e/support/room/room.settings.ts index 5ac72a794dd..e30cdcb7093 100644 --- a/e2e/support/room/room.settings.ts +++ b/e2e/support/room/room.settings.ts @@ -107,7 +107,8 @@ export const NO_APPROVAL = { }; /** - * Cap the meeting length and confine it to office hours. + * Cap the meeting length and confine it to office hours. The minimum length + * sets the last start time: a meeting must end by closing time. * * `app.events.*`, NOT `app.bookings.*`. The meeting form reads its limits from * the EVENT settings (`event-form.service.ts`: `app.events.max_duration`, @@ -117,6 +118,7 @@ export const NO_APPROVAL = { * the first run of `room-times.spec.ts`. */ export const LIMITED_HOURS = { + 'app.events.min_duration': 30, 'app.events.max_duration': 60, 'app.events.bookable_hours': { start: 9, end: 17 }, }; diff --git a/libs/bookings/src/lib/desk-select-modal/desk-filters.component.ts b/libs/bookings/src/lib/desk-select-modal/desk-filters.component.ts index e5adfaf119b..0fea663d2f4 100644 --- a/libs/bookings/src/lib/desk-select-modal/desk-filters.component.ts +++ b/libs/bookings/src/lib/desk-select-modal/desk-filters.component.ts @@ -167,6 +167,7 @@ import { BookingFormService } from '../booking-form.service'; [use_24hr]="use_24hr()" [timezone]="timezone()" [range]="bookable_hours()" + [min_duration]="60" >