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