Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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);
});
}
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
: '';
}
Expand Down
2 changes: 1 addition & 1 deletion apps/workplace/src/environments/settings.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<VisitorFlowDetailsComponent>;
Expand Down Expand Up @@ -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<string, boolean> = {
'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) => ({
Expand Down
12 changes: 6 additions & 6 deletions docs/qa/workplace-settings-final-status.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |

Expand Down Expand Up @@ -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.
Expand All @@ -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 |
Expand Down Expand Up @@ -307,15 +307,15 @@ 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 | |
| visitors.allow_international | visitor-invite.allow-international | 2 | PASS | Docs and schema wording corrected earlier in the PR |
| 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 |

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note

🤖 Claude Opus 5.5 responding on behalf of Alex Sorafumo

This row went from 2 to 4, but the totals above it did not change. The ### visitor-invite (13 specs, 16 Settings, 29 scenarios) rows (line 310) now add up to 31. The summary row still says 445 scenarios, but bookings.use-building-timezone.spec.ts now has 4 tests instead of 2. The doc says its counts match --list, so the header and summary need +2.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note

🤖 Claude Opus 5.5 responding on behalf of Alex Sorafumo

Fixed in a02aae8. I checked the counts with --list (449 tests, including the 2 preflight tests):

  • visitor-invite header: 29 → 31 scenarios
  • Summary: 445 → 447 scenarios
  • settings project row: 409 → 411 tests. This row was also out of date.

| 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 | |
Expand Down
2 changes: 1 addition & 1 deletion docs/settings/concierge.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion docs/settings/workplace.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.*`)

Expand Down
18 changes: 8 additions & 10 deletions libs/bookings/src/lib/invite-visitor-form.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 || ''
: '',
);
Expand Down
17 changes: 8 additions & 9 deletions libs/bookings/src/lib/visitor-invite-form.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 || ''
: '',
);
Expand Down
16 changes: 16 additions & 0 deletions libs/bookings/src/test/invite-visitor-form.component.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, boolean> = {
'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) =>
Expand Down
33 changes: 33 additions & 0 deletions libs/bookings/src/test/visitor-invite-form.component.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});

Expand All @@ -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<string, boolean> = {
'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' },
Expand Down
21 changes: 13 additions & 8 deletions libs/common/src/tests/test-helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -169,18 +169,23 @@ export const createSettingsServiceMock = (
overrides: Record<string, any> = {},
) => {
const version = signal(0);
const get = test_mock.fn(<T = unknown>(_: 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(<T = unknown>(_: 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(),
Expand Down
Loading