Skip to content

Commit 5b3df5a

Browse files
committed
Merge branch 'develop' into custom/HIO687AS
# Conflicts: # libs/form-fields/src/lib/duration-field.component.ts
2 parents cc78ef1 + 6c76d56 commit 5b3df5a

10 files changed

Lines changed: 197 additions & 117 deletions

File tree

apps/survey/project.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,7 @@
5959
{
6060
"type": "initial",
6161
"maximumWarning": "500kb",
62-
"maximumError": "1.5mb"
62+
"maximumError": "1.6mb"
6363
},
6464
{
6565
"type": "anyComponentStyle",

libs/common/src/lib/timezone-helpers.ts

Lines changed: 57 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,10 @@
1-
import { addMilliseconds, endOfDay, startOfDay } from 'date-fns';
1+
import {
2+
addMilliseconds,
3+
endOfDay,
4+
set,
5+
startOfDay,
6+
startOfMinute,
7+
} from 'date-fns';
28
import { fromZonedTime, getTimezoneOffset, toZonedTime } from 'date-fns-tz';
39
import { padLength } from './general';
410

@@ -46,7 +52,7 @@ export function getTimezoneOffsetString(tz: string) {
4652
const offset = getTimezoneOffsetInMinutes(tz);
4753
const hours = Math.floor(Math.abs(offset) / 60);
4854
const minutes = Math.abs(offset) % 60;
49-
const output = `${offset > 0 ? '+' : '-'}${padLength(hours, 2)}${padLength(
55+
const output = `${offset >= 0 ? '+' : '-'}${padLength(hours, 2)}${padLength(
5056
minutes,
5157
2,
5258
)}`;
@@ -58,7 +64,7 @@ export function getTimezoneOffsetInMinutes(timeZone, date = new Date()) {
5864
const options: Intl.DateTimeFormatOptions = {
5965
timeZone,
6066
hour12: false,
61-
timeZoneName: 'short',
67+
timeZoneName: 'shortOffset',
6268
};
6369
const formatter = new Intl.DateTimeFormat([], options);
6470
const parts = formatter.formatToParts(date);
@@ -91,3 +97,51 @@ export function getTimezoneDifferenceInHours(
9197
// Calculate the difference in hours
9298
return (offset1 - offset2) / 60;
9399
}
100+
101+
/**
102+
* Get the hours and minutes of a date as they appear in a target timezone.
103+
* Returns { hours, minutes } in the wall-clock time of the given timezone.
104+
*/
105+
export function getTimeInTimezone(
106+
date: Date | number,
107+
tz?: string,
108+
): { hours: number; minutes: number } {
109+
if (!tz) {
110+
const d = new Date(date);
111+
return { hours: d.getHours(), minutes: d.getMinutes() };
112+
}
113+
const zoned = toZonedTime(date, tz);
114+
return { hours: zoned.getHours(), minutes: zoned.getMinutes() };
115+
}
116+
117+
/**
118+
* Format a date's time as 'HH:mm' in a target timezone.
119+
* If no timezone is provided, uses the local timezone.
120+
*/
121+
export function formatTimeInTimezone(date: Date | number, tz?: string): string {
122+
const { hours, minutes } = getTimeInTimezone(date, tz);
123+
return `${padLength(hours, 2)}:${padLength(minutes, 2)}`;
124+
}
125+
126+
/**
127+
* Set hours and minutes on a date, interpreting them as wall-clock time in the
128+
* given timezone, and return the resulting UTC epoch milliseconds.
129+
* If no timezone is provided, interprets in local timezone.
130+
*/
131+
export function setTimeInTimezone(
132+
date: Date | number,
133+
hours: number,
134+
minutes: number,
135+
tz?: string,
136+
): number {
137+
if (!tz) {
138+
const d = set(new Date(date), { hours, minutes });
139+
return startOfMinute(d).valueOf();
140+
}
141+
// Convert the date to the target timezone's wall-clock representation
142+
const zoned = toZonedTime(date, tz);
143+
// Set the desired hours and minutes on the zoned representation
144+
const adjusted = set(zoned, { hours, minutes });
145+
// Convert back from the target timezone's wall-clock to UTC epoch
146+
return startOfMinute(fromZonedTime(adjusted, tz)).valueOf();
147+
}

libs/components/src/lib/global-loading.component.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import {
33
AsyncHandler,
44
firstTruthyValueFrom,
55
getLoadingMessage,
6+
nativeDomainError,
67
needsNativeDomain,
78
OrganisationService,
89
PlaceOS_Service,

libs/explore/src/lib/explore-desk-info.component.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ type DeskStatus =
2828
export interface DeskInfoData {
2929
id: string;
3030
map_id: string;
31-
user: string;
31+
user: WritableSignal<string>;
3232
name: string;
3333
start?: number;
3434
end?: number;
@@ -226,7 +226,7 @@ export class ExploreDeskInfoComponent extends AsyncHandler implements OnInit {
226226
public readonly id = signal(this._details.id);
227227
public readonly map_id = signal(this._details.map_id);
228228
public readonly name = signal(this._details.name);
229-
public readonly user = signal(this._details.user);
229+
public readonly user = this._details.user;
230230
public readonly start = signal(this._details.start);
231231
public readonly end = signal(this._details.end);
232232
public readonly department = signal(this._details.department);

libs/explore/src/lib/explore-desks.service.ts

Lines changed: 23 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,7 @@ export class ExploreDesksService extends AsyncHandler implements OnDestroy {
7979
private _signs_of_life = new BehaviorSubject<string[]>([]);
8080
private _statuses: Record<string, WritableSignal<string>> = {};
8181

82-
private _users: Record<string, string> = {};
82+
private _users: Record<string, WritableSignal<string>> = {};
8383
private _departments: Record<string, string> = {};
8484
private _desk_bookings = new Map<string, WritableSignal<Booking[]>>();
8585

@@ -337,7 +337,10 @@ export class ExploreDesksService extends AsyncHandler implements OnDestroy {
337337
const departments = this._settings.get('app.department_map') || {};
338338
for (const desk of desks) {
339339
const d_id = desk.map_id || desk.asset_id;
340-
this._users[d_id] = desk.staff_name;
340+
if (!this._users[d_id]) {
341+
this._users[d_id] = signal('');
342+
}
343+
this._users[d_id].set(desk.staff_name);
341344
this._departments[d_id] = departments[desk.department] || '';
342345
}
343346
this.processDevices(devices, system_id);
@@ -394,6 +397,19 @@ export class ExploreDesksService extends AsyncHandler implements OnDestroy {
394397
}
395398
if (!this._desk_bookings[d_id])
396399
this._desk_bookings[d_id] = signal([]);
400+
if (!this._users[d_id]) {
401+
this._users[d_id] = signal('');
402+
}
403+
if (show_desk_users) {
404+
const user_value =
405+
this._users[d_id]() ||
406+
desk.staff_name ||
407+
(desk as any).assigned_name ||
408+
'';
409+
this._users[d_id].set(user_value);
410+
} else {
411+
this._users[d_id].set('');
412+
}
397413
list.push({
398414
track_id: `desk:hover:${d_id}`,
399415
location: d_id,
@@ -404,11 +420,7 @@ export class ExploreDesksService extends AsyncHandler implements OnDestroy {
404420
id: d_id,
405421
map_id: desk.name,
406422
name: desk.name || desk.map_id,
407-
user: show_desk_users
408-
? this._users[d_id] ||
409-
desk.staff_name ||
410-
(desk as any).assigned_name
411-
: '',
423+
user: this._users[d_id],
412424
status: this._statuses[d_id],
413425
department: this._departments[d_id] || '',
414426
bookings: this._desk_bookings[d_id],
@@ -572,7 +584,10 @@ export class ExploreDesksService extends AsyncHandler implements OnDestroy {
572584
);
573585
throw e;
574586
});
575-
this._users[d_id] = (options.host || currentUser())?.name;
587+
if (!this._users[d_id]) {
588+
this._users[d_id] = signal('');
589+
}
590+
this._users[d_id].set((options.host || currentUser())?.name);
576591
notifySuccess(
577592
i18n('EXPLORE.DESK_BOOKING_SUCCESS', { name: desk.name || 'Desk' }),
578593
);

libs/explore/src/tests/explore-desk-info.component.spec.ts

Lines changed: 11 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
1+
import { signal } from '@angular/core';
12
import { createComponentFactory, Spectator } from '@ngneat/spectator/jest';
23
import { Booking, MAP_FEATURE_DATA } from '@placeos/common';
3-
import { signal } from '@angular/core';
44
import { ngMocks } from 'ng-mocks';
55

66
import { ExploreDeskInfoComponent } from '../lib/explore-desk-info.component';
@@ -39,9 +39,9 @@ describe('ExploreDeskInfoComponent', () => {
3939
id: 'desk-1',
4040
map_id: 'desk-1',
4141
name: 'Desk 1',
42-
user: '',
42+
user: signal(''),
4343
status: signal('free'),
44-
bookings: [booking],
44+
bookings: signal([booking]),
4545
date: selected.valueOf(),
4646
},
4747
},
@@ -50,10 +50,8 @@ describe('ExploreDeskInfoComponent', () => {
5050
spectator.component.now.set(selected.valueOf());
5151

5252
expect(spectator.component.display_user()).toBe('Taylor');
53-
expect(spectator.component.current_booking()).toBe(false);
54-
expect(spectator.component.next_booking()?.date).toBe(
55-
booking.date,
56-
);
53+
expect(spectator.component.current_booking()).toBeFalsy();
54+
expect(spectator.component.next_booking()?.date).toBe(booking.date);
5755
});
5856

5957
it('should show free at for the current booking', () => {
@@ -77,17 +75,19 @@ describe('ExploreDeskInfoComponent', () => {
7775
id: 'desk-1',
7876
map_id: 'desk-1',
7977
name: 'Desk 1',
80-
user: '',
78+
user: signal(''),
8179
status: signal('busy'),
82-
bookings: [booking],
80+
bookings: signal([booking]),
8381
date: current.valueOf(),
8482
},
8583
},
8684
],
8785
});
8886
spectator.component.now.set(current.valueOf());
8987

90-
expect(spectator.component.current_booking()).toBe(true);
91-
expect(spectator.component.display_end()).toBe(booking.date_end);
88+
expect(spectator.component.current_booking()).toBeTruthy();
89+
expect(spectator.component.current_booking()?.date_end).toBe(
90+
booking.date_end,
91+
);
9292
});
9393
});

libs/form-fields/src/lib/duration-field.component.ts

Lines changed: 19 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,22 @@
11
import { CommonModule } from '@angular/common';
22
import {
3-
AfterViewInit,
43
Component,
4+
computed,
55
forwardRef,
66
input,
77
model,
88
OnChanges,
99
OnInit,
1010
SimpleChanges,
11-
viewChild,
1211
} from '@angular/core';
1312
import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';
1413
import { MatFormFieldModule } from '@angular/material/form-field';
15-
import { MatMenuModule, MatMenuTrigger } from '@angular/material/menu';
16-
import { formatDuration, getTimezoneOffsetString } from '@placeos/common';
14+
import { MatMenuModule } from '@angular/material/menu';
15+
import {
16+
formatDuration,
17+
getTimeInTimezone,
18+
getTimezoneOffsetString,
19+
} from '@placeos/common';
1720
import { addMinutes } from 'date-fns';
1821
import { IconComponent } from 'libs/components/src/lib/icon.component';
1922

@@ -48,9 +51,9 @@ export interface DurationOption {
4851
: ''
4952
}}{{ selected?.name }}{{ selected?.date ? ')' : '' }}
5053
</div>
51-
@if (timezone() && tz) {
54+
@if (timezone() && tz()) {
5255
<div class="truncate text-xs opacity-30">
53-
{{ selected?.date | date: time_format + ' (z)' : tz }}
56+
{{ selected?.date | date: time_format + ' (z)' : tz() }}
5457
</div>
5558
}
5659
</div>
@@ -60,7 +63,6 @@ export interface DurationOption {
6063
@for (option of duration_options; track option.id) {
6164
<button
6265
mat-menu-item
63-
[attr.data-duration]="option.id"
6466
class="text-left"
6567
(click)="setValue(option.id)"
6668
>
@@ -80,13 +82,13 @@ export interface DurationOption {
8082
}}{{ option.name
8183
}}{{ option.date ? ')' : '' }}
8284
</div>
83-
@if (timezone() && tz) {
85+
@if (timezone() && tz()) {
8486
<div class="truncate text-xs opacity-30">
8587
{{
8688
option.date
8789
| date
8890
: time_format + ' (z)'
89-
: tz
91+
: tz()
9092
}}
9193
</div>
9294
}
@@ -125,7 +127,7 @@ export interface DurationOption {
125127
imports: [MatMenuModule, MatFormFieldModule, CommonModule, IconComponent],
126128
})
127129
export class DurationFieldComponent
128-
implements OnInit, OnChanges, AfterViewInit, ControlValueAccessor
130+
implements OnInit, OnChanges, ControlValueAccessor
129131
{
130132
/** Maximum duration option available */
131133
public readonly max = input(240);
@@ -158,8 +160,6 @@ export class DurationFieldComponent
158160
private _onChange: (_: number) => void;
159161
/** Form control on touch handler */
160162
private _onTouch: (_: number) => void;
161-
/** Menu trigger for the duration selection dropdown */
162-
private readonly _menu_trigger = viewChild(MatMenuTrigger);
163163

164164
public get time_format() {
165165
return this.use_24hr() ? 'HH : mm' : 'h : mm a';
@@ -173,12 +173,12 @@ export class DurationFieldComponent
173173
Intl.DateTimeFormat().resolvedOptions().timeZone,
174174
);
175175

176-
public get tz() {
176+
public readonly tz = computed(() => {
177177
const tz = this.timezone();
178178
if (!tz) return '';
179179
const tz_offset = getTimezoneOffsetString(tz);
180180
return tz_offset === this._local_tz ? '' : tz_offset;
181-
}
181+
});
182182

183183
public ngOnInit(): void {
184184
this.duration_options = this.generateDurationOptions(
@@ -210,36 +210,6 @@ export class DurationFieldComponent
210210
}
211211
}
212212

213-
public ngAfterViewInit(): void {
214-
const trigger = this._menu_trigger();
215-
if (trigger) {
216-
trigger.menuOpened.subscribe(() => {
217-
this._scrollToSelectedDuration();
218-
});
219-
}
220-
}
221-
222-
/** Scroll the menu to the selected duration option */
223-
private _scrollToSelectedDuration(): void {
224-
// Use requestAnimationFrame for immediate execution after render
225-
requestAnimationFrame(() => {
226-
const panel = document.querySelector('.mat-mdc-menu-panel');
227-
if (!panel) return;
228-
229-
// Find the selected duration element
230-
const target_element = panel.querySelector(
231-
`[data-duration="${this.duration}"]`,
232-
);
233-
234-
if (target_element) {
235-
target_element.scrollIntoView({
236-
block: 'center',
237-
behavior: 'instant',
238-
});
239-
}
240-
});
241-
}
242-
243213
/**
244214
* Update the form field value
245215
* @param new_value New value to set on the form field
@@ -355,8 +325,11 @@ export class DurationFieldComponent
355325
if (end_time === undefined || end_time === null || !time_value) {
356326
return max;
357327
}
358-
const date = new Date(time_value);
359-
const start_minutes = date.getHours() * 60 + date.getMinutes();
328+
// Use building timezone to compute start minutes since end_time
329+
// is in building timezone minutes-since-midnight
330+
const tz = this.timezone() || undefined;
331+
const { hours, minutes } = getTimeInTimezone(time_value, tz);
332+
const start_minutes = hours * 60 + minutes;
360333
return Math.max(0, Math.min(max, end_time - start_minutes));
361334
}
362335
}

0 commit comments

Comments
 (0)