@rapidmx/web-client 0.23.0 → 0.24.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/apps/admin/mailboxes/[uid].tsx +2 -1
- package/apps/shared/components/admin/settings/MailboxCreateForm.tsx +2 -1
- package/apps/shared/components/calendar/AppointmentQuickForm.tsx +2 -1
- package/apps/shared/components/calendar/EventDescriptionView.tsx +37 -5
- package/apps/shared/components/calendar/EventDetails.tsx +15 -6
- package/apps/shared/components/calendar/EventEditor.tsx +5 -2
- package/apps/shared/components/calendar/EventFormParts.tsx +45 -39
- package/apps/shared/components/calendar/EventQuickForm.tsx +3 -2
- package/apps/shared/components/calendar/FindATime.tsx +3 -2
- package/apps/shared/components/calendar/TimeComboBox.tsx +143 -0
- package/apps/shared/components/calendar/eventFormat.ts +15 -0
- package/apps/shared/components/calendar/timePicker.ts +106 -0
- package/apps/shared/components/mail/invite/inviteFormat.ts +6 -5
- package/apps/www/calendar/index.tsx +5 -2
- package/apps/www/settings/profile/index.tsx +8 -12
- package/dist/apps/admin/mailboxes/[uid].js +2 -1
- package/dist/apps/shared/components/admin/settings/MailboxCreateForm.js +2 -2
- package/dist/apps/shared/components/calendar/AppointmentQuickForm.js +2 -1
- package/dist/apps/shared/components/calendar/EventDescriptionView.d.ts +6 -1
- package/dist/apps/shared/components/calendar/EventDescriptionView.js +34 -4
- package/dist/apps/shared/components/calendar/EventDetails.js +4 -4
- package/dist/apps/shared/components/calendar/EventEditor.js +5 -2
- package/dist/apps/shared/components/calendar/EventFormParts.d.ts +1 -1
- package/dist/apps/shared/components/calendar/EventFormParts.js +9 -3
- package/dist/apps/shared/components/calendar/EventQuickForm.js +3 -2
- package/dist/apps/shared/components/calendar/FindATime.js +3 -2
- package/dist/apps/shared/components/calendar/TimeComboBox.d.ts +25 -0
- package/dist/apps/shared/components/calendar/TimeComboBox.js +77 -0
- package/dist/apps/shared/components/calendar/eventFormat.d.ts +4 -0
- package/dist/apps/shared/components/calendar/eventFormat.js +13 -0
- package/dist/apps/shared/components/calendar/timePicker.d.ts +31 -0
- package/dist/apps/shared/components/calendar/timePicker.js +89 -0
- package/dist/apps/shared/components/mail/invite/inviteFormat.js +6 -5
- package/dist/apps/www/calendar/index.js +5 -2
- package/dist/apps/www/settings/profile/index.js +6 -5
- package/dist/lib/auth/session.d.ts +14 -1
- package/dist/lib/auth/session.js +40 -3
- package/dist/lib/components/pickers/TimeZonePicker.d.ts +20 -0
- package/dist/lib/components/pickers/TimeZonePicker.js +100 -0
- package/dist/lib/util/api.d.ts +6 -0
- package/dist/lib/util/api.js +22 -1
- package/dist/lib/util/timeZone.d.ts +19 -1
- package/dist/lib/util/timeZone.js +46 -2
- package/lib/auth/session.ts +48 -3
- package/lib/components/pickers/TimeZonePicker.tsx +180 -0
- package/lib/util/api.ts +24 -1
- package/lib/util/timeZone.ts +50 -2
- package/package.json +1 -1
|
@@ -12,6 +12,7 @@ import ResourceSettingsCard from "../../shared/components/admin/mailboxes/Resour
|
|
|
12
12
|
import EscrowScopeCard from "../../shared/components/admin/mailboxes/EscrowScopeCard.js";
|
|
13
13
|
import Alert from "../../../lib/components/feedback/Alert.js";
|
|
14
14
|
import Button from "../../../lib/components/buttons/Button.js";
|
|
15
|
+
import { describeTimeZone } from "../../../lib/util/timeZone.js";
|
|
15
16
|
import Modal from "../../../lib/components/overlays/Modal.js";
|
|
16
17
|
|
|
17
18
|
function formatBytes(bytes: number): string {
|
|
@@ -156,7 +157,7 @@ function MailboxDetailContent({
|
|
|
156
157
|
<dt className="text-text-muted">Owner</dt>
|
|
157
158
|
<dd>{mailbox.ownerUserUid ?? "None (shared mailbox)"}</dd>
|
|
158
159
|
<dt className="text-text-muted">Timezone</dt>
|
|
159
|
-
<dd>{mailbox.timezone}</dd>
|
|
160
|
+
<dd>{describeTimeZone(mailbox.timezone)}</dd>
|
|
160
161
|
<dt className="text-text-muted">Quota</dt>
|
|
161
162
|
<dd>
|
|
162
163
|
{formatBytes(mailbox.usedBytes)} / {formatBytes(mailbox.quotaBytes)}
|
|
@@ -7,7 +7,7 @@ import { ApiRequestError } from "../../../../../lib/util/api.js";
|
|
|
7
7
|
import { getMailboxPolicy } from "../../../../../lib/admin/mailboxPolicyApi.js";
|
|
8
8
|
import { LeftoverConflict, leftoverConflictOf } from "../../../../../lib/admin/leftoverMailboxApi.js";
|
|
9
9
|
import { createMailbox, listMailboxDomains, Mailbox, resolveMailboxOwner, ResolvedPrincipal } from "../../../../../lib/mail/mailApi.js";
|
|
10
|
-
import { deviceTimeZone } from "../../../../../lib/util/timeZone.js";
|
|
10
|
+
import { describeTimeZone, deviceTimeZone, zoneClock } from "../../../../../lib/util/timeZone.js";
|
|
11
11
|
import { useApiClient } from "../../../../../lib/util/apiClientContext.js";
|
|
12
12
|
import Alert from "../../../../../lib/components/feedback/Alert.js";
|
|
13
13
|
import Button from "../../../../../lib/components/buttons/Button.js";
|
|
@@ -298,6 +298,7 @@ export default function MailboxCreateForm({ onCreated, defaults, submitLabel = "
|
|
|
298
298
|
onChange={(e) => setTimezone(e.target.value)}
|
|
299
299
|
placeholder="America/Los_Angeles"
|
|
300
300
|
/>
|
|
301
|
+
{zoneClock(timezone) && <p className="text-xs text-text-muted mt-1">{describeTimeZone(timezone)}</p>}
|
|
301
302
|
</FormField>
|
|
302
303
|
|
|
303
304
|
<FormField label="Quota (GB)" htmlFor="quotaGb">
|
|
@@ -19,6 +19,7 @@ import {
|
|
|
19
19
|
import { EventFormController } from "./eventForm.js";
|
|
20
20
|
import { CalendarField, INPUT_CLASS, IconRow, SELECT_CLASS } from "./EventFormParts.js";
|
|
21
21
|
import QuickFaceFrame from "./QuickFaceFrame.js";
|
|
22
|
+
import { describeTimeZone } from "../../../../lib/util/timeZone.js";
|
|
22
23
|
|
|
23
24
|
const DAY_NAMES = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"];
|
|
24
25
|
const DURATIONS = ["15", "30", "45", "60"];
|
|
@@ -298,7 +299,7 @@ export default function AppointmentQuickForm({ c, draft, onDraftChange, tabs, se
|
|
|
298
299
|
onChange={(e) => onDraftChange({ endTime: e.target.value })}
|
|
299
300
|
/>
|
|
300
301
|
</div>
|
|
301
|
-
<p className="text-xs text-text-muted mt-1">Times are in {timeZone
|
|
302
|
+
<p className="text-xs text-text-muted mt-1">Times are in {describeTimeZone(timeZone)}.</p>
|
|
302
303
|
</IconRow>
|
|
303
304
|
|
|
304
305
|
<IconRow icon={<HiOutlineMapPin size={20} />}>
|
|
@@ -5,18 +5,50 @@
|
|
|
5
5
|
import React, { ReactNode } from "react";
|
|
6
6
|
import { DescriptionNode, htmlToPlainText, parseEventDescription } from "../../../../lib/calendar/eventDescription.js";
|
|
7
7
|
|
|
8
|
-
|
|
8
|
+
const URL_PATTERN = /https?:\/\/[^\s<>"']+/gi;
|
|
9
|
+
const LINK_CLASS = "text-primary-dark underline break-words";
|
|
10
|
+
|
|
11
|
+
function count(text: string, character: string): number {
|
|
12
|
+
return text.split(character).length - 1;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* `text` with every web address in it made a link that opens in a new tab: a description's own links are its author's, but Google Meet joins, dial-in pages
|
|
17
|
+
* and the like are often only typed into the text. Punctuation that ends a sentence, or closes a bracket the address was not in, stays out of the link.
|
|
18
|
+
*/
|
|
19
|
+
export function linkifyText(text: string): ReactNode[] {
|
|
20
|
+
const parts: ReactNode[] = [];
|
|
21
|
+
let last = 0;
|
|
22
|
+
for (const match of text.matchAll(URL_PATTERN)) {
|
|
23
|
+
let url = match[0];
|
|
24
|
+
while (/[.,;:!?]$/.test(url) || (url.endsWith(")") && count(url, ")") > count(url, "(")) || (url.endsWith("]") && count(url, "]") > count(url, "["))) {
|
|
25
|
+
url = url.slice(0, -1);
|
|
26
|
+
}
|
|
27
|
+
const at = match.index;
|
|
28
|
+
parts.push(text.slice(last, at));
|
|
29
|
+
parts.push(
|
|
30
|
+
<a key={at} href={url} target="_blank" rel="noopener noreferrer" className={LINK_CLASS}>
|
|
31
|
+
{url}
|
|
32
|
+
</a>,
|
|
33
|
+
);
|
|
34
|
+
last = at + url.length;
|
|
35
|
+
}
|
|
36
|
+
parts.push(text.slice(last));
|
|
37
|
+
return parts;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function renderNodes(nodes: DescriptionNode[], insideLink = false): ReactNode[] {
|
|
9
41
|
return nodes.map((node, index) => {
|
|
10
42
|
if ("text" in node) {
|
|
11
|
-
return node.text
|
|
43
|
+
return insideLink ? node.text : <React.Fragment key={index}>{linkifyText(node.text)}</React.Fragment>;
|
|
12
44
|
}
|
|
13
45
|
switch (node.tag) {
|
|
14
46
|
case "br":
|
|
15
47
|
return <br key={index} />;
|
|
16
48
|
case "a":
|
|
17
49
|
return (
|
|
18
|
-
<a key={index} href={node.href} target="_blank" rel="noopener noreferrer" className=
|
|
19
|
-
{renderNodes(node.children)}
|
|
50
|
+
<a key={index} href={node.href} target="_blank" rel="noopener noreferrer" className={LINK_CLASS}>
|
|
51
|
+
{renderNodes(node.children, true)}
|
|
20
52
|
</a>
|
|
21
53
|
);
|
|
22
54
|
case "ul":
|
|
@@ -66,5 +98,5 @@ export default function EventDescriptionView({ html, text, className }: EventDes
|
|
|
66
98
|
if (htmlToPlainText(html) !== "") {
|
|
67
99
|
return <div className={["break-words", className].filter(Boolean).join(" ")}>{renderNodes(parseEventDescription(html))}</div>;
|
|
68
100
|
}
|
|
69
|
-
return text?.trim() ? <div className={["break-words whitespace-pre-wrap", className].filter(Boolean).join(" ")}>{text}</div> : null;
|
|
101
|
+
return text?.trim() ? <div className={["break-words whitespace-pre-wrap", className].filter(Boolean).join(" ")}>{linkifyText(text)}</div> : null;
|
|
70
102
|
}
|
|
@@ -22,13 +22,13 @@ import { htmlToPlainText } from "../../../../lib/calendar/eventDescription.js";
|
|
|
22
22
|
import { deleteEventOccurrence, deleteEventSeries } from "../../../../lib/calendar/calendarMutations.js";
|
|
23
23
|
import { CalendarOccurrence, describeRecurrence } from "../../../../lib/calendar/recurrence.js";
|
|
24
24
|
import { getVideoMeeting } from "../../../../lib/videoconf/videoMeetingsApi.js";
|
|
25
|
-
import { deviceTimeZone } from "../../../../lib/util/timeZone.js";
|
|
25
|
+
import { describeTimeZone, deviceTimeZone } from "../../../../lib/util/timeZone.js";
|
|
26
26
|
import Alert from "../../../../lib/components/feedback/Alert.js";
|
|
27
27
|
import Button from "../../../../lib/components/buttons/Button.js";
|
|
28
28
|
import { joinMeetingUrl } from "../../calendar/calendarReminders.js";
|
|
29
29
|
import EventDescriptionView from "./EventDescriptionView.js";
|
|
30
30
|
import RequestChangeForm from "./RequestChangeForm.js";
|
|
31
|
-
import { BUSY_STATUS_LABEL, RESPONSE_STATUS_LABEL, VISIBILITY_LABEL, describeGuestPermissions, describeReminder, formatStoredWhen } from "./eventFormat.js";
|
|
31
|
+
import { BUSY_STATUS_LABEL, RESPONSE_STATUS_LABEL, VISIBILITY_LABEL, describeGuestPermissions, describeReminder, formatStoredWhen, formatStoredWhenInZone } from "./eventFormat.js";
|
|
32
32
|
|
|
33
33
|
const ROLE_LABEL = { required: "", optional: "Optional", resource: "Room/equipment" } as const;
|
|
34
34
|
|
|
@@ -220,11 +220,20 @@ export default function EventDetails({
|
|
|
220
220
|
{error && <Alert>{error}</Alert>}
|
|
221
221
|
|
|
222
222
|
<DetailRow icon={<HiOutlineClock size={18} />}>
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
223
|
+
{!occurrence.allDay && eventZone && eventZone !== deviceTimeZone() ? (
|
|
224
|
+
// Set somewhere else: the time on the reader's own clock, and on the clock it was set on, each said in full so nobody has to convert.
|
|
225
|
+
<>
|
|
226
|
+
<div>
|
|
227
|
+
{formatStoredWhen(occurrence.startDate, occurrence.endDate, false)} • {describeTimeZone(deviceTimeZone(), new Date(occurrence.startDate))}
|
|
228
|
+
</div>
|
|
229
|
+
<div className="text-text-muted">
|
|
230
|
+
{formatStoredWhenInZone(occurrence.startDate, occurrence.endDate, eventZone)} • {describeTimeZone(eventZone, new Date(occurrence.startDate))}
|
|
231
|
+
</div>
|
|
232
|
+
</>
|
|
233
|
+
) : (
|
|
234
|
+
<div>{formatStoredWhen(occurrence.startDate, occurrence.endDate, occurrence.allDay)}</div>
|
|
227
235
|
)}
|
|
236
|
+
{occurrence.recurrenceRule && <div className="text-text-muted">Repeats {describeRecurrence(occurrence.recurrenceRule)}</div>}
|
|
228
237
|
</DetailRow>
|
|
229
238
|
|
|
230
239
|
{occurrence.location && (
|
|
@@ -34,6 +34,7 @@ import QuickCreateFaces, { QuickCreateConfig } from "./QuickCreateFaces.js";
|
|
|
34
34
|
import { EditScope, EventFormController, EventFormValues } from "./eventForm.js";
|
|
35
35
|
import { descriptionProblem, dialogFields, hasDescriptionText, initialDescriptionHtml } from "./eventDialogFields.js";
|
|
36
36
|
import { mergeGuests, msToWallString, wallStringToMs } from "./eventFormat.js";
|
|
37
|
+
import { nextHalfHour } from "./timePicker.js";
|
|
37
38
|
|
|
38
39
|
/**
|
|
39
40
|
* The Location text this modal writes when it mints a video meeting for an event, and the one value it will
|
|
@@ -190,8 +191,10 @@ export default function EventEditor({
|
|
|
190
191
|
start = `${format(initialStart ?? new Date(), "yyyy-MM-dd")}T00:00`;
|
|
191
192
|
end = start;
|
|
192
193
|
} else {
|
|
193
|
-
|
|
194
|
-
|
|
194
|
+
// With nothing clicked, a new event starts at the next half hour and lasts an hour.
|
|
195
|
+
const newStart = initialStart ?? nextHalfHour(new Date());
|
|
196
|
+
start = toDatetimeLocal(occurrence?.startDate ?? newStart.toISOString());
|
|
197
|
+
end = toDatetimeLocal(occurrence?.endDate ?? (initialEnd ?? new Date(newStart.getTime() + MS_PER_HOUR)).toISOString());
|
|
195
198
|
}
|
|
196
199
|
return {
|
|
197
200
|
title: occurrence?.title ?? "",
|
|
@@ -9,9 +9,12 @@ import Button from "../../../../lib/components/buttons/Button.js";
|
|
|
9
9
|
import GuestInput from "./GuestInput.js";
|
|
10
10
|
import LazyDescriptionEditor from "./LazyDescriptionEditor.js";
|
|
11
11
|
import RecurrenceEditor from "./RecurrenceEditor.js";
|
|
12
|
+
import TimeComboBox from "./TimeComboBox.js";
|
|
13
|
+
import TimeZonePicker from "../../../../lib/components/pickers/TimeZonePicker.js";
|
|
12
14
|
import ResourcePicker from "./ResourcePicker.js";
|
|
13
15
|
import { EventFormController } from "./eventForm.js";
|
|
14
16
|
import { hasDescriptionText } from "./eventDialogFields.js";
|
|
17
|
+
import { dayTimes, endTimeOptions, formatTimeOfDay } from "./timePicker.js";
|
|
15
18
|
import {
|
|
16
19
|
REMINDER_UNITS,
|
|
17
20
|
ReminderUnit,
|
|
@@ -20,6 +23,7 @@ import {
|
|
|
20
23
|
VISIBILITY_HELP,
|
|
21
24
|
VISIBILITY_LABEL,
|
|
22
25
|
bestReminderUnit,
|
|
26
|
+
zoneDate,
|
|
23
27
|
} from "./eventFormat.js";
|
|
24
28
|
|
|
25
29
|
export const INPUT_CLASS =
|
|
@@ -378,7 +382,9 @@ export function RecurrenceSelect({
|
|
|
378
382
|
);
|
|
379
383
|
}
|
|
380
384
|
|
|
381
|
-
|
|
385
|
+
const START_TIME_OPTIONS = dayTimes().map((time) => ({ value: time, label: formatTimeOfDay(time) }));
|
|
386
|
+
|
|
387
|
+
/** [start date] [start time] to [end time] [end date], then the "Time zone" link (and its menu), "All day" and how the event repeats. */
|
|
382
388
|
export function DateTimeControls({ c }: { c: EventFormController }) {
|
|
383
389
|
const { values } = c;
|
|
384
390
|
const [zoneOpen, setZoneOpen] = useState(false);
|
|
@@ -393,22 +399,27 @@ export function DateTimeControls({ c }: { c: EventFormController }) {
|
|
|
393
399
|
onChange={(e) => c.setStart(`${e.target.value}${values.start.slice(10)}`)}
|
|
394
400
|
/>
|
|
395
401
|
{!values.allDay && (
|
|
396
|
-
<
|
|
397
|
-
type="time"
|
|
402
|
+
<TimeComboBox
|
|
398
403
|
aria-label="Event start time"
|
|
399
|
-
className={`${SELECT_CLASS}
|
|
400
|
-
|
|
401
|
-
|
|
404
|
+
className={`${SELECT_CLASS} w-28`}
|
|
405
|
+
display={formatTimeOfDay(values.start.slice(11, 16))}
|
|
406
|
+
current={values.start.slice(11, 16)}
|
|
407
|
+
options={START_TIME_OPTIONS}
|
|
408
|
+
// Moving the start moves the end with it, so the event keeps its length: whatever the start is set to.
|
|
409
|
+
onSelect={(time) => c.setStart(`${values.start.slice(0, 11)}${time}`)}
|
|
410
|
+
onType={(time) => c.setStart(`${values.start.slice(0, 11)}${time}`)}
|
|
402
411
|
/>
|
|
403
412
|
)}
|
|
404
413
|
<span className="text-text-muted">to</span>
|
|
405
414
|
{!values.allDay && (
|
|
406
|
-
<
|
|
407
|
-
type="time"
|
|
415
|
+
<TimeComboBox
|
|
408
416
|
aria-label="Event end time"
|
|
409
|
-
className={`${SELECT_CLASS}
|
|
410
|
-
|
|
411
|
-
|
|
417
|
+
className={`${SELECT_CLASS} w-28`}
|
|
418
|
+
display={formatTimeOfDay(values.end.slice(11, 16))}
|
|
419
|
+
current={values.end}
|
|
420
|
+
options={endTimeOptions(values.start).map((option) => ({ value: option.value, label: option.label, hint: option.hint }))}
|
|
421
|
+
onSelect={c.setEnd}
|
|
422
|
+
onType={(time) => c.setEnd(`${values.end.slice(0, 11)}${time}`)}
|
|
412
423
|
/>
|
|
413
424
|
)}
|
|
414
425
|
<input
|
|
@@ -419,36 +430,31 @@ export function DateTimeControls({ c }: { c: EventFormController }) {
|
|
|
419
430
|
onChange={(e) => c.setEnd(`${e.target.value}${values.end.slice(10)}`)}
|
|
420
431
|
/>
|
|
421
432
|
</div>
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
onClick={() => setZoneOpen((open) => !open)}
|
|
432
|
-
>
|
|
433
|
-
Time zone
|
|
434
|
-
</button>
|
|
435
|
-
{!c.editingSingleOccurrence && (
|
|
436
|
-
<RecurrenceSelect value={values.recurrenceRule} onChange={(rule) => c.update({ recurrenceRule: rule })} allDay={values.allDay} startWeekday={c.startWeekday} />
|
|
437
|
-
)}
|
|
438
|
-
</div>
|
|
433
|
+
{/* The Time zone link sits above All day, and the zone menu it opens right under it - before how the event repeats. */}
|
|
434
|
+
<button
|
|
435
|
+
type="button"
|
|
436
|
+
aria-expanded={zoneOpen}
|
|
437
|
+
className="self-start text-sm text-primary-dark hover:underline"
|
|
438
|
+
onClick={() => setZoneOpen((open) => !open)}
|
|
439
|
+
>
|
|
440
|
+
Time zone
|
|
441
|
+
</button>
|
|
439
442
|
{zoneOpen && (
|
|
440
|
-
<
|
|
441
|
-
aria-label="
|
|
442
|
-
className={`${SELECT_CLASS}
|
|
443
|
+
<TimeZonePicker
|
|
444
|
+
aria-label="Event time zone"
|
|
445
|
+
className={`${SELECT_CLASS} max-w-full`}
|
|
443
446
|
value={values.formZone}
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
{c.
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
447
|
+
zones={c.timeZones}
|
|
448
|
+
at={zoneDate(values.start)}
|
|
449
|
+
onChange={c.onTimeZoneChange}
|
|
450
|
+
/>
|
|
451
|
+
)}
|
|
452
|
+
<label className="flex items-center gap-2 text-sm">
|
|
453
|
+
<input type="checkbox" checked={values.allDay} onChange={(e) => c.onAllDayChange(e.target.checked)} />
|
|
454
|
+
All day
|
|
455
|
+
</label>
|
|
456
|
+
{!c.editingSingleOccurrence && (
|
|
457
|
+
<RecurrenceSelect value={values.recurrenceRule} onChange={(rule) => c.update({ recurrenceRule: rule })} allDay={values.allDay} startWeekday={c.startWeekday} />
|
|
452
458
|
)}
|
|
453
459
|
</div>
|
|
454
460
|
);
|
|
@@ -9,8 +9,9 @@ import Alert from "../../../../lib/components/feedback/Alert.js";
|
|
|
9
9
|
import Button from "../../../../lib/components/buttons/Button.js";
|
|
10
10
|
import { EventShellContext } from "./EventShell.js";
|
|
11
11
|
import { CalendarField, DateTimeControls, DescriptionRow, GuestsRow, IconRow, LocationRow, VideoConferencingRow } from "./EventFormParts.js";
|
|
12
|
+
import { describeTimeZone } from "../../../../lib/util/timeZone.js";
|
|
12
13
|
import { EventFormController } from "./eventForm.js";
|
|
13
|
-
import { BUSY_STATUS_LABEL, VISIBILITY_LABEL, describeReminder, formatFormWhen, reminderOf } from "./eventFormat.js";
|
|
14
|
+
import { BUSY_STATUS_LABEL, VISIBILITY_LABEL, describeReminder, formatFormWhen, reminderOf, zoneDate } from "./eventFormat.js";
|
|
14
15
|
|
|
15
16
|
export interface EventQuickFormProps {
|
|
16
17
|
c: EventFormController;
|
|
@@ -79,7 +80,7 @@ export default function EventQuickForm({ c, onExpand, tabs, tabPanelProps }: Eve
|
|
|
79
80
|
>
|
|
80
81
|
<span className="block text-sm">{formatFormWhen(values.start, values.end, values.allDay)}</span>
|
|
81
82
|
<span className="block text-xs text-text-muted">
|
|
82
|
-
{values.formZone
|
|
83
|
+
{describeTimeZone(values.formZone, zoneDate(values.start))} •{" "}
|
|
83
84
|
{values.recurrenceRule ? `Repeats ${describeRecurrence(values.recurrenceRule)}` : "Does not repeat"}
|
|
84
85
|
</span>
|
|
85
86
|
</button>
|
|
@@ -19,7 +19,8 @@ import Button from "../../../../lib/components/buttons/Button.js";
|
|
|
19
19
|
import { useApiClient } from "../../../../lib/util/apiClientContext.js";
|
|
20
20
|
import { addDaysToKey } from "./allDay.js";
|
|
21
21
|
import { EventFormController } from "./eventForm.js";
|
|
22
|
-
import { formatFormWhen, localDateFromKey, msToWallString, wallStringToMs } from "./eventFormat.js";
|
|
22
|
+
import { formatFormWhen, localDateFromKey, msToWallString, wallStringToMs, zoneDate } from "./eventFormat.js";
|
|
23
|
+
import { describeTimeZone } from "../../../../lib/util/timeZone.js";
|
|
23
24
|
import {
|
|
24
25
|
SEARCH_DAYS,
|
|
25
26
|
WORK_END_HOUR,
|
|
@@ -238,7 +239,7 @@ export default function FindATime({ c }: { c: EventFormController }) {
|
|
|
238
239
|
>
|
|
239
240
|
›
|
|
240
241
|
</button>
|
|
241
|
-
<span className="text-xs text-text-muted">{formZone
|
|
242
|
+
<span className="text-xs text-text-muted">{describeTimeZone(formZone, zoneDate(dayKey))}</span>
|
|
242
243
|
</div>
|
|
243
244
|
|
|
244
245
|
{lookUp.status === "error" && (
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
2
|
+
// Copyright (C) 2026 Jean-Philippe Steinmetz
|
|
3
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
4
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
5
|
+
import React, { KeyboardEvent, useEffect, useId, useRef, useState } from "react";
|
|
6
|
+
import { parseTimeInput } from "./timePicker.js";
|
|
7
|
+
|
|
8
|
+
export interface TimeOption {
|
|
9
|
+
value: string;
|
|
10
|
+
label: string;
|
|
11
|
+
/** Said after the label, muted: the length of the event an end time makes. */
|
|
12
|
+
hint?: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface TimeComboBoxProps {
|
|
16
|
+
"aria-label": string;
|
|
17
|
+
/** The time being shown, as it reads in the field. */
|
|
18
|
+
display: string;
|
|
19
|
+
/** The `value` of the option that is the current one, if any of them is. */
|
|
20
|
+
current?: string;
|
|
21
|
+
options: TimeOption[];
|
|
22
|
+
/** An option was picked. */
|
|
23
|
+
onSelect: (value: string) => void;
|
|
24
|
+
/** A time was typed (`HH:mm`, already understood). */
|
|
25
|
+
onType: (time: string) => void;
|
|
26
|
+
className?: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* A time field that is a text box and a menu at once: focusing it opens a list of times to pick from, and whatever is typed instead - "3:45pm", "1530" -
|
|
31
|
+
* is understood when the field is left or Enter is pressed (and put back as it was when it isn't a time, or on Escape). The list follows the arrow keys.
|
|
32
|
+
*/
|
|
33
|
+
export default function TimeComboBox({ "aria-label": label, display, current, options, onSelect, onType, className }: TimeComboBoxProps) {
|
|
34
|
+
const listId = useId();
|
|
35
|
+
const [draft, setDraft] = useState<string | null>(null);
|
|
36
|
+
const [open, setOpen] = useState(false);
|
|
37
|
+
const [active, setActive] = useState(-1);
|
|
38
|
+
const selectedRef = useRef<HTMLLIElement>(null);
|
|
39
|
+
|
|
40
|
+
// Opening puts the current time in view, as the middle of the list.
|
|
41
|
+
useEffect(() => {
|
|
42
|
+
if (open) {
|
|
43
|
+
selectedRef.current?.scrollIntoView?.({ block: "center" });
|
|
44
|
+
}
|
|
45
|
+
}, [open]);
|
|
46
|
+
|
|
47
|
+
function close() {
|
|
48
|
+
setOpen(false);
|
|
49
|
+
setActive(-1);
|
|
50
|
+
setDraft(null);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function commitDraft() {
|
|
54
|
+
const time = draft === null ? null : parseTimeInput(draft);
|
|
55
|
+
if (time !== null) {
|
|
56
|
+
onType(time);
|
|
57
|
+
}
|
|
58
|
+
close();
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function pick(value: string) {
|
|
62
|
+
onSelect(value);
|
|
63
|
+
close();
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function handleKeyDown(event: KeyboardEvent<HTMLInputElement>) {
|
|
67
|
+
if (event.key === "ArrowDown" || event.key === "ArrowUp") {
|
|
68
|
+
event.preventDefault();
|
|
69
|
+
setOpen(true);
|
|
70
|
+
setActive((index) => {
|
|
71
|
+
const from = index < 0 ? options.findIndex((option) => option.value === current) : index;
|
|
72
|
+
return Math.min(Math.max(from + (event.key === "ArrowDown" ? 1 : -1), 0), options.length - 1);
|
|
73
|
+
});
|
|
74
|
+
} else if (event.key === "Enter") {
|
|
75
|
+
// Never the form's own submit: Enter here settles the time.
|
|
76
|
+
event.preventDefault();
|
|
77
|
+
if (open && active >= 0) {
|
|
78
|
+
pick(options[active].value);
|
|
79
|
+
} else {
|
|
80
|
+
commitDraft();
|
|
81
|
+
}
|
|
82
|
+
} else if (event.key === "Escape" && open) {
|
|
83
|
+
event.stopPropagation();
|
|
84
|
+
close();
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return (
|
|
89
|
+
<div className="relative">
|
|
90
|
+
<input
|
|
91
|
+
type="text"
|
|
92
|
+
role="combobox"
|
|
93
|
+
aria-label={label}
|
|
94
|
+
aria-expanded={open}
|
|
95
|
+
aria-controls={listId}
|
|
96
|
+
aria-autocomplete="list"
|
|
97
|
+
aria-activedescendant={active >= 0 ? `${listId}-${active}` : undefined}
|
|
98
|
+
autoComplete="off"
|
|
99
|
+
className={className}
|
|
100
|
+
value={draft ?? display}
|
|
101
|
+
onClick={() => setOpen(true)}
|
|
102
|
+
onFocus={(event) => {
|
|
103
|
+
event.currentTarget.select();
|
|
104
|
+
setOpen(true);
|
|
105
|
+
}}
|
|
106
|
+
onChange={(event) => setDraft(event.target.value)}
|
|
107
|
+
onBlur={commitDraft}
|
|
108
|
+
onKeyDown={handleKeyDown}
|
|
109
|
+
/>
|
|
110
|
+
{open && (
|
|
111
|
+
<ul
|
|
112
|
+
id={listId}
|
|
113
|
+
role="listbox"
|
|
114
|
+
aria-label={`${label} options`}
|
|
115
|
+
className="absolute left-0 top-full z-30 mt-1 max-h-56 w-44 overflow-y-auto rounded-md border border-border bg-surface py-1 shadow-lg"
|
|
116
|
+
>
|
|
117
|
+
{options.map((option, index) => (
|
|
118
|
+
<li
|
|
119
|
+
key={option.value}
|
|
120
|
+
id={`${listId}-${index}`}
|
|
121
|
+
ref={option.value === current ? selectedRef : undefined}
|
|
122
|
+
role="option"
|
|
123
|
+
aria-selected={option.value === current}
|
|
124
|
+
// Before the field's own blur, which would otherwise close the list and swallow the click.
|
|
125
|
+
onMouseDown={(event) => {
|
|
126
|
+
event.preventDefault();
|
|
127
|
+
pick(option.value);
|
|
128
|
+
}}
|
|
129
|
+
className={[
|
|
130
|
+
"flex cursor-pointer items-baseline gap-1.5 px-3 py-1.5 text-sm hover:bg-surface-alt",
|
|
131
|
+
option.value === current ? "font-semibold text-primary-dark" : "",
|
|
132
|
+
index === active ? "bg-surface-alt" : "",
|
|
133
|
+
].join(" ")}
|
|
134
|
+
>
|
|
135
|
+
{option.label}
|
|
136
|
+
{option.hint && <span className="text-xs font-normal text-text-muted">{option.hint}</span>}
|
|
137
|
+
</li>
|
|
138
|
+
))}
|
|
139
|
+
</ul>
|
|
140
|
+
)}
|
|
141
|
+
</div>
|
|
142
|
+
);
|
|
143
|
+
}
|
|
@@ -187,6 +187,21 @@ export function formatStoredWhen(startDate: string, endDate: string, allDay: boo
|
|
|
187
187
|
return formatWhen(new Date(startDate), new Date(endDate), false, now);
|
|
188
188
|
}
|
|
189
189
|
|
|
190
|
+
/** The date the form's start falls on, for reading a zone's offset as it will be for the event (daylight saving); now while there is no date. */
|
|
191
|
+
export function zoneDate(start: string): Date {
|
|
192
|
+
const day = new Date(`${start.slice(0, 10)}T12:00:00Z`);
|
|
193
|
+
return Number.isNaN(day.getTime()) ? new Date() : day;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** `formatWhen()` for a stored timed event as `zone`'s clock reads it, whatever zone the viewer is in. */
|
|
197
|
+
export function formatStoredWhenInZone(startDate: string, endDate: string, zone: string, now: Date = new Date()): string {
|
|
198
|
+
const onZoneClock = (iso: string) => {
|
|
199
|
+
const wall = new Date(toEventWallClock(new Date(iso).getTime(), zone, false));
|
|
200
|
+
return new Date(wall.getUTCFullYear(), wall.getUTCMonth(), wall.getUTCDate(), wall.getUTCHours(), wall.getUTCMinutes());
|
|
201
|
+
};
|
|
202
|
+
return formatWhen(onZoneClock(startDate), onZoneClock(endDate), false, now);
|
|
203
|
+
}
|
|
204
|
+
|
|
190
205
|
/** Adds the guests typed or pasted into the guests field to `current` - recipients separated by commas or semicolons, each an address or
|
|
191
206
|
* `Name <address>` (see `applyGuestChips()`, which keeps who is already there): an address already on the list (in any case) or in
|
|
192
207
|
* `skipAddresses` is skipped, and what is not an address comes back as `invalid` for the field to keep. */
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
2
|
+
// Copyright (C) 2026 Jean-Philippe Steinmetz
|
|
3
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
4
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
5
|
+
import { format } from "date-fns";
|
|
6
|
+
|
|
7
|
+
/** The step between the times a time field offers. */
|
|
8
|
+
export const TIME_STEP_MINUTES = 15;
|
|
9
|
+
const MS_PER_MINUTE = 60_000;
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* The start a new event gets when nothing was clicked: the next half hour (11:12 becomes 11:30; a time already on the half hour stays), with the
|
|
13
|
+
* event lasting an hour from there.
|
|
14
|
+
*/
|
|
15
|
+
export function nextHalfHour(now: Date): Date {
|
|
16
|
+
const slot = 30 * MS_PER_MINUTE;
|
|
17
|
+
return new Date(Math.ceil(now.getTime() / slot) * slot);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** `HH:mm` (24 hour) as a 12 hour clock reading: "13:05" is "1:05 PM". */
|
|
21
|
+
export function formatTimeOfDay(time: string): string {
|
|
22
|
+
// A cleared date leaves no time to read: the field is empty, not an error.
|
|
23
|
+
if (!/^\d\d:\d\d$/.test(time)) {
|
|
24
|
+
return "";
|
|
25
|
+
}
|
|
26
|
+
const [hours, minutes] = time.split(":").map(Number);
|
|
27
|
+
return format(new Date(2000, 0, 1, hours, minutes), "h:mm a");
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* What was typed into a time field as `HH:mm`, or `null` when it isn't a time. Takes 24 hour and 12 hour readings, with or without the colon and
|
|
32
|
+
* the space: "15:45", "3:45 pm", "3:45p", "345pm", "1130" and a bare hour such as "9" (9:00) are all times.
|
|
33
|
+
*/
|
|
34
|
+
export function parseTimeInput(text: string): string | null {
|
|
35
|
+
const match = /^\s*(\d{1,2})(?::?(\d{2}))?\s*(?:([ap])\.?m?\.?)?\s*$/i.exec(text);
|
|
36
|
+
if (!match) {
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
let hours = Number(match[1]);
|
|
40
|
+
const minutes = match[2] === undefined ? 0 : Number(match[2]);
|
|
41
|
+
const meridiem = match[3]?.toLowerCase();
|
|
42
|
+
if (minutes > 59) {
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
if (meridiem) {
|
|
46
|
+
if (hours < 1 || hours > 12) {
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
hours = (hours % 12) + (meridiem === "p" ? 12 : 0);
|
|
50
|
+
} else if (hours > 23) {
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
return `${String(hours).padStart(2, "0")}:${String(minutes).padStart(2, "0")}`;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Every time of a day, a quarter hour apart, as `HH:mm`. */
|
|
57
|
+
export function dayTimes(): string[] {
|
|
58
|
+
return Array.from({ length: (24 * 60) / TIME_STEP_MINUTES }, (_, i) => {
|
|
59
|
+
const minutes = i * TIME_STEP_MINUTES;
|
|
60
|
+
return `${String(Math.floor(minutes / 60)).padStart(2, "0")}:${String(minutes % 60).padStart(2, "0")}`;
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** A length of time as a menu says it: "15 mins", "1 hr", "1.5 hrs", "2 hrs". */
|
|
65
|
+
export function formatDuration(minutes: number): string {
|
|
66
|
+
if (minutes < 60) {
|
|
67
|
+
return `${minutes} min${minutes === 1 ? "" : "s"}`;
|
|
68
|
+
}
|
|
69
|
+
const hours = Math.round((minutes / 60) * 100) / 100;
|
|
70
|
+
return `${hours} ${hours === 1 ? "hr" : "hrs"}`;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** `YYYY-MM-DDTHH:mm` read as a wall clock, in minutes since the epoch (no zone: the daylight saving changes of a zone are not this field's business). */
|
|
74
|
+
function wallMinutes(value: string): number {
|
|
75
|
+
return Date.parse(`${value.slice(0, 16)}:00Z`) / MS_PER_MINUTE;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function wallString(minutes: number): string {
|
|
79
|
+
return new Date(minutes * MS_PER_MINUTE).toISOString().slice(0, 16);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export interface EndTimeOption {
|
|
83
|
+
/** The end as `YYYY-MM-DDTHH:mm`. */
|
|
84
|
+
value: string;
|
|
85
|
+
/** The time of day, as a 12 hour clock reading. */
|
|
86
|
+
label: string;
|
|
87
|
+
/** How long the event would last, "(1.5 hrs)". */
|
|
88
|
+
hint: string;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* The ends a time field offers for an event starting at `start` (`YYYY-MM-DDTHH:mm`): each quarter hour on the clock after the start, up to a
|
|
93
|
+
* day later, with the length of the event that end would make.
|
|
94
|
+
*/
|
|
95
|
+
export function endTimeOptions(start: string): EndTimeOption[] {
|
|
96
|
+
if (!/^\d{4}-\d\d-\d\dT\d\d:\d\d/.test(start)) {
|
|
97
|
+
return [];
|
|
98
|
+
}
|
|
99
|
+
const startMinutes = wallMinutes(start);
|
|
100
|
+
const options: EndTimeOption[] = [];
|
|
101
|
+
for (let at = Math.floor(startMinutes / TIME_STEP_MINUTES + 1) * TIME_STEP_MINUTES; at <= startMinutes + 24 * 60; at += TIME_STEP_MINUTES) {
|
|
102
|
+
const value = wallString(at);
|
|
103
|
+
options.push({ value, label: formatTimeOfDay(value.slice(11, 16)), hint: `(${formatDuration(at - startMinutes)})` });
|
|
104
|
+
}
|
|
105
|
+
return options;
|
|
106
|
+
}
|