@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
|
@@ -1,14 +1,25 @@
|
|
|
1
1
|
import type { CalendarAdapter } from './types.js';
|
|
2
2
|
export interface CompositeAdapterOptions {
|
|
3
3
|
/**
|
|
4
|
-
* Index of the adapter that handles
|
|
4
|
+
* Index of the adapter that handles `createEvent`.
|
|
5
5
|
* Defaults to `0` (the first adapter).
|
|
6
6
|
*/
|
|
7
7
|
primaryIndex?: number;
|
|
8
|
+
/**
|
|
9
|
+
* Called when one child's `fetchEvents` fails. With it, the others'
|
|
10
|
+
* events still load and the failure is yours to show; without it, one
|
|
11
|
+
* failing child fails the whole load (the calendar's `onerror`).
|
|
12
|
+
*/
|
|
13
|
+
onFetchError?: (error: unknown, adapterIndex: number) => void;
|
|
8
14
|
}
|
|
9
15
|
/**
|
|
10
16
|
* Create a CalendarAdapter that merges events from multiple child adapters.
|
|
11
17
|
*
|
|
18
|
+
* Updates and deletes go to the child that returned the event. An event from
|
|
19
|
+
* a child that cannot write it is refused with a `read-only` error — the
|
|
20
|
+
* calendar then hands the move to the host (`oneventmove`) instead of
|
|
21
|
+
* dropping it.
|
|
22
|
+
*
|
|
12
23
|
* @param adapters Array of child adapters to merge.
|
|
13
24
|
* @param options Optional configuration.
|
|
14
25
|
*/
|
|
@@ -1,6 +1,13 @@
|
|
|
1
|
+
import { CalendarReadOnlyError, EventNotFoundError, isNotFoundError } from './errors.js';
|
|
2
|
+
import { overlapsRange } from '../core/time.js';
|
|
1
3
|
/**
|
|
2
4
|
* Create a CalendarAdapter that merges events from multiple child adapters.
|
|
3
5
|
*
|
|
6
|
+
* Updates and deletes go to the child that returned the event. An event from
|
|
7
|
+
* a child that cannot write it is refused with a `read-only` error — the
|
|
8
|
+
* calendar then hands the move to the host (`oneventmove`) instead of
|
|
9
|
+
* dropping it.
|
|
10
|
+
*
|
|
4
11
|
* @param adapters Array of child adapters to merge.
|
|
5
12
|
* @param options Optional configuration.
|
|
6
13
|
*/
|
|
@@ -8,63 +15,118 @@ export function createCompositeAdapter(adapters, options = {}) {
|
|
|
8
15
|
if (adapters.length === 0) {
|
|
9
16
|
throw new Error('createCompositeAdapter requires at least one adapter');
|
|
10
17
|
}
|
|
11
|
-
const { primaryIndex = 0 } = options;
|
|
18
|
+
const { primaryIndex = 0, onFetchError } = options;
|
|
12
19
|
if (primaryIndex < 0 || primaryIndex >= adapters.length) {
|
|
13
20
|
throw new Error(`primaryIndex ${primaryIndex} is out of range [0, ${adapters.length - 1}]`);
|
|
14
21
|
}
|
|
15
22
|
const primary = adapters[primaryIndex];
|
|
23
|
+
/** Each child's last successful fetch, shown while it is failing. */
|
|
24
|
+
const lastGood = [];
|
|
25
|
+
/** Which child returned each event id — where its writes go. */
|
|
26
|
+
const owner = new Map();
|
|
27
|
+
// Flatten and deduplicate by id (first occurrence wins)
|
|
28
|
+
function mergeBatches(results) {
|
|
29
|
+
const seen = new Set();
|
|
30
|
+
const merged = [];
|
|
31
|
+
results.forEach((batch, i) => {
|
|
32
|
+
for (const ev of batch ?? []) {
|
|
33
|
+
if (seen.has(ev.id))
|
|
34
|
+
continue;
|
|
35
|
+
seen.add(ev.id);
|
|
36
|
+
owner.set(ev.id, adapters[i]);
|
|
37
|
+
merged.push(ev);
|
|
38
|
+
}
|
|
39
|
+
});
|
|
40
|
+
return merged;
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Run a write on the child that owns `id`. An id no fetch has returned
|
|
44
|
+
* (created elsewhere, or a host calling the adapter directly) is offered
|
|
45
|
+
* to each writable child in turn; only a "not found" moves on.
|
|
46
|
+
*/
|
|
47
|
+
async function routeWrite(id, op, run) {
|
|
48
|
+
const known = owner.get(id);
|
|
49
|
+
if (known) {
|
|
50
|
+
if (!known[op])
|
|
51
|
+
throw new CalendarReadOnlyError(`read-only: the adapter that holds ${id} cannot write it`);
|
|
52
|
+
return run(known);
|
|
53
|
+
}
|
|
54
|
+
let readOnlyChild = false;
|
|
55
|
+
for (const adapter of adapters) {
|
|
56
|
+
if (!adapter[op]) {
|
|
57
|
+
readOnlyChild = true;
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
try {
|
|
61
|
+
return await run(adapter);
|
|
62
|
+
}
|
|
63
|
+
catch (e) {
|
|
64
|
+
// A refusal or a real failure is an answer; only "not mine" asks on.
|
|
65
|
+
if (!isNotFoundError(e))
|
|
66
|
+
throw e;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
if (readOnlyChild)
|
|
70
|
+
throw new CalendarReadOnlyError(`read-only: no writable adapter holds ${id}`);
|
|
71
|
+
throw new EventNotFoundError(id);
|
|
72
|
+
}
|
|
73
|
+
const canUpdate = adapters.some((a) => a.updateEvent);
|
|
74
|
+
const canDelete = adapters.some((a) => a.deleteEvent);
|
|
75
|
+
const allSync = adapters.every((a) => a.fetchEventsSync);
|
|
16
76
|
return {
|
|
17
77
|
async fetchEvents(range) {
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
const seen = new Set();
|
|
21
|
-
const merged = [];
|
|
22
|
-
for (const batch of results) {
|
|
23
|
-
for (const ev of batch) {
|
|
24
|
-
if (!seen.has(ev.id)) {
|
|
25
|
-
seen.add(ev.id);
|
|
26
|
-
merged.push(ev);
|
|
27
|
-
}
|
|
28
|
-
}
|
|
78
|
+
if (!onFetchError) {
|
|
79
|
+
return mergeBatches(await Promise.all(adapters.map((a) => a.fetchEvents(range))));
|
|
29
80
|
}
|
|
30
|
-
|
|
81
|
+
const settled = await Promise.allSettled(adapters.map((a) => a.fetchEvents(range)));
|
|
82
|
+
return mergeBatches(settled.map((r, i) => {
|
|
83
|
+
if (r.status === 'fulfilled') {
|
|
84
|
+
lastGood[i] = r.value;
|
|
85
|
+
return r.value;
|
|
86
|
+
}
|
|
87
|
+
onFetchError(r.reason, i);
|
|
88
|
+
// The store treats a load as the whole truth for its range: keep
|
|
89
|
+
// what this child last gave, or its events would vanish.
|
|
90
|
+
return (lastGood[i] ?? []).filter((ev) => overlapsRange(ev, range.start, range.end));
|
|
91
|
+
}));
|
|
31
92
|
},
|
|
93
|
+
// Answers at once only when every child does; otherwise the async path.
|
|
94
|
+
...(allSync
|
|
95
|
+
? {
|
|
96
|
+
fetchEventsSync(range) {
|
|
97
|
+
const results = [];
|
|
98
|
+
for (const a of adapters) {
|
|
99
|
+
const r = a.fetchEventsSync(range);
|
|
100
|
+
if (!r)
|
|
101
|
+
return undefined;
|
|
102
|
+
results.push(r);
|
|
103
|
+
}
|
|
104
|
+
return mergeBatches(results);
|
|
105
|
+
},
|
|
106
|
+
}
|
|
107
|
+
: {}),
|
|
32
108
|
// Create always goes to primary
|
|
33
|
-
...(primary.createEvent
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
continue;
|
|
41
|
-
try {
|
|
42
|
-
return await adapter.updateEvent(id, patch);
|
|
43
|
-
}
|
|
44
|
-
catch (e) {
|
|
45
|
-
// A refusal is an answer: the adapter that owns this event
|
|
46
|
-
// cannot write it, and asking the others would turn that
|
|
47
|
-
// into a meaningless "not found" the host cannot act on.
|
|
48
|
-
if (e instanceof Error && e.message.includes('read-only'))
|
|
49
|
-
throw e;
|
|
50
|
-
// Otherwise: not this adapter's event, try the next.
|
|
51
|
-
}
|
|
109
|
+
...(primary.createEvent
|
|
110
|
+
? {
|
|
111
|
+
async createEvent(event) {
|
|
112
|
+
const created = await primary.createEvent(event);
|
|
113
|
+
owner.set(created.id, primary);
|
|
114
|
+
return created;
|
|
115
|
+
},
|
|
52
116
|
}
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
if (!adapter.deleteEvent)
|
|
58
|
-
continue;
|
|
59
|
-
try {
|
|
60
|
-
await adapter.deleteEvent(id);
|
|
61
|
-
return;
|
|
62
|
-
}
|
|
63
|
-
catch {
|
|
64
|
-
// Event not in this adapter, try next
|
|
65
|
-
}
|
|
117
|
+
: {}),
|
|
118
|
+
...(canUpdate
|
|
119
|
+
? {
|
|
120
|
+
updateEvent: (id, patch) => routeWrite(id, 'updateEvent', (a) => a.updateEvent(id, patch)),
|
|
66
121
|
}
|
|
67
|
-
|
|
68
|
-
|
|
122
|
+
: {}),
|
|
123
|
+
...(canDelete
|
|
124
|
+
? {
|
|
125
|
+
async deleteEvent(id) {
|
|
126
|
+
await routeWrite(id, 'deleteEvent', (a) => a.deleteEvent(id));
|
|
127
|
+
owner.delete(id);
|
|
128
|
+
},
|
|
129
|
+
}
|
|
130
|
+
: {}),
|
|
69
131
|
};
|
|
70
132
|
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The two refusals the calendar tells apart when a write fails.
|
|
3
|
+
*
|
|
4
|
+
* Throw `CalendarReadOnlyError` from an adapter that holds an event but
|
|
5
|
+
* cannot store a change to it: the Calendar keeps the dragged block where it
|
|
6
|
+
* was dropped and hands the move to the host (`oneventmove`), which owns the
|
|
7
|
+
* write. Throw `EventNotFoundError` when an id is not yours: a composite
|
|
8
|
+
* adapter then asks its next child, and a drag of it ends quietly.
|
|
9
|
+
*
|
|
10
|
+
* Plain `Error`s whose message contains "read-only" / "not found" are read
|
|
11
|
+
* the same way, so adapters written before these classes keep working.
|
|
12
|
+
*/
|
|
13
|
+
export declare class CalendarReadOnlyError extends Error {
|
|
14
|
+
constructor(message?: string);
|
|
15
|
+
}
|
|
16
|
+
export declare class EventNotFoundError extends Error {
|
|
17
|
+
constructor(id: string);
|
|
18
|
+
}
|
|
19
|
+
/** Whether an adapter refused a write it could not store. */
|
|
20
|
+
export declare function isReadOnlyError(e: unknown): boolean;
|
|
21
|
+
/** Whether an adapter answered "not my event". */
|
|
22
|
+
export declare function isNotFoundError(e: unknown): boolean;
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The two refusals the calendar tells apart when a write fails.
|
|
3
|
+
*
|
|
4
|
+
* Throw `CalendarReadOnlyError` from an adapter that holds an event but
|
|
5
|
+
* cannot store a change to it: the Calendar keeps the dragged block where it
|
|
6
|
+
* was dropped and hands the move to the host (`oneventmove`), which owns the
|
|
7
|
+
* write. Throw `EventNotFoundError` when an id is not yours: a composite
|
|
8
|
+
* adapter then asks its next child, and a drag of it ends quietly.
|
|
9
|
+
*
|
|
10
|
+
* Plain `Error`s whose message contains "read-only" / "not found" are read
|
|
11
|
+
* the same way, so adapters written before these classes keep working.
|
|
12
|
+
*/
|
|
13
|
+
export class CalendarReadOnlyError extends Error {
|
|
14
|
+
constructor(message = 'read-only: this adapter cannot store changes') {
|
|
15
|
+
super(message.includes('read-only') ? message : `read-only: ${message}`);
|
|
16
|
+
this.name = 'CalendarReadOnlyError';
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
export class EventNotFoundError extends Error {
|
|
20
|
+
constructor(id) {
|
|
21
|
+
super(`Event not found: ${id}`);
|
|
22
|
+
this.name = 'EventNotFoundError';
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
/** Whether an adapter refused a write it could not store. */
|
|
26
|
+
export function isReadOnlyError(e) {
|
|
27
|
+
return e instanceof CalendarReadOnlyError || (e instanceof Error && /read-only/i.test(e.message));
|
|
28
|
+
}
|
|
29
|
+
/** Whether an adapter answered "not my event". */
|
|
30
|
+
export function isNotFoundError(e) {
|
|
31
|
+
return e instanceof EventNotFoundError || (e instanceof Error && /not found/i.test(e.message));
|
|
32
|
+
}
|
package/dist/adapters/index.d.ts
CHANGED
|
@@ -12,3 +12,4 @@ export { withInitialEvents } from './seeded.js';
|
|
|
12
12
|
export type { CompositeAdapterOptions } from './composite.js';
|
|
13
13
|
export { createJmapAdapter } from './jmap.js';
|
|
14
14
|
export type { JmapClient, JmapCalendarAdapterOptions } from './jmap.js';
|
|
15
|
+
export { CalendarReadOnlyError, EventNotFoundError, isReadOnlyError, isNotFoundError } from './errors.js';
|
package/dist/adapters/index.js
CHANGED
|
@@ -5,3 +5,4 @@ export { createMappedAdapter } from './mapped.js';
|
|
|
5
5
|
export { createCompositeAdapter } from './composite.js';
|
|
6
6
|
export { withInitialEvents } from './seeded.js';
|
|
7
7
|
export { createJmapAdapter } from './jmap.js';
|
|
8
|
+
export { CalendarReadOnlyError, EventNotFoundError, isReadOnlyError, isNotFoundError } from './errors.js';
|
package/dist/adapters/jmap.js
CHANGED
|
@@ -1,37 +1,46 @@
|
|
|
1
|
+
import { addDaysMs } from '../core/time.js';
|
|
1
2
|
export function createJmapAdapter(client, options) {
|
|
2
3
|
const getAccountId = options.getAccountId;
|
|
3
4
|
const timeZone = options.timeZone ?? 'Etc/UTC';
|
|
5
|
+
/**
|
|
6
|
+
* Parse an RFC 8601/JSCalendar duration into its calendar part (weeks and
|
|
7
|
+
* days — whole days on the wall clock, which a DST change makes 23 or 25
|
|
8
|
+
* hours long) and its exact part (hours, minutes, seconds).
|
|
9
|
+
*/
|
|
4
10
|
function parseIsoDuration(duration) {
|
|
5
|
-
const match = /^P(?:(\d+)D)?(?:T(?:(\d+)H)?(?:(\d+)M)?(?:(\d+(?:\.\d+)?)S)?)?$/i.exec(duration);
|
|
11
|
+
const match = /^P(?:(\d+)W)?(?:(\d+)D)?(?:T(?:(\d+)H)?(?:(\d+)M)?(?:(\d+(?:\.\d+)?)S)?)?$/i.exec(duration);
|
|
6
12
|
if (!match)
|
|
7
|
-
return 0;
|
|
8
|
-
const
|
|
9
|
-
const
|
|
10
|
-
const
|
|
11
|
-
const
|
|
12
|
-
|
|
13
|
+
return { days: 0, ms: 0 };
|
|
14
|
+
const weeks = Number(match[1] ?? 0);
|
|
15
|
+
const days = Number(match[2] ?? 0);
|
|
16
|
+
const hours = Number(match[3] ?? 0);
|
|
17
|
+
const minutes = Number(match[4] ?? 0);
|
|
18
|
+
const seconds = Number(match[5] ?? 0);
|
|
19
|
+
return { days: weeks * 7 + days, ms: ((hours * 60 + minutes) * 60 + seconds) * 1000 };
|
|
13
20
|
}
|
|
14
21
|
function parseEventStart(event) {
|
|
22
|
+
// An all-day event is floating: it sits on its date wherever the viewer
|
|
23
|
+
// is. utcStart would pin it to the server zone's midnight instead.
|
|
24
|
+
if (event.start && event.showWithoutTime) {
|
|
25
|
+
const [datePart] = event.start.split('T');
|
|
26
|
+
const [year, month, day] = datePart.split('-').map(Number);
|
|
27
|
+
return new Date(year, month - 1, day);
|
|
28
|
+
}
|
|
15
29
|
if (event.utcStart)
|
|
16
30
|
return new Date(event.utcStart);
|
|
17
|
-
if (event.start)
|
|
18
|
-
if (event.showWithoutTime) {
|
|
19
|
-
const [datePart] = event.start.split('T');
|
|
20
|
-
const [year, month, day] = datePart.split('-').map(Number);
|
|
21
|
-
return new Date(year, month - 1, day);
|
|
22
|
-
}
|
|
31
|
+
if (event.start)
|
|
23
32
|
return new Date(event.start);
|
|
24
|
-
}
|
|
25
33
|
return new Date();
|
|
26
34
|
}
|
|
27
35
|
function parseEventEnd(event, start) {
|
|
28
|
-
if (event.utcEnd)
|
|
36
|
+
if (event.utcEnd && !event.showWithoutTime)
|
|
29
37
|
return new Date(event.utcEnd);
|
|
30
38
|
if (event.duration) {
|
|
31
|
-
|
|
39
|
+
const { days, ms } = parseIsoDuration(event.duration);
|
|
40
|
+
return new Date(addDaysMs(start.getTime(), days) + ms);
|
|
32
41
|
}
|
|
33
42
|
if (event.showWithoutTime) {
|
|
34
|
-
return new Date(
|
|
43
|
+
return new Date(addDaysMs(start.getTime(), 1));
|
|
35
44
|
}
|
|
36
45
|
return new Date(start.getTime() + 60 * 60 * 1000);
|
|
37
46
|
}
|
|
@@ -106,7 +106,7 @@ export interface MappedAdapterOptions<T = Record<string, unknown>> {
|
|
|
106
106
|
* Color palette used for auto-coloring.
|
|
107
107
|
* Defaults to VIVID_PALETTE.
|
|
108
108
|
*/
|
|
109
|
-
palette?: string[];
|
|
109
|
+
palette?: readonly string[];
|
|
110
110
|
/**
|
|
111
111
|
* When true (default), create/update/delete throw an error.
|
|
112
112
|
* Set to false and provide `onMutate` to enable writes.
|
|
@@ -167,4 +167,4 @@ export interface MutationHandler<T = Record<string, unknown>> {
|
|
|
167
167
|
* });
|
|
168
168
|
* ```
|
|
169
169
|
*/
|
|
170
|
-
export declare function createMappedAdapter<T extends
|
|
170
|
+
export declare function createMappedAdapter<T extends object = Record<string, unknown>>(sourceData: T[], options?: MappedAdapterOptions<T>): CalendarAdapter;
|
package/dist/adapters/mapped.js
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { VIVID_PALETTE } from '../core/palette.js';
|
|
2
|
+
import { overlapsRange } from '../core/time.js';
|
|
3
|
+
import { CalendarReadOnlyError, EventNotFoundError } from './errors.js';
|
|
2
4
|
// ── Internal helpers ────────────────────────────────────
|
|
3
5
|
let counter = 0;
|
|
4
6
|
function uid() {
|
|
@@ -23,7 +25,10 @@ function toDate(value) {
|
|
|
23
25
|
function combineDateAndTime(dateStr, timeStr) {
|
|
24
26
|
// Handle various date formats
|
|
25
27
|
const datePart = dateStr.includes('T') ? dateStr.split('T')[0] : dateStr;
|
|
26
|
-
|
|
28
|
+
// "7:00", "07:00" and "07:00:00" all mean the same wall-clock time.
|
|
29
|
+
const t = /^(\d{1,2}):(\d{2})(?::(\d{2}))?/.exec(timeStr.trim());
|
|
30
|
+
const time = t ? `${t[1].padStart(2, '0')}:${t[2]}:${t[3] ?? '00'}` : timeStr;
|
|
31
|
+
const d = new Date(`${datePart}T${time}`);
|
|
27
32
|
if (isNaN(d.getTime())) {
|
|
28
33
|
throw new Error(`Cannot combine date "${dateStr}" and time "${timeStr}"`);
|
|
29
34
|
}
|
|
@@ -158,11 +163,11 @@ export function createMappedAdapter(sourceData, options = {}) {
|
|
|
158
163
|
* Map a single raw record using the declarative field mapping.
|
|
159
164
|
*/
|
|
160
165
|
function mapWithFields(raw, index) {
|
|
161
|
-
const f = fields;
|
|
166
|
+
const f = fields ?? {};
|
|
162
167
|
const r = raw;
|
|
163
168
|
// ── ID ──
|
|
164
|
-
const id = f.id
|
|
165
|
-
? String(r[f.id]
|
|
169
|
+
const id = f.id && r[f.id] != null
|
|
170
|
+
? String(r[f.id])
|
|
166
171
|
: String(r['id'] ?? r['reference_id'] ?? r['externalId'] ?? r['_id'] ?? uid());
|
|
167
172
|
// ── Title ──
|
|
168
173
|
const titleKey = f.title ?? 'title';
|
|
@@ -287,15 +292,19 @@ export function createMappedAdapter(sourceData, options = {}) {
|
|
|
287
292
|
}
|
|
288
293
|
// ── Adapter interface ──
|
|
289
294
|
function overlaps(ev, range) {
|
|
290
|
-
return ev.start
|
|
295
|
+
return overlapsRange(ev, range.start, range.end);
|
|
291
296
|
}
|
|
292
297
|
return {
|
|
298
|
+
// The records are in memory: answer at once, so a server render holds them.
|
|
299
|
+
fetchEventsSync(range) {
|
|
300
|
+
return events.filter((ev) => overlaps(ev, range));
|
|
301
|
+
},
|
|
293
302
|
async fetchEvents(range) {
|
|
294
303
|
return events.filter((ev) => overlaps(ev, range));
|
|
295
304
|
},
|
|
296
305
|
async createEvent(data) {
|
|
297
306
|
if (readOnly) {
|
|
298
|
-
throw new
|
|
307
|
+
throw new CalendarReadOnlyError('Mapped adapter is read-only. Set readOnly: false and provide onMutate to enable writes.');
|
|
299
308
|
}
|
|
300
309
|
const handler = options.onMutate?.onCreate;
|
|
301
310
|
if (handler) {
|
|
@@ -312,11 +321,11 @@ export function createMappedAdapter(sourceData, options = {}) {
|
|
|
312
321
|
},
|
|
313
322
|
async updateEvent(id, patch) {
|
|
314
323
|
if (readOnly) {
|
|
315
|
-
throw new
|
|
324
|
+
throw new CalendarReadOnlyError('Mapped adapter is read-only. Set readOnly: false and provide onMutate to enable writes.');
|
|
316
325
|
}
|
|
317
326
|
const idx = events.findIndex((e) => e.id === id);
|
|
318
327
|
if (idx < 0)
|
|
319
|
-
throw new
|
|
328
|
+
throw new EventNotFoundError(id);
|
|
320
329
|
const handler = options.onMutate?.onUpdate;
|
|
321
330
|
if (handler) {
|
|
322
331
|
const raw = await handler(id, patch);
|
|
@@ -330,7 +339,7 @@ export function createMappedAdapter(sourceData, options = {}) {
|
|
|
330
339
|
},
|
|
331
340
|
async deleteEvent(id) {
|
|
332
341
|
if (readOnly) {
|
|
333
|
-
throw new
|
|
342
|
+
throw new CalendarReadOnlyError('Mapped adapter is read-only. Set readOnly: false and provide onMutate to enable writes.');
|
|
334
343
|
}
|
|
335
344
|
const handler = options.onMutate?.onDelete;
|
|
336
345
|
if (handler)
|
|
@@ -18,6 +18,6 @@ export interface MemoryAdapterOptions {
|
|
|
18
18
|
* Defaults to VIVID_PALETTE. Pass `generatePalette(accent)` to
|
|
19
19
|
* make event colors adapt to your theme.
|
|
20
20
|
*/
|
|
21
|
-
palette?: string[];
|
|
21
|
+
palette?: readonly string[];
|
|
22
22
|
}
|
|
23
23
|
export declare function createMemoryAdapter(initial?: TimelineEvent[], options?: MemoryAdapterOptions): CalendarAdapter;
|
package/dist/adapters/memory.js
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { VIVID_PALETTE } from '../core/palette.js';
|
|
2
|
+
import { overlapsRange } from '../core/time.js';
|
|
3
|
+
import { EventNotFoundError } from './errors.js';
|
|
2
4
|
let counter = 0;
|
|
3
5
|
function uid() {
|
|
4
6
|
return `mem-${Date.now()}-${++counter}`;
|
|
@@ -24,7 +26,7 @@ export function createMemoryAdapter(initial = [], options) {
|
|
|
24
26
|
return color ? { ...ev, color } : ev;
|
|
25
27
|
}
|
|
26
28
|
function overlaps(ev, range) {
|
|
27
|
-
return ev.start
|
|
29
|
+
return overlapsRange(ev, range.start, range.end);
|
|
28
30
|
}
|
|
29
31
|
const fetchEventsSync = (range) => events.filter((ev) => overlaps(ev, range)).map(withColor);
|
|
30
32
|
return {
|
|
@@ -40,14 +42,14 @@ export function createMemoryAdapter(initial = [], options) {
|
|
|
40
42
|
async updateEvent(id, patch) {
|
|
41
43
|
const idx = events.findIndex((e) => e.id === id);
|
|
42
44
|
if (idx < 0)
|
|
43
|
-
throw new
|
|
45
|
+
throw new EventNotFoundError(id);
|
|
44
46
|
events[idx] = { ...events[idx], ...patch, id };
|
|
45
47
|
return withColor(events[idx]);
|
|
46
48
|
},
|
|
47
49
|
async deleteEvent(id) {
|
|
48
50
|
const idx = events.findIndex((e) => e.id === id);
|
|
49
51
|
if (idx < 0)
|
|
50
|
-
throw new
|
|
52
|
+
throw new EventNotFoundError(id);
|
|
51
53
|
events.splice(idx, 1);
|
|
52
54
|
},
|
|
53
55
|
};
|
|
@@ -79,7 +79,7 @@ export interface RecurringAdapterOptions {
|
|
|
79
79
|
* or provide your own array of hex strings.
|
|
80
80
|
* Defaults to the built-in vivid palette.
|
|
81
81
|
*/
|
|
82
|
-
palette?: string[];
|
|
82
|
+
palette?: readonly string[];
|
|
83
83
|
/**
|
|
84
84
|
* Let projected occurrences be dragged (default: `false`).
|
|
85
85
|
*
|
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import { startOfWeek, addDaysMs, diffDays, 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
|
-
/**
|
|
34
|
-
|
|
35
|
-
|
|
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
|
-
/**
|
|
38
|
-
|
|
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
|
-
|
|
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,7 +110,7 @@ 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 = addDaysMs(weekMs, isoWeekdayToOffset(day));
|
|
113
|
+
const dayMs = addDaysMs(weekMs, isoWeekdayToOffset(day, mondayStart));
|
|
104
114
|
if (dayMs >= startMs) {
|
|
105
115
|
remaining--;
|
|
106
116
|
if (remaining === 0)
|
|
@@ -112,8 +122,12 @@ function computeUntilFromCount(rec, startDateObj, mondayStart) {
|
|
|
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();
|
|
@@ -174,7 +188,7 @@ function projectWeekly(rec, range, startDate, effectiveUntil, mondayStart, out)
|
|
|
174
188
|
}
|
|
175
189
|
while (weekMs < rangeEndMs) {
|
|
176
190
|
for (const day of days) {
|
|
177
|
-
const dayMs = addDaysMs(weekMs, isoWeekdayToOffset(day));
|
|
191
|
+
const dayMs = addDaysMs(weekMs, isoWeekdayToOffset(day, mondayStart));
|
|
178
192
|
if (startDateMs != null && dayMs < startDateMs)
|
|
179
193
|
continue;
|
|
180
194
|
if (dayMs >= rangeEndMs)
|
|
@@ -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 (
|
|
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,
|
|
293
|
+
projectDaily(colored, projected, sd, effectiveUntil, events);
|
|
276
294
|
break;
|
|
277
295
|
case 'weekly':
|
|
278
|
-
projectWeekly(colored,
|
|
296
|
+
projectWeekly(colored, projected, sd, effectiveUntil, mondayStart, events);
|
|
279
297
|
break;
|
|
280
298
|
case 'monthly':
|
|
281
|
-
projectMonthly(colored,
|
|
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 =
|
|
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
|
|
324
|
+
throw new EventNotFoundError(id);
|
|
307
325
|
}
|
|
308
|
-
throw new
|
|
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
|
}
|
package/dist/adapters/rest.d.ts
CHANGED
|
@@ -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;
|