@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.
- package/README.md +502 -267
- package/dist/adapters/composite.d.ts +12 -1
- package/dist/adapters/composite.js +109 -47
- package/dist/adapters/errors.d.ts +22 -0
- package/dist/adapters/errors.js +32 -0
- package/dist/adapters/index.d.ts +1 -0
- package/dist/adapters/index.js +1 -0
- package/dist/adapters/jmap.js +26 -17
- package/dist/adapters/mapped.d.ts +2 -2
- package/dist/adapters/mapped.js +18 -9
- package/dist/adapters/memory.d.ts +1 -1
- package/dist/adapters/memory.js +5 -3
- package/dist/adapters/recurring.d.ts +1 -1
- package/dist/adapters/recurring.js +43 -25
- package/dist/adapters/rest.d.ts +2 -2
- package/dist/adapters/rest.js +9 -4
- package/dist/calendar/Calendar.svelte +122 -78
- package/dist/calendar/Calendar.svelte.d.ts +69 -14
- package/dist/calendar/index.d.ts +2 -2
- package/dist/calendar/index.js +1 -1
- package/dist/core/clock.svelte.js +3 -2
- package/dist/core/locale.d.ts +11 -1
- package/dist/core/locale.js +16 -16
- package/dist/core/time.d.ts +9 -0
- package/dist/core/time.js +12 -5
- package/dist/core/timezone.d.ts +7 -3
- package/dist/core/timezone.js +10 -4
- package/dist/engine/event-store.svelte.js +94 -57
- package/dist/engine/view-state.svelte.d.ts +5 -1
- package/dist/engine/view-state.svelte.js +8 -6
- package/dist/headless/create-agenda.svelte.d.ts +8 -1
- package/dist/headless/create-agenda.svelte.js +36 -18
- package/dist/headless/create-calendar.svelte.js +57 -21
- package/dist/headless/create-range-agenda.svelte.d.ts +7 -0
- package/dist/headless/create-range-agenda.svelte.js +38 -17
- package/dist/headless/index.d.ts +1 -0
- package/dist/index.d.ts +10 -7
- package/dist/index.js +8 -5
- package/dist/primitives/DayHeader.svelte +3 -2
- package/dist/primitives/DayHeader.svelte.d.ts +1 -1
- package/dist/primitives/EmptySlot.svelte +5 -2
- package/dist/primitives/EventBlock.svelte +21 -14
- package/dist/primitives/FloatingPanel.svelte +110 -15
- package/dist/primitives/FloatingPanel.svelte.d.ts +7 -5
- package/dist/primitives/NowIndicator.svelte +11 -4
- package/dist/primitives/TimeGutter.svelte +10 -6
- package/dist/primitives/index.d.ts +1 -0
- package/dist/text-fit.d.ts +8 -2
- package/dist/views/agenda/AgendaDay.svelte +43 -35
- package/dist/views/agenda/AgendaWeek.svelte +75 -38
- package/dist/views/index.d.ts +2 -0
- package/dist/views/index.js +2 -0
- package/dist/views/mobile/MobileDay.svelte +99 -42
- package/dist/views/mobile/MobileWeek.svelte +31 -15
- package/dist/views/mobile/swipe.js +6 -1
- package/dist/views/month/MonthGrid.svelte +33 -21
- package/dist/views/month/MonthGrid.svelte.d.ts +1 -1
- package/dist/views/planner/PlannerScroll.svelte +438 -164
- package/dist/views/planner/PlannerWeek.svelte +150 -50
- package/dist/views/shared/context.svelte.d.ts +1 -1
- package/package.json +24 -9
- package/widget/widget.js +10000 -9520
- package/dist/assets/favicon.svg +0 -9
- package/dist/widget/CalendarWidget.svelte +0 -114
- package/dist/widget/CalendarWidget.svelte.d.ts +0 -37
- package/dist/widget/index.d.ts +0 -1
- package/dist/widget/index.js +0 -1
- package/dist/widget/widget.d.ts +0 -10
- package/dist/widget/widget.js +0 -96
|
@@ -1,25 +1,57 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import type { Component, Snippet } from 'svelte';
|
|
2
2
|
import type { CalendarAdapter } from '../adapters/types.js';
|
|
3
3
|
import type { CalendarViewId } from '../engine/view-state.svelte.js';
|
|
4
4
|
import type { TimelineEvent, BlockedSlot } from '../core/types.js';
|
|
5
|
-
import {
|
|
5
|
+
import type { CalendarLabels } from '../core/locale.js';
|
|
6
6
|
import type { AutoThemeOptions } from '../theme/auto.js';
|
|
7
|
+
import type { HeaderContext, NavigationContext } from '../headless/types.js';
|
|
8
|
+
/**
|
|
9
|
+
* What the Calendar passes to the active view's component. Engines (store,
|
|
10
|
+
* view state, drag, labels…) are read from context — `useCalendarContext()`.
|
|
11
|
+
*/
|
|
12
|
+
export interface CalendarViewProps {
|
|
13
|
+
events: TimelineEvent[];
|
|
14
|
+
/** The active `--dt-*` theme string */
|
|
15
|
+
style: string;
|
|
16
|
+
height: number | null;
|
|
17
|
+
mode: 'day' | 'week' | 'month';
|
|
18
|
+
mondayStart: boolean;
|
|
19
|
+
locale: string | undefined;
|
|
20
|
+
focusDate: Date;
|
|
21
|
+
oneventclick: (event: TimelineEvent, anchor?: DOMRect) => void;
|
|
22
|
+
/** Already gated by `readOnly` and validated; undefined when creating is off */
|
|
23
|
+
oneventcreate: ((range: {
|
|
24
|
+
start: Date;
|
|
25
|
+
end: Date;
|
|
26
|
+
}) => void) | undefined;
|
|
27
|
+
onexternaldrop: ((info: {
|
|
28
|
+
start: Date;
|
|
29
|
+
dataTransfer: DataTransfer;
|
|
30
|
+
}) => void) | undefined;
|
|
31
|
+
readOnly: boolean;
|
|
32
|
+
visibleHours: [number, number] | undefined;
|
|
33
|
+
selectedEventId: string | null;
|
|
34
|
+
}
|
|
7
35
|
/** One view registration */
|
|
8
36
|
export interface CalendarView {
|
|
9
37
|
id: CalendarViewId;
|
|
38
|
+
/** The view-type name shown in the header pills when a mode has several views */
|
|
10
39
|
label: string;
|
|
11
40
|
/** day, week or month */
|
|
12
41
|
mode: 'day' | 'week' | 'month';
|
|
13
|
-
/**
|
|
14
|
-
|
|
15
|
-
|
|
42
|
+
/**
|
|
43
|
+
* The Svelte component to render. It receives `CalendarViewProps` plus
|
|
44
|
+
* `props`; declare only the ones it uses.
|
|
45
|
+
*/
|
|
46
|
+
component: Component<any>;
|
|
47
|
+
/** Extra props passed through to the component */
|
|
16
48
|
props?: Record<string, unknown>;
|
|
17
49
|
}
|
|
18
|
-
interface
|
|
50
|
+
export interface CalendarProps {
|
|
19
51
|
/** Data adapter (required) */
|
|
20
52
|
adapter: CalendarAdapter;
|
|
21
|
-
/** Registered views */
|
|
22
|
-
views?: CalendarView[];
|
|
53
|
+
/** Registered views (default: `defaultViews`) */
|
|
54
|
+
views?: readonly CalendarView[];
|
|
23
55
|
/** Active view ID (defaults to first registered view) */
|
|
24
56
|
view?: CalendarViewId;
|
|
25
57
|
/** CSS theme string (--dt-* inline style) */
|
|
@@ -74,7 +106,10 @@ interface Props {
|
|
|
74
106
|
showDates?: boolean;
|
|
75
107
|
/** ISO weekdays to hide (1=Mon … 7=Sun). E.g. [6, 7] hides weekends. */
|
|
76
108
|
hideDays?: number[];
|
|
77
|
-
/**
|
|
109
|
+
/**
|
|
110
|
+
* Controlled current date — drives which date the calendar focuses on.
|
|
111
|
+
* An instant, like `initialDate`: with `timezone` it is read in that zone.
|
|
112
|
+
*/
|
|
78
113
|
currentDate?: Date;
|
|
79
114
|
/** Blocked/unavailable time slots — rendered as hatched regions, prevent event creation. */
|
|
80
115
|
blockedSlots?: BlockedSlot[];
|
|
@@ -117,12 +152,12 @@ interface Props {
|
|
|
117
152
|
* Replace the entire header chrome (date label + mode pills + nav arrows).
|
|
118
153
|
* Receives context: { dateLabel, mode, modes, switchMode, prev, next, goToday, isViewOnToday, focusDate }.
|
|
119
154
|
*/
|
|
120
|
-
header?: Snippet<[
|
|
155
|
+
header?: Snippet<[HeaderContext]>;
|
|
121
156
|
/**
|
|
122
157
|
* Replace just the navigation controls (arrows + today button).
|
|
123
158
|
* Receives context: { prev, next, goToday, isViewOnToday, focusDate, mode }.
|
|
124
159
|
*/
|
|
125
|
-
navigation?: Snippet<[
|
|
160
|
+
navigation?: Snippet<[NavigationContext]>;
|
|
126
161
|
/** `anchor` is the clicked block's viewport rect where a view has one (planner) — for positioning a FloatingPanel. */
|
|
127
162
|
oneventclick?: (event: TimelineEvent, anchor?: DOMRect) => void;
|
|
128
163
|
oneventcreate?: (range: {
|
|
@@ -136,13 +171,25 @@ interface Props {
|
|
|
136
171
|
start: Date;
|
|
137
172
|
dataTransfer: DataTransfer;
|
|
138
173
|
}) => void;
|
|
174
|
+
/**
|
|
175
|
+
* An event was dragged or resized. `newStart`/`newEnd` are real instants.
|
|
176
|
+
* Fires after the adapter stored the move, or — when the adapter refuses
|
|
177
|
+
* with a `read-only` error — so the host can store it itself.
|
|
178
|
+
*/
|
|
139
179
|
oneventmove?: (event: TimelineEvent, newStart: Date, newEnd: Date) => void;
|
|
180
|
+
/** Called with the active view id — once on mount, then on every change. */
|
|
140
181
|
onviewchange?: (viewId: CalendarViewId) => void;
|
|
141
|
-
/**
|
|
182
|
+
/**
|
|
183
|
+
* Called with the focused date — once on mount, then whenever it changes
|
|
184
|
+
* (navigation, scrolling, a tapped day). An instant on the focused day,
|
|
185
|
+
* like `currentDate` takes, so feeding it back is stable. With `timezone`
|
|
186
|
+
* read its date in that zone — `formatInTimeZone(d, timezone, …)` or
|
|
187
|
+
* `toZonedTime(d, timezone)`.
|
|
188
|
+
*/
|
|
142
189
|
ondatechange?: (date: Date) => void;
|
|
143
190
|
/** Called when the pointer enters an event (hover). */
|
|
144
191
|
oneventhover?: (event: TimelineEvent) => void;
|
|
145
|
-
/** Called when a day cell is clicked (month grid
|
|
192
|
+
/** Called when a day cell is clicked (month grid, agenda day heads). An instant, like `ondatechange`. Default: open that day in a day view. */
|
|
146
193
|
ondayclick?: (date: Date) => void;
|
|
147
194
|
/** Surfaced instead of silent console output when loading or mutations fail. */
|
|
148
195
|
onerror?: (error: Error) => void;
|
|
@@ -154,6 +201,14 @@ interface Props {
|
|
|
154
201
|
*/
|
|
155
202
|
timezone?: string;
|
|
156
203
|
}
|
|
157
|
-
|
|
204
|
+
/**
|
|
205
|
+
* The built-in views — the registry a Calendar uses without a `views` prop.
|
|
206
|
+
* Spread it to add your own view and keep these:
|
|
207
|
+
* `views={[...defaultViews, { id: 'day-kanban', ... }]}`.
|
|
208
|
+
* On phones (`mobile: 'auto'`), every view except `*-agenda` and `*-mobile`
|
|
209
|
+
* swaps to the registered `{mode}-mobile` view, when there is one.
|
|
210
|
+
*/
|
|
211
|
+
export declare const defaultViews: readonly CalendarView[];
|
|
212
|
+
declare const Calendar: Component<CalendarProps, {}, "">;
|
|
158
213
|
type Calendar = ReturnType<typeof Calendar>;
|
|
159
214
|
export default Calendar;
|
package/dist/calendar/index.d.ts
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
export { default as Calendar } from './Calendar.svelte';
|
|
2
|
-
export type { CalendarView } from './Calendar.svelte';
|
|
1
|
+
export { default as Calendar, defaultViews } from './Calendar.svelte';
|
|
2
|
+
export type { CalendarView, CalendarViewProps, CalendarProps } from './Calendar.svelte';
|
package/dist/calendar/index.js
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
// ─── Calendar shell barrel export ───────────────────────
|
|
2
|
-
export { default as Calendar } from './Calendar.svelte';
|
|
2
|
+
export { default as Calendar, defaultViews } from './Calendar.svelte';
|
|
@@ -24,8 +24,9 @@ export function createClock(timezone) {
|
|
|
24
24
|
// With a timezone, ticks are "zoned wall-clock" epoch ms — the same plane
|
|
25
25
|
// the wrapped adapter shifts event Dates into. Views stay zone-agnostic.
|
|
26
26
|
const now = () => (timezone ? toZonedTime(Date.now(), timezone).getTime() : Date.now());
|
|
27
|
-
|
|
28
|
-
let
|
|
27
|
+
const t0 = now();
|
|
28
|
+
let tick = $state(t0);
|
|
29
|
+
let today = $state(sod(t0));
|
|
29
30
|
let intervalId = null;
|
|
30
31
|
function start() {
|
|
31
32
|
intervalId = setInterval(() => {
|
package/dist/core/locale.d.ts
CHANGED
|
@@ -20,6 +20,8 @@ export interface CalendarLabels {
|
|
|
20
20
|
month: string;
|
|
21
21
|
planner: string;
|
|
22
22
|
agenda: string;
|
|
23
|
+
/** The scrolling-weeks view (`week-scroll`) in the view-type pills */
|
|
24
|
+
scroll: string;
|
|
23
25
|
now: string;
|
|
24
26
|
free: string;
|
|
25
27
|
allDay: string;
|
|
@@ -48,6 +50,10 @@ export interface CalendarLabels {
|
|
|
48
50
|
full: string;
|
|
49
51
|
/** Event status: limited availability */
|
|
50
52
|
limited: string;
|
|
53
|
+
/** A blocked slot without a label of its own */
|
|
54
|
+
unavailable: string;
|
|
55
|
+
/** Shown when a Calendar has no views to render */
|
|
56
|
+
noViews: string;
|
|
51
57
|
dayNavigation: string;
|
|
52
58
|
weekNavigation: string;
|
|
53
59
|
dayPlanner: string;
|
|
@@ -114,7 +120,11 @@ export declare function fmtDay(ms: number, todayMs: number, opts?: {
|
|
|
114
120
|
short?: boolean;
|
|
115
121
|
}, locale?: string): string;
|
|
116
122
|
/**
|
|
117
|
-
* Format a
|
|
123
|
+
* Format a date range label in the locale's own order:
|
|
124
|
+
* "Sep 21 – 27, 2026" (en-US), "21–27 wrz 2026" (pl-PL),
|
|
125
|
+
* "Dec 28, 2026 – Jan 3, 2027" across a year.
|
|
126
|
+
*
|
|
127
|
+
* `weekEndMs` is the last day shown (inclusive); default: six days on.
|
|
118
128
|
*/
|
|
119
129
|
export declare function fmtWeekRange(weekStartMs: number, locale?: string, weekEndMs?: number): string;
|
|
120
130
|
/**
|
package/dist/core/locale.js
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
* For date-fns locale integration later, this module can import from
|
|
8
8
|
* `date-fns/locale/*` and pass to `format()`.
|
|
9
9
|
*/
|
|
10
|
-
import {
|
|
10
|
+
import { addDaysMs } from './time.js';
|
|
11
11
|
/** English defaults — used unless overridden via `setLabels()`. */
|
|
12
12
|
export const defaultLabels = {
|
|
13
13
|
today: 'Today',
|
|
@@ -18,6 +18,7 @@ export const defaultLabels = {
|
|
|
18
18
|
month: 'Month',
|
|
19
19
|
planner: 'Planner',
|
|
20
20
|
agenda: 'Agenda',
|
|
21
|
+
scroll: 'Scroll',
|
|
21
22
|
now: 'now',
|
|
22
23
|
free: 'free',
|
|
23
24
|
allDay: 'All day',
|
|
@@ -42,6 +43,8 @@ export const defaultLabels = {
|
|
|
42
43
|
tentative: 'tentative',
|
|
43
44
|
full: 'full',
|
|
44
45
|
limited: 'limited',
|
|
46
|
+
unavailable: 'Unavailable',
|
|
47
|
+
noViews: 'No views registered.',
|
|
45
48
|
dayNavigation: 'Day navigation',
|
|
46
49
|
weekNavigation: 'Week navigation',
|
|
47
50
|
dayPlanner: 'Day planner',
|
|
@@ -155,9 +158,9 @@ export function fmtDay(ms, todayMs, opts, locale) {
|
|
|
155
158
|
const L = _labels;
|
|
156
159
|
if (ms === todayMs)
|
|
157
160
|
return opts?.short ? L.today : `${L.today} · ${short}`;
|
|
158
|
-
if (ms === todayMs -
|
|
161
|
+
if (ms === addDaysMs(todayMs, -1))
|
|
159
162
|
return opts?.short ? L.yesterday : `${L.yesterday} · ${short}`;
|
|
160
|
-
if (ms === todayMs
|
|
163
|
+
if (ms === addDaysMs(todayMs, 1))
|
|
161
164
|
return opts?.short ? L.tomorrow : `${L.tomorrow} · ${short}`;
|
|
162
165
|
if (opts?.short) {
|
|
163
166
|
return new Date(ms).toLocaleDateString(loc, { weekday: 'short', day: 'numeric' });
|
|
@@ -165,23 +168,20 @@ export function fmtDay(ms, todayMs, opts, locale) {
|
|
|
165
168
|
return new Date(ms).toLocaleDateString(loc, { weekday: 'short', month: 'short', day: 'numeric' });
|
|
166
169
|
}
|
|
167
170
|
/**
|
|
168
|
-
* Format a
|
|
171
|
+
* Format a date range label in the locale's own order:
|
|
172
|
+
* "Sep 21 – 27, 2026" (en-US), "21–27 wrz 2026" (pl-PL),
|
|
173
|
+
* "Dec 28, 2026 – Jan 3, 2027" across a year.
|
|
174
|
+
*
|
|
175
|
+
* `weekEndMs` is the last day shown (inclusive); default: six days on.
|
|
169
176
|
*/
|
|
170
177
|
export function fmtWeekRange(weekStartMs, locale, weekEndMs) {
|
|
171
178
|
const loc = locale ?? defaultLocale;
|
|
172
179
|
const s = new Date(weekStartMs);
|
|
173
|
-
const e = new Date(weekEndMs ?? weekStartMs
|
|
174
|
-
const
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
if (sy !== ey) {
|
|
179
|
-
return `${sm} ${s.getDate()}, ${sy} – ${em} ${e.getDate()}, ${ey}`;
|
|
180
|
-
}
|
|
181
|
-
if (sm !== em) {
|
|
182
|
-
return `${sm} ${s.getDate()} – ${em} ${e.getDate()}, ${ey}`;
|
|
183
|
-
}
|
|
184
|
-
return `${sm} ${s.getDate()} – ${e.getDate()}, ${ey}`;
|
|
180
|
+
const e = new Date(weekEndMs ?? addDaysMs(weekStartMs, 6));
|
|
181
|
+
const fmt = new Intl.DateTimeFormat(loc, { month: 'short', day: 'numeric', year: 'numeric' });
|
|
182
|
+
// Thin/narrow spaces around the dash vary by engine; plain spaces keep
|
|
183
|
+
// the label stable across browsers and in tests.
|
|
184
|
+
return fmt.formatRange(s, e).replace(/[\u2009\u202f]/g, ' ');
|
|
185
185
|
}
|
|
186
186
|
// ─── Shared time / duration formatting ──────────────────
|
|
187
187
|
/**
|
package/dist/core/time.d.ts
CHANGED
|
@@ -16,6 +16,15 @@ export declare function startOfWeek(ms: number, mondayStart?: boolean): number;
|
|
|
16
16
|
export declare function addDaysMs(ms: number, n: number): number;
|
|
17
17
|
/** Calendar-day difference between two timestamps */
|
|
18
18
|
export declare function diffDays(a: number, b: number): number;
|
|
19
|
+
/**
|
|
20
|
+
* Whether an event overlaps [start, end). A zero-length event (a deadline,
|
|
21
|
+
* a marker) has no extent to overlap with, so it counts where it sits —
|
|
22
|
+
* including exactly at `start`, which the plain interval test drops.
|
|
23
|
+
*/
|
|
24
|
+
export declare function overlapsRange(ev: {
|
|
25
|
+
start: Date;
|
|
26
|
+
end: Date;
|
|
27
|
+
}, start: Date, end: Date): boolean;
|
|
19
28
|
/** Zero-pad a number to 2 digits */
|
|
20
29
|
export declare function pad(n: number): string;
|
|
21
30
|
/**
|
package/dist/core/time.js
CHANGED
|
@@ -33,6 +33,14 @@ export function addDaysMs(ms, n) {
|
|
|
33
33
|
export function diffDays(a, b) {
|
|
34
34
|
return differenceInCalendarDays(a, b);
|
|
35
35
|
}
|
|
36
|
+
/**
|
|
37
|
+
* Whether an event overlaps [start, end). A zero-length event (a deadline,
|
|
38
|
+
* a marker) has no extent to overlap with, so it counts where it sits —
|
|
39
|
+
* including exactly at `start`, which the plain interval test drops.
|
|
40
|
+
*/
|
|
41
|
+
export function overlapsRange(ev, start, end) {
|
|
42
|
+
return ev.start < end && (ev.end > start || ev.start >= start);
|
|
43
|
+
}
|
|
36
44
|
/** Zero-pad a number to 2 digits */
|
|
37
45
|
export function pad(n) {
|
|
38
46
|
return n < 10 ? '0' + n : '' + n;
|
|
@@ -74,8 +82,7 @@ export function isMultiDay(ev) {
|
|
|
74
82
|
export function isAllDay(ev) {
|
|
75
83
|
if (ev.allDay)
|
|
76
84
|
return true;
|
|
77
|
-
|
|
78
|
-
if (duration < DAY_MS)
|
|
85
|
+
if (ev.end.getTime() < addDaysMs(ev.start.getTime(), 1))
|
|
79
86
|
return false;
|
|
80
87
|
const s = ev.start;
|
|
81
88
|
return s.getHours() === 0 && s.getMinutes() === 0 && s.getSeconds() === 0;
|
|
@@ -86,7 +93,7 @@ export function isAllDay(ev) {
|
|
|
86
93
|
*/
|
|
87
94
|
export function segmentForDay(ev, dayMs) {
|
|
88
95
|
const dayStart = sod(dayMs);
|
|
89
|
-
const dayEnd = dayStart
|
|
96
|
+
const dayEnd = addDaysMs(dayStart, 1);
|
|
90
97
|
const evStart = ev.start.getTime();
|
|
91
98
|
const evEnd = ev.end.getTime();
|
|
92
99
|
// No overlap
|
|
@@ -95,8 +102,8 @@ export function segmentForDay(ev, dayMs) {
|
|
|
95
102
|
const firstDayMs = sod(evStart);
|
|
96
103
|
// For end time: if event ends exactly at midnight, last day is the day before
|
|
97
104
|
const lastDayMs = sod(evEnd - 1);
|
|
98
|
-
const totalDays =
|
|
99
|
-
const dayIndex =
|
|
105
|
+
const totalDays = diffDays(lastDayMs, firstDayMs) + 1;
|
|
106
|
+
const dayIndex = diffDays(dayStart, firstDayMs) + 1;
|
|
100
107
|
return {
|
|
101
108
|
ev,
|
|
102
109
|
start: new Date(Math.max(evStart, dayStart)),
|
package/dist/core/timezone.d.ts
CHANGED
|
@@ -26,9 +26,13 @@ export declare function formatInTimeZone(date: Date | number, timezone: string,
|
|
|
26
26
|
* real instants. This is how the Calendar's `timezone` prop works: views keep
|
|
27
27
|
* doing plain local-time math on an already-shifted plane.
|
|
28
28
|
*
|
|
29
|
-
* Known
|
|
30
|
-
*
|
|
31
|
-
* resolve to one of the two
|
|
29
|
+
* Known limits shared by every wall-clock calendar UI:
|
|
30
|
+
* - during a DST fall-back in `timezone` the repeated hour is ambiguous on
|
|
31
|
+
* the wall clock, so writes made inside it resolve to one of the two
|
|
32
|
+
* instants (date-fns-tz picks the offset);
|
|
33
|
+
* - the plane is made of the *browser's* local Dates, so a wall-clock time
|
|
34
|
+
* that does not exist in the browser's own zone (its spring-forward hour,
|
|
35
|
+
* when that differs from `timezone`'s) displays an hour late.
|
|
32
36
|
*/
|
|
33
37
|
import type { CalendarAdapter } from '../adapters/types.js';
|
|
34
38
|
export declare function wrapAdapterWithTimezone(adapter: CalendarAdapter, timezone: string): CalendarAdapter;
|
package/dist/core/timezone.js
CHANGED
|
@@ -62,15 +62,21 @@ export function wrapAdapterWithTimezone(adapter, timezone) {
|
|
|
62
62
|
...(obj.start instanceof Date ? { start: fromZonedTime(obj.start, timezone) } : {}),
|
|
63
63
|
...(obj.end instanceof Date ? { end: fromZonedTime(obj.end, timezone) } : {}),
|
|
64
64
|
});
|
|
65
|
+
const unzoneRange = (range) => ({
|
|
66
|
+
start: fromZonedTime(range.start, timezone),
|
|
67
|
+
end: fromZonedTime(range.end, timezone),
|
|
68
|
+
});
|
|
65
69
|
const wrapped = {
|
|
66
70
|
async fetchEvents(range) {
|
|
67
|
-
const events = await adapter.fetchEvents(
|
|
68
|
-
start: fromZonedTime(range.start, timezone),
|
|
69
|
-
end: fromZonedTime(range.end, timezone),
|
|
70
|
-
});
|
|
71
|
+
const events = await adapter.fetchEvents(unzoneRange(range));
|
|
71
72
|
return events.map(zoneEvent);
|
|
72
73
|
},
|
|
73
74
|
};
|
|
75
|
+
// Keep the synchronous path: without it a seeded or in-memory adapter
|
|
76
|
+
// renders empty on the server and flashes a loading state on the client.
|
|
77
|
+
if (adapter.fetchEventsSync) {
|
|
78
|
+
wrapped.fetchEventsSync = (range) => adapter.fetchEventsSync(unzoneRange(range))?.map(zoneEvent);
|
|
79
|
+
}
|
|
74
80
|
if (adapter.createEvent) {
|
|
75
81
|
wrapped.createEvent = async (event) => zoneEvent(await adapter.createEvent(unzonePartial(event)));
|
|
76
82
|
}
|
|
@@ -17,7 +17,8 @@
|
|
|
17
17
|
*/
|
|
18
18
|
import { SvelteMap } from 'svelte/reactivity';
|
|
19
19
|
import { untrack } from 'svelte';
|
|
20
|
-
import { sod,
|
|
20
|
+
import { sod, addDaysMs, overlapsRange } from '../core/time.js';
|
|
21
|
+
import { CalendarReadOnlyError, isReadOnlyError, isNotFoundError } from '../adapters/errors.js';
|
|
21
22
|
/**
|
|
22
23
|
* Create a reactive event store backed by a CalendarAdapter.
|
|
23
24
|
*
|
|
@@ -30,15 +31,30 @@ import { sod, DAY_MS } from '../core/time.js';
|
|
|
30
31
|
export function createEventStore(adapter) {
|
|
31
32
|
const getAdapter = typeof adapter === 'function' ? adapter : () => adapter;
|
|
32
33
|
let eventMap = new SvelteMap();
|
|
33
|
-
|
|
34
|
+
/** The latest load is async and has not resolved yet */
|
|
35
|
+
let loadPending = $state(false);
|
|
36
|
+
/** Mutations in flight — a counter, since several can overlap */
|
|
37
|
+
let mutationsPending = $state(0);
|
|
38
|
+
const loading = $derived(loadPending || mutationsPending > 0);
|
|
34
39
|
let error = $state(null);
|
|
35
40
|
/** Guards against an older in-flight load pruning a newer one's result */
|
|
36
41
|
let loadSeq = 0;
|
|
42
|
+
/**
|
|
43
|
+
* Mutation clock. A load that was already in flight when an event was
|
|
44
|
+
* added, moved or removed must not undo that write when it lands — its
|
|
45
|
+
* snapshot predates it. Each mutation stamps its id; a load skips ids
|
|
46
|
+
* stamped after it started.
|
|
47
|
+
*/
|
|
48
|
+
let epoch = 0;
|
|
49
|
+
const touched = new Map();
|
|
50
|
+
function touch(id) {
|
|
51
|
+
touched.set(id, ++epoch);
|
|
52
|
+
}
|
|
37
53
|
// Derived array view of the map — consumers read this.
|
|
38
54
|
const eventArray = $derived([...eventMap.values()]);
|
|
39
55
|
// ── Internal helpers ──
|
|
40
56
|
function overlaps(ev, start, end) {
|
|
41
|
-
return ev
|
|
57
|
+
return overlapsRange(ev, start, end);
|
|
42
58
|
}
|
|
43
59
|
function removeEvent(id) {
|
|
44
60
|
eventMap.delete(id);
|
|
@@ -49,14 +65,39 @@ export function createEventStore(adapter) {
|
|
|
49
65
|
// Merge: upsert fetched, don't blow away events outside this range.
|
|
50
66
|
// Inside the range the adapter is authoritative — drop what it no
|
|
51
67
|
// longer returns, or deleted/moved events linger until remount.
|
|
52
|
-
|
|
68
|
+
// `since` is the mutation clock when the fetch started: ids written after
|
|
69
|
+
// that are newer than the adapter's answer and are left as they are.
|
|
70
|
+
function merge(fetched, range, since = epoch) {
|
|
71
|
+
const stale = (id) => (touched.get(id) ?? 0) > since;
|
|
53
72
|
const keep = new Set(fetched.map((ev) => ev.id));
|
|
54
73
|
for (const ev of [...eventMap.values()]) {
|
|
55
|
-
if (!keep.has(ev.id) && overlaps(ev, range.start, range.end))
|
|
74
|
+
if (!keep.has(ev.id) && !stale(ev.id) && overlaps(ev, range.start, range.end))
|
|
56
75
|
removeEvent(ev.id);
|
|
57
76
|
}
|
|
58
77
|
for (const ev of fetched)
|
|
59
|
-
|
|
78
|
+
if (!stale(ev.id))
|
|
79
|
+
upsertEvent(ev);
|
|
80
|
+
// Stamps no in-flight load can predate are no longer needed.
|
|
81
|
+
for (const [id, at] of touched)
|
|
82
|
+
if (at <= since)
|
|
83
|
+
touched.delete(id);
|
|
84
|
+
}
|
|
85
|
+
async function mutate(run) {
|
|
86
|
+
mutationsPending++;
|
|
87
|
+
error = null;
|
|
88
|
+
try {
|
|
89
|
+
return await run();
|
|
90
|
+
}
|
|
91
|
+
catch (e) {
|
|
92
|
+
// A refusal (read-only) or "not mine" is an answer the caller acts
|
|
93
|
+
// on — the Calendar hands the move to the host — not a failure.
|
|
94
|
+
if (!isReadOnlyError(e) && !isNotFoundError(e))
|
|
95
|
+
error = e instanceof Error ? e.message : String(e);
|
|
96
|
+
throw e;
|
|
97
|
+
}
|
|
98
|
+
finally {
|
|
99
|
+
mutationsPending--;
|
|
100
|
+
}
|
|
60
101
|
}
|
|
61
102
|
// ── Public API ──
|
|
62
103
|
return {
|
|
@@ -71,31 +112,46 @@ export function createEventStore(adapter) {
|
|
|
71
112
|
},
|
|
72
113
|
async load(range) {
|
|
73
114
|
const seq = ++loadSeq;
|
|
115
|
+
const since = epoch;
|
|
74
116
|
const adapter = getAdapter();
|
|
75
117
|
// In-memory adapters answer at once: no loading state, and a server
|
|
76
118
|
// render already holds the events.
|
|
77
|
-
|
|
119
|
+
let sync;
|
|
120
|
+
try {
|
|
121
|
+
sync = adapter.fetchEventsSync?.(range);
|
|
122
|
+
}
|
|
123
|
+
catch (e) {
|
|
124
|
+
// Callers fire load() and forget it (and on the server): a throw
|
|
125
|
+
// here must land in `error`, never as an unhandled rejection.
|
|
126
|
+
loadPending = false;
|
|
127
|
+
error = e instanceof Error ? e.message : String(e);
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
78
130
|
if (sync) {
|
|
79
131
|
error = null;
|
|
132
|
+
// This load supersedes any async one still in flight, whose
|
|
133
|
+
// finally will no longer clear the flag.
|
|
134
|
+
loadPending = false;
|
|
80
135
|
// Callers load from an effect; reading the map here would make that
|
|
81
136
|
// effect depend on what it writes.
|
|
82
|
-
untrack(() => merge(sync, range));
|
|
137
|
+
untrack(() => merge(sync, range, since));
|
|
83
138
|
return;
|
|
84
139
|
}
|
|
85
|
-
|
|
140
|
+
loadPending = true;
|
|
86
141
|
error = null;
|
|
87
142
|
try {
|
|
88
143
|
const fetched = await adapter.fetchEvents(range);
|
|
89
144
|
if (seq !== loadSeq)
|
|
90
145
|
return; // superseded by a newer load
|
|
91
|
-
merge(fetched, range);
|
|
146
|
+
merge(fetched, range, since);
|
|
92
147
|
}
|
|
93
148
|
catch (e) {
|
|
94
|
-
|
|
149
|
+
if (seq === loadSeq)
|
|
150
|
+
error = e instanceof Error ? e.message : String(e);
|
|
95
151
|
}
|
|
96
152
|
finally {
|
|
97
153
|
if (seq === loadSeq)
|
|
98
|
-
|
|
154
|
+
loadPending = false;
|
|
99
155
|
}
|
|
100
156
|
},
|
|
101
157
|
forRange(start, end) {
|
|
@@ -103,63 +159,42 @@ export function createEventStore(adapter) {
|
|
|
103
159
|
},
|
|
104
160
|
forDay(date) {
|
|
105
161
|
const dayStart = new Date(sod(date.getTime()));
|
|
106
|
-
const dayEnd = new Date(dayStart.getTime()
|
|
162
|
+
const dayEnd = new Date(addDaysMs(dayStart.getTime(), 1));
|
|
107
163
|
return eventArray.filter((ev) => overlaps(ev, dayStart, dayEnd));
|
|
108
164
|
},
|
|
109
165
|
byId(id) {
|
|
110
166
|
return eventMap.get(id);
|
|
111
167
|
},
|
|
112
168
|
async add(eventData) {
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
const created = await getAdapter().createEvent(eventData);
|
|
169
|
+
const adapter = getAdapter();
|
|
170
|
+
if (!adapter.createEvent)
|
|
171
|
+
throw new CalendarReadOnlyError('Adapter is read-only: createEvent not implemented');
|
|
172
|
+
return mutate(async () => {
|
|
173
|
+
const created = await adapter.createEvent(eventData);
|
|
119
174
|
upsertEvent(created);
|
|
175
|
+
touch(created.id);
|
|
120
176
|
return created;
|
|
121
|
-
}
|
|
122
|
-
catch (e) {
|
|
123
|
-
error = e instanceof Error ? e.message : String(e);
|
|
124
|
-
throw e;
|
|
125
|
-
}
|
|
126
|
-
finally {
|
|
127
|
-
loading = false;
|
|
128
|
-
}
|
|
177
|
+
});
|
|
129
178
|
},
|
|
130
179
|
async update(id, patch) {
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
const updated = await getAdapter().updateEvent(id, patch);
|
|
180
|
+
const adapter = getAdapter();
|
|
181
|
+
if (!adapter.updateEvent)
|
|
182
|
+
throw new CalendarReadOnlyError('Adapter is read-only: updateEvent not implemented');
|
|
183
|
+
await mutate(async () => {
|
|
184
|
+
const updated = await adapter.updateEvent(id, patch);
|
|
137
185
|
upsertEvent(updated);
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
error = e instanceof Error ? e.message : String(e);
|
|
141
|
-
throw e;
|
|
142
|
-
}
|
|
143
|
-
finally {
|
|
144
|
-
loading = false;
|
|
145
|
-
}
|
|
186
|
+
touch(id);
|
|
187
|
+
});
|
|
146
188
|
},
|
|
147
189
|
async remove(id) {
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
await getAdapter().deleteEvent(id);
|
|
190
|
+
const adapter = getAdapter();
|
|
191
|
+
if (!adapter.deleteEvent)
|
|
192
|
+
throw new CalendarReadOnlyError('Adapter is read-only: deleteEvent not implemented');
|
|
193
|
+
await mutate(async () => {
|
|
194
|
+
await adapter.deleteEvent(id);
|
|
154
195
|
removeEvent(id);
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
error = e instanceof Error ? e.message : String(e);
|
|
158
|
-
throw e;
|
|
159
|
-
}
|
|
160
|
-
finally {
|
|
161
|
-
loading = false;
|
|
162
|
-
}
|
|
196
|
+
touch(id);
|
|
197
|
+
});
|
|
163
198
|
},
|
|
164
199
|
async move(id, newStart, newEnd) {
|
|
165
200
|
// Optimistic update: apply locally first so the UI doesn't flash
|
|
@@ -167,6 +202,7 @@ export function createEventStore(adapter) {
|
|
|
167
202
|
const existing = eventMap.get(id);
|
|
168
203
|
if (existing) {
|
|
169
204
|
upsertEvent({ ...existing, start: newStart, end: newEnd });
|
|
205
|
+
touch(id);
|
|
170
206
|
}
|
|
171
207
|
try {
|
|
172
208
|
await this.update(id, { start: newStart, end: newEnd });
|
|
@@ -176,9 +212,10 @@ export function createEventStore(adapter) {
|
|
|
176
212
|
// adapter, where the HOST owns persistence (Calendar forwards to
|
|
177
213
|
// oneventmove). Reverting there snaps the block back to its old
|
|
178
214
|
// slot for the length of the host's round-trip.
|
|
179
|
-
|
|
180
|
-
if (existing && !msg.includes('read-only'))
|
|
215
|
+
if (existing && !isReadOnlyError(e)) {
|
|
181
216
|
upsertEvent(existing);
|
|
217
|
+
touch(id);
|
|
218
|
+
}
|
|
182
219
|
throw e;
|
|
183
220
|
}
|
|
184
221
|
},
|
|
@@ -15,7 +15,11 @@ export type ViewMode = 'day' | 'week' | 'month';
|
|
|
15
15
|
export interface ViewStateOptions {
|
|
16
16
|
view?: CalendarViewId;
|
|
17
17
|
mondayStart?: boolean;
|
|
18
|
-
/**
|
|
18
|
+
/**
|
|
19
|
+
* IANA timezone string (e.g. 'America/New_York'). Defaults to local timezone.
|
|
20
|
+
* "Today" (the default focus, `goToday()`) is today in this zone, as a
|
|
21
|
+
* wall-clock Date on the zoned plane the Calendar's `timezone` prop uses.
|
|
22
|
+
*/
|
|
19
23
|
timezone?: string;
|
|
20
24
|
/** Initial date to focus on (defaults to today). */
|
|
21
25
|
initialDate?: Date;
|