@nomideusz/svelte-calendar 0.6.0 → 0.6.3

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