@nomideusz/svelte-calendar 0.20.1 → 1.0.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.
Files changed (69) hide show
  1. package/README.md +502 -267
  2. package/dist/adapters/composite.d.ts +12 -1
  3. package/dist/adapters/composite.js +109 -47
  4. package/dist/adapters/errors.d.ts +22 -0
  5. package/dist/adapters/errors.js +32 -0
  6. package/dist/adapters/index.d.ts +1 -0
  7. package/dist/adapters/index.js +1 -0
  8. package/dist/adapters/jmap.js +26 -17
  9. package/dist/adapters/mapped.d.ts +2 -2
  10. package/dist/adapters/mapped.js +18 -9
  11. package/dist/adapters/memory.d.ts +1 -1
  12. package/dist/adapters/memory.js +5 -3
  13. package/dist/adapters/recurring.d.ts +1 -1
  14. package/dist/adapters/recurring.js +43 -25
  15. package/dist/adapters/rest.d.ts +2 -2
  16. package/dist/adapters/rest.js +9 -4
  17. package/dist/calendar/Calendar.svelte +122 -78
  18. package/dist/calendar/Calendar.svelte.d.ts +69 -14
  19. package/dist/calendar/index.d.ts +2 -2
  20. package/dist/calendar/index.js +1 -1
  21. package/dist/core/clock.svelte.js +3 -2
  22. package/dist/core/locale.d.ts +11 -1
  23. package/dist/core/locale.js +16 -16
  24. package/dist/core/time.d.ts +9 -0
  25. package/dist/core/time.js +12 -5
  26. package/dist/core/timezone.d.ts +7 -3
  27. package/dist/core/timezone.js +10 -4
  28. package/dist/engine/event-store.svelte.js +94 -57
  29. package/dist/engine/view-state.svelte.d.ts +5 -1
  30. package/dist/engine/view-state.svelte.js +8 -6
  31. package/dist/headless/create-agenda.svelte.d.ts +8 -1
  32. package/dist/headless/create-agenda.svelte.js +36 -18
  33. package/dist/headless/create-calendar.svelte.js +57 -21
  34. package/dist/headless/create-range-agenda.svelte.d.ts +7 -0
  35. package/dist/headless/create-range-agenda.svelte.js +38 -17
  36. package/dist/headless/index.d.ts +1 -0
  37. package/dist/index.d.ts +10 -7
  38. package/dist/index.js +8 -5
  39. package/dist/primitives/DayHeader.svelte +3 -2
  40. package/dist/primitives/DayHeader.svelte.d.ts +1 -1
  41. package/dist/primitives/EmptySlot.svelte +5 -2
  42. package/dist/primitives/EventBlock.svelte +21 -14
  43. package/dist/primitives/FloatingPanel.svelte +110 -15
  44. package/dist/primitives/FloatingPanel.svelte.d.ts +7 -5
  45. package/dist/primitives/NowIndicator.svelte +11 -4
  46. package/dist/primitives/TimeGutter.svelte +10 -6
  47. package/dist/primitives/index.d.ts +1 -0
  48. package/dist/text-fit.d.ts +8 -2
  49. package/dist/views/agenda/AgendaDay.svelte +43 -35
  50. package/dist/views/agenda/AgendaWeek.svelte +75 -38
  51. package/dist/views/index.d.ts +2 -0
  52. package/dist/views/index.js +2 -0
  53. package/dist/views/mobile/MobileDay.svelte +99 -42
  54. package/dist/views/mobile/MobileWeek.svelte +31 -15
  55. package/dist/views/mobile/swipe.js +6 -1
  56. package/dist/views/month/MonthGrid.svelte +33 -21
  57. package/dist/views/month/MonthGrid.svelte.d.ts +1 -1
  58. package/dist/views/planner/PlannerScroll.svelte +438 -164
  59. package/dist/views/planner/PlannerWeek.svelte +150 -50
  60. package/dist/views/shared/context.svelte.d.ts +1 -1
  61. package/package.json +24 -9
  62. package/widget/widget.js +10000 -9520
  63. package/dist/assets/favicon.svg +0 -9
  64. package/dist/widget/CalendarWidget.svelte +0 -114
  65. package/dist/widget/CalendarWidget.svelte.d.ts +0 -37
  66. package/dist/widget/index.d.ts +0 -1
  67. package/dist/widget/index.js +0 -1
  68. package/dist/widget/widget.d.ts +0 -10
  69. package/dist/widget/widget.js +0 -96
@@ -1,5 +1,6 @@
1
- import { startOfWeek, DAY_MS } from '../core/time.js';
1
+ import { startOfWeek, addDaysMs, diffDays, DAY_MS, overlapsRange } from '../core/time.js';
2
2
  import { VIVID_PALETTE } from '../core/palette.js';
3
+ import { CalendarReadOnlyError, EventNotFoundError } from './errors.js';
3
4
  // ── Helpers ─────────────────────────────────────────────
4
5
  /** Parse "HH:MM" into [hours, minutes] */
5
6
  function parseTime(time) {
@@ -30,16 +31,23 @@ function parseDate(str) {
30
31
  throw new Error(`Invalid date "${str}"`);
31
32
  return date;
32
33
  }
33
- /** ISO weekday offset from Monday: 1→0, 2→1, … 7→6 */
34
- function isoWeekdayToOffset(dayOfWeek) {
35
- return dayOfWeek - 1;
34
+ /**
35
+ * Days from the start of the week to an ISO weekday (1=Mon … 7=Sun).
36
+ * Monday weeks: 1→0 … 7→6. Sunday weeks: 7→0, 1→1 … 6→6.
37
+ */
38
+ function isoWeekdayToOffset(dayOfWeek, mondayStart) {
39
+ return mondayStart ? dayOfWeek - 1 : dayOfWeek % 7;
36
40
  }
37
- /** Normalize `dayOfWeek` to a sorted array */
38
- function normalizeDays(value) {
41
+ /**
42
+ * Normalize `dayOfWeek` to an array in week order — the projection walks it
43
+ * in order and stops at the first day past its bound, so Sunday must come
44
+ * first in a Sunday week.
45
+ */
46
+ function normalizeDays(value, mondayStart) {
39
47
  if (value == null)
40
48
  return [];
41
49
  const arr = Array.isArray(value) ? [...value] : [value];
42
- return arr.sort((a, b) => a - b);
50
+ return arr.sort((a, b) => isoWeekdayToOffset(a, mondayStart) - isoWeekdayToOffset(b, mondayStart));
43
51
  }
44
52
  /** Clamp day to last day of month (e.g. 31 → 28 in Feb) */
45
53
  function clampDayOfMonth(year, month, day) {
@@ -58,7 +66,9 @@ function createConcreteEvent(rec, date) {
58
66
  const [sh, sm] = parseTime(rec.startTime);
59
67
  const [eh, em] = parseTime(rec.endTime);
60
68
  const start = new Date(date.getFullYear(), date.getMonth(), date.getDate(), sh, sm);
61
- const end = new Date(date.getFullYear(), date.getMonth(), date.getDate(), eh, em);
69
+ // An end before the start runs past midnight (22:00–02:00).
70
+ const overnight = eh * 60 + em < sh * 60 + sm;
71
+ const end = new Date(date.getFullYear(), date.getMonth(), date.getDate() + (overnight ? 1 : 0), eh, em);
62
72
  return {
63
73
  id: `${rec.id}--${dateKey(date)}`,
64
74
  title: rec.title,
@@ -91,7 +101,7 @@ function computeUntilFromCount(rec, startDateObj, mondayStart) {
91
101
  return d;
92
102
  }
93
103
  case 'weekly': {
94
- const days = normalizeDays(rec.dayOfWeek);
104
+ const days = normalizeDays(rec.dayOfWeek, mondayStart);
95
105
  if (days.length === 0)
96
106
  return undefined;
97
107
  const startMs = startDateObj.getTime();
@@ -100,20 +110,24 @@ function computeUntilFromCount(rec, startDateObj, mondayStart) {
100
110
  const maxCycles = Math.ceil(count / days.length) + 2;
101
111
  for (let c = 0; c < maxCycles; c++) {
102
112
  for (const day of days) {
103
- const dayMs = weekMs + isoWeekdayToOffset(day) * DAY_MS;
113
+ const dayMs = addDaysMs(weekMs, isoWeekdayToOffset(day, mondayStart));
104
114
  if (dayMs >= startMs) {
105
115
  remaining--;
106
116
  if (remaining === 0)
107
117
  return new Date(dayMs);
108
118
  }
109
119
  }
110
- weekMs += interval * 7 * DAY_MS;
120
+ weekMs = addDaysMs(weekMs, interval * 7);
111
121
  }
112
122
  return undefined;
113
123
  }
114
124
  case 'monthly': {
125
+ // The start month counts only if its occurrence is on or after
126
+ // startDate (20 Jan with dayOfMonth 15 begins in February).
127
+ const first = clampDayOfMonth(startDateObj.getFullYear(), startDateObj.getMonth(), rec.dayOfMonth ?? 1);
115
128
  const totalMonths = startDateObj.getFullYear() * 12 +
116
129
  startDateObj.getMonth() +
130
+ (first < startDateObj ? interval : 0) +
117
131
  (count - 1) * interval;
118
132
  const y = Math.floor(totalMonths / 12);
119
133
  const m = totalMonths % 12;
@@ -150,7 +164,7 @@ function projectDaily(rec, range, startDate, effectiveUntil, out) {
150
164
  }
151
165
  function projectWeekly(rec, range, startDate, effectiveUntil, mondayStart, out) {
152
166
  const interval = rec.interval ?? 1;
153
- const days = normalizeDays(rec.dayOfWeek);
167
+ const days = normalizeDays(rec.dayOfWeek, mondayStart);
154
168
  if (days.length === 0)
155
169
  return;
156
170
  const startDateMs = startDate?.getTime();
@@ -165,16 +179,16 @@ function projectWeekly(rec, range, startDate, effectiveUntil, mondayStart, out)
165
179
  weekMs = anchorWeekMs;
166
180
  }
167
181
  else {
168
- const weeksBetween = Math.floor((rangeStartMs - anchorWeekMs) / (7 * DAY_MS));
182
+ const weeksBetween = Math.floor(diffDays(rangeStartMs, anchorWeekMs) / 7);
169
183
  const skip = Math.floor(weeksBetween / interval);
170
- weekMs = anchorWeekMs + skip * interval * 7 * DAY_MS;
171
- if (weekMs + 7 * DAY_MS <= rangeStartMs) {
172
- weekMs += interval * 7 * DAY_MS;
184
+ weekMs = addDaysMs(anchorWeekMs, skip * interval * 7);
185
+ if (addDaysMs(weekMs, 7) <= rangeStartMs) {
186
+ weekMs = addDaysMs(weekMs, interval * 7);
173
187
  }
174
188
  }
175
189
  while (weekMs < rangeEndMs) {
176
190
  for (const day of days) {
177
- const dayMs = weekMs + isoWeekdayToOffset(day) * DAY_MS;
191
+ const dayMs = addDaysMs(weekMs, isoWeekdayToOffset(day, mondayStart));
178
192
  if (startDateMs != null && dayMs < startDateMs)
179
193
  continue;
180
194
  if (dayMs >= rangeEndMs)
@@ -187,7 +201,7 @@ function projectWeekly(rec, range, startDate, effectiveUntil, mondayStart, out)
187
201
  out.push(ev);
188
202
  }
189
203
  }
190
- weekMs += interval * 7 * DAY_MS;
204
+ weekMs = addDaysMs(weekMs, interval * 7);
191
205
  }
192
206
  }
193
207
  function projectMonthly(rec, range, startDate, effectiveUntil, out) {
@@ -210,7 +224,7 @@ function projectMonthly(rec, range, startDate, effectiveUntil, out) {
210
224
  break;
211
225
  if (effectiveUntil && date > effectiveUntil)
212
226
  break;
213
- if (date >= range.start && (!startDate || date >= startDate)) {
227
+ if (!startDate || date >= startDate) {
214
228
  const ev = createConcreteEvent(rec, date);
215
229
  if (ev.start < range.end && ev.end > range.start) {
216
230
  out.push(ev);
@@ -254,6 +268,10 @@ export function createRecurringAdapter(schedule, options = {}) {
254
268
  }
255
269
  const fetchEventsSync = (range) => {
256
270
  const events = [];
271
+ // Project from a day earlier: an occurrence that runs past midnight
272
+ // (22:00–02:00) overlaps the range from the day before it. The
273
+ // overlap filter below trims back to the requested range.
274
+ const projected = { start: new Date(addDaysMs(range.start.getTime(), -1)), end: range.end };
257
275
  for (const rec of schedule) {
258
276
  const colored = { ...rec, color: resolveColor(rec) };
259
277
  const freq = rec.frequency ?? 'weekly';
@@ -272,13 +290,13 @@ export function createRecurringAdapter(schedule, options = {}) {
272
290
  }
273
291
  switch (freq) {
274
292
  case 'daily':
275
- projectDaily(colored, range, sd, effectiveUntil, events);
293
+ projectDaily(colored, projected, sd, effectiveUntil, events);
276
294
  break;
277
295
  case 'weekly':
278
- projectWeekly(colored, range, sd, effectiveUntil, mondayStart, events);
296
+ projectWeekly(colored, projected, sd, effectiveUntil, mondayStart, events);
279
297
  break;
280
298
  case 'monthly':
281
- projectMonthly(colored, range, sd, effectiveUntil, events);
299
+ projectMonthly(colored, projected, sd, effectiveUntil, events);
282
300
  break;
283
301
  }
284
302
  }
@@ -288,7 +306,7 @@ export function createRecurringAdapter(schedule, options = {}) {
288
306
  for (const d of rec.excludeDates ?? [])
289
307
  skipped.add(`${rec.id}--${d.replace(/-/g, '')}`);
290
308
  }
291
- const kept = skipped.size ? events.filter((e) => !skipped.has(e.id)) : events;
309
+ const kept = events.filter((e) => !skipped.has(e.id) && overlapsRange(e, range.start, range.end));
292
310
  return movable ? kept : kept.map((e) => ({ ...e, data: { ...e.data, readOnly: true } }));
293
311
  };
294
312
  return {
@@ -303,9 +321,9 @@ export function createRecurringAdapter(schedule, options = {}) {
303
321
  async updateEvent(id) {
304
322
  const ruleId = id.split('--')[0];
305
323
  if (!schedule.some((rec) => rec.id === ruleId)) {
306
- throw new Error(`Event not found: ${id}`);
324
+ throw new EventNotFoundError(id);
307
325
  }
308
- throw new Error(`read-only: ${id} is a projected occurrence. Add its date to the rule's excludeDates and store the change yourself.`);
326
+ throw new CalendarReadOnlyError(`read-only: ${id} is a projected occurrence. Add its date to the rule's excludeDates and store the change yourself.`);
309
327
  },
310
328
  };
311
329
  }
@@ -12,9 +12,9 @@ export interface RestAdapterOptions {
12
12
  baseUrl: string;
13
13
  /** Custom headers (e.g. Authorization) */
14
14
  headers?: Record<string, string>;
15
- /** Map API response to TimelineEvent[] */
15
+ /** Map API response to TimelineEvent[] (default: the body is an array of events, `start`/`end` parsed into Dates) */
16
16
  mapEvents?: (data: unknown) => TimelineEvent[];
17
- /** Map API response to a single TimelineEvent */
17
+ /** Map API response to a single TimelineEvent (default: the body is the event, `start`/`end` parsed into Dates) */
18
18
  mapEvent?: (data: unknown) => TimelineEvent;
19
19
  }
20
20
  export declare function createRestAdapter(options: RestAdapterOptions): CalendarAdapter;
@@ -1,7 +1,12 @@
1
+ /** JSON carries dates as strings; the calendar needs Date objects. */
2
+ function reviveEvent(data) {
3
+ const ev = data;
4
+ return { ...ev, start: new Date(ev.start), end: new Date(ev.end) };
5
+ }
1
6
  export function createRestAdapter(options) {
2
7
  const { baseUrl, headers = {} } = options;
3
- const mapEvents = options.mapEvents ?? ((data) => data);
4
- const mapEvent = options.mapEvent ?? ((data) => data);
8
+ const mapEvents = options.mapEvents ?? ((data) => data.map(reviveEvent));
9
+ const mapEvent = options.mapEvent ?? reviveEvent;
5
10
  async function request(path, init) {
6
11
  const res = await fetch(`${baseUrl}${path}`, {
7
12
  ...init,
@@ -40,14 +45,14 @@ export function createRestAdapter(options) {
40
45
  return mapEvent(data);
41
46
  },
42
47
  async updateEvent(id, patch) {
43
- const data = await request(`/events/${id}`, {
48
+ const data = await request(`/events/${encodeURIComponent(id)}`, {
44
49
  method: 'PATCH',
45
50
  body: JSON.stringify(patch),
46
51
  });
47
52
  return mapEvent(data);
48
53
  },
49
54
  async deleteEvent(id) {
50
- await request(`/events/${id}`, { method: 'DELETE' });
55
+ await request(`/events/${encodeURIComponent(id)}`, { method: 'DELETE' });
51
56
  },
52
57
  };
53
58
  }
@@ -17,25 +17,19 @@
17
17
  oneventcreate={handleCreate}
18
18
  />
19
19
  -->
20
- <script lang="ts">import { setContext, untrack } from "svelte";
21
- import { createEventStore } from "../engine/event-store.svelte.js";
22
- import { createViewState } from "../engine/view-state.svelte.js";
23
- import { createSelection } from "../engine/selection.svelte.js";
24
- import { createDragState } from "../engine/drag.svelte.js";
25
- import { onMount } from "svelte";
26
- import { getLabels, fmtWeekRange } from "../core/locale.js";
27
- import { auto } from "../theme/presets.js";
28
- import { probeHostTheme, observeHostTheme } from "../theme/auto.js";
29
- import Planner from "../views/planner/Planner.svelte";
20
+ <script module lang="ts">import Planner from "../views/planner/Planner.svelte";
30
21
  import PlannerScroll from "../views/planner/PlannerScroll.svelte";
31
22
  import Agenda from "../views/agenda/Agenda.svelte";
32
23
  import Mobile from "../views/mobile/Mobile.svelte";
33
24
  import MonthGrid from "../views/month/MonthGrid.svelte";
34
- import { wrapAdapterWithTimezone, toZonedTime, fromZonedTime } from "../core/timezone.js";
35
- /** Breakpoint (px) at which auto-mobile activates */
36
- const MOBILE_BREAKPOINT = 768;
37
- // ── Built-in views (used when no custom views are provided) ──
38
- const DEFAULT_VIEWS = [
25
+ /**
26
+ * The built-in views — the registry a Calendar uses without a `views` prop.
27
+ * Spread it to add your own view and keep these:
28
+ * `views={[...defaultViews, { id: 'day-kanban', ... }]}`.
29
+ * On phones (`mobile: 'auto'`), every view except `*-agenda` and `*-mobile`
30
+ * swaps to the registered `{mode}-mobile` view, when there is one.
31
+ */
32
+ export const defaultViews = [
39
33
  {
40
34
  id: "day-planner",
41
35
  label: "Planner",
@@ -85,10 +79,29 @@ const DEFAULT_VIEWS = [
85
79
  component: MonthGrid
86
80
  }
87
81
  ];
88
- let { adapter, views = DEFAULT_VIEWS, view: activeViewId, theme = auto, autoTheme, mondayStart = true, height: heightProp = 600, borderRadius = 12, dir, locale, labels: labelsProp, readOnly = false, visibleHours, initialDate, snapInterval = 15, minColumnWidth = 110, showModePills = true, showNavigation = true, equalDays = false, showDates = true, hideDays, currentDate, blockedSlots, days, minDuration, maxDuration, disabledDates, compact = false, columns = false, mobile: mobileProp = "auto", event: eventSnippet, empty: emptySnippet, dayHeader: dayHeaderSnippet, header: headerSnippet, navigation: navigationSnippet, oneventclick, oneventcreate, onexternaldrop, oneventmove, onviewchange, ondatechange, oneventhover, ondayclick, onerror, timezone } = $props();
82
+ </script>
83
+
84
+ <script lang="ts">import { setContext, untrack } from "svelte";
85
+ import { createEventStore } from "../engine/event-store.svelte.js";
86
+ import { createViewState } from "../engine/view-state.svelte.js";
87
+ import { createSelection } from "../engine/selection.svelte.js";
88
+ import { createDragState } from "../engine/drag.svelte.js";
89
+ import { onMount } from "svelte";
90
+ import { getLabels, getDefaultLocale, fmtWeekRange } from "../core/locale.js";
91
+ import { createClock } from "../core/clock.svelte.js";
92
+ import { auto } from "../theme/presets.js";
93
+ import { observeHostTheme } from "../theme/auto.js";
94
+ import { wrapAdapterWithTimezone, toZonedTime, fromZonedTime } from "../core/timezone.js";
95
+ import { isReadOnlyError, isNotFoundError } from "../adapters/errors.js";
96
+ /** Breakpoint (px) at which auto-mobile activates */
97
+ const MOBILE_BREAKPOINT = 768;
98
+ let { adapter, views = defaultViews, view: activeViewId, theme = auto, autoTheme, mondayStart = true, height: heightProp = 600, borderRadius = 12, dir, locale, labels: labelsProp, readOnly = false, visibleHours, initialDate, snapInterval = 15, minColumnWidth = 110, showModePills = true, showNavigation = true, equalDays = false, showDates = true, hideDays, currentDate, blockedSlots, days, minDuration, maxDuration, disabledDates, compact = false, columns = false, mobile: mobileProp = "auto", event: eventSnippet, empty: emptySnippet, dayHeader: dayHeaderSnippet, header: headerSnippet, navigation: navigationSnippet, oneventclick, oneventcreate, onexternaldrop, oneventmove, onviewchange, ondatechange, oneventhover, ondayclick, onerror, timezone } = $props();
89
99
  // In readOnly mode, suppress mutation callbacks. With a timezone, the
90
100
  // drag plane is zoned wall-clock — hosts always receive real instants.
91
101
  const unzone = (d) => timezone ? fromZonedTime(d, timezone) : d;
102
+ // Dates handed IN (initialDate, currentDate) are instants; the views work on
103
+ // the zoned wall-clock plane.
104
+ const zoneIn = (d) => d && timezone ? toZonedTime(d, timezone) : d;
92
105
  const effectiveCreate = $derived(readOnly || !oneventcreate ? undefined : (range) => oneventcreate({
93
106
  start: unzone(range.start),
94
107
  end: unzone(range.end)
@@ -105,9 +118,11 @@ function handleEventClick(ev, anchor) {
105
118
  oneventclick?.(ev, anchor);
106
119
  }
107
120
  // ── Mobile detection (container-based, not viewport) ──
108
- // Seeded from viewport width so the first client paint doesn't flash the
109
- // desktop chrome on phones; the ResizeObserver measurement takes over on mount.
110
- let containerWidth = $state(typeof window !== "undefined" && window.matchMedia?.(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`).matches ? window.innerWidth : 0);
121
+ // Starts unmeasured on server AND client: the first client render must be
122
+ // the markup the server sent, or hydration fails (a phone reload of an SSR
123
+ // page used to throw in the mobile branch). onMount measures before the
124
+ // next paint; a page without SSR renders the right layout at once.
125
+ let containerWidth = $state(0);
111
126
  const isMobileContainer = $derived(containerWidth > 0 && containerWidth < MOBILE_BREAKPOINT);
112
127
  const useMobile = $derived(mobileProp === "auto" ? isMobileContainer : Boolean(mobileProp));
113
128
  // Below this container width the mobile header can't fit pills + nav + a
@@ -154,24 +169,15 @@ const viewState = createViewState(untrack(() => ({
154
169
  view: activeViewId ?? views[0]?.id,
155
170
  mondayStart,
156
171
  // Focus lives on the zoned plane too — day boundaries follow the zone.
157
- initialDate: initialDate && timezone ? toZonedTime(initialDate, timezone) : initialDate,
172
+ // A controlled currentDate wins, so the server renders the right period.
173
+ initialDate: zoneIn(currentDate ?? initialDate),
158
174
  dayCount: days,
159
175
  timezone,
160
176
  modeForView: (viewId) => views.find((v) => v.id === viewId)?.mode
161
177
  })));
162
178
  const selection = createSelection();
163
179
  const drag = createDragState();
164
- // ── Drag commit handler ──
165
- // Views call this on pointer-up to process drag results.
166
- async function commitDrag() {
167
- if (readOnly) {
168
- drag.cancel();
169
- return;
170
- }
171
- const mode = drag.mode;
172
- const payload = drag.commit();
173
- if (!payload) return;
174
- let { start, end } = payload;
180
+ function validateRange(mode, start, end) {
175
181
  // Enforce min/max duration for create and resize
176
182
  if (mode === "create" || mode === "resize-start" || mode === "resize-end") {
177
183
  // Defensive floor: never accept a zero/negative duration, even when
@@ -181,24 +187,14 @@ async function commitDrag() {
181
187
  if (mode === "resize-start") start = new Date(end.getTime() - floorMs);
182
188
  else end = new Date(start.getTime() + floorMs);
183
189
  }
184
- const durationMs = end.getTime() - start.getTime();
185
- const durationMin = durationMs / 6e4;
186
- if (minDuration && durationMin < minDuration) {
187
- if (mode === "resize-start") {
188
- start = new Date(end.getTime() - minDuration * 6e4);
189
- } else {
190
- end = new Date(start.getTime() + minDuration * 6e4);
191
- }
192
- }
193
- if (maxDuration && durationMin > maxDuration) {
194
- if (mode === "resize-start") {
195
- start = new Date(end.getTime() - maxDuration * 6e4);
196
- } else {
197
- end = new Date(start.getTime() + maxDuration * 6e4);
198
- }
190
+ const durationMin = (end.getTime() - start.getTime()) / 6e4;
191
+ const clampTo = minDuration && durationMin < minDuration ? minDuration : maxDuration && durationMin > maxDuration ? maxDuration : null;
192
+ if (clampTo !== null) {
193
+ if (mode === "resize-start") start = new Date(end.getTime() - clampTo * 6e4);
194
+ else end = new Date(start.getTime() + clampTo * 6e4);
199
195
  }
200
196
  }
201
- // Reject if target lands on a disabled date
197
+ // Reject if the range touches a disabled date
202
198
  if (disabledDates?.length) {
203
199
  const startDay = new Date(start);
204
200
  startDay.setHours(0, 0, 0, 0);
@@ -208,10 +204,10 @@ async function commitDrag() {
208
204
  const dt = new Date(dd);
209
205
  dt.setHours(0, 0, 0, 0);
210
206
  const ts = dt.getTime();
211
- if (ts >= startDay.getTime() && ts <= endDay.getTime()) return;
207
+ if (ts >= startDay.getTime() && ts <= endDay.getTime()) return null;
212
208
  }
213
209
  }
214
- // Reject if target overlaps a blocked slot
210
+ // Reject if the range overlaps a blocked slot
215
211
  if (blockedSlots?.length) {
216
212
  const startH = start.getHours() + start.getMinutes() / 60;
217
213
  const endH = end.getHours() + end.getMinutes() / 60 + (end.getDate() !== start.getDate() ? 24 : 0);
@@ -219,25 +215,51 @@ async function commitDrag() {
219
215
  const isoDay = jsDay === 0 ? 7 : jsDay;
220
216
  for (const slot of blockedSlots) {
221
217
  if (slot.day && slot.day !== isoDay) continue;
222
- if (startH < slot.end && endH > slot.start) return;
218
+ if (startH < slot.end && endH > slot.start) return null;
223
219
  }
224
220
  }
221
+ return {
222
+ start,
223
+ end
224
+ };
225
+ }
226
+ // What views call for a click-to-create: the same checks a drag gets.
227
+ const checkedCreate = $derived.by(() => {
228
+ const create = effectiveCreate;
229
+ if (!create) return undefined;
230
+ return (range) => {
231
+ const ok = validateRange("create", range.start, range.end);
232
+ if (ok) create(ok);
233
+ };
234
+ });
235
+ // ── Drag commit handler ──
236
+ // Views call this on pointer-up to process drag results.
237
+ async function commitDrag() {
238
+ if (readOnly) {
239
+ drag.cancel();
240
+ return;
241
+ }
242
+ const mode = drag.mode;
243
+ const payload = drag.commit();
244
+ if (!payload || mode === "none") return;
245
+ const checked = validateRange(mode, payload.start, payload.end);
246
+ if (!checked) return;
247
+ const { start, end } = checked;
225
248
  if ((mode === "move" || mode === "resize-start" || mode === "resize-end") && payload.eventId) {
226
249
  try {
227
250
  await store.move(payload.eventId, start, end);
228
251
  const ev = store.byId(payload.eventId);
229
252
  if (ev) effectiveMove?.(ev, start, end);
230
253
  } catch (e) {
231
- const msg = e instanceof Error ? e.message : "";
232
254
  // Read-only adapter: the store can't persist, but the HOST still
233
255
  // gets the callback — it owns persistence (e.g. scheduler RPC) and
234
256
  // refetches. Missing event stays silent; real failures surface.
235
- if (msg.includes("read-only")) {
257
+ if (isReadOnlyError(e)) {
236
258
  const ev = store.byId(payload.eventId);
237
259
  if (ev) effectiveMove?.(ev, start, end);
238
- } else if (!msg.includes("not found")) {
239
- if (onerror) onerror(e instanceof Error ? e : new Error(String(e)));
240
- else console.warn("[calendar] drag commit failed:", e);
260
+ } else if (!isNotFoundError(e) && !onerror) {
261
+ // With onerror, the store.error effect below reports it — once.
262
+ console.warn("[calendar] drag commit failed:", e);
241
263
  }
242
264
  }
243
265
  } else if (mode === "create") {
@@ -282,7 +304,7 @@ setContext("calendar", {
282
304
  return handleEventClick;
283
305
  },
284
306
  get oneventcreate() {
285
- return effectiveCreate;
307
+ return checkedCreate;
286
308
  },
287
309
  get oneventmove() {
288
310
  return effectiveMove;
@@ -291,7 +313,7 @@ setContext("calendar", {
291
313
  return oneventhover;
292
314
  },
293
315
  get ondayclick() {
294
- return ondayclick ?? defaultDayClick;
316
+ return ondayclick ? (d) => ondayclick(unzone(d)) : defaultDayClick;
295
317
  },
296
318
  get timezone() {
297
319
  return timezone;
@@ -370,19 +392,27 @@ $effect(() => {
370
392
  end: range.end
371
393
  });
372
394
  });
373
- // Eager initial load — $effect runs after paint, but for sync adapters
374
- // (memory, recurring) this resolves in the same microtask.
375
- untrack(() => store.load({
376
- start: viewState.range.start,
377
- end: viewState.range.end
378
- }));
395
+ // Eager initial load for adapters that answer synchronously (memory,
396
+ // recurring, seeded): the server render and the first paint hold the
397
+ // events. An async adapter waits for the effect — by then the view has
398
+ // declared its wider load range, so it is fetched once, not twice.
399
+ untrack(() => {
400
+ if (effectiveAdapter.fetchEventsSync) store.load({
401
+ start: viewState.range.start,
402
+ end: viewState.range.end
403
+ });
404
+ });
379
405
  // Keep active view in sync when external view prop changes after mount.
380
406
  $effect(() => {
381
407
  if (activeViewId) viewState.setView(activeViewId);
382
408
  });
383
409
  // Sync controlled currentDate prop → viewState
384
410
  $effect(() => {
385
- if (currentDate) viewState.setFocusDate(currentDate);
411
+ const d = zoneIn(currentDate);
412
+ // Compared by time: the host often hands back what ondatechange gave it.
413
+ if (d && untrack(() => viewState.focusDate.getTime()) !== d.getTime()) {
414
+ untrack(() => viewState.setFocusDate(d));
415
+ }
386
416
  });
387
417
  // Sync days prop → viewState.dayCount
388
418
  $effect(() => {
@@ -390,8 +420,10 @@ $effect(() => {
390
420
  });
391
421
  // Notify host when focusDate changes
392
422
  $effect(() => {
393
- const d = viewState.focusDate;
394
- ondatechange?.(d);
423
+ // An instant, like currentDate takes — so a host that feeds it back
424
+ // (currentDate={d} ondatechange={(x) => d = x}) gets the same day back.
425
+ const d = unzone(viewState.focusDate);
426
+ untrack(() => ondatechange?.(d));
395
427
  });
396
428
  // Keep view state's week-start rule in sync with incoming prop changes.
397
429
  $effect(() => {
@@ -417,7 +449,7 @@ const resolvedView = $derived.by(() => {
417
449
  // Already a mobile view? Keep it.
418
450
  if (requested.id.endsWith("-mobile")) return requested;
419
451
  // Agenda views: keep as-is (they adapt internally via mobile context).
420
- if (requested.label === "Agenda") return requested;
452
+ if (requested.component === Agenda || requested.id.endsWith("-agenda")) return requested;
421
453
  // Planner / other views: remap to mobile variant with the same mode.
422
454
  const mobileVariant = views.find((v) => v.id === `${requested.mode}-mobile`);
423
455
  return mobileVariant ?? requested;
@@ -432,7 +464,7 @@ const dateLabel = $derived.by(() => {
432
464
  return "";
433
465
  }
434
466
  if (viewState.mode === "day") {
435
- return viewState.focusDate.toLocaleDateString(locale, {
467
+ return viewState.focusDate.toLocaleDateString(locale ?? getDefaultLocale(), {
436
468
  weekday: "long",
437
469
  month: "short",
438
470
  day: "numeric"
@@ -442,7 +474,7 @@ const dateLabel = $derived.by(() => {
442
474
  // The actual visible span — respects custom day counts (3/5-day views).
443
475
  return fmtWeekRange(viewState.range.start.getTime(), locale, viewState.range.end.getTime() - 1);
444
476
  }
445
- return viewState.focusDate.toLocaleDateString(locale, {
477
+ return viewState.focusDate.toLocaleDateString(locale ?? getDefaultLocale(), {
446
478
  month: "long",
447
479
  year: "numeric"
448
480
  });
@@ -482,6 +514,13 @@ const labelsForMode = $derived.by(() => {
482
514
  }
483
515
  return seen;
484
516
  });
517
+ /** Built-in view-type names are shown in the calendar's language. */
518
+ function viewLabel(label) {
519
+ if (label === "Planner") return L.planner;
520
+ if (label === "Agenda") return L.agenda;
521
+ if (label === "Scroll") return L.scroll;
522
+ return label;
523
+ }
485
524
  function switchLabel(label) {
486
525
  const target = desktopViews.find((v) => v.mode === viewState.mode && v.label === label);
487
526
  if (target) viewState.setView(target.id);
@@ -498,8 +537,11 @@ const defaultDayClick = $derived.by(() => {
498
537
  };
499
538
  });
500
539
  /** True when the current view range already includes today. */
540
+ // Ticks on the zoned plane, so "today" is today in `timezone` and the
541
+ // Today button re-enables when the day rolls over.
542
+ const clock = createClock(untrack(() => timezone));
501
543
  const viewIncludesToday = $derived.by(() => {
502
- const now = new Date();
544
+ const now = new Date(clock.tick);
503
545
  if (viewState.mode === "month") {
504
546
  // The month grid's range is week-aligned and includes adjacent-month
505
547
  // spill days — compare against the focused month instead.
@@ -528,15 +570,17 @@ function handleShortcuts(e) {
528
570
  if (e.defaultPrevented || e.metaKey || e.ctrlKey || e.altKey) return;
529
571
  const t = e.target;
530
572
  if (t && (t.tagName === "INPUT" || t.tagName === "TEXTAREA" || t.isContentEditable)) return;
573
+ // Arrows inside a radio group move between its options, not the period.
574
+ const inRadio = t?.getAttribute("role") === "radio";
575
+ // Arrows follow the reading direction: in RTL, ← is "later".
576
+ const rtl = !!calEl && getComputedStyle(calEl).direction === "rtl";
531
577
  if (e.key === "t" || e.key === "T") {
532
578
  e.preventDefault();
533
579
  viewState.goToday();
534
- } else if (e.key === "ArrowLeft") {
535
- e.preventDefault();
536
- viewState.prev();
537
- } else if (e.key === "ArrowRight") {
580
+ } else if ((e.key === "ArrowLeft" || e.key === "ArrowRight") && !inRadio) {
538
581
  e.preventDefault();
539
- viewState.next();
582
+ if (e.key === "ArrowRight" !== rtl) viewState.next();
583
+ else viewState.prev();
540
584
  }
541
585
  }
542
586
  /** Header context for custom header snippet */
@@ -674,7 +718,7 @@ const navCtx = $derived({
674
718
  aria-checked={activeView?.label === label}
675
719
  onclick={() => switchLabel(label)}
676
720
  >
677
- {label}
721
+ {viewLabel(label)}
678
722
  </button>
679
723
  {/each}
680
724
  </div>
@@ -711,7 +755,7 @@ const navCtx = $derived({
711
755
  {locale}
712
756
  focusDate={viewState.focusDate}
713
757
  oneventclick={handleEventClick}
714
- oneventcreate={effectiveCreate}
758
+ oneventcreate={checkedCreate}
715
759
  onexternaldrop={effectiveExternalDrop}
716
760
  readOnly={readOnly}
717
761
  visibleHours={visibleHours}
@@ -719,7 +763,7 @@ const navCtx = $derived({
719
763
  {...activeView.props ?? {}}
720
764
  />
721
765
  {:else}
722
- <div class="cal-empty">No views registered.</div>
766
+ <div class="cal-empty">{L.noViews}</div>
723
767
  {/if}
724
768
  </div>
725
769