@nomideusz/svelte-calendar 0.6.3 → 0.6.4

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 (43) hide show
  1. package/README.md +183 -5
  2. package/dist/adapters/composite.d.ts +15 -0
  3. package/dist/adapters/composite.js +65 -0
  4. package/dist/adapters/index.d.ts +5 -1
  5. package/dist/adapters/index.js +2 -0
  6. package/dist/adapters/mapped.d.ts +170 -0
  7. package/dist/adapters/mapped.js +343 -0
  8. package/dist/adapters/recurring.d.ts +4 -0
  9. package/dist/adapters/recurring.js +5 -10
  10. package/dist/adapters/types.d.ts +11 -2
  11. package/dist/calendar/Calendar.svelte +147 -76
  12. package/dist/calendar/Calendar.svelte.d.ts +12 -0
  13. package/dist/core/index.d.ts +3 -1
  14. package/dist/core/index.js +2 -0
  15. package/dist/core/measure.d.ts +104 -0
  16. package/dist/core/measure.js +195 -0
  17. package/dist/core/time.js +2 -2
  18. package/dist/core/types.d.ts +32 -0
  19. package/dist/engine/event-store.svelte.js +22 -7
  20. package/dist/headless/create-agenda.svelte.d.ts +67 -0
  21. package/dist/headless/create-agenda.svelte.js +139 -0
  22. package/dist/headless/create-calendar.svelte.d.ts +2 -0
  23. package/dist/headless/create-calendar.svelte.js +380 -0
  24. package/dist/headless/index.d.ts +4 -0
  25. package/dist/headless/index.js +2 -0
  26. package/dist/headless/types.d.ts +219 -0
  27. package/dist/headless/types.js +1 -0
  28. package/dist/index.d.ts +6 -4
  29. package/dist/index.js +4 -2
  30. package/dist/primitives/EventBlock.svelte +87 -2
  31. package/dist/views/agenda/AgendaDay.svelte +183 -89
  32. package/dist/views/agenda/AgendaWeek.svelte +148 -95
  33. package/dist/views/mobile/MobileDay.svelte +43 -16
  34. package/dist/views/mobile/MobileWeek.svelte +32 -17
  35. package/dist/views/planner/PlannerDay.svelte +91 -36
  36. package/dist/views/planner/PlannerWeek.svelte +107 -132
  37. package/dist/views/shared/context.svelte.d.ts +47 -0
  38. package/dist/views/shared/context.svelte.js +47 -0
  39. package/dist/views/shared/format.d.ts +19 -0
  40. package/dist/views/shared/format.js +46 -0
  41. package/dist/views/shared/index.d.ts +4 -0
  42. package/dist/views/shared/index.js +2 -0
  43. package/package.json +76 -71
@@ -6,9 +6,11 @@
6
6
  Events are positioned as horizontal cards with overlap support.
7
7
  -->
8
8
  <script lang="ts">
9
- import { getContext, onMount, tick, untrack, type Snippet } from 'svelte';
9
+ import { onMount, tick, untrack } from 'svelte';
10
+ import { useCalendarContext } from '../shared/context.svelte.js';
10
11
  import { fly } from 'svelte/transition';
11
12
  import { createClock } from '../../core/clock.svelte.js';
13
+ import { createTextMeasure, type ContentFit } from '../../core/measure.js';
12
14
  import type { TimelineEvent, BlockedSlot } from '../../core/types.js';
13
15
  import type { DragState } from '../../engine/drag.svelte.js';
14
16
  import type { ViewState } from '../../engine/view-state.svelte.js';
@@ -58,33 +60,21 @@
58
60
  }: Props = $props();
59
61
 
60
62
  // ── 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
-
63
+ const ctx = useCalendarContext();
67
64
  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())) ?? []));
65
+ const drag = $derived(ctx.drag);
66
+ const commitDragCtx = $derived(ctx.commitDrag);
67
+ const viewState = $derived(ctx.viewState);
68
+ const loadRangeCtx = $derived(ctx.loadRange);
69
+ const showNav = $derived(ctx.showNav);
70
+ const showDates = $derived(ctx.showDates);
71
+ const blockedSlots = $derived(ctx.blockedSlots);
72
+ const dayHeaderSnippet = $derived(ctx.dayHeaderSnippet);
73
+ const minDuration = $derived(ctx.minDuration);
74
+ const autoHeight = $derived(ctx.autoHeight);
75
+ const oneventhover = $derived(ctx.oneventhover);
76
+ const disabledSet = $derived(ctx.disabledSet);
77
+ const SNAP_MS = $derived(ctx.snapInterval * 60_000);
88
78
 
89
79
  // ─── State ──────────────────────────────────────────
90
80
  let following = $state(true);
@@ -113,7 +103,6 @@
113
103
  const startHour = $derived(visibleHours?.[0] ?? 0);
114
104
  const endHour = $derived(visibleHours?.[1] ?? 24);
115
105
  const hourCount = $derived(Math.max(1, endHour - startHour));
116
- const SNAP_MS = $derived((snapIntervalCtx?.current ?? 15) * 60_000);
117
106
  const DAY_GAP = 2;
118
107
 
119
108
  const dateLabel = $derived(
@@ -207,6 +196,16 @@
207
196
  const EVENT_GAP = 5;
208
197
  const MIN_EVENT_H = 32;
209
198
 
199
+ // Text measure for smart content fitting
200
+ const measure = createTextMeasure({
201
+ titleFont: '600 13px system-ui, sans-serif',
202
+ secondaryFont: '400 10px system-ui, sans-serif',
203
+ tagFont: '500 8px system-ui, sans-serif',
204
+ titleLineHeight: 16,
205
+ secondaryLineHeight: 13,
206
+ contentGap: 6,
207
+ });
208
+
210
209
  interface PositionedEvent {
211
210
  ev: TimelineEvent;
212
211
  x: number;
@@ -218,6 +217,7 @@
218
217
  isCurrent: boolean;
219
218
  isNext: boolean;
220
219
  isDragged: boolean;
220
+ fit: ContentFit;
221
221
  }
222
222
 
223
223
  const positionedEvents = $derived.by(() => {
@@ -297,20 +297,40 @@
297
297
  const result: PositionedEvent[] = infos.map(({ startMs: _s, endMs: _e, ...info }) => {
298
298
  const laneH = Math.max(MIN_EVENT_H, availH / info.groupMaxRow - EVENT_GAP);
299
299
  const topPx = contentTop + info.row * (availH / info.groupMaxRow);
300
- return { ...info, topPx, heightPx: laneH, isNext: info.isNext };
300
+ // Vertical text: text "width" = heightPx, content "height" = width
301
+ const fit = measure.fitContent({
302
+ title: info.ev.title,
303
+ subtitle: info.ev.subtitle,
304
+ location: info.ev.location,
305
+ time: `${fmtTime(info.ev.start, locale)} – ${fmtTime(info.ev.end, locale)}`,
306
+ tags: info.ev.tags,
307
+ maxWidth: laneH - 16, // vertical: heightPx minus padding
308
+ maxHeight: info.width - 16, // vertical: width minus padding
309
+ });
310
+ return { ...info, topPx, heightPx: laneH, isNext: info.isNext, fit };
301
311
  });
302
312
 
303
313
  if (draggedEv && dragP) {
304
314
  const x = timeToPx(dragP.start.getTime());
305
315
  const xEnd = timeToPx(dragP.end.getTime());
316
+ const dragH = Math.max(MIN_EVENT_H, availH - EVENT_GAP);
317
+ const dragW = Math.max(xEnd - x, 28);
306
318
  result.push({
307
- ev: draggedEv, x, width: Math.max(xEnd - x, 28),
319
+ ev: draggedEv, x, width: dragW,
308
320
  row: 0, groupMaxRow: 1,
309
321
  topPx: contentTop,
310
- heightPx: Math.max(MIN_EVENT_H, availH - EVENT_GAP),
322
+ heightPx: dragH,
311
323
  isCurrent: draggedEv.start.getTime() <= now && draggedEv.end.getTime() > now,
312
324
  isNext: false,
313
325
  isDragged: true,
326
+ fit: measure.fitContent({
327
+ title: draggedEv.title,
328
+ subtitle: draggedEv.subtitle,
329
+ location: draggedEv.location,
330
+ tags: draggedEv.tags,
331
+ maxWidth: dragH - 16,
332
+ maxHeight: dragW - 16,
333
+ }),
314
334
  });
315
335
  }
316
336
 
@@ -493,7 +513,7 @@
493
513
  let evDragEvent: TimelineEvent | null = null;
494
514
 
495
515
  function onEventPointerDown(e: PointerEvent, ev: TimelineEvent) {
496
- if (e.button !== 0 || !drag || readOnly) return;
516
+ if (e.button !== 0 || !drag || readOnly || ev.data?.readOnly) return;
497
517
  e.stopPropagation();
498
518
  evDragStartX = e.clientX;
499
519
  evDragOriginPx = timeToPx(ev.start.getTime());
@@ -623,6 +643,11 @@
623
643
  class:fs-event--current={p.isCurrent}
624
644
  class:fs-event--next={p.isNext}
625
645
  class:fs-event--dragging={p.isDragged}
646
+ class:fs-event--readonly={p.ev.data?.readOnly}
647
+ class:fs-event--cancelled={p.ev.status === 'cancelled'}
648
+ class:fs-event--tentative={p.ev.status === 'tentative'}
649
+ class:fs-event--full={p.ev.status === 'full'}
650
+ class:fs-event--limited={p.ev.status === 'limited'}
626
651
  style:left="{p.x}px"
627
652
  style:width="{p.width}px"
628
653
  style:top="{p.topPx}px"
@@ -630,7 +655,7 @@
630
655
  style:--ev-color={p.ev.color ?? 'var(--dt-accent)'}
631
656
  role="button"
632
657
  tabindex="0"
633
- aria-label="{p.ev.title}{p.isCurrent ? ` (${L.inProgress})` : ''}{p.isNext ? ` (${L.upNext})` : ''}"
658
+ aria-label="{p.ev.title}{p.ev.status === 'cancelled' ? ' (cancelled)' : ''}{p.ev.status === 'tentative' ? ' (tentative)' : ''}{p.ev.status === 'full' ? ' (full)' : ''}{p.ev.status === 'limited' ? ' (limited)' : ''}{p.isCurrent ? ` (${L.inProgress})` : ''}{p.isNext ? ` (${L.upNext})` : ''}"
634
659
  onpointerdown={(e) => onEventPointerDown(e, p.ev)}
635
660
  onpointerenter={() => oneventhover?.(p.ev)}
636
661
  onkeydown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); oneventclick?.(p.ev); } }}
@@ -641,14 +666,17 @@
641
666
  {:else if p.isNext}
642
667
  <span class="fs-ev-next-badge" aria-hidden="true">{L.upNext}</span>
643
668
  {/if}
644
- {#if p.heightPx > 64}
669
+ {#if p.fit.time}
645
670
  <span class="fs-ev-time">{fmtTime(p.ev.start, locale)} – {fmtTime(p.ev.end, locale)}</span>
646
671
  {/if}
647
672
  <span class="fs-ev-title">{p.ev.title}</span>
648
- {#if p.ev.subtitle && p.heightPx > 48}
673
+ {#if p.ev.subtitle && p.fit.subtitle}
649
674
  <span class="fs-ev-sub">{p.ev.subtitle}</span>
650
675
  {/if}
651
- {#if p.ev.tags?.length && p.heightPx > 72}
676
+ {#if p.ev.location && p.fit.location}
677
+ <span class="fs-ev-loc">{p.ev.location}</span>
678
+ {/if}
679
+ {#if p.ev.tags?.length && p.fit.tags}
652
680
  <span class="fs-ev-tags">
653
681
  {#each p.ev.tags as tag}
654
682
  <span class="fs-ev-tag">{tag}</span>
@@ -1058,6 +1086,26 @@
1058
1086
  cursor: grabbing;
1059
1087
  transition: none;
1060
1088
  }
1089
+ .fs-event--cancelled {
1090
+ opacity: 0.5;
1091
+ }
1092
+ .fs-event--cancelled .fs-ev-title {
1093
+ text-decoration: line-through;
1094
+ }
1095
+ .fs-event--tentative {
1096
+ opacity: 0.65;
1097
+ border: 1px dashed color-mix(in srgb, var(--ev-color) 40%, transparent);
1098
+ }
1099
+ .fs-event--full {
1100
+ opacity: 0.55;
1101
+ }
1102
+ .fs-event--limited {
1103
+ opacity: 0.65;
1104
+ border: 1px dashed color-mix(in srgb, var(--ev-color) 40%, transparent);
1105
+ }
1106
+ .fs-event--readonly {
1107
+ cursor: default;
1108
+ }
1061
1109
 
1062
1110
  /* Event inner (vertical text, bottom-to-top reading) */
1063
1111
  .fs-ev-inner {
@@ -1109,6 +1157,13 @@
1109
1157
  overflow: hidden;
1110
1158
  text-overflow: ellipsis;
1111
1159
  }
1160
+ .fs-ev-loc {
1161
+ font: 400 10px/1 var(--dt-sans, system-ui, sans-serif);
1162
+ color: var(--dt-text-3, rgba(148, 163, 184, 0.5));
1163
+ white-space: nowrap;
1164
+ overflow: hidden;
1165
+ text-overflow: ellipsis;
1166
+ }
1112
1167
  .fs-ev-tags { display: flex; gap: 4px; }
1113
1168
  .fs-ev-tag {
1114
1169
  font: 500 8px/1 var(--dt-sans, system-ui, sans-serif);
@@ -9,7 +9,8 @@
9
9
  • Generous whitespace, thin dividers, minimal chrome.
10
10
  -->
11
11
  <script lang="ts">
12
- import { getContext, onMount, tick, untrack, type Snippet } from 'svelte';
12
+ import { onMount, tick, untrack } from 'svelte';
13
+ import { useCalendarContext } from '../shared/context.svelte.js';
13
14
  import { createClock } from '../../core/clock.svelte.js';
14
15
  import type { TimelineEvent, BlockedSlot } from '../../core/types.js';
15
16
  import type { DragState } from '../../engine/drag.svelte.js';
@@ -48,58 +49,56 @@
48
49
  readOnly = false,
49
50
  }: Props = $props();
50
51
 
51
- // ── Drag support (available when inside Calendar) ──
52
- const drag = getContext<DragState>('calendar:drag') as DragState | undefined;
53
- const commitDragCtx = getContext<() => void>('calendar:commitDrag') as (() => void) | undefined;
54
- const viewState = getContext<ViewState>('calendar:viewState') as ViewState | undefined;
55
- 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;
56
-
52
+ const ctx = useCalendarContext();
57
53
  const clock = createClock();
58
- const showNavCtx = getContext<{ current: boolean }>('calendar:showNavigation') as { current: boolean } | undefined;
59
- const equalDaysCtx = getContext<{ current: boolean }>('calendar:equalDays') as { current: boolean } | undefined;
60
- const showDatesCtx = getContext<{ current: boolean }>('calendar:showDates') as { current: boolean } | undefined;
61
- const hideDaysCtx = getContext<{ current: number[] | undefined }>('calendar:hideDays') as { current: number[] | undefined } | undefined;
62
- const showNav = $derived(showNavCtx?.current ?? true);
63
- const equalDays = $derived(equalDaysCtx?.current ?? false);
64
- const showDates = $derived(showDatesCtx?.current ?? true);
65
- const hideDays = $derived(hideDaysCtx?.current);
66
-
67
- // ── New feature contexts ──
68
- const blockedSlotsCtx = getContext<{ current: BlockedSlot[] | undefined }>('calendar:blockedSlots') as { current: BlockedSlot[] | undefined } | undefined;
69
- 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;
70
- const minDurationCtx = getContext<{ current: number | undefined }>('calendar:minDuration') as { current: number | undefined } | undefined;
71
- const callbacksCtx = getContext<{ oneventhover?: (event: TimelineEvent) => void }>('calendar:callbacks') as { oneventhover?: (event: TimelineEvent) => void } | undefined;
72
- const disabledDatesCtx = getContext<{ current: Date[] | undefined }>('calendar:disabledDates') as { current: Date[] | undefined } | undefined;
73
- const autoHeightCtx = getContext<{ current: boolean }>('calendar:autoHeight') as { current: boolean } | undefined;
74
-
75
- const blockedSlots = $derived(blockedSlotsCtx?.current);
76
- const dayHeaderSnippet = $derived(dayHeaderSnippetCtx?.current);
77
- const minDuration = $derived(minDurationCtx?.current);
78
- const autoHeight = $derived(autoHeightCtx?.current ?? false);
79
- const oneventhover = $derived(callbacksCtx?.oneventhover);
80
- const disabledDates = $derived(disabledDatesCtx?.current);
81
- const disabledSet = $derived(new Set(disabledDates?.map(d => sod(d.getTime())) ?? []));
82
-
83
- // ─── Infinite-scroll config ────────────────────────
84
- const BUFFER_WEEKS = 6; // weeks rendered on each side of anchor
85
- const EDGE_WEEKS = 2; // rebase when this close to an edge
86
- const SHIFT_WEEKS = 3; // weeks to shift per rebase
54
+ const drag = $derived(ctx.drag);
55
+ const commitDragCtx = $derived(ctx.commitDrag);
56
+ const viewState = $derived(ctx.viewState);
57
+ const loadRangeCtx = $derived(ctx.loadRange);
58
+ const showNav = $derived(ctx.showNav);
59
+ const equalDays = $derived(ctx.equalDays);
60
+ const showDates = $derived(ctx.showDates);
61
+ const hideDays = $derived(ctx.hideDays);
62
+ const blockedSlots = $derived(ctx.blockedSlots);
63
+ const dayHeaderSnippet = $derived(ctx.dayHeaderSnippet);
64
+ const minDuration = $derived(ctx.minDuration);
65
+ const autoHeight = $derived(ctx.autoHeight);
66
+ const oneventhover = $derived(ctx.oneventhover);
67
+ const disabledSet = $derived(ctx.disabledSet);
68
+
69
+ // ─── Buffer config ─────────────────────────────
70
+ const INITIAL_BUFFER = 52; // ±1 year on load
71
+ const EXTEND_BY = 26; // +6 months when user hits edge
72
+ const EDGE_PX = 200; // extend when this close to scroll edge
73
+ let bufferBefore = $state(INITIAL_BUFFER);
74
+ let bufferAfter = $state(INITIAL_BUFFER);
87
75
  const MAX_EVENTS_SHOWN = 5;
88
76
 
89
- // Internal anchor drives layout; syncs bidirectionally with focusDate.
90
77
  const _initMs = untrack(() => sod(focusDate?.getTime() ?? Date.now()));
91
78
  let internalFocusMs = $state(_initMs);
92
79
  let lastExternalMs = _initMs;
93
- let rebasing = false;
94
80
 
95
81
  let el: HTMLDivElement;
96
82
  let scrolled = $state(false);
97
83
 
98
- function scrollCurrentWeekIntoContainer(behavior: ScrollBehavior = 'auto') {
84
+ function scrollWeekIntoContainer(targetMs?: number, behavior: ScrollBehavior = 'auto') {
99
85
  if (!el) return;
100
- const current = el.querySelector<HTMLElement>('.wg-week--current');
101
- if (!current) return;
102
- const targetTop = current.offsetTop - (el.clientHeight - current.offsetHeight) / 2;
86
+ let target: HTMLElement | null = null;
87
+ if (targetMs !== undefined) {
88
+ // Find the week row containing this date
89
+ const rows = el.querySelectorAll<HTMLElement>('[data-week]');
90
+ for (const row of rows) {
91
+ const weekMs = Number(row.dataset.week);
92
+ if (weekMs <= targetMs && targetMs < weekMs + customDays * DAY_MS) {
93
+ target = row;
94
+ break;
95
+ }
96
+ }
97
+ }
98
+ // Fall back to current week
99
+ if (!target) target = el.querySelector<HTMLElement>('.wg-week--current');
100
+ if (!target) return;
101
+ const targetTop = target.offsetTop - (el.clientHeight - target.offsetHeight) / 2;
103
102
  el.scrollTo({ top: Math.max(0, targetTop), behavior });
104
103
  }
105
104
 
@@ -117,8 +116,8 @@
117
116
  // what range we need. Calendar's single $effect handles loading.
118
117
  $effect(() => {
119
118
  if (!loadRangeCtx) return;
120
- const rangeStart = new Date(anchorPeriodStart - BUFFER_WEEKS * customDays * DAY_MS);
121
- const rangeEnd = new Date(anchorPeriodStart + (BUFFER_WEEKS + 1) * customDays * DAY_MS);
119
+ const rangeStart = new Date(anchorPeriodStart - bufferBefore * customDays * DAY_MS);
120
+ const rangeEnd = new Date(anchorPeriodStart + (bufferAfter + 1) * customDays * DAY_MS);
122
121
  loadRangeCtx.set({ start: rangeStart, end: rangeEnd });
123
122
  return () => loadRangeCtx.set(null);
124
123
  });
@@ -147,7 +146,7 @@
147
146
  const weeks = $derived.by(() => {
148
147
  const result: WeekRow[] = [];
149
148
 
150
- for (let w = -BUFFER_WEEKS; w <= BUFFER_WEEKS; w++) {
149
+ for (let w = -bufferBefore; w <= bufferAfter; w++) {
151
150
  const periodStart = anchorPeriodStart + w * customDays * DAY_MS;
152
151
  const isCurrent = todayMs >= periodStart && todayMs < periodStart + customDays * DAY_MS;
153
152
  const days: DayCell[] = [];
@@ -223,88 +222,20 @@
223
222
  // ─── Scroll to current week on mount ────────────────
224
223
  onMount(async () => {
225
224
  await tick();
226
- scrollCurrentWeekIntoContainer();
225
+ scrollWeekIntoContainer();
227
226
  });
228
227
 
229
- // ─── Infinite-scroll helpers ────────────────────────
230
-
231
- /** Detect external focusDate changes (from nav arrows). */
228
+ // ─── External navigation (arrows, goToday) ──────────
232
229
  $effect(() => {
233
230
  const ext = focusDate ? sod(focusDate.getTime()) : clock.today;
234
- if (ext !== lastExternalMs && !rebasing) {
231
+ if (ext !== lastExternalMs) {
235
232
  lastExternalMs = ext;
236
233
  internalFocusMs = ext;
237
- tick().then(() => scrollCurrentWeekIntoContainer('smooth'));
234
+ tick().then(() => scrollWeekIntoContainer(ext, 'smooth'));
238
235
  }
239
236
  });
240
237
 
241
- /** Check if viewport is near top/bottom edge; if so, rebase the window. */
242
- function checkVerticalEdges() {
243
- if (!el || !viewState || rebasing) return;
244
- const rows = el.querySelectorAll<HTMLElement>('[data-week]');
245
- if (!rows.length) return;
246
-
247
- const avgH = el.scrollHeight / rows.length;
248
- const threshold = avgH * EDGE_WEEKS;
249
- const maxScroll = el.scrollHeight - el.clientHeight;
250
-
251
- if (el.scrollTop < threshold) {
252
- rebaseWeeks(-1);
253
- } else if (maxScroll > 0 && maxScroll - el.scrollTop < threshold) {
254
- rebaseWeeks(1);
255
- }
256
- }
257
-
258
- /** Shift the rendered week window by SHIFT_WEEKS in the given direction. */
259
- function rebaseWeeks(direction: number) {
260
- if (rebasing) return;
261
- rebasing = true;
262
-
263
- // Save an anchor: first visible week row and its visual offset.
264
- const rows = el.querySelectorAll<HTMLElement>('[data-week]');
265
- let anchorWeekMs = 0;
266
- let anchorTop = 0;
267
- for (const row of rows) {
268
- const top = row.offsetTop - el.scrollTop;
269
- if (top + row.offsetHeight > 0) {
270
- anchorWeekMs = Number(row.dataset.week);
271
- anchorTop = top;
272
- break;
273
- }
274
- }
275
-
276
- const shift = SHIFT_WEEKS * direction;
277
- internalFocusMs += shift * customDays * DAY_MS;
278
- lastExternalMs = internalFocusMs;
279
- viewState?.setFocusDate(new Date(internalFocusMs));
280
-
281
- tick().then(() => {
282
- // Restore the anchor week to its previous visual position.
283
- const anchor = el.querySelector<HTMLElement>(`[data-week="${anchorWeekMs}"]`);
284
- if (anchor) {
285
- el.scrollTop = anchor.offsetTop - anchorTop;
286
- }
287
- rebasing = false;
288
- });
289
- }
290
-
291
- /** Push the currently visible centre week to viewState. */
292
- function syncFocusFromScroll() {
293
- if (!el || !viewState || rebasing) return;
294
- const centerY = el.scrollTop + el.clientHeight / 2;
295
- const rows = el.querySelectorAll<HTMLElement>('[data-week]');
296
- for (const row of rows) {
297
- if (row.offsetTop + row.offsetHeight >= centerY) {
298
- const ms = Number(row.dataset.week);
299
- if (ms && ms !== lastExternalMs) {
300
- lastExternalMs = ms;
301
- viewState.setFocusDate(new Date(ms));
302
- }
303
- break;
304
- }
305
- }
306
- }
307
-
238
+ // ─── Scroll state ─────────────────────────────────────
308
239
  function isCurrentWeekVisible(): boolean {
309
240
  if (!el) return false;
310
241
  const current = el.querySelector<HTMLElement>('.wg-week--current');
@@ -314,14 +245,26 @@
314
245
  return bottom > 0 && top < el.clientHeight;
315
246
  }
316
247
 
317
- let suppressScroll = false;
248
+ let extending = false;
318
249
 
319
250
  function handleUserScroll() {
320
- if (suppressScroll) return;
321
251
  scrolled = !isCurrentWeekVisible();
322
- if (!rebasing) {
323
- checkVerticalEdges();
324
- syncFocusFromScroll();
252
+ if (!el || extending) return;
253
+ // Extend buffer when user scrolls near the edge
254
+ if (el.scrollTop < EDGE_PX) {
255
+ extending = true;
256
+ const oldHeight = el.scrollHeight;
257
+ bufferBefore += EXTEND_BY;
258
+ // After DOM updates, compensate scroll for prepended content
259
+ tick().then(() => {
260
+ el.scrollTop += el.scrollHeight - oldHeight;
261
+ extending = false;
262
+ });
263
+ } else {
264
+ const bottomRemaining = el.scrollHeight - el.clientHeight - el.scrollTop;
265
+ if (bottomRemaining < EDGE_PX) {
266
+ bufferAfter += EXTEND_BY;
267
+ }
325
268
  }
326
269
  }
327
270
 
@@ -329,15 +272,9 @@
329
272
  internalFocusMs = clock.today;
330
273
  lastExternalMs = clock.today;
331
274
  viewState?.goToday();
332
- suppressScroll = true;
333
275
  scrolled = false;
334
276
  tick().then(() => {
335
- scrollCurrentWeekIntoContainer('smooth');
336
- // Keep suppressed until smooth scroll finishes, then recheck
337
- setTimeout(() => {
338
- suppressScroll = false;
339
- scrolled = !isCurrentWeekVisible();
340
- }, 600);
277
+ scrollWeekIntoContainer(clock.today, 'smooth');
341
278
  });
342
279
  }
343
280
 
@@ -374,7 +311,7 @@
374
311
  }
375
312
 
376
313
  function onEventPointerDown(e: PointerEvent, ev: TimelineEvent) {
377
- if (e.button !== 0 || !drag || readOnly) return;
314
+ if (e.button !== 0 || !drag || readOnly || ev.data?.readOnly) return;
378
315
  e.stopPropagation();
379
316
  evDragStartX = e.clientX;
380
317
  evDragStartY = e.clientY;
@@ -536,15 +473,23 @@
536
473
  class:wg-ev--selected={selectedEventId === ev.id}
537
474
  class:wg-ev--current={ev.start.getTime() <= clock.tick && ev.end.getTime() > clock.tick}
538
475
  class:wg-ev--dragging={evDragging && evDragId === ev.id}
476
+ class:wg-ev--readonly={ev.data?.readOnly}
477
+ class:wg-ev--cancelled={ev.status === 'cancelled'}
478
+ class:wg-ev--tentative={ev.status === 'tentative'}
479
+ class:wg-ev--full={ev.status === 'full'}
480
+ class:wg-ev--limited={ev.status === 'limited'}
539
481
  style:--ev-color={ev.color ?? 'var(--dt-accent)'}
540
482
  role="button"
541
483
  tabindex="0"
542
- aria-label="{ev.title}{ev.start.getTime() <= clock.tick && ev.end.getTime() > clock.tick ? ` (${L.inProgress})` : ''}"
484
+ aria-label="{ev.title}{ev.status === 'cancelled' ? ` (cancelled)` : ''}{ev.status === 'tentative' ? ` (tentative)` : ''}{ev.status === 'full' ? ` (full)` : ''}{ev.status === 'limited' ? ` (limited)` : ''}{ev.start.getTime() <= clock.tick && ev.end.getTime() > clock.tick ? ` (${L.inProgress})` : ''}"
543
485
  onpointerdown={(e) => onEventPointerDown(e, ev)} onpointerenter={() => oneventhover?.(ev)}
544
486
  onkeydown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); e.stopPropagation(); oneventclick?.(ev); } }}
545
487
  >
546
488
  <span class="wg-ev-time">{fmtAmPm(ev.start)}</span>
547
489
  <span class="wg-ev-title">{ev.title}</span>
490
+ {#if ev.location}
491
+ <span class="wg-ev-loc">{ev.location}</span>
492
+ {/if}
548
493
  </div>
549
494
  {/each}
550
495
  {#if day.events.length > MAX_EVENTS_SHOWN}
@@ -860,6 +805,27 @@
860
805
  background: color-mix(in srgb, var(--ev-color) 22%, var(--dt-surface, #10141c));
861
806
  }
862
807
 
808
+ .wg-ev--cancelled {
809
+ opacity: 0.5;
810
+ }
811
+ .wg-ev--cancelled .wg-ev-title {
812
+ text-decoration: line-through;
813
+ }
814
+ .wg-ev--tentative {
815
+ opacity: 0.65;
816
+ border: 1px dashed color-mix(in srgb, var(--ev-color) 40%, transparent);
817
+ }
818
+ .wg-ev--full {
819
+ opacity: 0.55;
820
+ }
821
+ .wg-ev--limited {
822
+ opacity: 0.65;
823
+ border: 1px dashed color-mix(in srgb, var(--ev-color) 40%, transparent);
824
+ }
825
+ .wg-ev--readonly {
826
+ cursor: default;
827
+ }
828
+
863
829
  .wg-ev-time {
864
830
  font: 400 10px / 1 var(--dt-sans, system-ui, sans-serif);
865
831
  color: var(--dt-text-3, rgba(0, 0, 0, 0.4));
@@ -875,6 +841,15 @@
875
841
  text-overflow: ellipsis;
876
842
  }
877
843
 
844
+ .wg-ev-loc {
845
+ font: 400 9px / 1 var(--dt-sans, system-ui, sans-serif);
846
+ color: var(--dt-text-3, rgba(0, 0, 0, 0.35));
847
+ white-space: nowrap;
848
+ overflow: hidden;
849
+ text-overflow: ellipsis;
850
+ flex-shrink: 1;
851
+ }
852
+
878
853
  .wg-ev-more {
879
854
  font: 500 10px / 1 var(--dt-sans, system-ui, sans-serif);
880
855
  color: var(--dt-text-3, rgba(0, 0, 0, 0.35));
@@ -0,0 +1,47 @@
1
+ import type { Snippet } from 'svelte';
2
+ import type { TimelineEvent, BlockedSlot } from '../../core/types.js';
3
+ import type { DragState } from '../../engine/drag.svelte.js';
4
+ import type { ViewState } from '../../engine/view-state.svelte.js';
5
+ export interface CalendarContext {
6
+ readonly viewState: ViewState | undefined;
7
+ readonly drag: DragState | undefined;
8
+ readonly commitDrag: (() => void) | undefined;
9
+ readonly snapInterval: number;
10
+ readonly showNav: boolean;
11
+ readonly equalDays: boolean;
12
+ readonly showDates: boolean;
13
+ readonly hideDays: number[] | undefined;
14
+ readonly isMobile: boolean;
15
+ readonly autoHeight: boolean;
16
+ readonly compact: boolean;
17
+ readonly readOnly: boolean;
18
+ readonly blockedSlots: BlockedSlot[] | undefined;
19
+ readonly dayHeaderSnippet: Snippet<[{
20
+ date: Date;
21
+ isToday: boolean;
22
+ dayName: string;
23
+ }]> | undefined;
24
+ readonly minDuration: number | undefined;
25
+ readonly maxDuration: number | undefined;
26
+ readonly oneventhover: ((event: TimelineEvent) => void) | undefined;
27
+ readonly disabledDates: Date[] | undefined;
28
+ readonly disabledSet: Set<number>;
29
+ readonly loadRange: {
30
+ current: {
31
+ start: Date;
32
+ end: Date;
33
+ } | null;
34
+ set: (r: {
35
+ start: Date;
36
+ end: Date;
37
+ } | null) => void;
38
+ } | undefined;
39
+ readonly eventSnippet: Snippet<[TimelineEvent]> | undefined;
40
+ readonly emptySnippet: Snippet | undefined;
41
+ }
42
+ /**
43
+ * Read the calendar context.
44
+ * Call at component init time (top-level script).
45
+ * Returns an object with getters that delegate to the raw context.
46
+ */
47
+ export declare function useCalendarContext(): CalendarContext;
@@ -0,0 +1,47 @@
1
+ /**
2
+ * Shared context reader for calendar views.
3
+ *
4
+ * Calendar.svelte sets a single 'calendar' context with reactive getters.
5
+ * This module reads it and returns a typed interface for views.
6
+ */
7
+ import { getContext } from 'svelte';
8
+ import { sod } from '../../core/time.js';
9
+ /**
10
+ * Read the calendar context.
11
+ * Call at component init time (top-level script).
12
+ * Returns an object with getters that delegate to the raw context.
13
+ */
14
+ export function useCalendarContext() {
15
+ const raw = getContext('calendar');
16
+ return {
17
+ get viewState() { return raw?.viewState; },
18
+ get drag() { return raw?.drag; },
19
+ get commitDrag() { return raw?.commitDrag; },
20
+ get snapInterval() { return raw?.snapInterval ?? 15; },
21
+ get showNav() { return raw?.showNavigation ?? true; },
22
+ get equalDays() { return raw?.equalDays ?? false; },
23
+ get showDates() { return raw?.showDates ?? true; },
24
+ get hideDays() { return raw?.hideDays; },
25
+ get isMobile() { return raw?.mobile ?? false; },
26
+ get autoHeight() { return raw?.autoHeight ?? false; },
27
+ get compact() { return raw?.compact ?? false; },
28
+ get readOnly() { return raw?.readOnly ?? false; },
29
+ get blockedSlots() { return raw?.blockedSlots; },
30
+ get dayHeaderSnippet() { return raw?.dayHeaderSnippet; },
31
+ get minDuration() { return raw?.minDuration; },
32
+ get maxDuration() { return raw?.maxDuration; },
33
+ get oneventhover() { return raw?.oneventhover; },
34
+ get disabledDates() { return raw?.disabledDates; },
35
+ get disabledSet() { return new Set(raw?.disabledDates?.map(d => sod(d.getTime())) ?? []); },
36
+ get loadRange() {
37
+ if (!raw)
38
+ return undefined;
39
+ return {
40
+ get current() { return raw.loadRange; },
41
+ set: (r) => raw.setLoadRange(r),
42
+ };
43
+ },
44
+ get eventSnippet() { return raw?.eventSnippet; },
45
+ get emptySnippet() { return raw?.emptySnippet; },
46
+ };
47
+ }