@nomideusz/svelte-calendar 0.21.0 → 1.0.1
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 -269
- 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 +37 -19
- 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 +12 -12
- package/dist/core/time.d.ts +9 -0
- package/dist/core/time.js +8 -0
- package/dist/core/timezone.d.ts +7 -3
- package/dist/core/timezone.js +10 -4
- package/dist/engine/event-store.svelte.js +93 -56
- package/dist/engine/view-state.svelte.d.ts +5 -1
- package/dist/engine/view-state.svelte.js +5 -3
- package/dist/headless/create-agenda.svelte.d.ts +8 -1
- package/dist/headless/create-agenda.svelte.js +32 -14
- package/dist/headless/create-calendar.svelte.js +53 -17
- package/dist/headless/create-range-agenda.svelte.d.ts +7 -0
- package/dist/headless/create-range-agenda.svelte.js +33 -12
- 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 +41 -33
- package/dist/views/agenda/AgendaWeek.svelte +71 -34
- package/dist/views/index.d.ts +2 -0
- package/dist/views/index.js +2 -0
- package/dist/views/mobile/MobileDay.svelte +98 -41
- package/dist/views/mobile/MobileWeek.svelte +29 -13
- package/dist/views/mobile/swipe.js +6 -1
- package/dist/views/month/MonthGrid.svelte +29 -17
- package/dist/views/month/MonthGrid.svelte.d.ts +1 -1
- package/dist/views/planner/PlannerScroll.svelte +198 -61
- package/dist/views/planner/PlannerWeek.svelte +148 -48
- package/dist/views/shared/context.svelte.d.ts +1 -1
- package/package.json +29 -14
- package/widget/widget.js +7983 -7688
- 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
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;
|
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, addDaysMs } from '../core/time.js';
|
|
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, addDaysMs } 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) {
|
|
@@ -110,56 +166,35 @@ export function createEventStore(adapter) {
|
|
|
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;
|
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
* vs.goToday() — jump to today
|
|
13
13
|
*/
|
|
14
14
|
import { startOfWeek as calcStartOfWeek, addDaysMs } from '../core/time.js';
|
|
15
|
+
import { nowInZone } from '../core/timezone.js';
|
|
15
16
|
function inferMode(view) {
|
|
16
17
|
if (view.startsWith('day'))
|
|
17
18
|
return 'day';
|
|
@@ -52,11 +53,12 @@ function computeRange(focus, mode, mondayStart, dayCount = 7) {
|
|
|
52
53
|
};
|
|
53
54
|
}
|
|
54
55
|
export function createViewState(options = {}) {
|
|
56
|
+
const timezone = options.timezone;
|
|
57
|
+
const today = () => (timezone ? nowInZone(timezone) : new Date());
|
|
55
58
|
let view = $state(options.view ?? 'week-planner');
|
|
56
|
-
let focusDate = $state(options.initialDate ??
|
|
59
|
+
let focusDate = $state(options.initialDate ?? today());
|
|
57
60
|
let mondayStart = $state(options.mondayStart ?? true);
|
|
58
61
|
let dayCount = $state(options.dayCount ?? 7);
|
|
59
|
-
const timezone = options.timezone;
|
|
60
62
|
const modeResolver = options.modeForView;
|
|
61
63
|
const mode = $derived(modeResolver?.(view) ?? inferMode(view));
|
|
62
64
|
const range = $derived(computeRange(focusDate, mode, mondayStart, dayCount));
|
|
@@ -112,7 +114,7 @@ export function createViewState(options = {}) {
|
|
|
112
114
|
focusDate = new Date(addDaysMs(focusDate.getTime(), days));
|
|
113
115
|
},
|
|
114
116
|
goToday() {
|
|
115
|
-
focusDate =
|
|
117
|
+
focusDate = today();
|
|
116
118
|
},
|
|
117
119
|
};
|
|
118
120
|
}
|
|
@@ -9,6 +9,13 @@ export interface AgendaOptions {
|
|
|
9
9
|
locale?: string;
|
|
10
10
|
/** Number of days to load ahead of focus date for the upcoming list (default: 7) */
|
|
11
11
|
lookahead?: number;
|
|
12
|
+
/**
|
|
13
|
+
* Show the agenda in an IANA timezone (e.g. 'Europe/Warsaw'), as the
|
|
14
|
+
* Calendar's `timezone` prop does: the day, "now" and every event Date are
|
|
15
|
+
* wall-clock values in that zone. `initialDate` and `setDate()` take real
|
|
16
|
+
* instants. Default: the viewer's zone.
|
|
17
|
+
*/
|
|
18
|
+
timezone?: string;
|
|
12
19
|
}
|
|
13
20
|
export interface HeadlessAgenda {
|
|
14
21
|
/** The focused date */
|
|
@@ -29,7 +36,7 @@ export interface HeadlessAgenda {
|
|
|
29
36
|
readonly dayEvents: TimelineEvent[];
|
|
30
37
|
/** All-day or multi-day events */
|
|
31
38
|
readonly allDay: TimelineEvent[];
|
|
32
|
-
/** Timed events that have ended
|
|
39
|
+
/** Timed events that have ended — measured against now, so every event of a past day and none of a future one */
|
|
33
40
|
readonly past: TimelineEvent[];
|
|
34
41
|
/** Timed events currently in progress */
|
|
35
42
|
readonly current: TimelineEvent[];
|
|
@@ -33,29 +33,47 @@
|
|
|
33
33
|
import { untrack } from 'svelte';
|
|
34
34
|
import { createEventStore } from '../engine/event-store.svelte.js';
|
|
35
35
|
import { createClock } from '../core/clock.svelte.js';
|
|
36
|
-
import { sod, addDaysMs, isAllDay, isMultiDay } from '../core/time.js';
|
|
36
|
+
import { sod, addDaysMs, isAllDay, isMultiDay, overlapsRange } from '../core/time.js';
|
|
37
|
+
import { toZonedTime, wrapAdapterWithTimezone } from '../core/timezone.js';
|
|
37
38
|
import { fmtTime as _fmtTime, fmtDuration } from '../core/locale.js';
|
|
38
39
|
import { timeUntilMs, progress as _progress, groupIntoSlots } from '../views/shared/format.js';
|
|
39
40
|
// ─── Implementation ─────────────────────────────────────
|
|
40
41
|
export function createAgenda(options) {
|
|
41
|
-
const { initialDate, locale, lookahead = 7, } = options;
|
|
42
|
-
const
|
|
42
|
+
const { initialDate, locale, lookahead = 7, timezone, } = options;
|
|
43
|
+
const rawAdapter = typeof options.adapter === 'function'
|
|
43
44
|
? options.adapter
|
|
44
45
|
: () => options.adapter;
|
|
45
|
-
const
|
|
46
|
-
|
|
46
|
+
const resolveAdapter = timezone
|
|
47
|
+
? () => wrapAdapterWithTimezone(rawAdapter(), timezone)
|
|
48
|
+
: rawAdapter;
|
|
49
|
+
/** An instant on the plane the day is drawn on (zoned wall-clock with `timezone`). */
|
|
50
|
+
const plane = (ms) => (timezone ? toZonedTime(ms, timezone).getTime() : ms);
|
|
51
|
+
// One store for the agenda's life: a getter adapter that changes identity
|
|
52
|
+
// reloads it instead of rebuilding it empty (every row blinking out).
|
|
53
|
+
const store = createEventStore(resolveAdapter);
|
|
54
|
+
const clock = createClock(timezone);
|
|
47
55
|
// ── Focus date (reactive, writable) ──
|
|
48
|
-
let focusDayMs = $state(sod(initialDate
|
|
56
|
+
let focusDayMs = $state(sod(initialDate ? plane(initialDate.getTime()) : clock.tick));
|
|
49
57
|
// ── Load events for focus date range ──
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
58
|
+
// Eager initial load for synchronous adapters (the server render holds
|
|
59
|
+
// the rows); the effect's first run then has nothing new to fetch — a
|
|
60
|
+
// seeded adapter would otherwise refetch the seed at once.
|
|
61
|
+
let eagerKey = untrack(() => {
|
|
62
|
+
if (!resolveAdapter().fetchEventsSync)
|
|
63
|
+
return '';
|
|
64
|
+
store.load({ start: new Date(focusDayMs), end: new Date(addDaysMs(focusDayMs, lookahead)) });
|
|
65
|
+
return `${focusDayMs}`;
|
|
54
66
|
});
|
|
55
|
-
|
|
56
|
-
|
|
67
|
+
$effect(() => {
|
|
68
|
+
rawAdapter(); // a new adapter reloads
|
|
57
69
|
const start = new Date(focusDayMs);
|
|
58
70
|
const end = new Date(addDaysMs(focusDayMs, lookahead));
|
|
71
|
+
const key = `${focusDayMs}`;
|
|
72
|
+
if (key === eagerKey) {
|
|
73
|
+
eagerKey = '';
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
eagerKey = '';
|
|
59
77
|
store.load({ start, end });
|
|
60
78
|
});
|
|
61
79
|
// ── Day derivations ──
|
|
@@ -70,7 +88,7 @@ export function createAgenda(options) {
|
|
|
70
88
|
}));
|
|
71
89
|
const dayEvents = $derived.by(() => {
|
|
72
90
|
return store.events
|
|
73
|
-
.filter((ev) => ev
|
|
91
|
+
.filter((ev) => overlapsRange(ev, new Date(focusDayMs), new Date(dayEnd)))
|
|
74
92
|
.sort((a, b) => a.start.getTime() - b.start.getTime());
|
|
75
93
|
});
|
|
76
94
|
const allDay = $derived(dayEvents.filter((ev) => isAllDay(ev) || isMultiDay(ev)));
|
|
@@ -96,7 +114,7 @@ export function createAgenda(options) {
|
|
|
96
114
|
function prev() { focusDayMs = addDaysMs(focusDayMs, -1); }
|
|
97
115
|
function next() { focusDayMs = addDaysMs(focusDayMs, 1); }
|
|
98
116
|
function goToday() { focusDayMs = clock.today; }
|
|
99
|
-
function setDate(date) { focusDayMs = sod(date.getTime()); }
|
|
117
|
+
function setDate(date) { focusDayMs = sod(plane(date.getTime())); }
|
|
100
118
|
// ── Format helpers ──
|
|
101
119
|
const fmtTime = (d) => _fmtTime(d, locale);
|
|
102
120
|
const fmtDur = (ev) => fmtDuration(ev.start, ev.end);
|
|
@@ -43,23 +43,25 @@ import { untrack } from 'svelte';
|
|
|
43
43
|
import { createEventStore } from '../engine/event-store.svelte.js';
|
|
44
44
|
import { createViewState } from '../engine/view-state.svelte.js';
|
|
45
45
|
import { toZonedTime, fromZonedTime, wrapAdapterWithTimezone } from '../core/timezone.js';
|
|
46
|
+
import { isReadOnlyError, isNotFoundError } from '../adapters/errors.js';
|
|
46
47
|
import { createSelection } from '../engine/selection.svelte.js';
|
|
47
48
|
import { createDragState } from '../engine/drag.svelte.js';
|
|
48
49
|
import { createClock } from '../core/clock.svelte.js';
|
|
49
50
|
import { sod, addDaysMs, HOUR_MS, startOfWeek as sowFn, isAllDay, isMultiDay, segmentForDay, } from '../core/time.js';
|
|
50
|
-
import { monthLong, weekdayLong, weekdayShort } from '../core/locale.js';
|
|
51
|
+
import { monthLong, weekdayLong, weekdayShort, fmtWeekRange, getDefaultLocale } from '../core/locale.js';
|
|
51
52
|
export function createCalendar(options) {
|
|
52
53
|
const { adapter, mondayStart: initialMondayStart = true, initialDate: rawInitialDate, locale, visibleHours, snapInterval = 15, equalDays = false, hideDays, blockedSlots, disabledDates, days: initialDayCount = 7, readOnly = false, minDuration, maxDuration, oneventclick, oneventcreate, oneventmove, } = options;
|
|
53
54
|
// ── Create engine ────────────────────────────────────
|
|
54
55
|
const timezone = options.timezone;
|
|
55
56
|
const initialDate = rawInitialDate && timezone ? toZonedTime(rawInitialDate, timezone) : rawInitialDate;
|
|
56
|
-
const
|
|
57
|
+
const effectiveAdapter = timezone ? wrapAdapterWithTimezone(adapter, timezone) : adapter;
|
|
58
|
+
const store = createEventStore(effectiveAdapter);
|
|
57
59
|
const viewState = createViewState({
|
|
58
60
|
view: options.view ?? 'week-planner',
|
|
59
61
|
mondayStart: initialMondayStart,
|
|
60
62
|
initialDate,
|
|
61
63
|
dayCount: initialDayCount,
|
|
62
|
-
|
|
64
|
+
timezone,
|
|
63
65
|
});
|
|
64
66
|
const selection = createSelection();
|
|
65
67
|
const drag = createDragState();
|
|
@@ -71,12 +73,25 @@ export function createCalendar(options) {
|
|
|
71
73
|
const endHour = visibleHours?.[1] ?? 24;
|
|
72
74
|
const hours = Array.from({ length: endHour - startHour }, (_, i) => startHour + i);
|
|
73
75
|
// ── Load events reactively ───────────────────────────
|
|
76
|
+
// Eager initial load for synchronous adapters (the server render holds the
|
|
77
|
+
// events); the effect's first run then has nothing new to fetch.
|
|
78
|
+
let eagerKey = untrack(() => {
|
|
79
|
+
if (!effectiveAdapter.fetchEventsSync)
|
|
80
|
+
return '';
|
|
81
|
+
const { start, end } = viewState.range;
|
|
82
|
+
store.load({ start, end });
|
|
83
|
+
return `${start.getTime()}-${end.getTime()}`;
|
|
84
|
+
});
|
|
74
85
|
$effect(() => {
|
|
75
86
|
const { start, end } = viewState.range;
|
|
87
|
+
const key = `${start.getTime()}-${end.getTime()}`;
|
|
88
|
+
if (key === eagerKey) {
|
|
89
|
+
eagerKey = '';
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
eagerKey = '';
|
|
76
93
|
store.load({ start, end });
|
|
77
94
|
});
|
|
78
|
-
// Eager initial load
|
|
79
|
-
untrack(() => store.load({ start: viewState.range.start, end: viewState.range.end }));
|
|
80
95
|
// ── Compute day cells ────────────────────────────────
|
|
81
96
|
const days = $derived.by(() => {
|
|
82
97
|
const { start, end } = viewState.range;
|
|
@@ -165,21 +180,36 @@ export function createCalendar(options) {
|
|
|
165
180
|
}
|
|
166
181
|
return { past, current, upcoming };
|
|
167
182
|
});
|
|
183
|
+
// "Now" on the same (zoned) plane as the range. A month view's range spills
|
|
184
|
+
// into the neighbouring months, so it compares the focused month instead.
|
|
185
|
+
function includesToday() {
|
|
186
|
+
const now = clock.tick;
|
|
187
|
+
if (viewState.mode === 'month') {
|
|
188
|
+
const f = viewState.focusDate;
|
|
189
|
+
const n = new Date(now);
|
|
190
|
+
return f.getMonth() === n.getMonth() && f.getFullYear() === n.getFullYear();
|
|
191
|
+
}
|
|
192
|
+
const { start, end } = viewState.range;
|
|
193
|
+
return now >= start.getTime() && now < end.getTime();
|
|
194
|
+
}
|
|
168
195
|
// ── Header context ───────────────────────────────────
|
|
169
196
|
const headerContext = $derived.by(() => {
|
|
170
197
|
const mode = viewState.mode;
|
|
171
198
|
const focus = viewState.focusDate;
|
|
172
|
-
const
|
|
173
|
-
const
|
|
174
|
-
const isViewOnToday = now >= start.getTime() && now < end.getTime();
|
|
199
|
+
const isViewOnToday = includesToday();
|
|
200
|
+
const loc = locale ?? getDefaultLocale();
|
|
175
201
|
let dateLabel;
|
|
176
202
|
if (mode === 'day') {
|
|
177
|
-
dateLabel = focus.toLocaleDateString(
|
|
203
|
+
dateLabel = focus.toLocaleDateString(loc, {
|
|
178
204
|
weekday: 'long', month: 'short', day: 'numeric',
|
|
179
205
|
});
|
|
180
206
|
}
|
|
207
|
+
else if (mode === 'week') {
|
|
208
|
+
const { start, end } = viewState.range;
|
|
209
|
+
dateLabel = fmtWeekRange(start.getTime(), loc, end.getTime() - 1);
|
|
210
|
+
}
|
|
181
211
|
else {
|
|
182
|
-
dateLabel = focus.toLocaleDateString(
|
|
212
|
+
dateLabel = focus.toLocaleDateString(loc, {
|
|
183
213
|
month: 'long', year: 'numeric',
|
|
184
214
|
});
|
|
185
215
|
}
|
|
@@ -189,7 +219,16 @@ export function createCalendar(options) {
|
|
|
189
219
|
modes: ['day', 'week', 'month'],
|
|
190
220
|
switchMode: (m) => {
|
|
191
221
|
const currentView = viewState.view;
|
|
192
|
-
const currentLabel = currentView.replace(/^(day|week)-/, '');
|
|
222
|
+
const currentLabel = currentView.replace(/^(day|week|month)-/, '');
|
|
223
|
+
// Month has one layout of its own; leaving it lands on the planner.
|
|
224
|
+
if (m === 'month') {
|
|
225
|
+
viewState.setView('month-grid');
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
if (currentView.startsWith('month-')) {
|
|
229
|
+
viewState.setView(`${m}-planner`);
|
|
230
|
+
return;
|
|
231
|
+
}
|
|
193
232
|
viewState.setView(`${m}-${currentLabel}`);
|
|
194
233
|
},
|
|
195
234
|
prev: () => viewState.prev(),
|
|
@@ -200,13 +239,11 @@ export function createCalendar(options) {
|
|
|
200
239
|
};
|
|
201
240
|
});
|
|
202
241
|
const navigationContext = $derived.by(() => {
|
|
203
|
-
const { start, end } = viewState.range;
|
|
204
|
-
const now = Date.now();
|
|
205
242
|
return {
|
|
206
243
|
prev: () => viewState.prev(),
|
|
207
244
|
next: () => viewState.next(),
|
|
208
245
|
goToday: () => viewState.goToday(),
|
|
209
|
-
isViewOnToday:
|
|
246
|
+
isViewOnToday: includesToday(),
|
|
210
247
|
focusDate: viewState.focusDate,
|
|
211
248
|
mode: viewState.mode,
|
|
212
249
|
};
|
|
@@ -288,15 +325,14 @@ export function createCalendar(options) {
|
|
|
288
325
|
oneventmove?.(ev, unzoneDate(start), unzoneDate(end));
|
|
289
326
|
}
|
|
290
327
|
catch (e) {
|
|
291
|
-
const msg = e instanceof Error ? e.message : '';
|
|
292
328
|
// Read-only adapter: the host still gets the callback — it owns
|
|
293
329
|
// persistence and refetches.
|
|
294
|
-
if (
|
|
330
|
+
if (isReadOnlyError(e)) {
|
|
295
331
|
const ev = store.byId(payload.eventId);
|
|
296
332
|
if (ev)
|
|
297
333
|
oneventmove?.(ev, unzoneDate(start), unzoneDate(end));
|
|
298
334
|
}
|
|
299
|
-
else if (!
|
|
335
|
+
else if (!isNotFoundError(e)) {
|
|
300
336
|
console.warn('[calendar] drag commit failed:', e);
|
|
301
337
|
}
|
|
302
338
|
return null;
|
|
@@ -12,6 +12,13 @@ export interface RangeAgendaOptions {
|
|
|
12
12
|
initialDate?: Date;
|
|
13
13
|
/** BCP 47 locale tag (e.g. 'en-US', 'pl-PL') for the format helpers */
|
|
14
14
|
locale?: string;
|
|
15
|
+
/**
|
|
16
|
+
* Show the agenda in an IANA timezone (e.g. 'Europe/Warsaw'), as the
|
|
17
|
+
* Calendar's `timezone` prop does: days, "today" and every event Date are
|
|
18
|
+
* wall-clock values in that zone (read them with `getHours()`, `getDate()`…).
|
|
19
|
+
* `initialDate` and `setDate()` take real instants. Default: the viewer's zone.
|
|
20
|
+
*/
|
|
21
|
+
timezone?: string;
|
|
15
22
|
}
|
|
16
23
|
export interface RangeAgendaDay {
|
|
17
24
|
/** Start-of-day timestamp (ms) */
|