@nomideusz/svelte-calendar 0.4.0 → 0.5.2

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 +840 -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
@@ -0,0 +1,957 @@
1
+ <!--
2
+ Planner day mode — horizontal filmstrip timeline.
3
+
4
+ Time flows left → right. Past on the left, future on the right.
5
+ The now-line tracks the current time. Drag to scroll through days.
6
+ Events are positioned as horizontal cards with overlap support.
7
+ -->
8
+ <script lang="ts">
9
+ import { getContext, onMount, tick, untrack } from 'svelte';
10
+ import { fly } from 'svelte/transition';
11
+ import { createClock } from '../../core/clock.svelte.js';
12
+ import type { TimelineEvent } from '../../core/types.js';
13
+ import type { DragState } from '../../engine/drag.svelte.js';
14
+ import type { ViewState } from '../../engine/view-state.svelte.js';
15
+ import { DAY_MS, HOUR_MS, sod } from '../../core/time.js';
16
+ import { isAllDay, isMultiDay, segmentForDay } from '../../core/time.js';
17
+ import type { DaySegment } from '../../core/time.js';
18
+ import { fmtH, fmtTime } from '../../core/locale.js';
19
+ import { getLabels } from '../../core/locale.js';
20
+
21
+ const L = $derived(getLabels());
22
+
23
+ interface Props {
24
+ /** Total height (null = fill parent) */
25
+ height?: number | null;
26
+ /** Events to render */
27
+ events?: TimelineEvent[];
28
+ /** Inline style for CSS variable overrides (theme) */
29
+ style?: string;
30
+ /** The date to centre this view on */
31
+ focusDate?: Date;
32
+ /** Locale for labels */
33
+ locale?: string;
34
+ /** Called when the user clicks an event */
35
+ oneventclick?: (event: TimelineEvent) => void;
36
+ /** Called when the user clicks an empty time slot */
37
+ oneventcreate?: (range: { start: Date; end: Date }) => void;
38
+ /** Currently selected event ID (for highlight) */
39
+ selectedEventId?: string | null;
40
+ /** Read-only mode */
41
+ readOnly?: boolean;
42
+ /** Visible hour range [startHour, endHour) */
43
+ visibleHours?: [number, number];
44
+ [key: string]: unknown;
45
+ }
46
+
47
+ let {
48
+ height = 520,
49
+ events = [],
50
+ style = '',
51
+ locale,
52
+ focusDate,
53
+ oneventclick,
54
+ oneventcreate,
55
+ selectedEventId = null,
56
+ readOnly = false,
57
+ visibleHours,
58
+ }: Props = $props();
59
+
60
+ // ── Context (available when inside Calendar) ──
61
+ const drag = getContext<DragState>('calendar:drag') as DragState | undefined;
62
+ const commitDragCtx = getContext<() => void>('calendar:commitDrag') as (() => void) | undefined;
63
+ const snapIntervalCtx = getContext<{ current: number }>('calendar:snapInterval');
64
+ const viewState = getContext<ViewState>('calendar:viewState') as ViewState | undefined;
65
+
66
+ const clock = createClock();
67
+
68
+ // ─── State ──────────────────────────────────────────
69
+ let following = $state(true);
70
+ let scrollDragging = $state(false);
71
+ let wasDragging = false;
72
+ let el: HTMLDivElement;
73
+ let containerW = $state(0);
74
+ let containerH = $state(520);
75
+ let dragStartX = 0;
76
+ let dragScrollStart = 0;
77
+ let rafId = 0;
78
+
79
+ // ─── Infinite-scroll config ────────────────────────
80
+ const BUFFER_DAYS = 7; // days rendered on each side of centre
81
+ const EDGE_DAYS = 2; // rebase when this close to an edge
82
+ const SHIFT_DAYS = 5; // days to shift per rebase
83
+
84
+ // Internal centre drives layout; syncs bidirectionally with focusDate.
85
+ const _initMs = untrack(() => sod(focusDate?.getTime() ?? Date.now()));
86
+ let internalCenterMs = $state(_initMs);
87
+ let lastExternalMs = _initMs;
88
+ let rebasing = false;
89
+ let visibleDayMs = $state(_initMs);
90
+
91
+ // ─── Derived Config ─────────────────────────────────
92
+ const startHour = $derived(visibleHours?.[0] ?? 0);
93
+ const endHour = $derived(visibleHours?.[1] ?? 24);
94
+ const hourCount = $derived(Math.max(1, endHour - startHour));
95
+ const SNAP_MS = $derived((snapIntervalCtx?.current ?? 15) * 60_000);
96
+ const DAY_GAP = 2;
97
+
98
+ const dateLabel = $derived(
99
+ new Date(visibleDayMs).toLocaleDateString(locale ?? 'en-US', {
100
+ weekday: 'long',
101
+ month: 'long',
102
+ day: 'numeric',
103
+ })
104
+ );
105
+
106
+ const count = 1 + 2 * BUFFER_DAYS;
107
+ const origin = $derived(internalCenterMs - BUFFER_DAYS * DAY_MS);
108
+
109
+ // Auto-calculate hourWidth — minimum 60px so hours stay readable.
110
+ // When 24h × 60px exceeds the container, the timeline scrolls horizontally.
111
+ const MIN_HOUR_W = 60;
112
+ const hourWidth = $derived(containerW > 0 ? Math.max(MIN_HOUR_W, containerW / hourCount) : 110);
113
+ const dayWidth = $derived(hourCount * hourWidth);
114
+ const totalWidth = $derived(count * (dayWidth + DAY_GAP));
115
+
116
+ // ─── Day Layouts ────────────────────────────────────
117
+ interface DayLayout {
118
+ ms: number;
119
+ today: boolean;
120
+ past: boolean;
121
+ x: number;
122
+ }
123
+
124
+ const days = $derived.by(() => {
125
+ const result: DayLayout[] = [];
126
+ for (let i = 0; i < count; i++) {
127
+ const ms = origin + i * DAY_MS;
128
+ result.push({
129
+ ms,
130
+ today: ms === clock.today,
131
+ past: ms < clock.today,
132
+ x: i * (dayWidth + DAY_GAP),
133
+ });
134
+ }
135
+ return result;
136
+ });
137
+
138
+ // ─── Coordinate Conversion ──────────────────────────
139
+ function timeToPx(ms: number): number {
140
+ const elapsed = ms - origin;
141
+ const dayIndex = Math.floor(elapsed / DAY_MS);
142
+ const hourInDay = (elapsed - dayIndex * DAY_MS) / HOUR_MS;
143
+ const hourOffset = hourInDay - startHour;
144
+ return dayIndex * (dayWidth + DAY_GAP) + hourOffset * hourWidth;
145
+ }
146
+
147
+ function pxToTime(px: number): number {
148
+ const dayStride = dayWidth + DAY_GAP;
149
+ const dayIndex = Math.max(0, Math.min(count - 1, Math.floor(px / dayStride)));
150
+ const localPx = px - dayIndex * dayStride;
151
+ const hour = startHour + localPx / hourWidth;
152
+ return origin + dayIndex * DAY_MS + hour * HOUR_MS;
153
+ }
154
+
155
+ const nowPx = $derived(timeToPx(clock.tick));
156
+
157
+ // ─── Separate all-day from timed events ────────────
158
+ const timedEvents = $derived(events.filter((ev) => !isAllDay(ev) && !isMultiDay(ev)));
159
+ const allDayEvents = $derived.by(() => {
160
+ const segs: DaySegment[] = [];
161
+ for (const ev of events) {
162
+ if (!isAllDay(ev) && !isMultiDay(ev)) continue;
163
+ const seg = segmentForDay(ev, visibleDayMs);
164
+ if (seg) segs.push(seg);
165
+ }
166
+ return segs;
167
+ });
168
+
169
+ // ─── Event Layout ───────────────────────────────────
170
+ const CONTENT_TOP = 56;
171
+ const ALLDAY_H = 24;
172
+ const contentTop = $derived(CONTENT_TOP + (allDayEvents.length > 0 ? ALLDAY_H + 4 : 0));
173
+ const EVENT_GAP = 5;
174
+ const MIN_EVENT_H = 32;
175
+
176
+ interface PositionedEvent {
177
+ ev: TimelineEvent;
178
+ x: number;
179
+ width: number;
180
+ row: number;
181
+ groupMaxRow: number;
182
+ topPx: number;
183
+ heightPx: number;
184
+ isCurrent: boolean;
185
+ isDragged: boolean;
186
+ }
187
+
188
+ const positionedEvents = $derived.by(() => {
189
+ const now = clock.tick;
190
+ const dragP = drag?.active && drag.mode === 'move' ? drag.payload : null;
191
+
192
+ const staticEvents: typeof timedEvents = [];
193
+ let draggedEv: TimelineEvent | null = null;
194
+ for (const ev of timedEvents) {
195
+ if (dragP?.eventId === ev.id) draggedEv = ev;
196
+ else staticEvents.push(ev);
197
+ }
198
+
199
+ const sorted = [...staticEvents].sort((a, b) => a.start.getTime() - b.start.getTime());
200
+
201
+ const infos = sorted.map((ev) => {
202
+ const s = ev.start.getTime();
203
+ const e = ev.end.getTime();
204
+ const x = timeToPx(s);
205
+ const xEnd = timeToPx(e);
206
+ return {
207
+ ev, x, width: Math.max(xEnd - x, 28), row: 0, groupMaxRow: 1,
208
+ isCurrent: s <= now && e > now, isDragged: false, startMs: s, endMs: e,
209
+ };
210
+ });
211
+
212
+ // Union-find overlap groups
213
+ const par = infos.map((_, i) => i);
214
+ function find(i: number): number {
215
+ while (par[i] !== i) { par[i] = par[par[i]]; i = par[i]; }
216
+ return i;
217
+ }
218
+ for (let i = 0; i < infos.length; i++) {
219
+ for (let j = i + 1; j < infos.length; j++) {
220
+ if (infos[j].startMs < infos[i].endMs) par[find(i)] = find(j);
221
+ else break;
222
+ }
223
+ }
224
+
225
+ const groups = new Map<number, number[]>();
226
+ for (let i = 0; i < infos.length; i++) {
227
+ const root = find(i);
228
+ if (!groups.has(root)) groups.set(root, []);
229
+ groups.get(root)!.push(i);
230
+ }
231
+
232
+ for (const [, indices] of groups) {
233
+ const rows: number[] = [];
234
+ for (const idx of indices) {
235
+ const inf = infos[idx];
236
+ let row = 0;
237
+ for (let r = 0; r < rows.length; r++) {
238
+ if (rows[r] <= inf.startMs) { row = r; rows[r] = inf.endMs; break; }
239
+ row = r + 1;
240
+ }
241
+ if (row >= rows.length) rows.push(inf.endMs);
242
+ infos[idx].row = row;
243
+ }
244
+ for (const idx of indices) infos[idx].groupMaxRow = rows.length;
245
+ }
246
+
247
+ const availH = containerH - contentTop - 8;
248
+ const result: PositionedEvent[] = infos.map(({ startMs: _s, endMs: _e, ...info }) => {
249
+ const laneH = Math.max(MIN_EVENT_H, availH / info.groupMaxRow - EVENT_GAP);
250
+ const topPx = contentTop + info.row * (availH / info.groupMaxRow);
251
+ return { ...info, topPx, heightPx: laneH };
252
+ });
253
+
254
+ if (draggedEv && dragP) {
255
+ const x = timeToPx(dragP.start.getTime());
256
+ const xEnd = timeToPx(dragP.end.getTime());
257
+ result.push({
258
+ ev: draggedEv, x, width: Math.max(xEnd - x, 28),
259
+ row: 0, groupMaxRow: 1,
260
+ topPx: contentTop,
261
+ heightPx: Math.max(MIN_EVENT_H, availH - EVENT_GAP),
262
+ isCurrent: draggedEv.start.getTime() <= now && draggedEv.end.getTime() > now,
263
+ isDragged: true,
264
+ });
265
+ }
266
+
267
+ return result;
268
+ });
269
+
270
+ // ─── Infinite-scroll helpers ────────────────────────
271
+
272
+ /** Detect external focusDate changes (from nav arrows). */
273
+ $effect(() => {
274
+ const ext = focusDate ? sod(focusDate.getTime()) : clock.today;
275
+ if (ext !== lastExternalMs && !rebasing) {
276
+ lastExternalMs = ext;
277
+ internalCenterMs = ext;
278
+ visibleDayMs = ext;
279
+ following = false;
280
+ // After DOM update, recenter scroll on focus day.
281
+ tick().then(() => {
282
+ if (el) {
283
+ const focusX = BUFFER_DAYS * (dayWidth + DAY_GAP);
284
+ el.scrollLeft = focusX + dayWidth / 2 - el.clientWidth / 2;
285
+ }
286
+ });
287
+ }
288
+ });
289
+
290
+ /** Check if viewport is near an edge; if so, rebase the window. */
291
+ function checkEdges() {
292
+ if (!el || !viewState || rebasing) return;
293
+ const stride = dayWidth + DAY_GAP;
294
+ const threshold = stride * EDGE_DAYS;
295
+ const maxScroll = el.scrollWidth - el.clientWidth;
296
+
297
+ if (el.scrollLeft < threshold) {
298
+ rebase(-1);
299
+ } else if (maxScroll > 0 && maxScroll - el.scrollLeft < threshold) {
300
+ rebase(1);
301
+ }
302
+ }
303
+
304
+ /** Shift the rendered window by SHIFT_DAYS in the given direction. */
305
+ function rebase(direction: number) {
306
+ if (rebasing) return;
307
+ rebasing = true;
308
+
309
+ const shift = SHIFT_DAYS * direction;
310
+ const stride = dayWidth + DAY_GAP;
311
+ const adj = -shift * stride;
312
+
313
+ // Pre-compensate scroll so the viewport stays visually stable.
314
+ if (el) el.scrollLeft += adj;
315
+ dragScrollStart += adj;
316
+
317
+ internalCenterMs += shift * DAY_MS;
318
+ lastExternalMs = internalCenterMs;
319
+ viewState?.setFocusDate(new Date(internalCenterMs));
320
+
321
+ tick().then(() => { rebasing = false; });
322
+ }
323
+
324
+ /** Push the currently visible centre day to viewState (updates Calendar date label). */
325
+ function syncFocusFromScroll() {
326
+ if (!el || !viewState || following || rebasing) return;
327
+ const centerX = el.scrollLeft + el.clientWidth / 2;
328
+ const centerDayMs = sod(pxToTime(centerX));
329
+ visibleDayMs = centerDayMs;
330
+ if (centerDayMs !== lastExternalMs) {
331
+ lastExternalMs = centerDayMs;
332
+ viewState.setFocusDate(new Date(centerDayMs));
333
+ }
334
+ }
335
+
336
+ // ─── Lifecycle ──────────────────────────────────────
337
+ onMount(() => {
338
+ const ro = new ResizeObserver((entries) => {
339
+ for (const entry of entries) {
340
+ containerW = entry.contentRect.width;
341
+ containerH = entry.contentRect.height;
342
+ }
343
+ });
344
+ ro.observe(el);
345
+
346
+ function frame() {
347
+ if (following && el && !scrollDragging) {
348
+ // Ensure today is in the strip.
349
+ if (internalCenterMs !== clock.today) {
350
+ internalCenterMs = clock.today;
351
+ lastExternalMs = clock.today;
352
+ viewState?.goToday();
353
+ }
354
+ visibleDayMs = clock.today;
355
+ const todayD = days.find((d) => d.today);
356
+ if (todayD) {
357
+ el.scrollLeft = todayD.x + dayWidth / 2 - el.clientWidth / 2;
358
+ }
359
+ } else if (el && !rebasing) {
360
+ checkEdges();
361
+ syncFocusFromScroll();
362
+ }
363
+ rafId = requestAnimationFrame(frame);
364
+ }
365
+ rafId = requestAnimationFrame(frame);
366
+ return () => { cancelAnimationFrame(rafId); ro.disconnect(); };
367
+ });
368
+
369
+ // ─── Drag-to-scroll ─────────────────────────────────
370
+ const SCROLL_THRESHOLD = 3;
371
+
372
+ function onPointerDown(e: PointerEvent) {
373
+ if (e.button !== 0) return;
374
+ if (!readOnly) return; // drag-to-scroll only in read-only mode
375
+ if ((e.target as HTMLElement).closest('.fs-event')) return;
376
+ dragStartX = e.clientX;
377
+ dragScrollStart = el.scrollLeft;
378
+ window.addEventListener('pointermove', onScrollMove);
379
+ window.addEventListener('pointerup', onScrollUp, { once: true });
380
+ window.addEventListener('pointercancel', onScrollUp, { once: true });
381
+ }
382
+
383
+ function onScrollMove(e: PointerEvent) {
384
+ const dx = e.clientX - dragStartX;
385
+ if (!scrollDragging && Math.abs(dx) >= SCROLL_THRESHOLD) {
386
+ scrollDragging = true;
387
+ wasDragging = true;
388
+ following = false;
389
+ }
390
+ if (scrollDragging) el.scrollLeft = dragScrollStart - dx;
391
+ }
392
+
393
+ function onScrollUp() {
394
+ window.removeEventListener('pointermove', onScrollMove);
395
+ window.removeEventListener('pointerup', onScrollUp);
396
+ window.removeEventListener('pointercancel', onScrollUp);
397
+ scrollDragging = false;
398
+ }
399
+
400
+ // ─── Click-to-create ────────────────────────────────
401
+ function handleTrackClick(e: MouseEvent) {
402
+ if (wasDragging) { wasDragging = false; return; }
403
+ if (!oneventcreate || readOnly) return;
404
+ if ((e.target as HTMLElement).closest('.fs-event')) return;
405
+
406
+ const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
407
+ const clickX = e.clientX - rect.left + el.scrollLeft;
408
+
409
+ for (const d of days) {
410
+ if (clickX >= d.x && clickX < d.x + dayWidth) {
411
+ const frac = (clickX - d.x) / dayWidth;
412
+ const hour = Math.floor(startHour + frac * hourCount);
413
+ const start = new Date(d.ms + hour * HOUR_MS);
414
+ const end = new Date(d.ms + (hour + 1) * HOUR_MS);
415
+ oneventcreate({ start, end });
416
+ return;
417
+ }
418
+ }
419
+ }
420
+
421
+ // ─── Event drag-to-move ─────────────────────────────
422
+ const DRAG_THRESHOLD = 5;
423
+ let evDragStartX = 0;
424
+ let evDragOriginPx = 0;
425
+ let evDragStarted = false;
426
+ let evDragging = $state(false);
427
+ let evDragId = $state<string | null>(null);
428
+ let evDragEvent: TimelineEvent | null = null;
429
+
430
+ function onEventPointerDown(e: PointerEvent, ev: TimelineEvent) {
431
+ if (e.button !== 0 || !drag || readOnly) return;
432
+ e.stopPropagation();
433
+ evDragStartX = e.clientX;
434
+ evDragOriginPx = timeToPx(ev.start.getTime());
435
+ evDragStarted = false;
436
+ evDragId = ev.id;
437
+ evDragEvent = ev;
438
+ window.addEventListener('pointermove', onEvMove);
439
+ window.addEventListener('pointerup', onEvUp, { once: true });
440
+ window.addEventListener('pointercancel', onEvCancel, { once: true });
441
+ }
442
+
443
+ function onEvMove(e: PointerEvent) {
444
+ const ev = evDragEvent;
445
+ if (!drag || !ev || evDragId !== ev.id) return;
446
+ const dx = e.clientX - evDragStartX;
447
+ if (!evDragStarted && Math.abs(dx) < DRAG_THRESHOLD) return;
448
+
449
+ if (!evDragStarted) {
450
+ evDragStarted = true;
451
+ evDragging = true;
452
+ drag.beginMove(ev.id, ev.start, ev.end);
453
+ }
454
+
455
+ const duration = ev.end.getTime() - ev.start.getTime();
456
+ const raw = pxToTime(evDragOriginPx + dx);
457
+ const snapped = Math.round(raw / SNAP_MS) * SNAP_MS;
458
+ drag.updatePointer(new Date(snapped), new Date(snapped + duration));
459
+ }
460
+
461
+ function cleanupEvDrag() {
462
+ window.removeEventListener('pointermove', onEvMove);
463
+ window.removeEventListener('pointerup', onEvUp);
464
+ window.removeEventListener('pointercancel', onEvCancel);
465
+ evDragStarted = false;
466
+ evDragging = false;
467
+ evDragId = null;
468
+ evDragEvent = null;
469
+ }
470
+
471
+ function onEvUp() {
472
+ if (!drag) { cleanupEvDrag(); return; }
473
+ if (!evDragStarted && evDragEvent) oneventclick?.(evDragEvent);
474
+ else if (evDragStarted) commitDragCtx?.();
475
+ cleanupEvDrag();
476
+ }
477
+
478
+ function onEvCancel() {
479
+ if (drag && evDragStarted) drag.cancel();
480
+ cleanupEvDrag();
481
+ }
482
+ </script>
483
+
484
+ <div class="fs" style={style || undefined} style:height={height ? `${height}px` : '100%'} role="region" aria-label={L.dayPlanner}>
485
+ <div
486
+ class="fs-scroll"
487
+ class:fs-grabbing={scrollDragging}
488
+ class:fs-readonly={readOnly}
489
+ bind:this={el}
490
+ onwheel={(e) => { e.preventDefault(); el.scrollLeft += e.deltaY || e.deltaX; following = false; }}
491
+ onpointerdown={onPointerDown}
492
+ role="application"
493
+ aria-label={L.scrollableDayPlanner}
494
+ >
495
+ <div class="fs-track" style:width="{totalWidth}px" onclick={handleTrackClick} role="none">
496
+ {#each days as d (d.ms)}
497
+ <div
498
+ class="fs-day"
499
+ class:fs-today={d.today}
500
+ class:fs-past={d.past}
501
+ style:left="{d.x}px"
502
+ style:width="{dayWidth}px"
503
+ >
504
+ {#each { length: hourCount } as _, h}
505
+ {@const hour = startHour + h}
506
+ {@const x = h * hourWidth}
507
+ <div class="fs-tick" style:left="{x}px">
508
+ <span class="fs-tick-lb">{fmtH(hour, locale)}</span>
509
+ </div>
510
+ <div class="fs-tick fs-tick--half" style:left="{x + hourWidth * 0.5}px"></div>
511
+ {/each}
512
+ </div>
513
+ {/each}
514
+
515
+ <!-- Now line -->
516
+ <div class="fs-now" style:left="{nowPx}px">
517
+ <span class="fs-now-tag">{clock.hm}<span class="fs-now-sec">{clock.s}</span></span>
518
+ <div class="fs-now-line"></div>
519
+ </div>
520
+
521
+ <!-- Events -->
522
+ {#each positionedEvents as p (p.ev.id)}
523
+ <div
524
+ class="fs-event"
525
+ class:fs-event--selected={selectedEventId === p.ev.id}
526
+ class:fs-event--current={p.isCurrent}
527
+ class:fs-event--dragging={p.isDragged}
528
+ style:left="{p.x}px"
529
+ style:width="{p.width}px"
530
+ style:top="{p.topPx}px"
531
+ style:height="{p.heightPx}px"
532
+ style:--ev-color={p.ev.color ?? 'var(--dt-accent)'}
533
+ role="button"
534
+ tabindex="0"
535
+ aria-label="{p.ev.title}{p.isCurrent ? ` (${L.inProgress})` : ''}"
536
+ onpointerdown={(e) => onEventPointerDown(e, p.ev)}
537
+ onkeydown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); oneventclick?.(p.ev); } }}
538
+ >
539
+ <div class="fs-ev-inner">
540
+ {#if p.isCurrent}
541
+ <span class="fs-ev-live" aria-hidden="true"></span>
542
+ {/if}
543
+ {#if p.heightPx > 64}
544
+ <span class="fs-ev-time">{fmtTime(p.ev.start, locale)} – {fmtTime(p.ev.end, locale)}</span>
545
+ {/if}
546
+ <span class="fs-ev-title">{p.ev.title}</span>
547
+ {#if p.ev.subtitle && p.heightPx > 48}
548
+ <span class="fs-ev-sub">{p.ev.subtitle}</span>
549
+ {/if}
550
+ {#if p.ev.tags?.length && p.heightPx > 72}
551
+ <span class="fs-ev-tags">
552
+ {#each p.ev.tags as tag}
553
+ <span class="fs-ev-tag">{tag}</span>
554
+ {/each}
555
+ </span>
556
+ {/if}
557
+ </div>
558
+ </div>
559
+ {/each}
560
+ </div>
561
+ </div>
562
+
563
+ <!-- All-day events banner (outside scroll so it stays in viewport) -->
564
+ {#if allDayEvents.length > 0}
565
+ <div class="fs-allday" style:top="{CONTENT_TOP}px">
566
+ {#each allDayEvents as seg (seg.ev.id)}
567
+ <div
568
+ class="fs-ad"
569
+ class:fs-ad--start={seg.isStart}
570
+ class:fs-ad--selected={selectedEventId === seg.ev.id}
571
+ style:--ev-color={seg.ev.color ?? 'var(--dt-accent)'}
572
+ role="button"
573
+ tabindex="0"
574
+ aria-label="{seg.ev.title}{seg.totalDays > 1 ? `, ${L.dayNOfTotal(seg.dayIndex, seg.totalDays)}` : `, ${L.allDay}`}"
575
+ onclick={() => oneventclick?.(seg.ev)}
576
+ onkeydown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); oneventclick?.(seg.ev); } }}
577
+ >
578
+ <span class="fs-ad-dot" aria-hidden="true"></span>
579
+ <span class="fs-ad-title">{seg.ev.title}</span>
580
+ {#if seg.totalDays > 1}
581
+ <span class="fs-ad-span">{L.day} {seg.dayIndex}/{seg.totalDays}</span>
582
+ {:else}
583
+ <span class="fs-ad-span">{L.allDay}</span>
584
+ {/if}
585
+ </div>
586
+ {/each}
587
+ </div>
588
+ {/if}
589
+
590
+ <div class="fs-date-label">{dateLabel}</div>
591
+
592
+ <nav class="fs-nav" aria-label={L.dayNavigation}>
593
+ <button
594
+ class="fs-nav-pill fs-nav-today"
595
+ class:fs-nav-today--hidden={following}
596
+ onclick={() => { internalCenterMs = clock.today; lastExternalMs = clock.today; viewState?.goToday(); following = true; }}
597
+ aria-label={L.goToToday}
598
+ tabindex={following ? -1 : 0}
599
+ >
600
+ {L.today}
601
+ </button>
602
+ <button
603
+ class="fs-nav-pill"
604
+ onclick={() => { const prev = internalCenterMs - DAY_MS; internalCenterMs = prev; lastExternalMs = prev; viewState?.prev(); following = false; }}
605
+ aria-label={L.previousDay}
606
+ >
607
+ <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" width="12" height="12" aria-hidden="true"><path d="M10 3 5 8l5 5"/></svg>
608
+ </button>
609
+ <button
610
+ class="fs-nav-pill"
611
+ onclick={() => { const next = internalCenterMs + DAY_MS; internalCenterMs = next; lastExternalMs = next; viewState?.next(); following = false; }}
612
+ aria-label={L.nextDay}
613
+ >
614
+ <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" width="12" height="12" aria-hidden="true"><path d="M6 3l5 5-5 5"/></svg>
615
+ </button>
616
+ </nav>
617
+ </div>
618
+
619
+ <style>
620
+ /* ─── Container ──────────────────────────────────── */
621
+ .fs {
622
+ position: relative;
623
+ overflow: hidden;
624
+ user-select: none;
625
+ font-variant-numeric: tabular-nums;
626
+ }
627
+
628
+ /* ─── Horizontal scroll ──────────────────────────── */
629
+ .fs-scroll {
630
+ width: 100%;
631
+ height: 100%;
632
+ overflow-x: auto;
633
+ overflow-y: hidden;
634
+ touch-action: pan-x;
635
+ cursor: default;
636
+ scrollbar-width: thin;
637
+ scrollbar-color: var(--dt-scrollbar, rgba(0, 0, 0, 0.08)) transparent;
638
+ }
639
+ .fs-scroll::-webkit-scrollbar { height: 5px; }
640
+ .fs-scroll::-webkit-scrollbar-thumb {
641
+ background: var(--dt-scrollbar, rgba(0, 0, 0, 0.08));
642
+ border-radius: 4px;
643
+ }
644
+ .fs-scroll::-webkit-scrollbar-track { background: transparent; }
645
+ .fs-readonly { cursor: grab; }
646
+ .fs-grabbing { cursor: grabbing; }
647
+
648
+ .fs-track {
649
+ position: relative;
650
+ height: 100%;
651
+ }
652
+
653
+ /* ─── Day block ──────────────────────────────────── */
654
+ .fs-day {
655
+ position: absolute;
656
+ top: 0;
657
+ height: 100%;
658
+ border-left: 1px solid var(--dt-border-day, rgba(148, 163, 184, 0.14));
659
+ box-sizing: border-box;
660
+ }
661
+ .fs-day:first-of-type { border-left: none; }
662
+ .fs-today { background: var(--dt-today-bg, rgba(239, 68, 68, 0.025)); }
663
+ .fs-past { opacity: 0.7; }
664
+
665
+ /* ─── Hour ticks ─────────────────────────────────── */
666
+ .fs-tick {
667
+ position: absolute;
668
+ top: 0;
669
+ bottom: 0;
670
+ width: 0;
671
+ }
672
+ .fs-tick::before {
673
+ content: '';
674
+ position: absolute;
675
+ top: 18px;
676
+ bottom: 0;
677
+ width: 1px;
678
+ background: var(--dt-border, rgba(148, 163, 184, 0.07));
679
+ }
680
+ .fs-tick-lb {
681
+ position: absolute;
682
+ top: 2px;
683
+ left: 5px;
684
+ font: 500 10px/1 var(--dt-mono, ui-monospace, monospace);
685
+ color: var(--dt-text-3, rgba(100, 116, 139, 0.7));
686
+ white-space: nowrap;
687
+ pointer-events: none;
688
+ }
689
+ .fs-tick--half { bottom: auto; height: calc(18px + 8px); }
690
+ .fs-tick--half::before {
691
+ top: 18px;
692
+ height: 6px;
693
+ bottom: auto;
694
+ opacity: 0.4;
695
+ }
696
+
697
+ /* ─── Now-line ────────────────────────────────────── */
698
+ .fs-now {
699
+ position: absolute;
700
+ top: 0;
701
+ bottom: 0;
702
+ z-index: 10;
703
+ pointer-events: none;
704
+ transform: translateX(-1px);
705
+ }
706
+ .fs-now-line {
707
+ position: absolute;
708
+ top: 0;
709
+ bottom: 0;
710
+ left: 0;
711
+ width: 2px;
712
+ background: var(--dt-accent, #ef4444);
713
+ box-shadow: 0 0 8px var(--dt-glow, rgba(239, 68, 68, 0.35));
714
+ }
715
+ .fs-now-tag {
716
+ position: absolute;
717
+ top: 8px;
718
+ left: 8px;
719
+ font: 700 11px/1 var(--dt-mono, ui-monospace, monospace);
720
+ color: var(--dt-accent, #ef4444);
721
+ background: color-mix(in srgb, var(--dt-bg, #0b0e14) 92%, var(--dt-accent, #ef4444));
722
+ border: 1px solid var(--dt-accent-dim, rgba(239, 68, 68, 0.18));
723
+ padding: 3px 6px;
724
+ border-radius: 4px;
725
+ white-space: nowrap;
726
+ z-index: 1;
727
+ }
728
+ .fs-now-sec {
729
+ font-weight: 400;
730
+ opacity: 0.5;
731
+ font-size: 10px;
732
+ }
733
+
734
+ /* ─── Floating date label ────────────────────────── */
735
+ .fs-date-label {
736
+ position: absolute;
737
+ top: 22px;
738
+ left: 50%;
739
+ transform: translateX(-50%);
740
+ z-index: 20;
741
+ font: 600 11px/1 var(--dt-sans, system-ui, sans-serif);
742
+ letter-spacing: 0.04em;
743
+ text-transform: uppercase;
744
+ color: var(--dt-text, rgba(226, 232, 240, 0.85));
745
+ background: color-mix(in srgb, var(--dt-surface, #10141c) 85%, transparent);
746
+ backdrop-filter: blur(6px);
747
+ -webkit-backdrop-filter: blur(6px);
748
+ padding: 8px 16px;
749
+ border-radius: 8px;
750
+ border: 1px solid var(--dt-border, rgba(148, 163, 184, 0.07));
751
+ pointer-events: none;
752
+ white-space: nowrap;
753
+ }
754
+
755
+ /* ─── Navigation pills ────────────────────────────── */
756
+ .fs-nav {
757
+ position: absolute;
758
+ top: 22px;
759
+ right: 14px;
760
+ z-index: 20;
761
+ display: flex;
762
+ gap: 2px;
763
+ background: color-mix(in srgb, var(--dt-surface, #10141c) 85%, transparent);
764
+ backdrop-filter: blur(6px);
765
+ -webkit-backdrop-filter: blur(6px);
766
+ border-radius: 8px;
767
+ padding: 2px;
768
+ border: 1px solid var(--dt-border, rgba(148, 163, 184, 0.07));
769
+ }
770
+ .fs-nav-pill {
771
+ border: none;
772
+ background: transparent;
773
+ color: var(--dt-text-2, rgba(148, 163, 184, 0.55));
774
+ cursor: pointer;
775
+ font: 600 11px / 1 var(--dt-sans, 'Outfit', system-ui, sans-serif);
776
+ padding: 6px 12px;
777
+ border-radius: 6px;
778
+ letter-spacing: 0.04em;
779
+ text-transform: uppercase;
780
+ transition: background 100ms, color 100ms;
781
+ display: inline-flex;
782
+ align-items: center;
783
+ justify-content: center;
784
+ }
785
+ .fs-nav-pill:hover {
786
+ color: var(--dt-text, rgba(226, 232, 240, 0.85));
787
+ }
788
+ .fs-nav-today {
789
+ max-width: 60px;
790
+ overflow: hidden;
791
+ white-space: nowrap;
792
+ transition: max-width 250ms ease, padding 250ms ease, opacity 200ms ease;
793
+ }
794
+ .fs-nav-today--hidden {
795
+ max-width: 0;
796
+ padding-left: 0;
797
+ padding-right: 0;
798
+ opacity: 0;
799
+ pointer-events: none;
800
+ }
801
+ .fs-nav-pill:focus-visible {
802
+ outline: 2px solid color-mix(in srgb, var(--dt-accent, #ef4444) 55%, transparent);
803
+ outline-offset: 2px;
804
+ }
805
+
806
+ /* ─── All-day strip ─────────────────────────────── */
807
+ .fs-allday {
808
+ position: absolute;
809
+ left: 0;
810
+ right: 0;
811
+ display: flex;
812
+ gap: 6px;
813
+ padding: 0 8px;
814
+ z-index: 7;
815
+ overflow-x: auto;
816
+ scrollbar-width: none;
817
+ pointer-events: auto;
818
+ }
819
+ .fs-allday::-webkit-scrollbar { display: none; }
820
+
821
+ .fs-ad {
822
+ display: flex;
823
+ align-items: center;
824
+ gap: 4px;
825
+ padding: 2px 8px;
826
+ border-radius: 4px;
827
+ background: color-mix(in srgb, var(--ev-color) 18%, var(--dt-surface, #10141c));
828
+ border-left: 3px solid var(--ev-color);
829
+ white-space: nowrap;
830
+ flex-shrink: 0;
831
+ cursor: pointer;
832
+ transition: background 0.15s;
833
+ }
834
+ .fs-ad:hover {
835
+ background: color-mix(in srgb, var(--ev-color) 28%, var(--dt-surface, #10141c));
836
+ }
837
+ .fs-ad:focus-visible {
838
+ outline: 2px solid color-mix(in srgb, var(--dt-accent, #ef4444) 55%, transparent);
839
+ outline-offset: 2px;
840
+ }
841
+ .fs-ad--selected {
842
+ background: color-mix(in srgb, var(--ev-color) 30%, var(--dt-surface, #10141c));
843
+ border-left-width: 4px;
844
+ }
845
+
846
+ .fs-ad-dot {
847
+ width: 6px;
848
+ height: 6px;
849
+ border-radius: 50%;
850
+ background: var(--ev-color);
851
+ flex-shrink: 0;
852
+ }
853
+
854
+ .fs-ad-title {
855
+ font-size: 0.7rem;
856
+ font-weight: 500;
857
+ color: var(--dt-text, #e2e8f0);
858
+ max-width: 120px;
859
+ overflow: hidden;
860
+ text-overflow: ellipsis;
861
+ }
862
+
863
+ .fs-ad-span {
864
+ font-size: 0.6rem;
865
+ color: var(--dt-text-2, rgba(148, 163, 184, 0.55));
866
+ flex-shrink: 0;
867
+ }
868
+
869
+ /* ─── Events ─────────────────────────────────────── */
870
+ .fs-event {
871
+ position: absolute;
872
+ z-index: 6;
873
+ border-radius: 6px;
874
+ cursor: pointer;
875
+ background: color-mix(in srgb, var(--ev-color) 15%, var(--dt-surface, #10141c));
876
+ overflow: hidden;
877
+ display: flex;
878
+ align-items: center;
879
+ justify-content: center;
880
+ transition: box-shadow 120ms, background 120ms;
881
+ }
882
+ .fs-event:hover {
883
+ background: color-mix(in srgb, var(--ev-color) 25%, var(--dt-surface, #10141c));
884
+ box-shadow: 0 2px 12px color-mix(in srgb, var(--ev-color) 25%, transparent);
885
+ }
886
+ .fs-event--selected {
887
+ box-shadow: 0 0 0 2px var(--ev-color), 0 2px 14px color-mix(in srgb, var(--ev-color) 35%, transparent);
888
+ }
889
+ .fs-event--current {
890
+ background: color-mix(in srgb, var(--ev-color) 22%, var(--dt-surface, #10141c));
891
+ box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--ev-color) 20%, transparent);
892
+ }
893
+ .fs-event--dragging {
894
+ opacity: 0.85;
895
+ z-index: 50;
896
+ box-shadow: 0 4px 24px rgba(0, 0, 0, 0.35);
897
+ cursor: grabbing;
898
+ transition: none;
899
+ }
900
+
901
+ /* Event inner (vertical text, bottom-to-top reading) */
902
+ .fs-ev-inner {
903
+ writing-mode: vertical-rl;
904
+ transform: rotate(180deg);
905
+ display: flex;
906
+ align-items: center;
907
+ gap: 6px;
908
+ max-height: 100%;
909
+ overflow: hidden;
910
+ padding: 8px 4px;
911
+ }
912
+ .fs-ev-live {
913
+ flex-shrink: 0;
914
+ width: 7px;
915
+ height: 7px;
916
+ border-radius: 50%;
917
+ background: var(--ev-color);
918
+ }
919
+ .fs-ev-title {
920
+ font: 600 13px/1.15 var(--dt-sans, system-ui, sans-serif);
921
+ color: var(--dt-text, rgba(226, 232, 240, 0.92));
922
+ overflow: hidden;
923
+ text-overflow: ellipsis;
924
+ white-space: nowrap;
925
+ }
926
+ .fs-ev-time {
927
+ font: 400 10px/1 var(--dt-mono, ui-monospace, monospace);
928
+ color: var(--dt-text-2, rgba(148, 163, 184, 0.72));
929
+ opacity: 0.7;
930
+ white-space: nowrap;
931
+ }
932
+ .fs-ev-sub {
933
+ font: 400 11px/1 var(--dt-sans, system-ui, sans-serif);
934
+ color: var(--dt-text-2, rgba(148, 163, 184, 0.72));
935
+ opacity: 0.6;
936
+ white-space: nowrap;
937
+ overflow: hidden;
938
+ text-overflow: ellipsis;
939
+ }
940
+ .fs-ev-tags { display: flex; gap: 4px; }
941
+ .fs-ev-tag {
942
+ font: 500 8px/1 var(--dt-sans, system-ui, sans-serif);
943
+ color: var(--ev-color, var(--dt-accent));
944
+ background: color-mix(in srgb, var(--ev-color, var(--dt-accent)) 18%, transparent);
945
+ padding: 1px 4px;
946
+ border-radius: 3px;
947
+ white-space: nowrap;
948
+ }
949
+
950
+ /* ─── Focus-visible ──────────────────────────────── */
951
+ .fs-event:focus-visible {
952
+ outline: 2px solid var(--dt-accent, #ef4444);
953
+ outline-offset: 2px;
954
+ }
955
+ </style>
956
+
957
+