@remit/web-client 0.0.290 → 0.0.292

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.
@@ -0,0 +1,91 @@
1
+ /**
2
+ * What the span a draft names runs into, across every calendar the reader
3
+ * holds — so the editor says so before Save rather than the grid after it.
4
+ *
5
+ * Read on the weeks from the draft's first day through its last, which for a
6
+ * single-day draft is the grid's own cache entry, and measured by the same rule the invitation card uses. Undefined
7
+ * while there is no span to check (all day, a time missing, an end not after
8
+ * the start) or no answer to
9
+ * check it against: drawing "nothing else is booked" over a read that has not
10
+ * landed is a claim, not a delay.
11
+ */
12
+ import {
13
+ type CalendarClash,
14
+ type CalendarEventData,
15
+ clashesWith,
16
+ type EventDraft,
17
+ } from "@remit/ui";
18
+ import { useMemo } from "react";
19
+ import { formatEventWhen } from "@/lib/calendar-format";
20
+ import {
21
+ isDrawnInstance,
22
+ toCalendarEventData,
23
+ UNZONED_CALENDAR,
24
+ } from "./instance";
25
+ import { useCalendarEventWindow } from "./useCalendarEvents";
26
+ import { useCalendars } from "./useCalendars";
27
+ import { type CalendarWindow, calendarWindow, isoAtInZone } from "./window";
28
+
29
+ const EVERY_CALENDAR: readonly string[] = [];
30
+ const NO_WINDOW: CalendarWindow = { from: "", to: "" };
31
+
32
+ export function useDraftClashes(
33
+ draft: EventDraft,
34
+ /** The resource being edited, which never clashes with itself. */
35
+ calendarObjectId = "",
36
+ ): CalendarClash[] | undefined {
37
+ const { timeZoneByCalendarId } = useCalendars();
38
+ const checkable =
39
+ draft.date !== "" &&
40
+ !draft.allDay &&
41
+ draft.endDate !== "" &&
42
+ draft.startTime !== "" &&
43
+ draft.endTime !== "" &&
44
+ `${draft.endDate}T${draft.endTime}` > `${draft.date}T${draft.startTime}`;
45
+ const { instances, isLoading, error } = useCalendarEventWindow({
46
+ ...(checkable
47
+ ? {
48
+ from: calendarWindow("week", draft.date).from,
49
+ to: calendarWindow("week", draft.endDate).to,
50
+ }
51
+ : NO_WINDOW),
52
+ calendarIds: EVERY_CALENDAR,
53
+ enabled: checkable,
54
+ });
55
+
56
+ const zone = timeZoneByCalendarId[draft.calendarId] ?? UNZONED_CALENDAR;
57
+
58
+ return useMemo(() => {
59
+ if (!checkable || isLoading || error) return undefined;
60
+ const start = isoAtInZone(draft.date, draft.startTime, zone);
61
+ const end = isoAtInZone(draft.endDate, draft.endTime, zone);
62
+ const others: CalendarEventData[] = instances
63
+ .filter(
64
+ (instance) =>
65
+ isDrawnInstance(instance) &&
66
+ instance.calendarObjectId !== calendarObjectId,
67
+ )
68
+ .map((instance) =>
69
+ toCalendarEventData(
70
+ instance,
71
+ timeZoneByCalendarId[instance.calendarId] ?? UNZONED_CALENDAR,
72
+ ),
73
+ );
74
+ return clashesWith({ start, end }, others).map((event) => ({
75
+ id: event.id,
76
+ label: `${event.title}, ${formatEventWhen(event)}`,
77
+ }));
78
+ }, [
79
+ checkable,
80
+ isLoading,
81
+ error,
82
+ draft.date,
83
+ draft.endDate,
84
+ draft.startTime,
85
+ draft.endTime,
86
+ zone,
87
+ instances,
88
+ calendarObjectId,
89
+ timeZoneByCalendarId,
90
+ ]);
91
+ }
@@ -0,0 +1,23 @@
1
+ import assert from "node:assert";
2
+ import { describe, test } from "node:test";
3
+ import { calendarUrlSegmentFor } from "./calendar-collection-copy.js";
4
+
5
+ describe("calendarUrlSegmentFor", () => {
6
+ test("folds a name into a path segment", () => {
7
+ assert.strictEqual(
8
+ calendarUrlSegmentFor("Harbour Projects"),
9
+ "harbour-projects",
10
+ );
11
+ });
12
+
13
+ test("drops accents and punctuation at either end", () => {
14
+ assert.strictEqual(
15
+ calendarUrlSegmentFor(" Café & Crèche! "),
16
+ "cafe-creche",
17
+ );
18
+ });
19
+
20
+ test("stays within the 64 characters the API accepts", () => {
21
+ assert.strictEqual(calendarUrlSegmentFor("a".repeat(80)).length, 64);
22
+ });
23
+ });
@@ -0,0 +1,21 @@
1
+ export interface CollectionConfirmCopy {
2
+ title: string;
3
+ description: string;
4
+ }
5
+
6
+ export const deleteCalendarConfirmCopy = (
7
+ calendarName: string,
8
+ ): CollectionConfirmCopy => ({
9
+ title: `Delete ${calendarName}?`,
10
+ description: `This removes ${calendarName} and every event in it, and any subscription address stops working. It cannot be undone.`,
11
+ });
12
+
13
+ /** A starting address for a new calendar, from the name typed so far. */
14
+ export const calendarUrlSegmentFor = (displayName: string): string =>
15
+ displayName
16
+ .normalize("NFKD")
17
+ .replace(/[̀-ͯ]/g, "")
18
+ .toLowerCase()
19
+ .replace(/[^a-z0-9]+/g, "-")
20
+ .replace(/^-+|-+$/g, "")
21
+ .slice(0, 64);
@@ -1,7 +1,9 @@
1
1
  import { SettingsShell } from "@remit/ui";
2
2
  import { createFileRoute, useNavigate } from "@tanstack/react-router";
3
3
  import { useState } from "react";
4
- import { CalendarFeedPanel } from "@/components/settings/CalendarFeedPanel";
4
+ import { CalendarCreateCard } from "@/components/settings/CalendarCreateCard";
5
+ import { CalendarSettingsPanel } from "@/components/settings/CalendarSettingsPanel";
6
+ import { useCalendarCollectionWrites } from "@/hooks/calendar/useCalendarCollectionWrites";
5
7
  import { useCalendars } from "@/hooks/calendar/useCalendars";
6
8
  import { SETTINGS_ID_TO_PATH, SETTINGS_NAV_ITEMS } from "@/routes/settings";
7
9
 
@@ -11,6 +13,13 @@ export const Route = createFileRoute("/settings/calendars")({
11
13
 
12
14
  const calendarsHelp = (
13
15
  <div className="space-y-3">
16
+ <p>
17
+ Each calendar has a name, a time zone its floating times are read in, and
18
+ an address CalDAV clients reach it by. The address is fixed when the
19
+ calendar is added; the name and zone can change. Deleting a calendar
20
+ deletes every event in it. The default calendar is where accepted
21
+ invitations land, so it cannot be deleted.
22
+ </p>
14
23
  <p>
15
24
  A subscription address lets Apple Calendar, Google Calendar, Outlook and
16
25
  Thunderbird show a calendar from here, read-only and kept up to date.
@@ -31,14 +40,16 @@ const calendarsHelp = (
31
40
  function CalendarsSettings() {
32
41
  const navigate = useNavigate();
33
42
  const [helpOpen, setHelpOpen] = useState(true);
34
- const { calendars, isLoading } = useCalendars();
43
+ const { calendars, timeZoneByCalendarId, isLoading } = useCalendars();
44
+ const { createCalendar, isWriting } = useCalendarCollectionWrites();
45
+ const [createProblem, setCreateProblem] = useState("");
35
46
 
36
47
  return (
37
48
  <SettingsShell
38
49
  items={SETTINGS_NAV_ITEMS}
39
50
  activeId="calendars"
40
51
  title="Calendars"
41
- description="Share a calendar with any client that can subscribe to a URL."
52
+ description="Add, rename and delete calendars, and share one with any client that can subscribe to a URL."
42
53
  help={calendarsHelp}
43
54
  helpOpen={helpOpen}
44
55
  onToggleHelp={() => setHelpOpen((open) => !open)}
@@ -55,17 +66,28 @@ function CalendarsSettings() {
55
66
  aria-busy="true"
56
67
  aria-label="Loading calendars"
57
68
  />
58
- ) : calendars.length === 0 ? (
59
- <p className="py-12 text-sm text-fg-muted">No calendars yet.</p>
60
69
  ) : (
61
70
  <div className="space-y-4">
62
71
  {calendars.map((calendar) => (
63
- <CalendarFeedPanel
72
+ <CalendarSettingsPanel
64
73
  key={calendar.id}
65
74
  calendarId={calendar.id}
66
75
  calendarName={calendar.name}
76
+ timezone={timeZoneByCalendarId[calendar.id] ?? ""}
67
77
  />
68
78
  ))}
79
+ <CalendarCreateCard
80
+ isBusy={isWriting}
81
+ problem={createProblem}
82
+ onCreate={(calendar) => {
83
+ setCreateProblem("");
84
+ return createCalendar(calendar).then((outcome) => {
85
+ if (outcome.kind === "written") return true;
86
+ setCreateProblem(outcome.message);
87
+ return false;
88
+ });
89
+ }}
90
+ />
69
91
  </div>
70
92
  )}
71
93
  </SettingsShell>