@nomideusz/svelte-calendar 0.6.3 → 0.6.5
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 +192 -10
- package/dist/adapters/composite.d.ts +15 -0
- package/dist/adapters/composite.js +65 -0
- package/dist/adapters/index.d.ts +5 -1
- package/dist/adapters/index.js +2 -0
- package/dist/adapters/mapped.d.ts +170 -0
- package/dist/adapters/mapped.js +343 -0
- package/dist/adapters/recurring.d.ts +4 -0
- package/dist/adapters/recurring.js +5 -10
- package/dist/adapters/types.d.ts +11 -2
- package/dist/assets/favicon.svg +9 -1
- package/dist/calendar/Calendar.svelte +161 -90
- package/dist/calendar/Calendar.svelte.d.ts +12 -0
- package/dist/core/index.d.ts +3 -1
- package/dist/core/index.js +2 -0
- package/dist/core/measure.d.ts +104 -0
- package/dist/core/measure.js +195 -0
- package/dist/core/time.js +2 -2
- package/dist/core/types.d.ts +32 -0
- package/dist/engine/event-store.svelte.js +22 -7
- package/dist/headless/create-agenda.svelte.d.ts +67 -0
- package/dist/headless/create-agenda.svelte.js +139 -0
- package/dist/headless/create-calendar.svelte.d.ts +2 -0
- package/dist/headless/create-calendar.svelte.js +380 -0
- package/dist/headless/index.d.ts +4 -0
- package/dist/headless/index.js +2 -0
- package/dist/headless/types.d.ts +219 -0
- package/dist/headless/types.js +1 -0
- package/dist/index.d.ts +6 -4
- package/dist/index.js +4 -2
- package/dist/primitives/DayHeader.svelte +6 -6
- package/dist/primitives/EmptySlot.svelte +3 -3
- package/dist/primitives/EventBlock.svelte +99 -14
- package/dist/primitives/NowIndicator.svelte +5 -5
- package/dist/views/agenda/AgendaDay.svelte +215 -116
- package/dist/views/agenda/AgendaWeek.svelte +166 -113
- package/dist/views/mobile/MobileDay.svelte +45 -21
- package/dist/views/mobile/MobileWeek.svelte +85 -35
- package/dist/views/planner/PlannerDay.svelte +116 -60
- package/dist/views/planner/PlannerWeek.svelte +228 -166
- package/dist/views/shared/context.svelte.d.ts +47 -0
- package/dist/views/shared/context.svelte.js +47 -0
- package/dist/views/shared/format.d.ts +19 -0
- package/dist/views/shared/format.js +46 -0
- package/dist/views/shared/index.d.ts +4 -0
- package/dist/views/shared/index.js +2 -0
- package/package.json +9 -2
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Text measurement utilities powered by Pretext.
|
|
3
|
+
*
|
|
4
|
+
* Provides DOM-free text measurement for calendar event blocks.
|
|
5
|
+
* Pretext is an optional peer dependency — if not installed,
|
|
6
|
+
* these functions degrade gracefully to simple heuristics.
|
|
7
|
+
*
|
|
8
|
+
* Usage:
|
|
9
|
+
* const m = createTextMeasure({ font: '500 12px Inter' });
|
|
10
|
+
* const fits = m.fits('Meeting with team', 120, 16); // width, lineHeight
|
|
11
|
+
* const { height, lineCount } = m.measure('Long event title...', 120, 16);
|
|
12
|
+
* m.clear(); // release caches
|
|
13
|
+
*/
|
|
14
|
+
const DEFAULTS = {
|
|
15
|
+
titleFont: '500 12px system-ui, sans-serif',
|
|
16
|
+
secondaryFont: '400 10px system-ui, sans-serif',
|
|
17
|
+
tagFont: '500 8px system-ui, sans-serif',
|
|
18
|
+
titleLineHeight: 16,
|
|
19
|
+
secondaryLineHeight: 13,
|
|
20
|
+
contentGap: 3,
|
|
21
|
+
};
|
|
22
|
+
/**
|
|
23
|
+
* Create a text measure instance.
|
|
24
|
+
* Lazily loads Pretext on first use. Falls back to char-width heuristics if unavailable.
|
|
25
|
+
*/
|
|
26
|
+
export function createTextMeasure(opts = {}) {
|
|
27
|
+
const config = { ...DEFAULTS, ...opts };
|
|
28
|
+
// Pretext module — loaded lazily
|
|
29
|
+
let pretext = null;
|
|
30
|
+
let loadAttempted = false;
|
|
31
|
+
let pretextAvailable = false;
|
|
32
|
+
// Cache prepared texts (font+text → prepared handle)
|
|
33
|
+
const cache = new Map();
|
|
34
|
+
function tryLoadPretext() {
|
|
35
|
+
if (loadAttempted)
|
|
36
|
+
return pretextAvailable;
|
|
37
|
+
loadAttempted = true;
|
|
38
|
+
try {
|
|
39
|
+
// Dynamic require/import for optional dependency
|
|
40
|
+
// This works because Pretext is a synchronous API
|
|
41
|
+
const mod = globalThis.__pretextModule;
|
|
42
|
+
if (mod) {
|
|
43
|
+
pretext = mod;
|
|
44
|
+
pretextAvailable = true;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
// Not available — that's fine
|
|
49
|
+
}
|
|
50
|
+
return pretextAvailable;
|
|
51
|
+
}
|
|
52
|
+
function getPrepared(text, font) {
|
|
53
|
+
const key = `${font}\0${text}`;
|
|
54
|
+
let prepared = cache.get(key);
|
|
55
|
+
if (!prepared) {
|
|
56
|
+
prepared = pretext.prepare(text, font);
|
|
57
|
+
cache.set(key, prepared);
|
|
58
|
+
}
|
|
59
|
+
return prepared;
|
|
60
|
+
}
|
|
61
|
+
// Heuristic fallback: estimate chars per line from average char width
|
|
62
|
+
function heuristicMeasure(text, maxWidth, lineHeight, font) {
|
|
63
|
+
// Rough average char widths by font size
|
|
64
|
+
const sizeMatch = font.match(/(\d+)px/);
|
|
65
|
+
const fontSize = sizeMatch ? parseInt(sizeMatch[1]) : 12;
|
|
66
|
+
const avgCharWidth = fontSize * 0.55; // rough average for Latin text
|
|
67
|
+
const charsPerLine = Math.max(1, Math.floor(maxWidth / avgCharWidth));
|
|
68
|
+
const lineCount = Math.max(1, Math.ceil(text.length / charsPerLine));
|
|
69
|
+
return { height: lineCount * lineHeight, lineCount };
|
|
70
|
+
}
|
|
71
|
+
function measureOne(text, maxWidth, lineHeight, font) {
|
|
72
|
+
if (!text)
|
|
73
|
+
return { height: 0, lineCount: 0 };
|
|
74
|
+
if (pretextAvailable && pretext) {
|
|
75
|
+
const prepared = getPrepared(text, font);
|
|
76
|
+
return pretext.layout(prepared, maxWidth, lineHeight);
|
|
77
|
+
}
|
|
78
|
+
return heuristicMeasure(text, maxWidth, lineHeight, font);
|
|
79
|
+
}
|
|
80
|
+
return {
|
|
81
|
+
get available() {
|
|
82
|
+
tryLoadPretext();
|
|
83
|
+
return pretextAvailable;
|
|
84
|
+
},
|
|
85
|
+
measure(text, maxWidth, lineHeight) {
|
|
86
|
+
tryLoadPretext();
|
|
87
|
+
return measureOne(text, maxWidth, lineHeight, config.titleFont);
|
|
88
|
+
},
|
|
89
|
+
fits(text, maxWidth, lineHeight) {
|
|
90
|
+
tryLoadPretext();
|
|
91
|
+
const { lineCount } = measureOne(text, maxWidth, lineHeight, config.titleFont);
|
|
92
|
+
return lineCount <= 1;
|
|
93
|
+
},
|
|
94
|
+
measureStack(items, maxWidth, gap = config.contentGap) {
|
|
95
|
+
tryLoadPretext();
|
|
96
|
+
const breakdown = [];
|
|
97
|
+
let totalHeight = 0;
|
|
98
|
+
for (const item of items) {
|
|
99
|
+
if (!item.text) {
|
|
100
|
+
breakdown.push({ height: 0, lineCount: 0 });
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
const font = item.font ?? config.secondaryFont;
|
|
104
|
+
const lh = item.lineHeight ?? config.secondaryLineHeight;
|
|
105
|
+
const result = measureOne(item.text, maxWidth, lh, font);
|
|
106
|
+
breakdown.push(result);
|
|
107
|
+
if (result.height > 0) {
|
|
108
|
+
totalHeight += (totalHeight > 0 ? gap : 0) + result.height;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
return { height: totalHeight, breakdown };
|
|
112
|
+
},
|
|
113
|
+
fitContent(opts) {
|
|
114
|
+
tryLoadPretext();
|
|
115
|
+
const { title, subtitle, location, time, tags, maxWidth, maxHeight } = opts;
|
|
116
|
+
const gap = config.contentGap;
|
|
117
|
+
// Title always measured
|
|
118
|
+
const titleResult = measureOne(title, maxWidth, config.titleLineHeight, config.titleFont);
|
|
119
|
+
let used = titleResult.height;
|
|
120
|
+
const result = {
|
|
121
|
+
title: true,
|
|
122
|
+
titleLines: titleResult.lineCount,
|
|
123
|
+
subtitle: false,
|
|
124
|
+
location: false,
|
|
125
|
+
time: false,
|
|
126
|
+
tags: false,
|
|
127
|
+
totalHeight: used,
|
|
128
|
+
};
|
|
129
|
+
// Try adding time
|
|
130
|
+
if (time) {
|
|
131
|
+
const h = measureOne(time, maxWidth, config.secondaryLineHeight, config.secondaryFont);
|
|
132
|
+
if (used + gap + h.height <= maxHeight) {
|
|
133
|
+
result.time = true;
|
|
134
|
+
used += gap + h.height;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
// Try adding subtitle
|
|
138
|
+
if (subtitle) {
|
|
139
|
+
const h = measureOne(subtitle, maxWidth, config.secondaryLineHeight, config.secondaryFont);
|
|
140
|
+
if (used + gap + h.height <= maxHeight) {
|
|
141
|
+
result.subtitle = true;
|
|
142
|
+
used += gap + h.height;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
// Try adding location
|
|
146
|
+
if (location) {
|
|
147
|
+
const h = measureOne(location, maxWidth, config.secondaryLineHeight, config.secondaryFont);
|
|
148
|
+
if (used + gap + h.height <= maxHeight) {
|
|
149
|
+
result.location = true;
|
|
150
|
+
used += gap + h.height;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
// Try adding tags
|
|
154
|
+
if (tags?.length) {
|
|
155
|
+
const tagText = tags.join(' ');
|
|
156
|
+
const h = measureOne(tagText, maxWidth, config.secondaryLineHeight, config.tagFont);
|
|
157
|
+
if (used + gap + h.height <= maxHeight) {
|
|
158
|
+
result.tags = true;
|
|
159
|
+
used += gap + h.height;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
result.totalHeight = used;
|
|
163
|
+
return result;
|
|
164
|
+
},
|
|
165
|
+
clear() {
|
|
166
|
+
cache.clear();
|
|
167
|
+
if (pretextAvailable && pretext) {
|
|
168
|
+
pretext.clearCache();
|
|
169
|
+
}
|
|
170
|
+
},
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
/**
|
|
174
|
+
* Initialize Pretext for text measurement.
|
|
175
|
+
* Call this once in your app's entry point (e.g., +layout.svelte onMount).
|
|
176
|
+
* If not called, measurement falls back to character-width heuristics.
|
|
177
|
+
*
|
|
178
|
+
* Usage:
|
|
179
|
+
* import { initTextMeasure } from '@nomideusz/svelte-calendar';
|
|
180
|
+
* onMount(() => initTextMeasure());
|
|
181
|
+
*/
|
|
182
|
+
export async function initTextMeasure() {
|
|
183
|
+
try {
|
|
184
|
+
const mod = await import('@chenglou/pretext');
|
|
185
|
+
globalThis.__pretextModule = {
|
|
186
|
+
prepare: mod.prepare,
|
|
187
|
+
layout: mod.layout,
|
|
188
|
+
clearCache: mod.clearCache,
|
|
189
|
+
};
|
|
190
|
+
return true;
|
|
191
|
+
}
|
|
192
|
+
catch {
|
|
193
|
+
return false;
|
|
194
|
+
}
|
|
195
|
+
}
|
package/dist/core/time.js
CHANGED
|
@@ -95,8 +95,8 @@ export function segmentForDay(ev, dayMs) {
|
|
|
95
95
|
const firstDayMs = sod(evStart);
|
|
96
96
|
// For end time: if event ends exactly at midnight, last day is the day before
|
|
97
97
|
const lastDayMs = sod(evEnd - 1);
|
|
98
|
-
const totalDays = Math.
|
|
99
|
-
const dayIndex = Math.
|
|
98
|
+
const totalDays = Math.floor((lastDayMs - firstDayMs) / DAY_MS) + 1;
|
|
99
|
+
const dayIndex = Math.floor((dayStart - firstDayMs) / DAY_MS) + 1;
|
|
100
100
|
return {
|
|
101
101
|
ev,
|
|
102
102
|
start: new Date(Math.max(evStart, dayStart)),
|
package/dist/core/types.d.ts
CHANGED
|
@@ -5,6 +5,16 @@
|
|
|
5
5
|
* Represents an event on the timeline / scheduler.
|
|
6
6
|
* Core interface for all views, adapters, and the event store.
|
|
7
7
|
*/
|
|
8
|
+
/**
|
|
9
|
+
* Event status — universal across scheduling domains.
|
|
10
|
+
*
|
|
11
|
+
* 'confirmed' — default, normal event
|
|
12
|
+
* 'cancelled' — visually struck through, still shown on grid
|
|
13
|
+
* 'tentative' — e.g. awaiting confirmation
|
|
14
|
+
* 'full' — no remaining capacity
|
|
15
|
+
* 'limited' — low remaining capacity
|
|
16
|
+
*/
|
|
17
|
+
export type EventStatus = 'confirmed' | 'cancelled' | 'tentative' | 'full' | 'limited';
|
|
8
18
|
export interface TimelineEvent {
|
|
9
19
|
id: string;
|
|
10
20
|
title: string;
|
|
@@ -19,6 +29,28 @@ export interface TimelineEvent {
|
|
|
19
29
|
subtitle?: string;
|
|
20
30
|
/** Tags displayed as small pills (e.g. ["Beginner", "Yoga"]) */
|
|
21
31
|
tags?: string[];
|
|
32
|
+
/**
|
|
33
|
+
* Location or room where the event takes place.
|
|
34
|
+
* Universal across domains: yoga studio, meeting room, classroom, venue.
|
|
35
|
+
*/
|
|
36
|
+
location?: string;
|
|
37
|
+
/**
|
|
38
|
+
* Event status. Defaults to `'confirmed'` when omitted.
|
|
39
|
+
* Cancelled events are visually distinct but still shown on the grid.
|
|
40
|
+
*/
|
|
41
|
+
status?: EventStatus;
|
|
42
|
+
/**
|
|
43
|
+
* ID from the external / upstream system (e.g. booking platform,
|
|
44
|
+
* CRM, LMS). Preserved during mapping so the consumer can
|
|
45
|
+
* cross-reference back to the source.
|
|
46
|
+
*/
|
|
47
|
+
externalId?: string;
|
|
48
|
+
/**
|
|
49
|
+
* Resource this event belongs to — for multi-resource views.
|
|
50
|
+
* Could be a room ID, instructor ID, court number, etc.
|
|
51
|
+
* The calendar can group columns/lanes by this value.
|
|
52
|
+
*/
|
|
53
|
+
resourceId?: string;
|
|
22
54
|
/** Arbitrary payload from the source app (bookings, attendees, etc.) */
|
|
23
55
|
data?: Record<string, unknown>;
|
|
24
56
|
}
|
|
@@ -30,11 +30,6 @@ export function createEventStore(adapter) {
|
|
|
30
30
|
function overlaps(ev, start, end) {
|
|
31
31
|
return ev.start < end && ev.end > start;
|
|
32
32
|
}
|
|
33
|
-
function replaceEvent(id, updated) {
|
|
34
|
-
if (eventMap.has(id)) {
|
|
35
|
-
eventMap.set(id, updated);
|
|
36
|
-
}
|
|
37
|
-
}
|
|
38
33
|
function removeEvent(id) {
|
|
39
34
|
eventMap.delete(id);
|
|
40
35
|
}
|
|
@@ -81,6 +76,8 @@ export function createEventStore(adapter) {
|
|
|
81
76
|
return eventMap.get(id);
|
|
82
77
|
},
|
|
83
78
|
async add(eventData) {
|
|
79
|
+
if (!adapter.createEvent)
|
|
80
|
+
throw new Error('Adapter is read-only: createEvent not implemented');
|
|
84
81
|
loading = true;
|
|
85
82
|
error = null;
|
|
86
83
|
try {
|
|
@@ -97,11 +94,13 @@ export function createEventStore(adapter) {
|
|
|
97
94
|
}
|
|
98
95
|
},
|
|
99
96
|
async update(id, patch) {
|
|
97
|
+
if (!adapter.updateEvent)
|
|
98
|
+
throw new Error('Adapter is read-only: updateEvent not implemented');
|
|
100
99
|
loading = true;
|
|
101
100
|
error = null;
|
|
102
101
|
try {
|
|
103
102
|
const updated = await adapter.updateEvent(id, patch);
|
|
104
|
-
|
|
103
|
+
upsertEvent(updated);
|
|
105
104
|
}
|
|
106
105
|
catch (e) {
|
|
107
106
|
error = e instanceof Error ? e.message : String(e);
|
|
@@ -112,6 +111,8 @@ export function createEventStore(adapter) {
|
|
|
112
111
|
}
|
|
113
112
|
},
|
|
114
113
|
async remove(id) {
|
|
114
|
+
if (!adapter.deleteEvent)
|
|
115
|
+
throw new Error('Adapter is read-only: deleteEvent not implemented');
|
|
115
116
|
loading = true;
|
|
116
117
|
error = null;
|
|
117
118
|
try {
|
|
@@ -127,7 +128,21 @@ export function createEventStore(adapter) {
|
|
|
127
128
|
}
|
|
128
129
|
},
|
|
129
130
|
async move(id, newStart, newEnd) {
|
|
130
|
-
|
|
131
|
+
// Optimistic update: apply locally first so the UI doesn't flash
|
|
132
|
+
// back to the old position between drag.commit() and adapter response.
|
|
133
|
+
const existing = eventMap.get(id);
|
|
134
|
+
if (existing) {
|
|
135
|
+
upsertEvent({ ...existing, start: newStart, end: newEnd });
|
|
136
|
+
}
|
|
137
|
+
try {
|
|
138
|
+
await this.update(id, { start: newStart, end: newEnd });
|
|
139
|
+
}
|
|
140
|
+
catch (e) {
|
|
141
|
+
// Revert optimistic update on failure
|
|
142
|
+
if (existing)
|
|
143
|
+
upsertEvent(existing);
|
|
144
|
+
throw e;
|
|
145
|
+
}
|
|
131
146
|
},
|
|
132
147
|
};
|
|
133
148
|
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import type { TimelineEvent } from '../core/types.js';
|
|
2
|
+
import type { CalendarAdapter } from '../adapters/types.js';
|
|
3
|
+
export interface AgendaOptions {
|
|
4
|
+
/** Data source (required). Can be a getter for reactive adapters. */
|
|
5
|
+
adapter: CalendarAdapter | (() => CalendarAdapter);
|
|
6
|
+
/** Initial date to focus on (default: today) */
|
|
7
|
+
initialDate?: Date;
|
|
8
|
+
/** BCP 47 locale tag (e.g. 'en-US', 'pl-PL') */
|
|
9
|
+
locale?: string;
|
|
10
|
+
/** Number of days to load ahead of focus date for the upcoming list (default: 7) */
|
|
11
|
+
lookahead?: number;
|
|
12
|
+
}
|
|
13
|
+
export interface HeadlessAgenda {
|
|
14
|
+
/** The focused date */
|
|
15
|
+
readonly focusDate: Date;
|
|
16
|
+
/** Start-of-day ms for focus date */
|
|
17
|
+
readonly focusDayMs: number;
|
|
18
|
+
/** Is the focus date today? */
|
|
19
|
+
readonly isToday: boolean;
|
|
20
|
+
/** Is the focus date in the past? */
|
|
21
|
+
readonly isPast: boolean;
|
|
22
|
+
/** Is the focus date in the future? */
|
|
23
|
+
readonly isFuture: boolean;
|
|
24
|
+
/** Formatted date label (e.g. "Monday, April 14") */
|
|
25
|
+
readonly dateLabel: string;
|
|
26
|
+
/** Whether the adapter is loading */
|
|
27
|
+
readonly loading: boolean;
|
|
28
|
+
/** All events for the focus day, sorted by start */
|
|
29
|
+
readonly dayEvents: TimelineEvent[];
|
|
30
|
+
/** All-day or multi-day events */
|
|
31
|
+
readonly allDay: TimelineEvent[];
|
|
32
|
+
/** Timed events that have ended (today only; empty for other days) */
|
|
33
|
+
readonly past: TimelineEvent[];
|
|
34
|
+
/** Timed events currently in progress */
|
|
35
|
+
readonly current: TimelineEvent[];
|
|
36
|
+
/** Timed events coming up */
|
|
37
|
+
readonly upcoming: TimelineEvent[];
|
|
38
|
+
/** Grouped upcoming slots (overlapping events merged) */
|
|
39
|
+
readonly upcomingSlots: import('../views/shared/format.js').TimeSlot[];
|
|
40
|
+
/** Total event count for the day */
|
|
41
|
+
readonly count: number;
|
|
42
|
+
/** Format a Date to locale time string (e.g. "2:30 PM" or "14:30") */
|
|
43
|
+
fmtTime(date: Date): string;
|
|
44
|
+
/** Format event duration (e.g. "1h 30m") */
|
|
45
|
+
fmtDuration(event: TimelineEvent): string;
|
|
46
|
+
/** Format time range (e.g. "14:00 – 15:30") */
|
|
47
|
+
fmtRange(event: TimelineEvent): string;
|
|
48
|
+
/** Time until event starts (e.g. "in 2h 15m") — live, updates every second */
|
|
49
|
+
eta(event: TimelineEvent): string;
|
|
50
|
+
/** Progress of an in-progress event (0–1) — live */
|
|
51
|
+
progress(event: TimelineEvent): number;
|
|
52
|
+
/** Go to previous day */
|
|
53
|
+
prev(): void;
|
|
54
|
+
/** Go to next day */
|
|
55
|
+
next(): void;
|
|
56
|
+
/** Go to today */
|
|
57
|
+
goToday(): void;
|
|
58
|
+
/** Set focus to a specific date */
|
|
59
|
+
setDate(date: Date): void;
|
|
60
|
+
/** Current epoch ms, ticks every second */
|
|
61
|
+
readonly now: number;
|
|
62
|
+
/** Formatted current time "HH:MM" */
|
|
63
|
+
readonly timeLabel: string;
|
|
64
|
+
/** Today's start-of-day ms */
|
|
65
|
+
readonly todayMs: number;
|
|
66
|
+
}
|
|
67
|
+
export declare function createAgenda(options: AgendaOptions): HeadlessAgenda;
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* createAgenda() — headless single-day agenda state.
|
|
3
|
+
*
|
|
4
|
+
* Returns reactive state for rendering a day's events with zero DOM.
|
|
5
|
+
* Lighter than createCalendar: no view-state, drag, or selection engines.
|
|
6
|
+
* Manages its own focus date, loads events from the adapter, and categorises
|
|
7
|
+
* them into past / current / upcoming buckets that update every second.
|
|
8
|
+
*
|
|
9
|
+
* Must be called during component initialisation (Svelte 5 rune scope).
|
|
10
|
+
*
|
|
11
|
+
* @example
|
|
12
|
+
* ```svelte
|
|
13
|
+
* <script>
|
|
14
|
+
* import { createAgenda, createMemoryAdapter } from '@nomideusz/svelte-calendar';
|
|
15
|
+
*
|
|
16
|
+
* const adapter = createMemoryAdapter([...]);
|
|
17
|
+
* const agenda = createAgenda({ adapter });
|
|
18
|
+
* </script>
|
|
19
|
+
*
|
|
20
|
+
* <h2>{agenda.dateLabel}</h2>
|
|
21
|
+
* <button onclick={agenda.prev}>←</button>
|
|
22
|
+
* <button onclick={agenda.next}>→</button>
|
|
23
|
+
*
|
|
24
|
+
* {#each agenda.upcoming as ev}
|
|
25
|
+
* <div>
|
|
26
|
+
* <span>{agenda.fmtTime(ev.start)}</span>
|
|
27
|
+
* <span>{ev.title}</span>
|
|
28
|
+
* <span>{agenda.eta(ev)}</span>
|
|
29
|
+
* </div>
|
|
30
|
+
* {/each}
|
|
31
|
+
* ```
|
|
32
|
+
*/
|
|
33
|
+
import { untrack } from 'svelte';
|
|
34
|
+
import { createEventStore } from '../engine/event-store.svelte.js';
|
|
35
|
+
import { createClock } from '../core/clock.svelte.js';
|
|
36
|
+
import { sod, DAY_MS, isAllDay, isMultiDay } from '../core/time.js';
|
|
37
|
+
import { fmtTime as _fmtTime, fmtDuration } from '../core/locale.js';
|
|
38
|
+
import { timeUntilMs, progress as _progress, groupIntoSlots } from '../views/shared/format.js';
|
|
39
|
+
// ─── Implementation ─────────────────────────────────────
|
|
40
|
+
export function createAgenda(options) {
|
|
41
|
+
const { initialDate, locale, lookahead = 7, } = options;
|
|
42
|
+
const resolveAdapter = typeof options.adapter === 'function'
|
|
43
|
+
? options.adapter
|
|
44
|
+
: () => options.adapter;
|
|
45
|
+
const store = $derived(createEventStore(resolveAdapter()));
|
|
46
|
+
const clock = createClock();
|
|
47
|
+
// ── Focus date (reactive, writable) ──
|
|
48
|
+
let focusDayMs = $state(sod(initialDate?.getTime() ?? Date.now()));
|
|
49
|
+
// ── Load events for focus date range ──
|
|
50
|
+
$effect(() => {
|
|
51
|
+
const start = new Date(focusDayMs);
|
|
52
|
+
const end = new Date(focusDayMs + lookahead * DAY_MS);
|
|
53
|
+
store.load({ start, end });
|
|
54
|
+
});
|
|
55
|
+
// Eager initial load
|
|
56
|
+
untrack(() => {
|
|
57
|
+
const start = new Date(focusDayMs);
|
|
58
|
+
const end = new Date(focusDayMs + lookahead * DAY_MS);
|
|
59
|
+
store.load({ start, end });
|
|
60
|
+
});
|
|
61
|
+
// ── Day derivations ──
|
|
62
|
+
const dayEnd = $derived(focusDayMs + DAY_MS);
|
|
63
|
+
const isToday = $derived(focusDayMs === clock.today);
|
|
64
|
+
const isPast = $derived(focusDayMs < clock.today);
|
|
65
|
+
const isFuture = $derived(focusDayMs > clock.today);
|
|
66
|
+
const dateLabel = $derived(new Date(focusDayMs).toLocaleDateString(locale ?? undefined, {
|
|
67
|
+
weekday: 'long',
|
|
68
|
+
month: 'long',
|
|
69
|
+
day: 'numeric',
|
|
70
|
+
}));
|
|
71
|
+
const dayEvents = $derived.by(() => {
|
|
72
|
+
return store.events
|
|
73
|
+
.filter((ev) => ev.start.getTime() < dayEnd && ev.end.getTime() > focusDayMs)
|
|
74
|
+
.sort((a, b) => a.start.getTime() - b.start.getTime());
|
|
75
|
+
});
|
|
76
|
+
const allDay = $derived(dayEvents.filter((ev) => isAllDay(ev) || isMultiDay(ev)));
|
|
77
|
+
const timed = $derived(dayEvents.filter((ev) => !isAllDay(ev) && !isMultiDay(ev)));
|
|
78
|
+
const categorised = $derived.by(() => {
|
|
79
|
+
const now = clock.tick;
|
|
80
|
+
const past = [];
|
|
81
|
+
const current = [];
|
|
82
|
+
const upcoming = [];
|
|
83
|
+
for (const ev of timed) {
|
|
84
|
+
const s = ev.start.getTime();
|
|
85
|
+
const e = ev.end.getTime();
|
|
86
|
+
if (e <= now)
|
|
87
|
+
past.push(ev);
|
|
88
|
+
else if (s <= now && e > now)
|
|
89
|
+
current.push(ev);
|
|
90
|
+
else
|
|
91
|
+
upcoming.push(ev);
|
|
92
|
+
}
|
|
93
|
+
return { past, current, upcoming, upcomingSlots: groupIntoSlots(upcoming) };
|
|
94
|
+
});
|
|
95
|
+
// ── Navigation ──
|
|
96
|
+
function prev() { focusDayMs = focusDayMs - DAY_MS; }
|
|
97
|
+
function next() { focusDayMs = focusDayMs + DAY_MS; }
|
|
98
|
+
function goToday() { focusDayMs = clock.today; }
|
|
99
|
+
function setDate(date) { focusDayMs = sod(date.getTime()); }
|
|
100
|
+
// ── Format helpers ──
|
|
101
|
+
const fmtTime = (d) => _fmtTime(d, locale);
|
|
102
|
+
const fmtDur = (ev) => fmtDuration(ev.start, ev.end);
|
|
103
|
+
const fmtRange = (ev) => `${_fmtTime(ev.start, locale)} – ${_fmtTime(ev.end, locale)}`;
|
|
104
|
+
const eta = (ev) => timeUntilMs(ev.start.getTime(), clock.tick);
|
|
105
|
+
const progress = (ev) => _progress(ev, clock.tick);
|
|
106
|
+
return {
|
|
107
|
+
// State
|
|
108
|
+
get focusDate() { return new Date(focusDayMs); },
|
|
109
|
+
get focusDayMs() { return focusDayMs; },
|
|
110
|
+
get isToday() { return isToday; },
|
|
111
|
+
get isPast() { return isPast; },
|
|
112
|
+
get isFuture() { return isFuture; },
|
|
113
|
+
get dateLabel() { return dateLabel; },
|
|
114
|
+
get loading() { return store.loading; },
|
|
115
|
+
// Events
|
|
116
|
+
get dayEvents() { return dayEvents; },
|
|
117
|
+
get allDay() { return allDay; },
|
|
118
|
+
get past() { return categorised.past; },
|
|
119
|
+
get current() { return categorised.current; },
|
|
120
|
+
get upcoming() { return categorised.upcoming; },
|
|
121
|
+
get upcomingSlots() { return categorised.upcomingSlots; },
|
|
122
|
+
get count() { return dayEvents.length; },
|
|
123
|
+
// Format helpers
|
|
124
|
+
fmtTime,
|
|
125
|
+
fmtDuration: fmtDur,
|
|
126
|
+
fmtRange,
|
|
127
|
+
eta,
|
|
128
|
+
progress,
|
|
129
|
+
// Navigation
|
|
130
|
+
prev,
|
|
131
|
+
next,
|
|
132
|
+
goToday,
|
|
133
|
+
setDate,
|
|
134
|
+
// Clock
|
|
135
|
+
get now() { return clock.tick; },
|
|
136
|
+
get timeLabel() { return clock.hm; },
|
|
137
|
+
get todayMs() { return clock.today; },
|
|
138
|
+
};
|
|
139
|
+
}
|