@nomideusz/svelte-calendar 0.6.3 → 0.6.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/README.md +183 -5
  2. package/dist/adapters/composite.d.ts +15 -0
  3. package/dist/adapters/composite.js +65 -0
  4. package/dist/adapters/index.d.ts +5 -1
  5. package/dist/adapters/index.js +2 -0
  6. package/dist/adapters/mapped.d.ts +170 -0
  7. package/dist/adapters/mapped.js +343 -0
  8. package/dist/adapters/recurring.d.ts +4 -0
  9. package/dist/adapters/recurring.js +5 -10
  10. package/dist/adapters/types.d.ts +11 -2
  11. package/dist/calendar/Calendar.svelte +147 -76
  12. package/dist/calendar/Calendar.svelte.d.ts +12 -0
  13. package/dist/core/index.d.ts +3 -1
  14. package/dist/core/index.js +2 -0
  15. package/dist/core/measure.d.ts +104 -0
  16. package/dist/core/measure.js +195 -0
  17. package/dist/core/time.js +2 -2
  18. package/dist/core/types.d.ts +32 -0
  19. package/dist/engine/event-store.svelte.js +22 -7
  20. package/dist/headless/create-agenda.svelte.d.ts +67 -0
  21. package/dist/headless/create-agenda.svelte.js +139 -0
  22. package/dist/headless/create-calendar.svelte.d.ts +2 -0
  23. package/dist/headless/create-calendar.svelte.js +380 -0
  24. package/dist/headless/index.d.ts +4 -0
  25. package/dist/headless/index.js +2 -0
  26. package/dist/headless/types.d.ts +219 -0
  27. package/dist/headless/types.js +1 -0
  28. package/dist/index.d.ts +6 -4
  29. package/dist/index.js +4 -2
  30. package/dist/primitives/EventBlock.svelte +87 -2
  31. package/dist/views/agenda/AgendaDay.svelte +183 -89
  32. package/dist/views/agenda/AgendaWeek.svelte +148 -95
  33. package/dist/views/mobile/MobileDay.svelte +43 -16
  34. package/dist/views/mobile/MobileWeek.svelte +32 -17
  35. package/dist/views/planner/PlannerDay.svelte +91 -36
  36. package/dist/views/planner/PlannerWeek.svelte +107 -132
  37. package/dist/views/shared/context.svelte.d.ts +47 -0
  38. package/dist/views/shared/context.svelte.js +47 -0
  39. package/dist/views/shared/format.d.ts +19 -0
  40. package/dist/views/shared/format.js +46 -0
  41. package/dist/views/shared/index.d.ts +4 -0
  42. package/dist/views/shared/index.js +2 -0
  43. package/package.json +76 -71
@@ -0,0 +1,343 @@
1
+ import { VIVID_PALETTE } from '../core/palette.js';
2
+ // ── Internal helpers ────────────────────────────────────
3
+ let counter = 0;
4
+ function uid() {
5
+ return `mapped-${Date.now()}-${++counter}`;
6
+ }
7
+ /** Parse a date/time value into a Date object */
8
+ function toDate(value) {
9
+ if (value instanceof Date)
10
+ return value;
11
+ if (typeof value === 'number')
12
+ return new Date(value);
13
+ if (typeof value === 'string') {
14
+ const d = new Date(value);
15
+ if (isNaN(d.getTime())) {
16
+ throw new Error(`Cannot parse date: "${value}"`);
17
+ }
18
+ return d;
19
+ }
20
+ throw new Error(`Cannot convert to Date: ${typeof value}`);
21
+ }
22
+ /** Combine "YYYY-MM-DD" date and "HH:MM" time into a Date */
23
+ function combineDateAndTime(dateStr, timeStr) {
24
+ // Handle various date formats
25
+ const datePart = dateStr.includes('T') ? dateStr.split('T')[0] : dateStr;
26
+ const d = new Date(`${datePart}T${timeStr}:00`);
27
+ if (isNaN(d.getTime())) {
28
+ throw new Error(`Cannot combine date "${dateStr}" and time "${timeStr}"`);
29
+ }
30
+ return d;
31
+ }
32
+ /** Coerce a source value into an EventStatus */
33
+ function coerceStatus(value, fieldName) {
34
+ if (typeof value === 'string') {
35
+ const lower = value.toLowerCase();
36
+ if (lower === 'cancelled' || lower === 'canceled')
37
+ return 'cancelled';
38
+ if (lower === 'tentative')
39
+ return 'tentative';
40
+ if (lower === 'full')
41
+ return 'full';
42
+ if (lower === 'limited')
43
+ return 'limited';
44
+ return 'confirmed';
45
+ }
46
+ if (typeof value === 'boolean') {
47
+ // E.g. is_cancelled: true → 'cancelled'
48
+ const lowerField = fieldName.toLowerCase();
49
+ if (lowerField.includes('cancel'))
50
+ return value ? 'cancelled' : 'confirmed';
51
+ if (lowerField.includes('tentative'))
52
+ return value ? 'tentative' : 'confirmed';
53
+ return value ? 'confirmed' : 'tentative';
54
+ }
55
+ return 'confirmed';
56
+ }
57
+ /** Parse an RGB string like "rgb(62, 101, 255)" to a hex color */
58
+ function normalizeColor(value) {
59
+ if (!value)
60
+ return undefined;
61
+ if (typeof value !== 'string')
62
+ return undefined;
63
+ const str = value.trim();
64
+ // Already hex
65
+ if (str.startsWith('#'))
66
+ return str;
67
+ // rgb(r, g, b) format
68
+ const rgbMatch = str.match(/^rgb\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*\)$/i);
69
+ if (rgbMatch) {
70
+ const r = parseInt(rgbMatch[1], 10);
71
+ const g = parseInt(rgbMatch[2], 10);
72
+ const b = parseInt(rgbMatch[3], 10);
73
+ return `#${((1 << 24) | (r << 16) | (g << 8) | b).toString(16).slice(1)}`;
74
+ }
75
+ // CSS named colors and other formats — pass through
76
+ return str;
77
+ }
78
+ /**
79
+ * Collect unmapped fields into the `data` payload.
80
+ */
81
+ function collectData(raw, mappedKeys, include) {
82
+ const data = {};
83
+ const keys = include === '*' ? Object.keys(raw) : include;
84
+ for (const key of keys) {
85
+ if (!mappedKeys.has(key) && raw[key] !== undefined) {
86
+ data[key] = raw[key];
87
+ }
88
+ }
89
+ return data;
90
+ }
91
+ // ── Factory ─────────────────────────────────────────────
92
+ /**
93
+ * Create a CalendarAdapter from an array of external records + mapping config.
94
+ *
95
+ * @example
96
+ * ```ts
97
+ * // Yoga school schedule
98
+ * const adapter = createMappedAdapter(yogaSchedule.events, {
99
+ * fields: {
100
+ * title: 'class_name',
101
+ * start: 'starts_at_iso',
102
+ * end: 'ends_at_iso',
103
+ * subtitle: 'teacher',
104
+ * location: 'room',
105
+ * color: 'color',
106
+ * externalId: 'reference_id',
107
+ * status: 'is_cancelled',
108
+ * tags: ['is_free', 'is_bookable_online'],
109
+ * },
110
+ * });
111
+ *
112
+ * // Medical appointments
113
+ * const adapter = createMappedAdapter(appointments, {
114
+ * fields: {
115
+ * title: 'procedure_name',
116
+ * start: 'scheduled_at',
117
+ * end: 'scheduled_end',
118
+ * subtitle: 'doctor_name',
119
+ * location: 'office',
120
+ * resourceId: 'doctor_id',
121
+ * },
122
+ * });
123
+ *
124
+ * // Full custom mapper
125
+ * const adapter = createMappedAdapter(rawData, {
126
+ * mapEvent: (raw) => ({
127
+ * id: raw.uid,
128
+ * title: `${raw.first_name} ${raw.last_name}`,
129
+ * start: new Date(raw.timestamp),
130
+ * end: new Date(raw.timestamp + raw.duration_ms),
131
+ * location: raw.room,
132
+ * }),
133
+ * });
134
+ * ```
135
+ */
136
+ export function createMappedAdapter(sourceData, options = {}) {
137
+ const { fields, mapEvent: customMapper, includeData = '*', autoColor = true, palette = VIVID_PALETTE, readOnly = true, } = options;
138
+ // Build the internal event list
139
+ const events = [];
140
+ const colorAssignments = new Map();
141
+ let colorIndex = 0;
142
+ function resolveColor(ev) {
143
+ // When autoColor is on, always assign from palette (ignore source color)
144
+ if (!autoColor && ev.color)
145
+ return ev.color;
146
+ const key = ev.category ?? ev.title;
147
+ if (!colorAssignments.has(key)) {
148
+ colorAssignments.set(key, palette[colorIndex % palette.length]);
149
+ colorIndex++;
150
+ }
151
+ return colorAssignments.get(key);
152
+ }
153
+ function withColor(ev) {
154
+ const color = resolveColor(ev);
155
+ return color ? { ...ev, color } : ev;
156
+ }
157
+ /**
158
+ * Map a single raw record using the declarative field mapping.
159
+ */
160
+ function mapWithFields(raw, index) {
161
+ const f = fields;
162
+ const r = raw;
163
+ // ── ID ──
164
+ const id = f.id
165
+ ? String(r[f.id] ?? '')
166
+ : String(r['id'] ?? r['reference_id'] ?? r['externalId'] ?? r['_id'] ?? uid());
167
+ // ── Title ──
168
+ const titleKey = f.title ?? 'title';
169
+ const title = String(r[titleKey] ?? r['name'] ?? r['class_name'] ?? `Event ${index + 1}`);
170
+ // ── Start / End ──
171
+ let start;
172
+ let end;
173
+ if (f.start && r[f.start] !== undefined) {
174
+ start = toDate(r[f.start]);
175
+ }
176
+ else if (f.date && f.startTime && r[f.date] && r[f.startTime]) {
177
+ start = combineDateAndTime(String(r[f.date]), String(r[f.startTime]));
178
+ }
179
+ else if (r['start'] !== undefined) {
180
+ start = toDate(r['start']);
181
+ }
182
+ else if (r['starts_at_iso'] !== undefined) {
183
+ start = toDate(r['starts_at_iso']);
184
+ }
185
+ else if (r['start_time'] !== undefined && r['date'] !== undefined) {
186
+ start = combineDateAndTime(String(r['date']), String(r['start_time']));
187
+ }
188
+ else {
189
+ throw new Error(`Cannot determine start time for record ${index}: no matching field found`);
190
+ }
191
+ if (f.end && r[f.end] !== undefined) {
192
+ end = toDate(r[f.end]);
193
+ }
194
+ else if (f.date && f.endTime && r[f.date] && r[f.endTime]) {
195
+ end = combineDateAndTime(String(r[f.date]), String(r[f.endTime]));
196
+ }
197
+ else if (r['end'] !== undefined) {
198
+ end = toDate(r['end']);
199
+ }
200
+ else if (r['ends_at_iso'] !== undefined) {
201
+ end = toDate(r['ends_at_iso']);
202
+ }
203
+ else if (r['end_time'] !== undefined && r['date'] !== undefined) {
204
+ end = combineDateAndTime(String(r['date']), String(r['end_time']));
205
+ }
206
+ else {
207
+ // Default: 1 hour after start
208
+ end = new Date(start.getTime() + 60 * 60 * 1000);
209
+ }
210
+ // ── Optional fields ──
211
+ const color = normalizeColor(r[f.color ?? 'color']);
212
+ const subtitle = f.subtitle ? String(r[f.subtitle] ?? '') || undefined : undefined;
213
+ const location = f.location
214
+ ? String(r[f.location] ?? '') || undefined
215
+ : r['room'] ? String(r['room']) : r['location'] ? String(r['location']) : undefined;
216
+ const category = f.category ? String(r[f.category] ?? '') || undefined : undefined;
217
+ const resourceId = f.resourceId ? String(r[f.resourceId] ?? '') || undefined : undefined;
218
+ const externalId = f.externalId
219
+ ? String(r[f.externalId] ?? '') || undefined
220
+ : r['reference_id'] ? String(r['reference_id']) : undefined;
221
+ // ── Status ──
222
+ let status;
223
+ if (f.status && r[f.status] !== undefined) {
224
+ status = coerceStatus(r[f.status], f.status);
225
+ }
226
+ else if (r['is_cancelled'] !== undefined) {
227
+ status = coerceStatus(r['is_cancelled'], 'is_cancelled');
228
+ }
229
+ // ── Tags ──
230
+ let tags;
231
+ if (f.tags && f.tags.length > 0) {
232
+ tags = [];
233
+ for (const tagKey of f.tags) {
234
+ const val = r[tagKey];
235
+ if (val === true) {
236
+ // Convert key name to human-readable tag
237
+ tags.push(tagKey.replace(/^is_/, '').replace(/_/g, ' '));
238
+ }
239
+ else if (typeof val === 'string' && val) {
240
+ tags.push(val);
241
+ }
242
+ }
243
+ if (tags.length === 0)
244
+ tags = undefined;
245
+ }
246
+ // ── Data (unmapped fields) ──
247
+ const mappedKeys = new Set();
248
+ for (const v of Object.values(f)) {
249
+ if (typeof v === 'string')
250
+ mappedKeys.add(v);
251
+ if (Array.isArray(v))
252
+ v.forEach((k) => mappedKeys.add(k));
253
+ }
254
+ // Also mark auto-detected keys as mapped
255
+ for (const k of ['id', 'title', 'name', 'class_name', 'start', 'end',
256
+ 'starts_at_iso', 'ends_at_iso', 'start_time', 'end_time', 'date',
257
+ 'color', 'room', 'location', 'reference_id', 'is_cancelled']) {
258
+ mappedKeys.add(k);
259
+ }
260
+ const data = collectData(r, mappedKeys, includeData);
261
+ const ev = { id, title, start, end };
262
+ if (color)
263
+ ev.color = color;
264
+ if (subtitle)
265
+ ev.subtitle = subtitle;
266
+ if (location)
267
+ ev.location = location;
268
+ if (category)
269
+ ev.category = category;
270
+ if (resourceId)
271
+ ev.resourceId = resourceId;
272
+ if (externalId)
273
+ ev.externalId = externalId;
274
+ if (status && status !== 'confirmed')
275
+ ev.status = status;
276
+ if (tags)
277
+ ev.tags = tags;
278
+ if (Object.keys(data).length > 0)
279
+ ev.data = data;
280
+ return ev;
281
+ }
282
+ // ── Build events array ──
283
+ for (let i = 0; i < sourceData.length; i++) {
284
+ const mapper = customMapper ?? mapWithFields;
285
+ const ev = withColor(mapper(sourceData[i], i));
286
+ events.push(ev);
287
+ }
288
+ // ── Adapter interface ──
289
+ function overlaps(ev, range) {
290
+ return ev.start < range.end && ev.end > range.start;
291
+ }
292
+ return {
293
+ async fetchEvents(range) {
294
+ return events.filter((ev) => overlaps(ev, range));
295
+ },
296
+ async createEvent(data) {
297
+ if (readOnly) {
298
+ throw new Error('Mapped adapter is read-only. Set readOnly: false and provide onMutate to enable writes.');
299
+ }
300
+ const handler = options.onMutate?.onCreate;
301
+ if (handler) {
302
+ const raw = await handler(data);
303
+ const mapper = customMapper ?? mapWithFields;
304
+ const ev = withColor(mapper(raw, events.length));
305
+ events.push(ev);
306
+ return ev;
307
+ }
308
+ // Fallback: create locally
309
+ const ev = { ...data, id: uid() };
310
+ events.push(withColor(ev));
311
+ return ev;
312
+ },
313
+ async updateEvent(id, patch) {
314
+ if (readOnly) {
315
+ throw new Error('Mapped adapter is read-only. Set readOnly: false and provide onMutate to enable writes.');
316
+ }
317
+ const idx = events.findIndex((e) => e.id === id);
318
+ if (idx < 0)
319
+ throw new Error(`Event not found: ${id}`);
320
+ const handler = options.onMutate?.onUpdate;
321
+ if (handler) {
322
+ const raw = await handler(id, patch);
323
+ const mapper = customMapper ?? mapWithFields;
324
+ const ev = withColor(mapper(raw, idx));
325
+ events[idx] = ev;
326
+ return ev;
327
+ }
328
+ events[idx] = { ...events[idx], ...patch, id };
329
+ return events[idx];
330
+ },
331
+ async deleteEvent(id) {
332
+ if (readOnly) {
333
+ throw new Error('Mapped adapter is read-only. Set readOnly: false and provide onMutate to enable writes.');
334
+ }
335
+ const handler = options.onMutate?.onDelete;
336
+ if (handler)
337
+ await handler(id);
338
+ const idx = events.findIndex((e) => e.id === id);
339
+ if (idx >= 0)
340
+ events.splice(idx, 1);
341
+ },
342
+ };
343
+ }
@@ -57,6 +57,10 @@ export interface RecurringEvent {
57
57
  tags?: string[];
58
58
  /** Category for grouping */
59
59
  category?: string;
60
+ /** Location or room name */
61
+ location?: string;
62
+ /** Resource ID for multi-resource views */
63
+ resourceId?: string;
60
64
  /** Arbitrary payload */
61
65
  data?: Record<string, unknown>;
62
66
  }
@@ -68,7 +68,9 @@ function createConcreteEvent(rec, date) {
68
68
  category: rec.category,
69
69
  subtitle: rec.subtitle,
70
70
  tags: rec.tags,
71
- data: { ...rec.data, recurringId: rec.id },
71
+ location: rec.location,
72
+ resourceId: rec.resourceId,
73
+ data: { ...rec.data, recurringId: rec.id, readOnly: true },
72
74
  };
73
75
  }
74
76
  // ── Count → until resolution ────────────────────────────
@@ -283,14 +285,7 @@ export function createRecurringAdapter(schedule, options = {}) {
283
285
  }
284
286
  return events;
285
287
  },
286
- async createEvent() {
287
- throw new Error('createRecurringAdapter is read-only. Use a memory or REST adapter for mutations.');
288
- },
289
- async updateEvent() {
290
- throw new Error('createRecurringAdapter is read-only. Use a memory or REST adapter for mutations.');
291
- },
292
- async deleteEvent() {
293
- throw new Error('createRecurringAdapter is read-only. Use a memory or REST adapter for mutations.');
294
- },
288
+ // Read-only adapter: CRUD methods intentionally omitted.
289
+ // Use createMemoryAdapter or createRestAdapter for mutations.
295
290
  };
296
291
  }
@@ -15,9 +15,18 @@ export interface CalendarAdapter {
15
15
  /** Fetch events that overlap the given date range */
16
16
  fetchEvents(range: DateRange): Promise<TimelineEvent[]>;
17
17
  /** Create a new event, return it with a server-assigned ID */
18
- createEvent(event: Omit<TimelineEvent, 'id'>): Promise<TimelineEvent>;
18
+ createEvent?(event: Omit<TimelineEvent, 'id'>): Promise<TimelineEvent>;
19
19
  /** Update an event, return the full updated event */
20
- updateEvent(id: string, patch: Partial<TimelineEvent>): Promise<TimelineEvent>;
20
+ updateEvent?(id: string, patch: Partial<TimelineEvent>): Promise<TimelineEvent>;
21
21
  /** Delete an event by ID */
22
+ deleteEvent?(id: string): Promise<void>;
23
+ }
24
+ /**
25
+ * A CalendarAdapter that supports full CRUD operations.
26
+ * Use this type when you need to guarantee write support.
27
+ */
28
+ export interface WritableCalendarAdapter extends CalendarAdapter {
29
+ createEvent(event: Omit<TimelineEvent, 'id'>): Promise<TimelineEvent>;
30
+ updateEvent(id: string, patch: Partial<TimelineEvent>): Promise<TimelineEvent>;
22
31
  deleteEvent(id: string): Promise<void>;
23
32
  }