@nomideusz/svelte-calendar 0.4.0 → 0.5.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.
Files changed (73) hide show
  1. package/README.md +331 -568
  2. package/dist/adapters/recurring.d.ts +45 -8
  3. package/dist/adapters/recurring.js +227 -49
  4. package/dist/adapters/rest.js +6 -1
  5. package/dist/calendar/Calendar.svelte +118 -30
  6. package/dist/calendar/Calendar.svelte.d.ts +9 -8
  7. package/dist/calendar/index.d.ts +0 -1
  8. package/dist/calendar/index.js +0 -1
  9. package/dist/core/index.d.ts +5 -4
  10. package/dist/core/index.js +2 -3
  11. package/dist/core/locale.d.ts +74 -0
  12. package/dist/core/locale.js +96 -4
  13. package/dist/core/time.d.ts +30 -0
  14. package/dist/core/time.js +42 -0
  15. package/dist/core/types.d.ts +1 -72
  16. package/dist/core/types.js +1 -8
  17. package/dist/engine/event-store.svelte.js +2 -2
  18. package/dist/engine/index.d.ts +1 -1
  19. package/dist/engine/view-state.svelte.d.ts +11 -6
  20. package/dist/engine/view-state.svelte.js +5 -4
  21. package/dist/index.d.ts +6 -7
  22. package/dist/index.js +4 -4
  23. package/dist/primitives/EmptySlot.svelte +6 -16
  24. package/dist/primitives/EventBlock.svelte +6 -19
  25. package/dist/primitives/NowIndicator.svelte +7 -4
  26. package/dist/primitives/TimeGutter.svelte +1 -1
  27. package/dist/theme/index.d.ts +1 -1
  28. package/dist/theme/index.js +1 -1
  29. package/dist/theme/presets.d.ts +11 -19
  30. package/dist/theme/presets.js +25 -111
  31. package/dist/views/agenda/Agenda.svelte +17 -1516
  32. package/dist/views/agenda/Agenda.svelte.d.ts +0 -14
  33. package/dist/views/agenda/AgendaDay.svelte +976 -0
  34. package/dist/views/agenda/AgendaDay.svelte.d.ts +14 -0
  35. package/dist/views/agenda/AgendaWeek.svelte +775 -0
  36. package/dist/views/agenda/AgendaWeek.svelte.d.ts +15 -0
  37. package/dist/views/agenda/index.d.ts +2 -0
  38. package/dist/views/agenda/index.js +2 -0
  39. package/dist/views/index.d.ts +2 -6
  40. package/dist/views/index.js +2 -6
  41. package/dist/views/planner/Planner.svelte +17 -0
  42. package/dist/views/planner/Planner.svelte.d.ts +7 -0
  43. package/dist/views/planner/PlannerDay.svelte +957 -0
  44. package/dist/views/{day/DayGrid.svelte.d.ts → planner/PlannerDay.svelte.d.ts} +6 -16
  45. package/dist/views/planner/PlannerWeek.svelte +826 -0
  46. package/dist/views/{week/WeekGrid.svelte.d.ts → planner/PlannerWeek.svelte.d.ts} +4 -9
  47. package/dist/views/planner/index.d.ts +1 -0
  48. package/dist/views/planner/index.js +1 -0
  49. package/dist/widget/CalendarWidget.svelte +54 -33
  50. package/dist/widget/CalendarWidget.svelte.d.ts +1 -1
  51. package/package.json +70 -65
  52. package/widget/svelte-calendar.css +1 -0
  53. package/widget/widget.js +122 -260
  54. package/dist/calendar/Toolbar.svelte +0 -244
  55. package/dist/calendar/Toolbar.svelte.d.ts +0 -21
  56. package/dist/views/day/DayGrid.svelte +0 -1104
  57. package/dist/views/day/DayTimeline.svelte +0 -533
  58. package/dist/views/day/DayTimeline.svelte.d.ts +0 -8
  59. package/dist/views/day/index.d.ts +0 -2
  60. package/dist/views/day/index.js +0 -2
  61. package/dist/views/schedule/WeekSchedule.svelte +0 -137
  62. package/dist/views/schedule/WeekSchedule.svelte.d.ts +0 -40
  63. package/dist/views/schedule/index.d.ts +0 -1
  64. package/dist/views/schedule/index.js +0 -2
  65. package/dist/views/settings/Settings.svelte +0 -585
  66. package/dist/views/settings/Settings.svelte.d.ts +0 -39
  67. package/dist/views/settings/index.d.ts +0 -2
  68. package/dist/views/settings/index.js +0 -1
  69. package/dist/views/week/WeekGrid.svelte +0 -632
  70. package/dist/views/week/WeekHeatmap.svelte +0 -461
  71. package/dist/views/week/WeekHeatmap.svelte.d.ts +0 -9
  72. package/dist/views/week/index.d.ts +0 -2
  73. package/dist/views/week/index.js +0 -2
@@ -1,16 +1,54 @@
1
1
  import type { CalendarAdapter } from './types.js';
2
2
  /**
3
- * A weekly recurring event definition.
3
+ * A recurring event definition.
4
+ *
5
+ * Backward-compatible: the only required fields are `id`, `title`,
6
+ * `startTime`, `endTime`, and `dayOfWeek` (for weekly, the default).
4
7
  */
5
8
  export interface RecurringEvent {
6
9
  id: string;
7
10
  title: string;
8
- /** ISO weekday: 1 = Monday … 7 = Sunday */
9
- dayOfWeek: number;
10
11
  /** Start time in "HH:MM" 24-hour format */
11
12
  startTime: string;
12
13
  /** End time in "HH:MM" 24-hour format */
13
14
  endTime: string;
15
+ /** Recurrence frequency (default: `'weekly'`) */
16
+ frequency?: 'daily' | 'weekly' | 'monthly';
17
+ /**
18
+ * Repeat every N periods — e.g. `2` with `weekly` = biweekly.
19
+ * Requires `startDate` when > 1 so the adapter knows the anchor.
20
+ * @default 1
21
+ */
22
+ interval?: number;
23
+ /**
24
+ * ISO weekday(s): 1 = Monday … 7 = Sunday.
25
+ * Required for `weekly` frequency. Pass an array for multiple days
26
+ * (e.g. `[1, 3, 5]` for Mon/Wed/Fri).
27
+ */
28
+ dayOfWeek?: number | number[];
29
+ /**
30
+ * Day of month (1–31). Required for `monthly` frequency.
31
+ * Clamped to the last day of shorter months (e.g. 31 → 28 in Feb).
32
+ * @default 1
33
+ */
34
+ dayOfMonth?: number;
35
+ /**
36
+ * First possible occurrence in `"YYYY-MM-DD"` format.
37
+ * Events before this date are excluded. Required when using
38
+ * `interval > 1` or `count`.
39
+ */
40
+ startDate?: string;
41
+ /**
42
+ * Last possible occurrence in `"YYYY-MM-DD"` format.
43
+ * No events are generated after this date.
44
+ */
45
+ until?: string;
46
+ /**
47
+ * Maximum number of occurrences, counted from `startDate`.
48
+ * Alternative to `until` — if both are set the stricter bound wins.
49
+ * Requires `startDate`.
50
+ */
51
+ count?: number;
14
52
  /** Accent color */
15
53
  color?: string;
16
54
  /** Optional subtitle displayed below the title */
@@ -30,15 +68,14 @@ export interface RecurringAdapterOptions {
30
68
  /**
31
69
  * Auto-assign colors to events by category or title.
32
70
  * true → use the default vivid palette
33
- * string → hex accent color (e.g. '#6366f1') to generate a
34
- * theme-harmonious palette via golden-angle hue rotation
71
+ * string → hex accent color to generate a harmonious palette
35
72
  */
36
73
  autoColor?: boolean | string;
37
74
  }
38
75
  /**
39
- * Create a CalendarAdapter that projects recurring weekly events
40
- * onto concrete dates for whatever range the calendar requests.
76
+ * Create a CalendarAdapter that projects recurring events onto concrete
77
+ * dates for whatever range the calendar requests.
41
78
  *
42
- * Read-only by default — create/update/delete throw unless custom handlers are provided.
79
+ * Read-only by default — create/update/delete throw.
43
80
  */
44
81
  export declare function createRecurringAdapter(schedule: RecurringEvent[], options?: RecurringAdapterOptions): CalendarAdapter;
@@ -1,70 +1,231 @@
1
- import { startOfWeek } from '../core/time.js';
2
- import { DAY_MS } from '../core/time.js';
1
+ import { startOfWeek, DAY_MS } from '../core/time.js';
3
2
  import { generatePalette, VIVID_PALETTE } from '../core/palette.js';
3
+ // ── Helpers ─────────────────────────────────────────────
4
4
  /** Parse "HH:MM" into [hours, minutes] */
5
5
  function parseTime(time) {
6
- const [h, m] = time.split(':').map(Number);
7
- return [h, m ?? 0];
6
+ const parts = time.split(':');
7
+ if (parts.length < 2) {
8
+ throw new Error(`Invalid time format "${time}": expected "HH:MM"`);
9
+ }
10
+ const h = Number(parts[0]);
11
+ const m = Number(parts[1]);
12
+ if (!Number.isInteger(h) || h < 0 || h > 23) {
13
+ throw new Error(`Invalid hours in time "${time}": expected 0–23`);
14
+ }
15
+ if (!Number.isInteger(m) || m < 0 || m > 59) {
16
+ throw new Error(`Invalid minutes in time "${time}": expected 0–59`);
17
+ }
18
+ return [h, m];
8
19
  }
9
- /**
10
- * Convert ISO weekday (1=Mon…7=Sun) to JS Date weekday offset from Monday.
11
- * Monday = 0, Tuesday = 1, … Sunday = 6
12
- */
20
+ /** Parse "YYYY-MM-DD" to a midnight Date */
21
+ function parseDate(str) {
22
+ const parts = str.split('-');
23
+ if (parts.length !== 3)
24
+ throw new Error(`Invalid date "${str}": expected "YYYY-MM-DD"`);
25
+ const y = Number(parts[0]);
26
+ const m = Number(parts[1]) - 1;
27
+ const d = Number(parts[2]);
28
+ const date = new Date(y, m, d);
29
+ if (Number.isNaN(date.getTime()))
30
+ throw new Error(`Invalid date "${str}"`);
31
+ return date;
32
+ }
33
+ /** ISO weekday offset from Monday: 1→0, 2→1, … 7→6 */
13
34
  function isoWeekdayToOffset(dayOfWeek) {
14
- return dayOfWeek - 1; // 1→0, 2→1, …, 7→6
35
+ return dayOfWeek - 1;
15
36
  }
16
- /**
17
- * Project a recurring event onto a specific week, returning a concrete TimelineEvent.
18
- */
19
- function projectToWeek(rec, weekStartMs, weekIndex) {
20
- const dayOffset = isoWeekdayToOffset(rec.dayOfWeek);
21
- const dayMs = weekStartMs + dayOffset * DAY_MS;
22
- const dayDate = new Date(dayMs);
37
+ /** Normalize `dayOfWeek` to a sorted array */
38
+ function normalizeDays(value) {
39
+ if (value == null)
40
+ return [];
41
+ const arr = Array.isArray(value) ? [...value] : [value];
42
+ return arr.sort((a, b) => a - b);
43
+ }
44
+ /** Clamp day to last day of month (e.g. 31 → 28 in Feb) */
45
+ function clampDayOfMonth(year, month, day) {
46
+ const lastDay = new Date(year, month + 1, 0).getDate();
47
+ return new Date(year, month, Math.min(day, lastDay));
48
+ }
49
+ /** Create a concrete TimelineEvent from a recurring def + occurrence date */
50
+ function createConcreteEvent(rec, date, instanceKey) {
23
51
  const [sh, sm] = parseTime(rec.startTime);
24
52
  const [eh, em] = parseTime(rec.endTime);
25
- const start = new Date(dayDate.getFullYear(), dayDate.getMonth(), dayDate.getDate(), sh, sm);
26
- const end = new Date(dayDate.getFullYear(), dayDate.getMonth(), dayDate.getDate(), eh, em);
53
+ const start = new Date(date.getFullYear(), date.getMonth(), date.getDate(), sh, sm);
54
+ const end = new Date(date.getFullYear(), date.getMonth(), date.getDate(), eh, em);
27
55
  return {
28
- id: `${rec.id}--w${weekIndex}--d${rec.dayOfWeek}`,
56
+ id: `${rec.id}--${instanceKey}`,
29
57
  title: rec.title,
30
58
  start,
31
59
  end,
32
60
  color: rec.color,
33
61
  category: rec.category,
34
- data: {
35
- ...rec.data,
36
- recurringId: rec.id,
37
- ...(rec.subtitle ? { subtitle: rec.subtitle } : {}),
38
- ...(rec.tags ? { tags: rec.tags } : {}),
39
- },
62
+ subtitle: rec.subtitle,
63
+ tags: rec.tags,
64
+ data: { ...rec.data, recurringId: rec.id },
40
65
  };
41
66
  }
67
+ // ── Count → until resolution ────────────────────────────
42
68
  /**
43
- * Find all weeks that overlap a given date range.
44
- * Returns an array of { weekStartMs, weekIndex } objects.
69
+ * When `count` is set, compute the date of the last occurrence so the
70
+ * projection loop only needs to check date bounds.
45
71
  */
46
- function getOverlappingWeeks(range, mondayStart) {
47
- const weeks = [];
48
- let cursor = startOfWeek(range.start.getTime(), mondayStart);
49
- let index = 0;
50
- while (cursor < range.end.getTime()) {
51
- weeks.push({ weekStartMs: cursor, weekIndex: index });
52
- cursor += 7 * DAY_MS;
53
- index++;
72
+ function computeUntilFromCount(rec, startDateObj, mondayStart) {
73
+ const count = rec.count;
74
+ if (count == null || count <= 0)
75
+ return undefined;
76
+ const freq = rec.frequency ?? 'weekly';
77
+ const interval = rec.interval ?? 1;
78
+ switch (freq) {
79
+ case 'daily': {
80
+ const d = new Date(startDateObj);
81
+ d.setDate(d.getDate() + (count - 1) * interval);
82
+ return d;
83
+ }
84
+ case 'weekly': {
85
+ const days = normalizeDays(rec.dayOfWeek);
86
+ if (days.length === 0)
87
+ return undefined;
88
+ const startMs = startDateObj.getTime();
89
+ let weekMs = startOfWeek(startMs, mondayStart);
90
+ let remaining = count;
91
+ const maxCycles = Math.ceil(count / days.length) + 2;
92
+ for (let c = 0; c < maxCycles; c++) {
93
+ for (const day of days) {
94
+ const dayMs = weekMs + isoWeekdayToOffset(day) * DAY_MS;
95
+ if (dayMs >= startMs) {
96
+ remaining--;
97
+ if (remaining === 0)
98
+ return new Date(dayMs);
99
+ }
100
+ }
101
+ weekMs += interval * 7 * DAY_MS;
102
+ }
103
+ return undefined;
104
+ }
105
+ case 'monthly': {
106
+ const totalMonths = startDateObj.getFullYear() * 12 +
107
+ startDateObj.getMonth() +
108
+ (count - 1) * interval;
109
+ const y = Math.floor(totalMonths / 12);
110
+ const m = totalMonths % 12;
111
+ return clampDayOfMonth(y, m, rec.dayOfMonth ?? 1);
112
+ }
54
113
  }
55
- return weeks;
56
114
  }
115
+ // ── Projection functions ────────────────────────────────
116
+ function projectDaily(rec, range, startDate, effectiveUntil, out) {
117
+ const interval = rec.interval ?? 1;
118
+ // Anchor at startDate or range.start (midnight)
119
+ const anchor = new Date(startDate ?? range.start);
120
+ anchor.setHours(0, 0, 0, 0);
121
+ const rangeStart = new Date(range.start);
122
+ rangeStart.setHours(0, 0, 0, 0);
123
+ // Skip ahead to first occurrence at or after range.start
124
+ const cursor = new Date(anchor);
125
+ if (cursor < rangeStart) {
126
+ const daysBetween = Math.round((rangeStart.getTime() - cursor.getTime()) / DAY_MS);
127
+ const skip = Math.floor(daysBetween / interval);
128
+ cursor.setDate(cursor.getDate() + skip * interval);
129
+ if (cursor < rangeStart)
130
+ cursor.setDate(cursor.getDate() + interval);
131
+ }
132
+ let idx = 0;
133
+ while (cursor < range.end) {
134
+ if (effectiveUntil && cursor > effectiveUntil)
135
+ break;
136
+ const ev = createConcreteEvent(rec, cursor, `d${idx}`);
137
+ if (ev.start < range.end && ev.end > range.start) {
138
+ out.push(ev);
139
+ }
140
+ cursor.setDate(cursor.getDate() + interval);
141
+ idx++;
142
+ }
143
+ }
144
+ function projectWeekly(rec, range, startDate, effectiveUntil, mondayStart, out) {
145
+ const interval = rec.interval ?? 1;
146
+ const days = normalizeDays(rec.dayOfWeek);
147
+ if (days.length === 0)
148
+ return;
149
+ const startDateMs = startDate?.getTime();
150
+ const rangeStartMs = range.start.getTime();
151
+ const rangeEndMs = range.end.getTime();
152
+ // Anchor week: week containing startDate, or range.start if none
153
+ const anchorMs = startDateMs ?? rangeStartMs;
154
+ const anchorWeekMs = startOfWeek(anchorMs, mondayStart);
155
+ // Jump to first interval-aligned week that could overlap the range
156
+ let weekMs;
157
+ if (anchorWeekMs >= rangeStartMs) {
158
+ weekMs = anchorWeekMs;
159
+ }
160
+ else {
161
+ const weeksBetween = Math.floor((rangeStartMs - anchorWeekMs) / (7 * DAY_MS));
162
+ const skip = Math.floor(weeksBetween / interval);
163
+ weekMs = anchorWeekMs + skip * interval * 7 * DAY_MS;
164
+ if (weekMs + 7 * DAY_MS <= rangeStartMs) {
165
+ weekMs += interval * 7 * DAY_MS;
166
+ }
167
+ }
168
+ let weekIndex = Math.round((weekMs - anchorWeekMs) / (7 * DAY_MS));
169
+ while (weekMs < rangeEndMs) {
170
+ for (const day of days) {
171
+ const dayMs = weekMs + isoWeekdayToOffset(day) * DAY_MS;
172
+ if (startDateMs != null && dayMs < startDateMs)
173
+ continue;
174
+ if (dayMs >= rangeEndMs)
175
+ break;
176
+ if (effectiveUntil && dayMs > effectiveUntil.getTime())
177
+ return;
178
+ const dayDate = new Date(dayMs);
179
+ const ev = createConcreteEvent(rec, dayDate, `w${weekIndex}--d${day}`);
180
+ if (ev.start < range.end && ev.end > range.start) {
181
+ out.push(ev);
182
+ }
183
+ }
184
+ weekMs += interval * 7 * DAY_MS;
185
+ weekIndex += interval;
186
+ }
187
+ }
188
+ function projectMonthly(rec, range, startDate, effectiveUntil, out) {
189
+ const interval = rec.interval ?? 1;
190
+ const dayOfMonth = rec.dayOfMonth ?? 1;
191
+ const anchor = startDate ?? range.start;
192
+ let totalMonths = anchor.getFullYear() * 12 + anchor.getMonth();
193
+ // Skip ahead to first interval-aligned month at or near range.start
194
+ if (startDate && startDate < range.start) {
195
+ const rangeMonths = range.start.getFullYear() * 12 + range.start.getMonth();
196
+ const gap = rangeMonths - totalMonths;
197
+ const skip = Math.floor(gap / interval);
198
+ totalMonths += skip * interval;
199
+ }
200
+ for (let i = 0; i < 120; i++) {
201
+ const y = Math.floor(totalMonths / 12);
202
+ const m = totalMonths % 12;
203
+ const date = clampDayOfMonth(y, m, dayOfMonth);
204
+ if (date >= range.end)
205
+ break;
206
+ if (effectiveUntil && date > effectiveUntil)
207
+ break;
208
+ if (date >= range.start && (!startDate || date >= startDate)) {
209
+ const ev = createConcreteEvent(rec, date, `m${y}-${String(m + 1).padStart(2, '0')}`);
210
+ if (ev.start < range.end && ev.end > range.start) {
211
+ out.push(ev);
212
+ }
213
+ }
214
+ totalMonths += interval;
215
+ }
216
+ }
217
+ // ── Adapter factory ─────────────────────────────────────
57
218
  /** Default palette for auto-coloring */
58
219
  const AUTO_COLORS = VIVID_PALETTE;
59
220
  /**
60
- * Create a CalendarAdapter that projects recurring weekly events
61
- * onto concrete dates for whatever range the calendar requests.
221
+ * Create a CalendarAdapter that projects recurring events onto concrete
222
+ * dates for whatever range the calendar requests.
62
223
  *
63
- * Read-only by default — create/update/delete throw unless custom handlers are provided.
224
+ * Read-only by default — create/update/delete throw.
64
225
  */
65
226
  export function createRecurringAdapter(schedule, options = {}) {
66
227
  const { mondayStart = true, colorMap, autoColor } = options;
67
- // Resolve palette: vivid default or theme-aware
228
+ // Resolve palette
68
229
  const palette = autoColor
69
230
  ? typeof autoColor === 'string'
70
231
  ? generatePalette(autoColor)
@@ -93,16 +254,33 @@ export function createRecurringAdapter(schedule, options = {}) {
93
254
  }
94
255
  return {
95
256
  async fetchEvents(range) {
96
- const weeks = getOverlappingWeeks(range, mondayStart);
97
257
  const events = [];
98
- for (const { weekStartMs, weekIndex } of weeks) {
99
- for (const rec of schedule) {
100
- const coloredRec = { ...rec, color: resolveColor(rec) };
101
- const ev = projectToWeek(coloredRec, weekStartMs, weekIndex);
102
- // Only include if the event overlaps the requested range
103
- if (ev.start < range.end && ev.end > range.start) {
104
- events.push(ev);
105
- }
258
+ for (const rec of schedule) {
259
+ const colored = { ...rec, color: resolveColor(rec) };
260
+ const freq = rec.frequency ?? 'weekly';
261
+ // Parse bounds
262
+ const sd = rec.startDate ? parseDate(rec.startDate) : undefined;
263
+ const untilDate = rec.until ? parseDate(rec.until) : undefined;
264
+ const countUntil = sd
265
+ ? computeUntilFromCount(rec, sd, mondayStart)
266
+ : undefined;
267
+ // Effective until = tighter of the two bounds
268
+ let effectiveUntil = untilDate;
269
+ if (countUntil) {
270
+ effectiveUntil = effectiveUntil
271
+ ? countUntil < effectiveUntil ? countUntil : effectiveUntil
272
+ : countUntil;
273
+ }
274
+ switch (freq) {
275
+ case 'daily':
276
+ projectDaily(colored, range, sd, effectiveUntil, events);
277
+ break;
278
+ case 'weekly':
279
+ projectWeekly(colored, range, sd, effectiveUntil, mondayStart, events);
280
+ break;
281
+ case 'monthly':
282
+ projectMonthly(colored, range, sd, effectiveUntil, events);
283
+ break;
106
284
  }
107
285
  }
108
286
  return events;
@@ -16,7 +16,12 @@ export function createRestAdapter(options) {
16
16
  }
17
17
  if (res.status === 204)
18
18
  return undefined;
19
- return res.json();
19
+ try {
20
+ return await res.json();
21
+ }
22
+ catch {
23
+ throw new Error(`Calendar API error: invalid JSON response from ${path}`);
24
+ }
20
25
  }
21
26
  return {
22
27
  async fetchEvents(range) {
@@ -1,18 +1,18 @@
1
1
  <!--
2
2
  Calendar — the unified orchestrator.
3
3
 
4
- Brings together: adapter → event store → view state + selection → toolbar + active view.
4
+ Brings together: adapter → event store → view state + selection → active view.
5
5
  Provides context so any descendant view can read the store/state via getContext().
6
6
 
7
7
  Usage:
8
8
  <Calendar
9
9
  adapter={myAdapter}
10
10
  views={[
11
- { id: 'week-terrain', label: 'Terrain', component: WeekTimelineV4 },
12
- { id: 'week-agenda', label: 'Agenda', component: WeekTimelineV5 },
13
- { id: 'day', label: 'Day', component: DayTimeline },
11
+ { id: 'week-planner', label: 'Planner', component: Planner, props: { mode: 'week' } },
12
+ { id: 'week-agenda', label: 'Agenda', component: Agenda },
13
+ { id: 'day-planner', label: 'Planner', component: Planner, props: { mode: 'day' } },
14
14
  ]}
15
- defaultView="week-terrain"
15
+ defaultView="week-planner"
16
16
  theme={presets.midnight}
17
17
  height={600}
18
18
  oneventclick={handleClick}
@@ -20,7 +20,7 @@
20
20
  />
21
21
  -->
22
22
  <script lang="ts">
23
- import { setContext, untrack, type Component } from 'svelte';
23
+ import { setContext, untrack, type Component, type Snippet } from 'svelte';
24
24
  import type { CalendarAdapter } from '../adapters/types.js';
25
25
  import type { CalendarViewId } from '../engine/view-state.svelte.js';
26
26
  import { createEventStore, type EventStore } from '../engine/event-store.svelte.js';
@@ -28,7 +28,7 @@
28
28
  import { createSelection, type Selection } from '../engine/selection.svelte.js';
29
29
  import { createDragState, type DragState } from '../engine/drag.svelte.js';
30
30
  import type { TimelineEvent } from '../core/types.js';
31
- import Toolbar from './Toolbar.svelte';
31
+ import { getLabels } from '../core/locale.js';
32
32
 
33
33
  /** One view registration */
34
34
  export interface CalendarView {
@@ -55,10 +55,6 @@
55
55
  mondayStart?: boolean;
56
56
  /** Total height */
57
57
  height?: number;
58
- /** Show toolbar */
59
- showToolbar?: boolean;
60
- /** Links to display in toolbar */
61
- links?: { href: string; label: string }[];
62
58
  /** Text direction: 'ltr' (default), 'rtl', or 'auto' */
63
59
  dir?: 'ltr' | 'rtl' | 'auto';
64
60
  /** BCP 47 locale tag (e.g. 'en-US', 'ar-SA') — sets lang and locale for formatting */
@@ -67,6 +63,16 @@
67
63
  readOnly?: boolean;
68
64
  /** Visible hour range: [startHour, endHour). Crops the grid to these hours. */
69
65
  visibleHours?: [number, number];
66
+ /** Initial date to focus on (defaults to today) */
67
+ initialDate?: Date;
68
+ /** Drag snap interval in minutes (default: 15) */
69
+ snapInterval?: number;
70
+
71
+ // ── Snippets ──
72
+ /** Custom event rendering snippet */
73
+ event?: Snippet<[TimelineEvent]>;
74
+ /** Content to show when no events are loaded */
75
+ empty?: Snippet;
70
76
 
71
77
  // ── Callbacks ──
72
78
  oneventclick?: (event: TimelineEvent) => void;
@@ -78,16 +84,18 @@
78
84
  let {
79
85
  adapter,
80
86
  views = [],
81
- defaultView = 'week-grid',
87
+ defaultView = 'week-planner',
82
88
  theme = '',
83
89
  mondayStart = true,
84
90
  height = 600,
85
- showToolbar = true,
86
- links = [],
87
91
  dir,
88
92
  locale,
89
93
  readOnly = false,
90
94
  visibleHours,
95
+ initialDate,
96
+ snapInterval = 15,
97
+ event: eventSnippet,
98
+ empty: emptySnippet,
91
99
  oneventclick,
92
100
  oneventcreate,
93
101
  oneventmove,
@@ -98,18 +106,13 @@
98
106
  const effectiveCreate = $derived(readOnly ? undefined : oneventcreate);
99
107
  const effectiveMove = $derived(readOnly ? undefined : oneventmove);
100
108
 
101
- import { setDefaultLocale } from '../core/locale.js';
102
-
103
- // ── Set locale when provided ──
104
- $effect(() => {
105
- if (locale) setDefaultLocale(locale);
106
- });
107
-
108
109
  // ── Create reactive state ──
109
110
  const store: EventStore = $derived(createEventStore(adapter));
110
111
  const viewState: ViewState = createViewState(untrack(() => ({
111
112
  defaultView,
112
113
  mondayStart,
114
+ initialDate,
115
+ granularityForView: (viewId) => views.find((v) => v.id === viewId)?.granularity,
113
116
  })));
114
117
  const selection: Selection = createSelection();
115
118
  const drag: DragState = createDragState();
@@ -148,6 +151,9 @@
148
151
  });
149
152
  setContext('calendar:readOnly', { get current() { return readOnly; } });
150
153
  setContext('calendar:visibleHours', { get current() { return visibleHours; } });
154
+ setContext('calendar:snapInterval', { get current() { return snapInterval; } });
155
+ setContext('calendar:eventSnippet', { get current() { return eventSnippet; } });
156
+ setContext('calendar:emptySnippet', { get current() { return emptySnippet; } });
151
157
 
152
158
  // ── Load events when range changes ──
153
159
  $effect(() => {
@@ -167,7 +173,7 @@
167
173
  }
168
174
  });
169
175
 
170
- // Notify host when active view changes (e.g. via toolbar concept/granularity toggles).
176
+ // Notify host when active view changes (e.g. via granularity toggles).
171
177
  $effect(() => {
172
178
  onviewchange?.(viewState.view);
173
179
  });
@@ -175,21 +181,58 @@
175
181
  // ── Resolve active view ──
176
182
  const activeView = $derived(views.find((v) => v.id === viewState.view) ?? views[0]);
177
183
 
178
- const toolbarViews = $derived(
179
- views.map((v) => ({ id: v.id, label: v.label, granularity: v.granularity })),
180
- );
184
+ // ── Date label (always visible, centered over views) ──
185
+ const dateLabel = $derived.by(() => {
186
+ if (viewState.granularity === 'day') {
187
+ return viewState.focusDate.toLocaleDateString(locale, {
188
+ weekday: 'long',
189
+ month: 'short',
190
+ day: 'numeric',
191
+ });
192
+ }
193
+ return viewState.focusDate.toLocaleDateString(locale, {
194
+ month: 'long',
195
+ year: 'numeric',
196
+ });
197
+ });
198
+
199
+ // Which granularities are available?
200
+ const granularities = $derived.by(() => {
201
+ const g = new Set(views.map((v) => v.granularity));
202
+ return (['day', 'week'] as const).filter((key) => g.has(key));
203
+ });
204
+
205
+ const L = $derived(getLabels());
181
206
  </script>
182
207
 
183
208
  <div
184
209
  class="cal"
185
210
  style="{theme}; --cal-h: {height}px"
186
211
  role="region"
187
- aria-label="Calendar"
212
+ aria-label={L.calendar}
188
213
  dir={dir}
189
214
  lang={locale}
190
215
  >
191
- {#if showToolbar}
192
- <Toolbar {viewState} views={toolbarViews} {links} {locale} />
216
+ <!-- Floating granularity pills (hidden for Agenda views) -->
217
+ {#if granularities.length > 1 && activeView?.label !== 'Agenda'}
218
+ <div class="cal-pills" role="group" aria-label={L.viewMode}>
219
+ {#each granularities as g}
220
+ <button
221
+ class="cal-pill"
222
+ class:cal-pill--active={viewState.granularity === g}
223
+ aria-pressed={viewState.granularity === g}
224
+ onclick={() => {
225
+ const currentLabel = views.find((v) => v.id === viewState.view)?.label;
226
+ const match = views.find((v) => v.granularity === g && v.label === currentLabel);
227
+ const fallback = views.find((v) => v.granularity === g);
228
+ const target = match ?? fallback;
229
+ if (target) viewState.setView(target.id);
230
+ }}
231
+ >
232
+ {g === 'day' ? L.day : L.week}
233
+ </button>
234
+ {/each}
235
+ </div>
193
236
  {/if}
194
237
 
195
238
  <div class="cal-body">
@@ -198,7 +241,7 @@
198
241
  <Comp
199
242
  events={store.events}
200
243
  style={theme}
201
- height={height - (showToolbar ? 48 : 0)}
244
+ height={null}
202
245
  mondayStart={viewState.mondayStart}
203
246
  {locale}
204
247
  focusDate={viewState.focusDate}
@@ -225,15 +268,60 @@
225
268
  height: var(--cal-h, 600px);
226
269
  background: var(--dt-bg, #0b0e14);
227
270
  border-radius: 12px;
228
- overflow: hidden;
271
+ overflow: clip;
229
272
  display: flex;
230
273
  flex-direction: column;
231
274
  border: 1px solid var(--dt-border, rgba(148, 163, 184, 0.07));
232
275
  }
233
276
 
277
+ /* ── Floating pills ── */
278
+ .cal-pills {
279
+ position: absolute;
280
+ top: 22px;
281
+ bottom: auto;
282
+ left: 10px;
283
+ z-index: 20;
284
+ display: flex;
285
+ gap: 2px;
286
+ background: color-mix(in srgb, var(--dt-surface, #10141c) 85%, transparent);
287
+ backdrop-filter: blur(6px);
288
+ -webkit-backdrop-filter: blur(6px);
289
+ border-radius: 8px;
290
+ padding: 2px;
291
+ border: 1px solid var(--dt-border, rgba(148, 163, 184, 0.07));
292
+ }
293
+
294
+ .cal-pill {
295
+ border: none;
296
+ background: transparent;
297
+ color: var(--dt-text-2, rgba(148, 163, 184, 0.55));
298
+ cursor: pointer;
299
+ font: 600 11px / 1 var(--dt-sans, 'Outfit', system-ui, sans-serif);
300
+ padding: 6px 12px;
301
+ border-radius: 6px;
302
+ letter-spacing: 0.04em;
303
+ text-transform: uppercase;
304
+ transition: background 100ms, color 100ms;
305
+ }
306
+
307
+ .cal-pill:hover {
308
+ color: var(--dt-text, rgba(226, 232, 240, 0.85));
309
+ }
310
+
311
+ .cal-pill--active {
312
+ background: var(--dt-accent, #ef4444);
313
+ color: var(--dt-btn-text, #fff);
314
+ }
315
+
316
+ .cal-pill:focus-visible {
317
+ outline: 2px solid color-mix(in srgb, var(--dt-accent, #ef4444) 55%, transparent);
318
+ outline-offset: 2px;
319
+ }
320
+
234
321
  .cal-body {
235
322
  flex: 1;
236
323
  min-height: 0;
324
+ position: relative;
237
325
  overflow: hidden;
238
326
  }
239
327