@nomideusz/svelte-calendar 0.1.0

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 (77) hide show
  1. package/README.md +168 -0
  2. package/dist/adapters/index.d.ts +4 -0
  3. package/dist/adapters/index.js +2 -0
  4. package/dist/adapters/memory.d.ts +14 -0
  5. package/dist/adapters/memory.js +33 -0
  6. package/dist/adapters/rest.d.ts +20 -0
  7. package/dist/adapters/rest.js +48 -0
  8. package/dist/adapters/types.d.ts +23 -0
  9. package/dist/adapters/types.js +1 -0
  10. package/dist/assets/favicon.svg +1 -0
  11. package/dist/calendar/Calendar.svelte +213 -0
  12. package/dist/calendar/Calendar.svelte.d.ts +45 -0
  13. package/dist/calendar/Toolbar.svelte +241 -0
  14. package/dist/calendar/Toolbar.svelte.d.ts +19 -0
  15. package/dist/calendar/index.d.ts +3 -0
  16. package/dist/calendar/index.js +3 -0
  17. package/dist/core/clock.svelte.d.ts +21 -0
  18. package/dist/core/clock.svelte.js +53 -0
  19. package/dist/core/index.d.ts +6 -0
  20. package/dist/core/index.js +8 -0
  21. package/dist/core/locale.d.ts +38 -0
  22. package/dist/core/locale.js +96 -0
  23. package/dist/core/time.d.ts +37 -0
  24. package/dist/core/time.js +68 -0
  25. package/dist/core/types.d.ts +91 -0
  26. package/dist/core/types.js +11 -0
  27. package/dist/engine/drag.svelte.d.ts +31 -0
  28. package/dist/engine/drag.svelte.js +58 -0
  29. package/dist/engine/event-store.svelte.d.ts +47 -0
  30. package/dist/engine/event-store.svelte.js +120 -0
  31. package/dist/engine/index.d.ts +8 -0
  32. package/dist/engine/index.js +5 -0
  33. package/dist/engine/selection.svelte.d.ts +24 -0
  34. package/dist/engine/selection.svelte.js +58 -0
  35. package/dist/engine/view-state.svelte.d.ts +24 -0
  36. package/dist/engine/view-state.svelte.js +74 -0
  37. package/dist/index.d.ts +13 -0
  38. package/dist/index.js +14 -0
  39. package/dist/primitives/DayHeader.svelte +103 -0
  40. package/dist/primitives/DayHeader.svelte.d.ts +15 -0
  41. package/dist/primitives/EmptySlot.svelte +101 -0
  42. package/dist/primitives/EmptySlot.svelte.d.ts +16 -0
  43. package/dist/primitives/EventBlock.svelte +241 -0
  44. package/dist/primitives/EventBlock.svelte.d.ts +24 -0
  45. package/dist/primitives/NowIndicator.svelte +175 -0
  46. package/dist/primitives/NowIndicator.svelte.d.ts +22 -0
  47. package/dist/primitives/TimeGutter.svelte +105 -0
  48. package/dist/primitives/TimeGutter.svelte.d.ts +14 -0
  49. package/dist/primitives/index.d.ts +5 -0
  50. package/dist/primitives/index.js +6 -0
  51. package/dist/theme/index.d.ts +2 -0
  52. package/dist/theme/index.js +2 -0
  53. package/dist/theme/presets.d.ts +24 -0
  54. package/dist/theme/presets.js +89 -0
  55. package/dist/views/agenda/Agenda.svelte +1404 -0
  56. package/dist/views/agenda/Agenda.svelte.d.ts +20 -0
  57. package/dist/views/agenda/index.d.ts +1 -0
  58. package/dist/views/agenda/index.js +1 -0
  59. package/dist/views/day/DayGrid.svelte +1063 -0
  60. package/dist/views/day/DayGrid.svelte.d.ts +33 -0
  61. package/dist/views/day/DayTimeline.svelte +505 -0
  62. package/dist/views/day/DayTimeline.svelte.d.ts +4 -0
  63. package/dist/views/day/index.d.ts +2 -0
  64. package/dist/views/day/index.js +2 -0
  65. package/dist/views/index.d.ts +5 -0
  66. package/dist/views/index.js +5 -0
  67. package/dist/views/settings/Settings.svelte +303 -0
  68. package/dist/views/settings/Settings.svelte.d.ts +23 -0
  69. package/dist/views/settings/index.d.ts +2 -0
  70. package/dist/views/settings/index.js +1 -0
  71. package/dist/views/week/WeekGrid.svelte +594 -0
  72. package/dist/views/week/WeekGrid.svelte.d.ts +21 -0
  73. package/dist/views/week/WeekHeatmap.svelte +453 -0
  74. package/dist/views/week/WeekHeatmap.svelte.d.ts +4 -0
  75. package/dist/views/week/index.d.ts +2 -0
  76. package/dist/views/week/index.js +2 -0
  77. package/package.json +62 -0
@@ -0,0 +1,1063 @@
1
+ <!--
2
+ DayGrid — Hey-inspired horizontal filmstrip.
3
+
4
+ Time flows left → right. Past on the left, future on the right.
5
+ The now-line is always visible. Drag to scroll through your life.
6
+
7
+ Night blocks (22:00 – 06:00) are collapsed into a compact strip.
8
+ Click to expand and see the full night hours.
9
+
10
+ Events are positioned as horizontal cards with full-width lanes.
11
+ -->
12
+ <script lang="ts">
13
+ import { getContext, onMount } from 'svelte';
14
+ import { fly } from 'svelte/transition';
15
+ import { createClock } from '../../core/clock.svelte.js';
16
+ import type { TimelineEvent } from '../../core/types.js';
17
+ import type { DragState } from '../../engine/drag.svelte.js';
18
+ import { DAY_MS, HOUR_MS, HOURS, sod } from '../../core/time.js';
19
+ import { fmtH, fmtDay } from '../../core/locale.js';
20
+
21
+ interface Props {
22
+ /** Number of past days visible */
23
+ pastDays?: number;
24
+ /** Number of future days visible */
25
+ futureDays?: number;
26
+ /** Pixel width of one hour */
27
+ hourWidth?: number;
28
+ /** Where "now" sits in the viewport (0 = left, 1 = right) */
29
+ nowPosition?: number;
30
+ /** Total height */
31
+ height?: number;
32
+ /** Events to render */
33
+ events?: TimelineEvent[];
34
+ /** Inline style for CSS variable overrides (theme) */
35
+ style?: string;
36
+ /** The date to centre this view on */
37
+ focusDate?: Date;
38
+ /** Called when the user clicks an event */
39
+ oneventclick?: (event: TimelineEvent) => void;
40
+ /** Called when the user clicks an empty time slot */
41
+ oneventcreate?: (range: { start: Date; end: Date }) => void;
42
+ /** Currently selected event ID (for highlight) */
43
+ selectedEventId?: string | null;
44
+ /** Start weeks on Monday */
45
+ mondayStart?: boolean;
46
+ }
47
+
48
+ let {
49
+ pastDays = 2,
50
+ futureDays = 14,
51
+ hourWidth = 110,
52
+ nowPosition = 0.25,
53
+ height = 520,
54
+ events = [],
55
+ style = '',
56
+ focusDate,
57
+ oneventclick,
58
+ oneventcreate,
59
+ selectedEventId = null,
60
+ }: Props = $props();
61
+
62
+ // ── Drag support (available when inside Calendar) ──
63
+ const drag = getContext<DragState>('calendar:drag') as DragState | undefined;
64
+ const commitDragCtx = getContext<() => void>('calendar:commitDrag') as (() => void) | undefined;
65
+
66
+ const clock = createClock();
67
+
68
+ // ─── State ──────────────────────────────────────────────
69
+ let following = $state(true);
70
+ let dragging = $state(false);
71
+ let rafId = 0;
72
+ let dragStartX = 0;
73
+ let dragScrollStart = 0;
74
+ let el: HTMLDivElement;
75
+
76
+ /** Night blocks: track which days have expanded nights */
77
+ let nightExpanded = $state<Set<number>>(new Set());
78
+
79
+ // ─── Night block constants ──────────────────────────────
80
+ /** Night = 22:00 → 06:00 (8 hours). Collapsed width = small strip. */
81
+ const NIGHT_START = 22; // 22:00
82
+ const NIGHT_END = 6; // 06:00
83
+ const NIGHT_HOURS = 8; // 22→06
84
+ const NIGHT_COLLAPSED_W = 48; // px when collapsed
85
+
86
+ // ─── Derived ────────────────────────────────────────────
87
+ const count = $derived(pastDays + 1 + futureDays);
88
+ const centerMs = $derived(focusDate ? sod(focusDate.getTime()) : clock.today);
89
+ const origin = $derived(centerMs - pastDays * DAY_MS);
90
+
91
+ // Each day has: daytime hours (06:00–22:00 = 16h) at full width
92
+ // + a night block that is either collapsed or expanded.
93
+ // Night blocks sit between days (end of day → start of next day).
94
+
95
+ const dayW_day = $derived(16 * hourWidth); // 06:00–22:00
96
+ const nightW_expanded = $derived(NIGHT_HOURS * hourWidth);
97
+
98
+ interface DayLayout {
99
+ ms: number;
100
+ name: string;
101
+ today: boolean;
102
+ past: boolean;
103
+ /** X offset of the 06:00 mark (daytime start) */
104
+ dayX: number;
105
+ /** Width of the daytime portion */
106
+ dayWidth: number;
107
+ /** X offset of the night block (22:00) AFTER this day */
108
+ nightX: number;
109
+ /** Width of the night block */
110
+ nightW: number;
111
+ /** Is the night block expanded? */
112
+ nightOpen: boolean;
113
+ }
114
+
115
+ const dayLayouts = $derived.by(() => {
116
+ const layouts: DayLayout[] = [];
117
+ let x = 0;
118
+
119
+ for (let i = 0; i < count; i++) {
120
+ const ms = origin + i * DAY_MS;
121
+ const isToday = ms === clock.today;
122
+ const isPast = ms < clock.today;
123
+ const nightOpen = nightExpanded.has(i);
124
+ const nightW = nightOpen ? nightW_expanded : NIGHT_COLLAPSED_W;
125
+
126
+ layouts.push({
127
+ ms,
128
+ name: fmtDay(ms, clock.today, { short: true }),
129
+ today: isToday,
130
+ past: isPast,
131
+ dayX: x,
132
+ dayWidth: dayW_day,
133
+ nightX: x + dayW_day,
134
+ nightW,
135
+ nightOpen,
136
+ });
137
+
138
+ x += dayW_day + nightW;
139
+ }
140
+
141
+ return layouts;
142
+ });
143
+
144
+ const totalW = $derived(() => {
145
+ const last = dayLayouts[dayLayouts.length - 1];
146
+ return last ? last.nightX + last.nightW : 0;
147
+ });
148
+
149
+ /** Convert an absolute timestamp to a pixel X position */
150
+ function timeToPx(ms: number): number {
151
+ for (let i = 0; i < dayLayouts.length; i++) {
152
+ const d = dayLayouts[i];
153
+ const dayStart = d.ms;
154
+ const dayEnd = dayStart + DAY_MS;
155
+
156
+ if (ms < dayStart || ms >= dayEnd) continue;
157
+
158
+ const hourOfDay = (ms - dayStart) / HOUR_MS;
159
+
160
+ if (hourOfDay < NIGHT_END) {
161
+ // Before 6am — we're in the previous day's night block
162
+ // This is handled by the previous day's layout, so skip
163
+ // Actually: this IS this day's early morning
164
+ // Early morning (00:00–06:00) is part of prev day's night block
165
+ if (i > 0) {
166
+ const prev = dayLayouts[i - 1];
167
+ if (prev.nightOpen) {
168
+ // Position within the expanded night block
169
+ // Night block covers 22:00 prev → 06:00 this
170
+ // hourOfDay is 0–6, which is 2–8 hours into the night
171
+ const nightFrac = (hourOfDay + (24 - NIGHT_START)) / NIGHT_HOURS;
172
+ return prev.nightX + nightFrac * prev.nightW;
173
+ } else {
174
+ // Collapsed — map into the tiny strip
175
+ const nightFrac = (hourOfDay + (24 - NIGHT_START)) / NIGHT_HOURS;
176
+ return prev.nightX + nightFrac * prev.nightW;
177
+ }
178
+ }
179
+ // First day: treat as start
180
+ return d.dayX;
181
+ }
182
+
183
+ if (hourOfDay >= NIGHT_START) {
184
+ // 22:00+ — in this day's night block
185
+ if (d.nightOpen) {
186
+ const nightFrac = (hourOfDay - NIGHT_START) / NIGHT_HOURS;
187
+ return d.nightX + nightFrac * d.nightW;
188
+ } else {
189
+ const nightFrac = (hourOfDay - NIGHT_START) / NIGHT_HOURS;
190
+ return d.nightX + nightFrac * d.nightW;
191
+ }
192
+ }
193
+
194
+ // Daytime (06:00–22:00)
195
+ const dayFrac = (hourOfDay - NIGHT_END) / 16;
196
+ return d.dayX + dayFrac * d.dayWidth;
197
+ }
198
+ // Fallback: beyond range
199
+ const last = dayLayouts[dayLayouts.length - 1];
200
+ return last ? last.nightX + last.nightW : 0;
201
+ }
202
+
203
+ /** Convert a pixel X position back to a timestamp (inverse of timeToPx) */
204
+ function pxToTime(px: number): number {
205
+ // Before the first day
206
+ if (dayLayouts.length > 0 && px < dayLayouts[0].dayX) {
207
+ const d = dayLayouts[0];
208
+ const before = d.dayX - px;
209
+ return d.ms + NIGHT_END * HOUR_MS - (before / hourWidth) * HOUR_MS;
210
+ }
211
+
212
+ for (let i = 0; i < dayLayouts.length; i++) {
213
+ const d = dayLayouts[i];
214
+
215
+ // Daytime region (06:00–22:00)
216
+ if (px >= d.dayX && px < d.dayX + d.dayWidth) {
217
+ const frac = (px - d.dayX) / d.dayWidth;
218
+ const hourOfDay = NIGHT_END + frac * 16;
219
+ return d.ms + hourOfDay * HOUR_MS;
220
+ }
221
+
222
+ // Night region (22:00 this day → 06:00 next day)
223
+ if (px >= d.nightX && px < d.nightX + d.nightW) {
224
+ const frac = (px - d.nightX) / d.nightW;
225
+ const hoursIntoNight = frac * NIGHT_HOURS;
226
+ return d.ms + (NIGHT_START + hoursIntoNight) * HOUR_MS;
227
+ }
228
+ }
229
+
230
+ // Beyond range — extrapolate from the last layout
231
+ const last = dayLayouts[dayLayouts.length - 1];
232
+ if (last) {
233
+ const past = px - (last.nightX + last.nightW);
234
+ return last.ms + DAY_MS + NIGHT_END * HOUR_MS + (past / hourWidth) * HOUR_MS;
235
+ }
236
+ return origin;
237
+ }
238
+
239
+ /** Now-line X position */
240
+ const nowPx = $derived(timeToPx(clock.tick));
241
+
242
+ // ─── Event positioning ──────────────────────────────────
243
+ // ─── Layout constants ────────────────────────────────
244
+ const CONTENT_TOP = 56; // below header(38) + tick labels(18)
245
+ const EVENT_GAP = 5; // px gap between event rows
246
+ const MIN_EVENT_H = 32;
247
+
248
+ function fmtDuration(ev: TimelineEvent): string {
249
+ const mins = Math.round((ev.end.getTime() - ev.start.getTime()) / 60000);
250
+ if (mins < 60) return `${mins}min`;
251
+ const h = Math.floor(mins / 60);
252
+ const m = mins % 60;
253
+ return m > 0 ? `${h}h ${m}m` : `${h}h`;
254
+ }
255
+
256
+ interface PositionedEvent {
257
+ ev: TimelineEvent;
258
+ x: number;
259
+ width: number;
260
+ row: number;
261
+ groupMaxRow: number;
262
+ topPx: number;
263
+ heightPx: number;
264
+ isCurrent: boolean;
265
+ isDragged: boolean;
266
+ }
267
+
268
+ const positionedEvents = $derived.by(() => {
269
+ const now = clock.tick;
270
+ const dragP = drag?.active && drag.mode === 'move' ? drag.payload : null;
271
+
272
+ // Separate dragged event from static events
273
+ const staticEvents: typeof events = [];
274
+ let draggedEv: TimelineEvent | null = null;
275
+ for (const ev of events) {
276
+ if (dragP?.eventId === ev.id) draggedEv = ev;
277
+ else staticEvents.push(ev);
278
+ }
279
+
280
+ // Build static (non-dragged) layout — stable during drag
281
+ const sorted = [...staticEvents].sort(
282
+ (a, b) => a.start.getTime() - b.start.getTime(),
283
+ );
284
+
285
+ const infos = sorted.map((ev) => {
286
+ const startMs = ev.start.getTime();
287
+ const endMs = ev.end.getTime();
288
+ const x = timeToPx(startMs);
289
+ const xEnd = timeToPx(endMs);
290
+ return {
291
+ ev,
292
+ x,
293
+ width: Math.max(xEnd - x, 28),
294
+ row: 0,
295
+ groupMaxRow: 1,
296
+ isCurrent: startMs <= now && endMs > now,
297
+ isDragged: false,
298
+ startMs,
299
+ endMs,
300
+ };
301
+ });
302
+
303
+ // Build overlap groups using union-find
304
+ const par = infos.map((_, i) => i);
305
+ function find(i: number): number {
306
+ while (par[i] !== i) { par[i] = par[par[i]]; i = par[i]; }
307
+ return i;
308
+ }
309
+
310
+ for (let i = 0; i < infos.length; i++) {
311
+ for (let j = i + 1; j < infos.length; j++) {
312
+ if (infos[j].startMs < infos[i].endMs) {
313
+ par[find(i)] = find(j);
314
+ } else {
315
+ break; // sorted by start; no further overlaps
316
+ }
317
+ }
318
+ }
319
+
320
+ // Group events by root
321
+ const groups = new Map<number, number[]>();
322
+ for (let i = 0; i < infos.length; i++) {
323
+ const root = find(i);
324
+ if (!groups.has(root)) groups.set(root, []);
325
+ groups.get(root)!.push(i);
326
+ }
327
+
328
+ // Assign rows per overlap group (greedy interval packing)
329
+ for (const [, indices] of groups) {
330
+ const rows: number[] = [];
331
+ for (const idx of indices) {
332
+ const inf = infos[idx];
333
+ let row = 0;
334
+ for (let r = 0; r < rows.length; r++) {
335
+ if (rows[r] <= inf.startMs) {
336
+ row = r;
337
+ rows[r] = inf.endMs;
338
+ break;
339
+ }
340
+ row = r + 1;
341
+ }
342
+ if (row >= rows.length) rows.push(inf.endMs);
343
+ infos[idx].row = row;
344
+ }
345
+ const maxR = rows.length;
346
+ for (const idx of indices) {
347
+ infos[idx].groupMaxRow = maxR;
348
+ }
349
+ }
350
+
351
+ // Calculate pixel sizes — events stretch to fill available height
352
+ const availH = height - CONTENT_TOP - 8;
353
+ const result: PositionedEvent[] = infos.map(({ startMs: _s, endMs: _e, ...info }) => {
354
+ const laneH = Math.max(MIN_EVENT_H, availH / info.groupMaxRow - EVENT_GAP);
355
+ const topPx = CONTENT_TOP + info.row * (availH / info.groupMaxRow);
356
+ return { ...info, topPx, heightPx: laneH };
357
+ });
358
+
359
+ // Append dragged event as floating overlay (not in layout)
360
+ if (draggedEv && dragP) {
361
+ const dStartMs = dragP.start.getTime();
362
+ const dEndMs = dragP.end.getTime();
363
+ const x = timeToPx(dStartMs);
364
+ const xEnd = timeToPx(dEndMs);
365
+ result.push({
366
+ ev: draggedEv,
367
+ x,
368
+ width: Math.max(xEnd - x, 28),
369
+ row: 0,
370
+ groupMaxRow: 1,
371
+ topPx: CONTENT_TOP,
372
+ heightPx: Math.max(MIN_EVENT_H, availH - EVENT_GAP),
373
+ isCurrent: draggedEv.start.getTime() <= now && draggedEv.end.getTime() > now,
374
+ isDragged: true,
375
+ });
376
+ }
377
+
378
+ return result;
379
+ });
380
+
381
+ // ─── Night toggle ───────────────────────────────────────
382
+ function toggleNight(dayIndex: number) {
383
+ const next = new Set(nightExpanded);
384
+ if (next.has(dayIndex)) next.delete(dayIndex);
385
+ else next.add(dayIndex);
386
+ nightExpanded = next;
387
+ }
388
+
389
+ // ─── Lifecycle ──────────────────────────────────────────
390
+ onMount(() => {
391
+ function frame() {
392
+ if (following && el && !dragging) {
393
+ el.scrollLeft = nowPx - el.clientWidth * nowPosition;
394
+ }
395
+ rafId = requestAnimationFrame(frame);
396
+ }
397
+ rafId = requestAnimationFrame(frame);
398
+ return () => cancelAnimationFrame(rafId);
399
+ });
400
+
401
+ // ─── Drag-to-scroll ─────────────────────────────────────
402
+ const SCROLL_THRESHOLD = 3;
403
+ let scrollDragPending = false;
404
+
405
+ function onPointerDown(e: PointerEvent) {
406
+ if (e.button !== 0) return;
407
+ const target = e.target as HTMLElement;
408
+ if (target.closest('.fs-event')) return;
409
+ // Collapsed night blocks handle their own click (expand)
410
+ const night = target.closest('.fs-night');
411
+ if (night && !night.classList.contains('fs-night--open')) return;
412
+ // Collapse button inside expanded nights
413
+ if (target.closest('.fs-night-toggle')) return;
414
+
415
+ dragStartX = e.clientX;
416
+ dragScrollStart = el.scrollLeft;
417
+ scrollDragPending = true;
418
+
419
+ // Use window-level listeners so clicks on children aren't blocked
420
+ window.addEventListener('pointermove', onWindowPointerMove);
421
+ window.addEventListener('pointerup', onWindowPointerUp, { once: true });
422
+ window.addEventListener('pointercancel', onWindowPointerUp, { once: true });
423
+ }
424
+
425
+ function onWindowPointerMove(e: PointerEvent) {
426
+ const dx = e.clientX - dragStartX;
427
+
428
+ if (!dragging && Math.abs(dx) >= SCROLL_THRESHOLD) {
429
+ dragging = true;
430
+ following = false;
431
+ }
432
+
433
+ if (dragging) {
434
+ el.scrollLeft = dragScrollStart - dx;
435
+ }
436
+ }
437
+
438
+ function onWindowPointerUp() {
439
+ window.removeEventListener('pointermove', onWindowPointerMove);
440
+ scrollDragPending = false;
441
+ dragging = false;
442
+ }
443
+
444
+ function userInput() {
445
+ if (!dragging) following = false;
446
+ }
447
+
448
+ function jumpNow() {
449
+ following = true;
450
+ }
451
+
452
+ // ─── Click-to-create ────────────────────────────────────
453
+ function handleTrackClick(e: MouseEvent) {
454
+ if (!oneventcreate) return;
455
+ // Don't create events when clicking on night blocks or events
456
+ const target = e.target as HTMLElement;
457
+ if (target.closest('.fs-night') || target.closest('.fs-event')) return;
458
+
459
+ const track = e.currentTarget as HTMLElement;
460
+ const rect = track.getBoundingClientRect();
461
+ const clickX = e.clientX - rect.left + el.scrollLeft;
462
+
463
+ // Find which day this X falls in
464
+ for (const d of dayLayouts) {
465
+ if (clickX >= d.dayX && clickX < d.dayX + d.dayWidth) {
466
+ const frac = (clickX - d.dayX) / d.dayWidth;
467
+ const hour = Math.floor(NIGHT_END + frac * 16);
468
+ const start = new Date(d.ms + hour * 3_600_000);
469
+ const end = new Date(d.ms + (hour + 1) * 3_600_000);
470
+ oneventcreate({ start, end });
471
+ return;
472
+ }
473
+ }
474
+ }
475
+
476
+ // ─── Event drag-to-move ───────────────────────────────────────
477
+ const DRAG_THRESHOLD = 5;
478
+ const SNAP_MS = 15 * 60_000; // 15-minute snap
479
+ let evDragStartX = 0;
480
+ let evDragOriginPx = 0;
481
+ let evDragStarted = false;
482
+ let evDragging = $state(false);
483
+ let evDragId = $state<string | null>(null);
484
+ let evDragEvent: TimelineEvent | null = null;
485
+
486
+ function onEventPointerDown(e: PointerEvent, ev: TimelineEvent) {
487
+ if (e.button !== 0 || !drag) return;
488
+ e.stopPropagation();
489
+ evDragStartX = e.clientX;
490
+ evDragOriginPx = timeToPx(ev.start.getTime());
491
+ evDragStarted = false;
492
+ evDragId = ev.id;
493
+ evDragEvent = ev;
494
+
495
+ // Window-level listeners so pointerup always fires, even outside component
496
+ window.addEventListener('pointermove', onEvWindowPointerMove);
497
+ window.addEventListener('pointerup', onEvWindowPointerUp, { once: true });
498
+ window.addEventListener('pointercancel', onEvWindowPointerCancel, { once: true });
499
+ }
500
+
501
+ function onEvWindowPointerMove(e: PointerEvent) {
502
+ const ev = evDragEvent;
503
+ if (!drag || !ev || evDragId !== ev.id) return;
504
+ const dx = e.clientX - evDragStartX;
505
+ if (!evDragStarted && Math.abs(dx) < DRAG_THRESHOLD) return;
506
+
507
+ if (!evDragStarted) {
508
+ evDragStarted = true;
509
+ evDragging = true;
510
+ drag.beginMove(ev.id, ev.start, ev.end);
511
+ }
512
+
513
+ const duration = ev.end.getTime() - ev.start.getTime();
514
+ const rawNewStartMs = pxToTime(evDragOriginPx + dx);
515
+ const snappedStart = Math.round(rawNewStartMs / SNAP_MS) * SNAP_MS;
516
+ drag.updatePointer(new Date(snappedStart), new Date(snappedStart + duration));
517
+ }
518
+
519
+ function cleanupEvDrag() {
520
+ window.removeEventListener('pointermove', onEvWindowPointerMove);
521
+ window.removeEventListener('pointerup', onEvWindowPointerUp);
522
+ window.removeEventListener('pointercancel', onEvWindowPointerCancel);
523
+ evDragStarted = false;
524
+ evDragging = false;
525
+ evDragId = null;
526
+ evDragEvent = null;
527
+ }
528
+
529
+ function onEvWindowPointerUp() {
530
+ if (!drag) { cleanupEvDrag(); return; }
531
+
532
+ if (!evDragStarted) {
533
+ if (evDragEvent) oneventclick?.(evDragEvent);
534
+ } else {
535
+ commitDragCtx?.();
536
+ }
537
+ cleanupEvDrag();
538
+ }
539
+
540
+ function onEvWindowPointerCancel() {
541
+ if (drag && evDragStarted) drag.cancel();
542
+ cleanupEvDrag();
543
+ }
544
+ </script>
545
+
546
+ <div class="fs" style={style || undefined} style:height="{height}px" role="region" aria-label="Day timeline">
547
+ <div
548
+ class="fs-scroll"
549
+ class:fs-grabbing={dragging}
550
+ bind:this={el}
551
+ onwheel={userInput}
552
+ onpointerdown={onPointerDown}
553
+ role="application"
554
+ aria-label="Scrollable day timeline — drag to scroll"
555
+ >
556
+ <div class="fs-track" style:width="{totalW()}px" onclick={handleTrackClick} role="none">
557
+ <!-- Day blocks -->
558
+ {#each dayLayouts as d, i (d.ms)}
559
+ <!-- Daytime block (06:00 – 22:00) -->
560
+ <div
561
+ class="fs-day"
562
+ class:fs-today={d.today}
563
+ class:fs-past={d.past}
564
+ style:left="{d.dayX}px"
565
+ style:width="{d.dayWidth}px"
566
+ >
567
+ <!-- Day header -->
568
+ <div class="fs-day-hd">
569
+ <span class="fs-day-name">{d.name}</span>
570
+ </div>
571
+
572
+ <!-- Hour ticks (06:00 – 21:00) -->
573
+ {#each { length: 16 } as _, h}
574
+ {@const hour = NIGHT_END + h}
575
+ {@const x = h * hourWidth}
576
+ <div class="fs-tick" style:left="{x}px">
577
+ <span class="fs-tick-lb">{fmtH(hour)}</span>
578
+ </div>
579
+ <div class="fs-tick fs-tick--half" style:left="{x + hourWidth * 0.5}px"></div>
580
+ {/each}
581
+ </div>
582
+
583
+ <!-- Night block (22:00 – 06:00 next day) -->
584
+ {#if i < count - 1}
585
+ <div
586
+ class="fs-night"
587
+ class:fs-night--open={d.nightOpen}
588
+ style:left="{d.nightX}px"
589
+ style:width="{d.nightW}px"
590
+ role="button"
591
+ tabindex="0"
592
+ aria-label={d.nightOpen ? 'Collapse night hours' : 'Expand night hours, 10pm to 6am'}
593
+ aria-expanded={d.nightOpen}
594
+ onclick={(e) => { e.stopPropagation(); toggleNight(i); }}
595
+ onkeydown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); e.stopPropagation(); toggleNight(i); } }}
596
+ >
597
+ {#if d.nightOpen}
598
+ <!-- Expanded night: show hour ticks -->
599
+ {#each { length: NIGHT_HOURS } as _, h}
600
+ {@const hour = (NIGHT_START + h) % 24}
601
+ {@const x = h * hourWidth}
602
+ <div class="fs-tick fs-tick--night" style:left="{x}px">
603
+ <span class="fs-tick-lb">{fmtH(hour)}</span>
604
+ </div>
605
+ {/each}
606
+ <button class="fs-night-toggle" onclick={(e) => { e.stopPropagation(); toggleNight(i); }}>
607
+ Collapse night
608
+ </button>
609
+ {:else}
610
+ <!-- Collapsed night: simple dark band -->
611
+ <div class="fs-night-band">
612
+ <span class="fs-night-label">10pm – 6am</span>
613
+ <span class="fs-night-chevron" aria-hidden="true">‹ expand ›</span>
614
+ </div>
615
+ {/if}
616
+ </div>
617
+ {/if}
618
+ {/each}
619
+
620
+ <!-- Now line -->
621
+ <div class="fs-now" style:left="{nowPx}px">
622
+ <span class="fs-now-tag">{clock.hm}<span class="fs-now-sec">{clock.s}</span></span>
623
+ <div class="fs-now-line"></div>
624
+ </div>
625
+
626
+ <!-- Events (stretched vertically to fill available height) -->
627
+ {#each positionedEvents as p (p.ev.id)}
628
+ <div
629
+ class="fs-event"
630
+ class:fs-event--selected={selectedEventId === p.ev.id}
631
+ class:fs-event--current={p.isCurrent}
632
+ class:fs-event--dragging={p.isDragged}
633
+ style:left="{p.x}px"
634
+ style:width="{p.width}px"
635
+ style:top="{p.topPx}px"
636
+ style:height="{p.heightPx}px"
637
+ style:--ev-color={p.ev.color ?? 'var(--dt-accent)'}
638
+ role="button"
639
+ tabindex="0"
640
+ aria-label="{p.ev.title}{p.isCurrent ? ' (in progress)' : ''}"
641
+ onpointerdown={(e) => onEventPointerDown(e, p.ev)}
642
+ onkeydown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); oneventclick?.(p.ev); } }}
643
+ >
644
+ <div class="fs-ev-inner">
645
+ {#if p.isCurrent}
646
+ <span class="fs-ev-live" aria-hidden="true"></span>
647
+ {/if}
648
+ {#if p.heightPx > 64}
649
+ <span class="fs-ev-time">
650
+ {p.ev.start.getHours().toString().padStart(2, '0')}:{p.ev.start.getMinutes().toString().padStart(2, '0')}{p.ev.start.getHours() < 12 ? 'AM' : 'PM'} – {p.ev.end.getHours().toString().padStart(2, '0')}:{p.ev.end.getMinutes().toString().padStart(2, '0')}{p.ev.end.getHours() < 12 ? 'AM' : 'PM'}
651
+ </span>
652
+ {/if}
653
+ <span class="fs-ev-title">{p.ev.title}</span>
654
+ </div>
655
+ </div>
656
+ {/each}
657
+ </div>
658
+ </div>
659
+
660
+ <!-- Jump to now -->
661
+ {#if !following}
662
+ <button class="fs-btn" onclick={jumpNow} transition:fly={{ y: 6, duration: 180 }}>
663
+ <svg
664
+ viewBox="0 0 24 24"
665
+ fill="none"
666
+ stroke="currentColor"
667
+ stroke-width="2.5"
668
+ stroke-linecap="round"
669
+ >
670
+ <circle cx="12" cy="12" r="10" />
671
+ <circle cx="12" cy="12" r="3" fill="currentColor" />
672
+ </svg>
673
+ Now
674
+ </button>
675
+ {/if}
676
+ </div>
677
+
678
+ <style>
679
+ /* ─── Container ──────────────────────────────────── */
680
+ .fs {
681
+ --dt-bg: #0b0e14;
682
+ --dt-surface: #10141c;
683
+ --dt-border: rgba(148, 163, 184, 0.07);
684
+ --dt-border-day: rgba(148, 163, 184, 0.14);
685
+ --dt-text: rgba(226, 232, 240, 0.92);
686
+ --dt-text-2: rgba(148, 163, 184, 0.72);
687
+ --dt-text-3: rgba(100, 116, 139, 0.7);
688
+ --dt-accent: #ef4444;
689
+ --dt-accent-dim: rgba(239, 68, 68, 0.18);
690
+ --dt-glow: rgba(239, 68, 68, 0.35);
691
+ --dt-today-bg: rgba(239, 68, 68, 0.025);
692
+ --dt-btn-text: #fff;
693
+ --dt-scrollbar: rgba(148, 163, 184, 0.12);
694
+ --dt-night: #06080d;
695
+ --dt-mono: 'SF Mono', 'Cascadia Code', 'Fira Code', Consolas, monospace;
696
+ --dt-sans: system-ui, -apple-system, 'Segoe UI', sans-serif;
697
+
698
+ position: relative;
699
+ background: var(--dt-bg);
700
+ border: 1px solid var(--dt-border);
701
+ border-radius: 10px;
702
+ overflow: hidden;
703
+ user-select: none;
704
+ font-variant-numeric: tabular-nums;
705
+ }
706
+
707
+ /* ─── Horizontal scroll ──────────────────────────── */
708
+ .fs-scroll {
709
+ width: 100%;
710
+ height: 100%;
711
+ overflow-x: auto;
712
+ overflow-y: hidden;
713
+ touch-action: pan-x;
714
+ cursor: grab;
715
+ scrollbar-width: thin;
716
+ scrollbar-color: var(--dt-scrollbar) transparent;
717
+ }
718
+
719
+ .fs-scroll::-webkit-scrollbar {
720
+ height: 5px;
721
+ }
722
+
723
+ .fs-scroll::-webkit-scrollbar-thumb {
724
+ background: var(--dt-scrollbar);
725
+ border-radius: 4px;
726
+ }
727
+
728
+ .fs-scroll::-webkit-scrollbar-track {
729
+ background: transparent;
730
+ }
731
+
732
+ .fs-grabbing {
733
+ cursor: grabbing;
734
+ }
735
+
736
+ .fs-track {
737
+ position: relative;
738
+ height: 100%;
739
+ }
740
+
741
+ /* ─── Day block ──────────────────────────────────── */
742
+ .fs-day {
743
+ position: absolute;
744
+ top: 0;
745
+ height: 100%;
746
+ border-left: 1px solid var(--dt-border-day);
747
+ box-sizing: border-box;
748
+ transition: background 0.2s;
749
+ }
750
+
751
+ .fs-day:first-of-type {
752
+ border-left: none;
753
+ }
754
+
755
+ .fs-today {
756
+ background: var(--dt-today-bg);
757
+ }
758
+
759
+ .fs-past {
760
+ opacity: 0.7;
761
+ }
762
+
763
+ /* Day header */
764
+ .fs-day-hd {
765
+ display: flex;
766
+ align-items: center;
767
+ gap: 8px;
768
+ padding: 10px 14px;
769
+ height: 38px;
770
+ box-sizing: border-box;
771
+ border-bottom: 1px solid var(--dt-border);
772
+ }
773
+
774
+ .fs-day-name {
775
+ font: 600 12px / 1 var(--dt-sans);
776
+ letter-spacing: 0.04em;
777
+ text-transform: uppercase;
778
+ color: var(--dt-text-2);
779
+ white-space: nowrap;
780
+ }
781
+
782
+ .fs-today .fs-day-name {
783
+ color: var(--dt-accent);
784
+ }
785
+
786
+ /* ─── Hour ticks ─────────────────────────────────── */
787
+ .fs-tick {
788
+ position: absolute;
789
+ top: 38px;
790
+ bottom: 0;
791
+ width: 0;
792
+ }
793
+
794
+ .fs-tick::before {
795
+ content: '';
796
+ position: absolute;
797
+ top: 18px;
798
+ bottom: 0;
799
+ width: 1px;
800
+ background: var(--dt-border);
801
+ }
802
+
803
+ .fs-tick-lb {
804
+ position: absolute;
805
+ top: 2px;
806
+ left: 5px;
807
+ font: 500 10px / 1 var(--dt-mono);
808
+ color: var(--dt-text-3);
809
+ white-space: nowrap;
810
+ pointer-events: none;
811
+ }
812
+
813
+ .fs-tick--half {
814
+ bottom: auto;
815
+ height: calc(18px + 8px);
816
+ }
817
+
818
+ .fs-tick--half::before {
819
+ top: 18px;
820
+ height: 6px;
821
+ bottom: auto;
822
+ opacity: 0.4;
823
+ }
824
+
825
+ .fs-tick--night::before {
826
+ opacity: 0.3;
827
+ }
828
+
829
+ .fs-tick--night .fs-tick-lb {
830
+ opacity: 0.5;
831
+ }
832
+
833
+ /* ─── Night block ────────────────────────────────── */
834
+ .fs-night {
835
+ position: absolute;
836
+ top: 0;
837
+ height: 100%;
838
+ background: var(--dt-night);
839
+ cursor: pointer;
840
+ transition: width 0.3s ease;
841
+ overflow: hidden;
842
+ border-left: 1px solid rgba(148, 163, 184, 0.04);
843
+ border-right: 1px solid rgba(148, 163, 184, 0.04);
844
+ }
845
+
846
+ .fs-night--open {
847
+ cursor: default;
848
+ background: color-mix(in srgb, var(--dt-night) 90%, var(--dt-bg));
849
+ }
850
+
851
+ /* Collapsed night: simple dark band */
852
+ .fs-night-band {
853
+ display: flex;
854
+ flex-direction: column;
855
+ align-items: center;
856
+ justify-content: center;
857
+ height: 100%;
858
+ gap: 4px;
859
+ writing-mode: vertical-rl;
860
+ text-orientation: mixed;
861
+ transform: rotate(180deg);
862
+ }
863
+
864
+ .fs-night-label {
865
+ font: 500 10px / 1 var(--dt-sans);
866
+ letter-spacing: 0.06em;
867
+ text-transform: uppercase;
868
+ color: var(--dt-text-3);
869
+ white-space: nowrap;
870
+ }
871
+
872
+ .fs-night-chevron {
873
+ font: 400 9px / 1 var(--dt-sans);
874
+ letter-spacing: 0.1em;
875
+ text-transform: uppercase;
876
+ color: var(--dt-text-3);
877
+ opacity: 0.5;
878
+ }
879
+
880
+ .fs-night-toggle {
881
+ position: absolute;
882
+ top: 8px;
883
+ right: 8px;
884
+ z-index: 2;
885
+ font: 500 10px / 1 var(--dt-sans);
886
+ letter-spacing: 0.06em;
887
+ text-transform: uppercase;
888
+ color: var(--dt-text-3);
889
+ background: rgba(148, 163, 184, 0.06);
890
+ border: 1px solid rgba(148, 163, 184, 0.08);
891
+ padding: 4px 10px;
892
+ border-radius: 4px;
893
+ cursor: pointer;
894
+ transition: color 0.15s;
895
+ }
896
+
897
+ .fs-night-toggle:hover {
898
+ color: var(--dt-text-2);
899
+ }
900
+
901
+ /* ─── Now-line ────────────────────────────────────── */
902
+ .fs-now {
903
+ position: absolute;
904
+ top: 0;
905
+ bottom: 0;
906
+ z-index: 10;
907
+ pointer-events: none;
908
+ transform: translateX(-1px);
909
+ }
910
+
911
+ .fs-now-line {
912
+ position: absolute;
913
+ top: 0;
914
+ bottom: 0;
915
+ left: 0;
916
+ width: 2px;
917
+ background: var(--dt-accent);
918
+ box-shadow: 0 0 8px var(--dt-glow);
919
+ }
920
+
921
+ .fs-now-tag {
922
+ position: absolute;
923
+ top: 8px;
924
+ left: 8px;
925
+ font: 700 11px / 1 var(--dt-mono);
926
+ color: var(--dt-accent);
927
+ background: color-mix(in srgb, var(--dt-bg) 92%, var(--dt-accent));
928
+ border: 1px solid var(--dt-accent-dim);
929
+ padding: 3px 6px;
930
+ border-radius: 4px;
931
+ white-space: nowrap;
932
+ z-index: 1;
933
+ }
934
+
935
+ .fs-now-sec {
936
+ font-weight: 400;
937
+ opacity: 0.5;
938
+ font-size: 10px;
939
+ }
940
+
941
+ /* ─── Jump button ────────────────────────────────── */
942
+ .fs-btn {
943
+ position: absolute;
944
+ bottom: 14px;
945
+ right: 14px;
946
+ z-index: 20;
947
+ display: inline-flex;
948
+ align-items: center;
949
+ gap: 5px;
950
+ padding: 5px 13px 5px 9px;
951
+ font: 600 11px / 1 var(--dt-sans);
952
+ color: var(--dt-btn-text);
953
+ background: var(--dt-accent);
954
+ border: none;
955
+ border-radius: 100px;
956
+ cursor: pointer;
957
+ box-shadow: 0 2px 16px var(--dt-glow);
958
+ transition:
959
+ transform 120ms ease,
960
+ box-shadow 120ms ease;
961
+ }
962
+
963
+ .fs-btn svg {
964
+ width: 13px;
965
+ height: 13px;
966
+ }
967
+
968
+ .fs-btn:hover {
969
+ transform: scale(1.06);
970
+ box-shadow: 0 4px 24px var(--dt-glow);
971
+ }
972
+
973
+ .fs-btn:active {
974
+ transform: scale(0.96);
975
+ }
976
+
977
+ /* ─── Events ─────────────────────────────────────── */
978
+ .fs-event {
979
+ position: absolute;
980
+ z-index: 6;
981
+ border-radius: 6px;
982
+ cursor: pointer;
983
+ background: color-mix(in srgb, var(--ev-color) 15%, transparent);
984
+ border-top: 3px solid var(--ev-color);
985
+ overflow: hidden;
986
+ display: flex;
987
+ align-items: center;
988
+ justify-content: center;
989
+ transition:
990
+ box-shadow 120ms,
991
+ background 120ms;
992
+ }
993
+
994
+ .fs-event:hover {
995
+ background: color-mix(in srgb, var(--ev-color) 28%, transparent);
996
+ box-shadow: 0 2px 12px color-mix(in srgb, var(--ev-color) 25%, transparent);
997
+ }
998
+
999
+ .fs-event--selected {
1000
+ box-shadow:
1001
+ 0 0 0 2px var(--ev-color),
1002
+ 0 2px 14px color-mix(in srgb, var(--ev-color) 35%, transparent);
1003
+ }
1004
+
1005
+ .fs-event--current {
1006
+ background: color-mix(in srgb, var(--ev-color) 22%, transparent);
1007
+ box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--ev-color) 20%, transparent);
1008
+ }
1009
+
1010
+ .fs-event--dragging {
1011
+ opacity: 0.85;
1012
+ z-index: 50;
1013
+ box-shadow: 0 4px 24px rgba(0, 0, 0, 0.35);
1014
+ cursor: grabbing;
1015
+ transition: none;
1016
+ }
1017
+
1018
+ /* Inner wrapper: rotated 180° vertical-rl = bottom-to-top reading */
1019
+ .fs-ev-inner {
1020
+ writing-mode: vertical-rl;
1021
+ transform: rotate(180deg);
1022
+ display: flex;
1023
+ align-items: center;
1024
+ gap: 6px;
1025
+ max-height: 100%;
1026
+ overflow: hidden;
1027
+ padding: 8px 4px;
1028
+ }
1029
+
1030
+ .fs-ev-live {
1031
+ flex-shrink: 0;
1032
+ width: 7px;
1033
+ height: 7px;
1034
+ border-radius: 50%;
1035
+ background: var(--ev-color);
1036
+ }
1037
+
1038
+ .fs-ev-title {
1039
+ font: 600 13px / 1.15 var(--dt-sans);
1040
+ color: var(--dt-text);
1041
+ overflow: hidden;
1042
+ text-overflow: ellipsis;
1043
+ white-space: nowrap;
1044
+ }
1045
+
1046
+ .fs-ev-time {
1047
+ font: 400 10px / 1 var(--dt-mono);
1048
+ color: var(--dt-text-2);
1049
+ opacity: 0.7;
1050
+ white-space: nowrap;
1051
+ }
1052
+
1053
+ /* ─── Focus-visible (accessibility) ──────────── */
1054
+ .fs-event:focus-visible,
1055
+ .fs-night:focus-visible {
1056
+ outline: 2px solid var(--dt-accent);
1057
+ outline-offset: 2px;
1058
+ }
1059
+
1060
+ .fs-night:hover {
1061
+ background: color-mix(in srgb, var(--dt-night) 85%, var(--dt-text-3));
1062
+ }
1063
+ </style>