diff --git a/apps/workplace/e2e/settings/bookings/bookings.use-building-timezone.spec.ts b/apps/workplace/e2e/settings/bookings/bookings.use-building-timezone.spec.ts index d36dca6bc81..dbb3d96c240 100644 --- a/apps/workplace/e2e/settings/bookings/bookings.use-building-timezone.spec.ts +++ b/apps/workplace/e2e/settings/bookings/bookings.use-building-timezone.spec.ts @@ -1,26 +1,53 @@ - +import type { Page } from '@playwright/test'; import { expect } from '../../../../../e2e/support/fixtures'; import { type Debug, test, timezoneBuildings, untouchedVisitor, visitor } from '../support/settings-harness'; test.use({ readOnlyTimezone: 'Etc/UTC' }); -for (const enabled of [false, true]) { - test('bookings.use_building_timezone ' + enabled + ' gates the visitor secondary timezone label', - async ({ staffPage, audit }) => { - await timezoneBuildings(staffPage, audit); - const form = await visitor(staffPage, audit, { - 'app.bookings.use_building_timezone': enabled, 'app.visitors.use_building_timezone': false, - }); - expect(audit.fixtures['timezone buildings'] ?? 0).toBeGreaterThan(0); - const field = form.root.locator('a-time-field[name="time"]'); - await expect(field.locator('button[time-field]')).toBeVisible(); - await expect.poll(() => field.evaluate((element) => { - const c = (window as Debug).ng?.getComponent(element) as { timezone(): string } | undefined; - return c?.timezone(); - })).toBe(enabled ? 'Pacific/Honolulu' : ''); - const secondary = field.locator('button[time-field] div.truncate.text-xs'); - await expect(secondary).toHaveCount(enabled ? 1 : 0); - if (enabled) await expect(secondary).toBeVisible(); - await untouchedVisitor(form); +/** The effective value of a setting, as the app reads it (unset is null). */ +const effective = (page: Page, key: string) => + page.evaluate((name) => (window as Debug).setting?.(name) ?? null, key); + +// Settings: bookings.use_building_timezone and visitors.use_building_timezone +// on the visitor invite form. InviteVisitorForm uses the building timezone when +// visitors.use_building_timezone ?? bookings.use_building_timezone is true +// (invite-visitor-form.component.ts), so an explicit visitors value overrides +// bookings and an unset visitors value falls back to it. +const SCENARIOS = [ + { label: 'bookings.use_building_timezone true gates the visitor secondary timezone label', bookings: true, visitors: null, shown: true }, + { label: 'bookings.use_building_timezone false gates the visitor secondary timezone label', bookings: false, visitors: null, shown: false }, + { label: 'visitors.use_building_timezone true gates the visitor secondary timezone label', bookings: false, visitors: true, shown: true }, + { + label: 'visitors.use_building_timezone false overrides bookings.use_building_timezone true and hides the visitor secondary timezone label', + bookings: true, + visitors: false, + shown: false, + }, +]; + +for (const scenario of SCENARIOS) { + test(`${scenario.label}`, async ({ staffPage, audit }) => { + await timezoneBuildings(staffPage, audit); + const form = await visitor(staffPage, audit, { + 'app.bookings.use_building_timezone': scenario.bookings, + 'app.visitors.use_building_timezone': scenario.visitors, }); + expect(audit.fixtures['timezone buildings'] ?? 0).toBeGreaterThan(0); + const field = form.root.locator('a-time-field[name="time"]'); + await expect(field.locator('button[time-field]')).toBeVisible(); + await expect + .poll(() => effective(staffPage, 'app.bookings.use_building_timezone')) + .toBe(scenario.bookings); + await expect + .poll(() => effective(staffPage, 'app.visitors.use_building_timezone')) + .toBe(scenario.visitors); + await expect.poll(() => field.evaluate((element) => { + const c = (window as Debug).ng?.getComponent(element) as { timezone(): string } | undefined; + return c?.timezone(); + })).toBe(scenario.shown ? 'Pacific/Honolulu' : ''); + const secondary = field.locator('button[time-field] div.truncate.text-xs'); + await expect(secondary).toHaveCount(scenario.shown ? 1 : 0); + if (scenario.shown) await expect(secondary).toBeVisible(); + await untouchedVisitor(form); + }); } diff --git a/apps/workplace/e2e/settings/lockers/lockers.use-building-timezone.spec.ts b/apps/workplace/e2e/settings/lockers/lockers.use-building-timezone.spec.ts index ed33ea97357..c355250a2d6 100644 --- a/apps/workplace/e2e/settings/lockers/lockers.use-building-timezone.spec.ts +++ b/apps/workplace/e2e/settings/lockers/lockers.use-building-timezone.spec.ts @@ -43,7 +43,7 @@ const SCENARIOS = [ test.describe('use_building_timezone on the locker form', () => { for (const scenario of SCENARIOS) { - test(scenario.label, async ({ staffPage, audit }) => { + test(`${scenario.label}`, async ({ staffPage, audit }) => { await timezoneBuildings(staffPage, audit); const root = await lockerForm(staffPage, audit, { 'app.bookings.use_building_timezone': scenario.bookings, diff --git a/apps/workplace/src/app/book/visitor-flow-new/visitor-flow-details.component.ts b/apps/workplace/src/app/book/visitor-flow-new/visitor-flow-details.component.ts index f214ea37081..023263db1aa 100644 --- a/apps/workplace/src/app/book/visitor-flow-new/visitor-flow-details.component.ts +++ b/apps/workplace/src/app/book/visitor-flow-new/visitor-flow-details.component.ts @@ -361,8 +361,8 @@ export class VisitorFlowDetailsComponent implements OnInit { } public get timezone() { - return this._settings.get('app.bookings.use_building_timezone') || - this._settings.get('app.visitors.use_building_timezone') + return (this._settings.get('app.visitors.use_building_timezone') ?? + this._settings.get('app.bookings.use_building_timezone')) ? this._org.building.timezone : ''; } diff --git a/apps/workplace/src/environments/settings.schema.json b/apps/workplace/src/environments/settings.schema.json index e0e9d712d9f..c74bf735593 100644 --- a/apps/workplace/src/environments/settings.schema.json +++ b/apps/workplace/src/environments/settings.schema.json @@ -287,7 +287,7 @@ }, "use_building_timezone": { "type": "boolean", - "description": "Whether visitor invite times use the building's timezone. app.bookings.use_building_timezone also turns this on" + "description": "Whether visitor invite times use the building's timezone. Overrides app.bookings.use_building_timezone" }, "allow_international": { "type": "boolean", diff --git a/apps/workplace/src/tests/book/visitor-flow-details.component.spec.ts b/apps/workplace/src/tests/book/visitor-flow-details.component.spec.ts index 10597fd9ca4..fbf908e0a3e 100644 --- a/apps/workplace/src/tests/book/visitor-flow-details.component.spec.ts +++ b/apps/workplace/src/tests/book/visitor-flow-details.component.spec.ts @@ -17,6 +17,7 @@ import { } from '@placeos/common'; import { VisitorFlowDetailsComponent } from 'apps/workplace/src/app/book/visitor-flow-new/visitor-flow-details.component'; import { MockProvider } from 'ng-mocks'; +import type { Mock } from 'vitest'; describe('VisitorFlowDetailsComponent', () => { let spectator: SpectatorRouting; @@ -133,6 +134,20 @@ describe('VisitorFlowDetailsComponent', () => { expect(spectator.component.effective_min_duration()).toBe(45); }); + it('should let the visitors timezone setting override the bookings setting', () => { + const values: Record = { + 'app.bookings.use_building_timezone': true, + }; + (spectator.inject(SettingsService).get as Mock).mockImplementation( + (key: string) => values[key], + ); + const org = spectator.inject(OrganisationService) as any; + org.building = { ...org.building, timezone: 'Australia/Sydney' }; + expect(spectator.component.timezone).toBe('Australia/Sydney'); + values['app.visitors.use_building_timezone'] = false; + expect(spectator.component.timezone).toBe(''); + }); + it('should hide visitor type switching when editing an existing booking', () => { const service = spectator.inject(BookingFormService); model.update((m) => ({ diff --git a/docs/qa/workplace-settings-final-status.md b/docs/qa/workplace-settings-final-status.md index 45ab4c79fef..45b76628b24 100644 --- a/docs/qa/workplace-settings-final-status.md +++ b/docs/qa/workplace-settings-final-status.md @@ -6,7 +6,7 @@ Updated 30 Oct 2026. PR #595, branch `test/e2e-workplace-settings`, with develop | Group | Specs | Unique Settings | Scenarios | Status | |---|---|---|---|---| -| **In PR #595** | **187** | **215** | **445** | **443 passed locally on 3 Nov with develop merged in; the 2 PPT-2871 override cases added in 2e9a3f467 passed 35/35 (5 repeats) on 3 Nov** | +| **In PR #595** | **187** | **215** | **447** | **443 passed locally on 3 Nov with develop merged in; the 2 PPT-2871 override cases added in 2e9a3f467 passed 35/35 (5 repeats) on 3 Nov** | | Held locally, not in the PR | 2 (+1 product check) | 2 | 6 | See "Held locally" | | Excluded or ignored by developer decision | – | 4 | – | Not tested | @@ -46,9 +46,9 @@ bunx playwright test -c apps/workplace/playwright.config.ts --project=settings-b bunx playwright test -c apps/workplace/playwright.config.ts --project=settings --list # counts without running ``` -| Project | `--list` (3 Nov 2026, 2e9a3f467) | Needs the stack | +| Project | `--list` (4 Nov 2026, PR #665) | Needs the stack | |---|---|---| -| `settings` | 409 tests in 171 spec files (plus the 2 `preflight` tests it depends on) | yes | +| `settings` | 411 tests in 171 spec files (plus the 2 `preflight` tests it depends on) | yes | | `settings-browser` | 36 tests in 16 spec files | no | - `settings`: every settings spec except the browser-only ones. Depends on `preflight`, so it fails fast when the stack is down. @@ -74,7 +74,7 @@ bunx playwright test -c apps/workplace/playwright.config.ts --project=settings - | bookings.max_duration | bookings.duration-bounds | 2 | PASS | | | bookings.duration_step | bookings.duration-step | 2 | PASS | | | bookings.has_assets | bookings.has-assets | 4 | PASS | Generic key with desks.has_assets unset, plus desks.has_assets overriding it both ways (PPT-2864) | -| bookings.use_building_timezone | bookings.use-building-timezone | 2 | PASS | Also tested on the locker form (lockers area) | +| bookings.use_building_timezone | bookings.use-building-timezone | 2 | PASS | Tested on the visitor form with visitors.use_building_timezone unset. Also tested on the locker form (lockers area) | ### chat (1 spec, 1 Setting, 2 scenarios) | Setting | Spec | Scen. | Status | Notes | @@ -307,7 +307,7 @@ bunx playwright test -c apps/workplace/playwright.config.ts --project=settings - | schedule.use_driver | schedule.use-driver | 2 | PASS | | | schedule.use_websocket | schedule.use-websocket | 2 | PASS | | -### visitor-invite (13 specs, 16 Settings, 29 scenarios) +### visitor-invite (13 specs, 16 Settings, 31 scenarios) | Setting | Spec | Scen. | Status | Notes | |---|---|---|---|---| | visitors.allow_all_day | visitor-invite.allow-all-day | 2 | PASS | | @@ -315,7 +315,7 @@ bunx playwright test -c apps/workplace/playwright.config.ts --project=settings - | visitors.allow_pass_number | visitor-invite.allow-pass-number | 2 | PASS | | | visitors.allow_visitor_internal_or_external | visitor-invite.allow-visitor-internal-or-external | 2 | PASS | | | visitors.bookable_hours | visitor-invite.bookable-hours | 3 | PASS | Overrides bookings.bookable_hours, falls back to it, and both unset offer the whole day | -| visitors.use_building_timezone | visitor-invite.building-timezone | 2 | PASS | | +| visitors.use_building_timezone | visitor-invite.building-timezone, bookings.use-building-timezone | 4 | PASS | True with bookings.use_building_timezone false, and false overriding bookings.use_building_timezone true. The forms read visitors ?? bookings | | visitors.can_book_for_anyone | visitor-invite.can-book-for-anyone | 2 | PASS | | | visitors.can_book_for_others | visitor-invite.can-book-for-others | 2 | PASS | | | visitors.min_duration | visitor-invite.duration-options | 1 | PASS | | diff --git a/docs/settings/concierge.md b/docs/settings/concierge.md index cf0ba265044..a58e6f0cc0b 100644 --- a/docs/settings/concierge.md +++ b/docs/settings/concierge.md @@ -321,7 +321,7 @@ Each custom column requires a `field` (the booking extension data field to displ | `visitors.max_duration` | number | – | Maximum visitor invite duration in minutes. Falls back to `bookings.max_duration`, then `180`. | | `visitors.duration_step` | number | – | Step in minutes between the durations of the visitor invite duration field. Falls back to `bookings.duration_step`, then `15`. | | `visitors.custom_duration_options` | number[] | – | Custom list of selectable visitor invite durations, in minutes. Falls back to `bookings.custom_duration_options`. | -| `visitors.use_building_timezone` | boolean | – | Handle visitor invite dates and times in the active building's timezone. The invite form uses the building timezone when this key or `bookings.use_building_timezone` is `true`. | +| `visitors.use_building_timezone` | boolean | – | Handle visitor invite dates and times in the active building's timezone. The invite form reads this key. When it is unset, it uses `bookings.use_building_timezone`. | ## Reports diff --git a/docs/settings/workplace.md b/docs/settings/workplace.md index 7d0ea959fa1..d2477f0b44e 100644 --- a/docs/settings/workplace.md +++ b/docs/settings/workplace.md @@ -197,7 +197,7 @@ These settings override the matching `bookings.*` settings for visitor invites. | `visitors.duration_step` | number | – | Step, in minutes, between duration options for visitor invites. Falls back to `bookings.duration_step`, then `15`. | | `visitors.custom_duration_options` | number[] | – | Custom list of duration options, in minutes, for visitor invites. Falls back to `bookings.custom_duration_options`. | | `visitors.show_calendar_links` | boolean | – | Show "add to calendar" links on the visitor invite success view. Off when not set. | -| `visitors.use_building_timezone` | boolean | – | Display and book visitor invite times using the building's timezone. `bookings.use_building_timezone` also turns this on. | +| `visitors.use_building_timezone` | boolean | – | Display and book visitor invite times using the building's timezone. Overrides `bookings.use_building_timezone`. | ## Meetings & Events (`events.*`) diff --git a/libs/bookings/src/lib/invite-visitor-form.component.ts b/libs/bookings/src/lib/invite-visitor-form.component.ts index 84e2bfdbbe5..e40989686f6 100644 --- a/libs/bookings/src/lib/invite-visitor-form.component.ts +++ b/libs/bookings/src/lib/invite-visitor-form.component.ts @@ -812,14 +812,6 @@ export class InviteVisitorFormComponent implements OnInit { 'use_24_hour_time', false, ); - private readonly _booking_use_building_timezone = this._settings.signal( - 'bookings.use_building_timezone', - false, - ); - private readonly _visitor_use_building_timezone = this._settings.signal( - 'visitors.use_building_timezone', - false, - ); private readonly _visitor_min_duration = this._settings.signal( 'visitors.min_duration', null, @@ -871,9 +863,15 @@ export class InviteVisitorFormComponent implements OnInit { public readonly time_format = this._settings.time_format_signal; public readonly use_24hr = this._use_24hr; + /** + * Building timezone when enabled, else '' for local time. + * `visitors.use_building_timezone` overrides the bookings setting when set. + * Reads `get()` because the cached setting signals keep their last value + * when a building switch unsets a key, which breaks the fallback. + */ public readonly timezone = computed(() => - this._booking_use_building_timezone() || - this._visitor_use_building_timezone() + (this._settings.get('app.visitors.use_building_timezone') ?? + this._settings.get('app.bookings.use_building_timezone')) ? this._org.building?.timezone || '' : '', ); diff --git a/libs/bookings/src/lib/visitor-invite-form.component.ts b/libs/bookings/src/lib/visitor-invite-form.component.ts index 23868bb9b29..f9ae58aaac0 100644 --- a/libs/bookings/src/lib/visitor-invite-form.component.ts +++ b/libs/bookings/src/lib/visitor-invite-form.component.ts @@ -545,16 +545,15 @@ export class VisitorInviteFormComponent false, ); public readonly use_24hr = settingSignal('use_24_hour_time', false); - private readonly _visitor_use_bld_tz = settingSignal( - 'visitors.use_building_timezone', - false, - ); - private readonly _booking_use_bld_tz = settingSignal( - 'bookings.use_building_timezone', - false, - ); + /** + * Building timezone when enabled, else '' for local time. + * `visitors.use_building_timezone` overrides the bookings setting when set. + * Reads `get()` because the cached setting signals keep their last value + * when a building switch unsets a key, which breaks the fallback. + */ public readonly timezone = computed(() => - this._visitor_use_bld_tz() || this._booking_use_bld_tz() + (this._settings.get('app.visitors.use_building_timezone') ?? + this._settings.get('app.bookings.use_building_timezone')) ? this._org.building?.timezone || '' : '', ); diff --git a/libs/bookings/src/test/invite-visitor-form.component.spec.ts b/libs/bookings/src/test/invite-visitor-form.component.spec.ts index 2d8d5ee11b6..92b9f6f5cc7 100644 --- a/libs/bookings/src/test/invite-visitor-form.component.spec.ts +++ b/libs/bookings/src/test/invite-visitor-form.component.spec.ts @@ -233,6 +233,22 @@ describe('InviteVisitorFormComponent', () => { expect('a-user-search-field').not.toExist(); }); + it('should let the visitors timezone setting override the bookings setting', () => { + const settings = spectator.inject(SettingsService); + (spectator.inject(OrganisationService) as any).building = { + id: 'bld-1', + timezone: 'Australia/Sydney', + }; + const values: Record = { + 'app.bookings.use_building_timezone': true, + }; + (settings.get as Mock).mockImplementation((key: string) => values[key]); + expect(spectator.component.timezone()).toBe('Australia/Sydney'); + values['app.visitors.use_building_timezone'] = false; + (settings.get as Mock).mockImplementation((key: string) => values[key]); + expect(spectator.component.timezone()).toBe(''); + }); + it('should show unrestricted host search when visitor settings allow anyone', () => { const settings = spectator.inject(SettingsService); (settings.get as Mock).mockImplementation((key: string) => diff --git a/libs/bookings/src/test/visitor-invite-form.component.spec.ts b/libs/bookings/src/test/visitor-invite-form.component.spec.ts index b1fc1750223..a63ceeb7b9c 100644 --- a/libs/bookings/src/test/visitor-invite-form.component.spec.ts +++ b/libs/bookings/src/test/visitor-invite-form.component.spec.ts @@ -82,6 +82,7 @@ describe('VisitorInviteFormComponent', () => { settingSignal('bookings.min_duration').set(30); settingSignal('bookings.max_duration').set(180); settingSignal('bookings.custom_duration_options').set([]); + settingSignal('visitors.use_building_timezone').set(undefined); spectator = createComponent(); }); @@ -99,6 +100,38 @@ describe('VisitorInviteFormComponent', () => { expect(spectator.component.effective_min_duration()).toBe(45); }); + it('should let the visitors timezone setting override the bookings setting', () => { + (spectator.inject(OrganisationService) as any).building = { + id: 'bld-1', + timezone: 'Australia/Sydney', + }; + const settings = spectator.inject(SettingsService); + const values: Record = { + 'app.bookings.use_building_timezone': true, + }; + (settings.get as Mock).mockImplementation((key: string) => values[key]); + expect(spectator.component.timezone()).toBe('Australia/Sydney'); + values['app.visitors.use_building_timezone'] = false; + (settings.get as Mock).mockImplementation((key: string) => values[key]); + expect(spectator.component.timezone()).toBe(''); + }); + + it('should fall back to the bookings timezone setting when a building switch leaves a stale visitors signal', () => { + (spectator.inject(OrganisationService) as any).building = { + id: 'bld-1', + timezone: 'Australia/Sydney', + }; + // The cached signal keeps `false` from the last building, but the + // new building does not set the visitors key. + settingSignal('visitors.use_building_timezone').set(false); + const settings = spectator.inject(SettingsService); + (settings.get as Mock).mockImplementation( + (key: string) => + ({ 'app.bookings.use_building_timezone': true })[key], + ); + expect(spectator.component.timezone()).toBe('Australia/Sydney'); + }); + it('should filter visitor suggestions by the search term', () => { spectator.component.visitors.set([ { email: 'alice@example.com', name: 'Alice', company: 'Acme' }, diff --git a/libs/common/src/tests/test-helpers.ts b/libs/common/src/tests/test-helpers.ts index c9005478787..cc974baeb6b 100644 --- a/libs/common/src/tests/test-helpers.ts +++ b/libs/common/src/tests/test-helpers.ts @@ -169,18 +169,23 @@ export const createSettingsServiceMock = ( overrides: Record = {}, ) => { const version = signal(0); - const get = test_mock.fn((_: string): T => undefined as T); + // `get` reads `version`, so a `computed` that calls `get()` recomputes + // when a spec changes the mock, like the real service on override changes. + const get = test_mock.fn((_: string): T => { + version(); + return undefined as T; + }); const bump = () => version.update((value) => value + 1); const mock_implementation = get.mockImplementation.bind(get); - const mock_return_value = get.mockReturnValue.bind(get); get.mockImplementation = ((fn) => { bump(); - return mock_implementation(fn); - }); - get.mockReturnValue = ((value) => { - bump(); - return mock_return_value(value); - }); + return mock_implementation((...args) => { + version(); + return fn(...args); + }); + }) as typeof get.mockImplementation; + get.mockReturnValue = ((value) => + get.mockImplementation(() => value)) as typeof get.mockReturnValue; return { get, saveUserSetting: test_mock.fn(),