@nomideusz/svelte-calendar 0.9.0 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,24 +1,23 @@
1
1
  <!--
2
- Planner week mode — Multi-week vertical scroll.
3
-
4
- Modelled after Hey Calendar's week view:
5
- Weeks stack vertically. Scroll up = past, down = future.
6
- Day headers in each week row: "MON 23", "TUE 24", with accent pill for today.
7
- Month label in left gutter, vertical bottom-to-top.
8
- Events are clean horizontal bars with colour fill, "9AM- 10AM Title" inline.
9
- Generous whitespace, thin dividers, minimal chrome.
2
+ Planner week mode — vertical time grid (Google-Calendar-style).
3
+
4
+ Left time gutter with localized hour labels; N day columns (viewState.dayCount).
5
+ Sticky day-header row + sticky all-day strip; the grid scrolls vertically.
6
+ Timed events are absolutely positioned by start/end; overlaps share the
7
+ column via union-find lanes.
8
+ Drag-to-move (vertical = time, horizontal = day), top/bottom resize
9
+ handles, drag-to-create with ghost preview all through ctx.drag /
10
+ ctx.commitDrag so Calendar's validation (min/max duration, blocked,
11
+ disabled) applies on drop.
12
+ • Now-line across today's column only; auto-scrolls to the current time.
10
13
  -->
11
- <script lang="ts">import { onMount, tick, untrack } from "svelte";
12
- import { flip } from "svelte/animate";
13
- import { crossfade } from "svelte/transition";
14
- import { prefersReducedMotion } from "svelte/motion";
14
+ <script lang="ts">import { untrack } from "svelte";
15
15
  import { useCalendarContext } from "../shared/context.svelte.js";
16
16
  import EventContent from "../shared/EventContent.svelte";
17
17
  import { createClock } from "../../core/clock.svelte.js";
18
- import { DAY_MS, HOUR_MS, sod } from "../../core/time.js";
19
- import { startOfWeek as sowFn, fractionalHour, isAllDay, isMultiDay, segmentForDay } from "../../core/time.js";
20
- import { weekdayShort, monthLong, fmtTime as _fmtTime, getLabels } from "../../core/locale.js";
21
- const L = $derived(getLabels());
18
+ import { DAY_MS, HOUR_MS, sod, addDaysMs } from "../../core/time.js";
19
+ import { startOfWeek as sowFn, isAllDay, isMultiDay, segmentForDay } from "../../core/time.js";
20
+ import { fmtH, fmtTime, fmtDuration, weekdayShort } from "../../core/locale.js";
22
21
  let {
23
22
  mondayStart = true,
24
23
  locale,
@@ -29,12 +28,12 @@ let {
29
28
  oneventclick,
30
29
  oneventcreate,
31
30
  selectedEventId = null,
32
- readOnly = false
31
+ readOnly = false,
32
+ visibleHours
33
33
  } = $props();
34
34
  const ctx = useCalendarContext();
35
+ const L = $derived(ctx.labels);
35
36
  const clock = createClock(ctx.timezone);
36
- const ANIM = $derived(prefersReducedMotion.current ? 0 : 180);
37
- const [previewSend, previewReceive] = crossfade({ duration: () => prefersReducedMotion.current ? 0 : 160 });
38
37
  const drag = $derived(ctx.drag);
39
38
  const commitDragCtx = $derived(ctx.commitDrag);
40
39
  const viewState = $derived(ctx.viewState);
@@ -48,823 +47,1349 @@ const minDuration = $derived(ctx.minDuration);
48
47
  const autoHeight = $derived(ctx.autoHeight);
49
48
  const oneventhover = $derived(ctx.oneventhover);
50
49
  const disabledSet = $derived(ctx.disabledSet);
51
- const INITIAL_BUFFER = 52;
52
- const EXTEND_BY = 26;
53
- const EDGE_PX = 200;
54
- let bufferBefore = $state(INITIAL_BUFFER);
55
- let bufferAfter = $state(INITIAL_BUFFER);
56
- const MAX_EVENTS_SHOWN = 5;
57
- const _initMs = untrack(() => sod(focusDate?.getTime() ?? Date.now()));
58
- let internalFocusMs = $state(_initMs);
59
- let lastExternalMs = _initMs;
60
- let el;
61
- function scrollWeekIntoContainer(targetMs, behavior = "auto") {
62
- if (!el) return;
63
- let target = null;
64
- if (targetMs !== void 0) {
65
- const rows = el.querySelectorAll("[data-week]");
66
- for (const row of rows) {
67
- const weekMs = Number(row.dataset.week);
68
- if (weekMs <= targetMs && targetMs < weekMs + customDays * DAY_MS) {
69
- target = row;
70
- break;
71
- }
72
- }
73
- }
74
- if (!target) target = el.querySelector(".wg-week--current");
75
- if (!target) return;
76
- const targetTop = target.offsetTop - (el.clientHeight - target.offsetHeight) / 2;
77
- el.scrollTo({ top: Math.max(0, targetTop), behavior });
78
- }
50
+ const SNAP_MS = $derived(ctx.snapInterval * 6e4);
51
+ const HOUR_H = 48;
52
+ const GUTTER_W = 48;
53
+ const MIN_COL_W = 110;
54
+ const ALLDAY_MAX = 3;
55
+ const startHour = $derived(visibleHours?.[0] ?? 0);
56
+ const endHour = $derived(visibleHours?.[1] ?? 24);
57
+ const hourCount = $derived(Math.max(1, endHour - startHour));
58
+ const gridHeight = $derived(hourCount * HOUR_H);
59
+ let scrollEl;
60
+ let colsEl;
79
61
  const todayMs = $derived(clock.today);
80
62
  const customDays = $derived(viewState?.dayCount ?? 7);
81
- const anchorPeriodStart = $derived(
82
- customDays === 7 ? sowFn(internalFocusMs, mondayStart) : sod(internalFocusMs)
83
- );
63
+ const weekStartMs = $derived.by(() => {
64
+ const r = viewState?.range;
65
+ if (r) return sod(r.start.getTime());
66
+ const f = focusDate?.getTime() ?? todayMs;
67
+ return customDays === 7 ? sowFn(f, mondayStart) : sod(f);
68
+ });
69
+ const weekEndMs = $derived(addDaysMs(weekStartMs, customDays));
70
+ const weekHasToday = $derived(todayMs >= weekStartMs && todayMs < weekEndMs);
71
+ const dayCols = $derived.by(() => {
72
+ const cols = [];
73
+ for (let d = 0; d < customDays; d++) {
74
+ const ms = addDaysMs(weekStartMs, d);
75
+ const date = new Date(ms);
76
+ const dow = date.getDay();
77
+ const isoDay = dow === 0 ? 7 : dow;
78
+ if (hideDays?.includes(isoDay)) continue;
79
+ cols.push({
80
+ ms,
81
+ isToday: ms === todayMs,
82
+ isPast: equalDays ? false : ms < todayMs,
83
+ isWeekend: dow === 0 || dow === 6,
84
+ isDisabled: disabledSet.has(ms),
85
+ isoDay,
86
+ dayNum: date.getDate()
87
+ });
88
+ }
89
+ return cols;
90
+ });
91
+ const innerMinWidth = $derived(GUTTER_W + dayCols.length * MIN_COL_W);
84
92
  $effect(() => {
85
93
  if (!loadRangeCtx) return;
86
- const rangeStart = new Date(anchorPeriodStart - bufferBefore * customDays * DAY_MS);
87
- const rangeEnd = new Date(anchorPeriodStart + (bufferAfter + 1) * customDays * DAY_MS);
94
+ const rangeStart = new Date(weekStartMs - 7 * DAY_MS);
95
+ const rangeEnd = new Date(weekEndMs + 7 * DAY_MS);
88
96
  loadRangeCtx.set({ start: rangeStart, end: rangeEnd });
89
97
  return () => loadRangeCtx.set(null);
90
98
  });
91
- const weeks = $derived.by(() => {
92
- const result = [];
93
- for (let w = -bufferBefore; w <= bufferAfter; w++) {
94
- const periodStart = anchorPeriodStart + w * customDays * DAY_MS;
95
- const isCurrent = todayMs >= periodStart && todayMs < periodStart + customDays * DAY_MS;
96
- const days = [];
97
- for (let d = 0; d < customDays; d++) {
98
- const ms = periodStart + d * DAY_MS;
99
- const date = new Date(ms);
100
- const dayNum = date.getDate();
101
- const dow = date.getDay();
102
- const isWeekend = dow === 0 || dow === 6;
103
- const isToday = ms === todayMs;
104
- const isPast = equalDays ? false : ms < todayMs;
105
- const isFirstOfMonth = dayNum === 1;
106
- const monthLabel2 = d === 0 || isFirstOfMonth ? monthLong(ms, locale).toUpperCase() : null;
107
- const dayEnd = ms + DAY_MS;
108
- const dayEventsAll = events.filter((ev) => ev.start.getTime() < dayEnd && ev.end.getTime() > ms).sort((a, b) => a.start.getTime() - b.start.getTime());
109
- const timedEvents = [];
110
- const allDaySegments = [];
111
- for (const ev of dayEventsAll) {
112
- if (isAllDay(ev) || isMultiDay(ev)) {
113
- const seg = segmentForDay(ev, ms);
114
- if (seg) allDaySegments.push(seg);
115
- } else {
116
- timedEvents.push(ev);
99
+ const allDayByDay = $derived.by(() => {
100
+ const map = /* @__PURE__ */ new Map();
101
+ for (const day of dayCols) {
102
+ const segs = [];
103
+ for (const ev of events) {
104
+ if (!isAllDay(ev) && !isMultiDay(ev)) continue;
105
+ const seg = segmentForDay(ev, day.ms);
106
+ if (seg) segs.push(seg);
107
+ }
108
+ if (segs.length) map.set(day.ms, segs);
109
+ }
110
+ return map;
111
+ });
112
+ const hasAllDayRow = $derived(allDayByDay.size > 0);
113
+ let adExpanded = $state({});
114
+ const movingId = $derived(drag?.active && drag.mode === "move" ? drag.payload?.eventId ?? null : null);
115
+ const movingEvent = $derived(movingId ? events.find((e) => e.id === movingId) ?? null : null);
116
+ const layoutByDay = $derived.by(() => {
117
+ const rsP = drag?.active && (drag.mode === "resize-start" || drag.mode === "resize-end") ? drag.payload : null;
118
+ const map = /* @__PURE__ */ new Map();
119
+ for (const day of dayCols) {
120
+ let find = function(i) {
121
+ while (par[i] !== i) {
122
+ par[i] = par[par[i]];
123
+ i = par[i];
124
+ }
125
+ return i;
126
+ };
127
+ const dayEnd = day.ms + DAY_MS;
128
+ const bandStart = day.ms + startHour * HOUR_MS;
129
+ const bandEnd = day.ms + endHour * HOUR_MS;
130
+ const infos = [];
131
+ for (const ev of events) {
132
+ if (isAllDay(ev) || isMultiDay(ev)) continue;
133
+ if (ev.id === movingId) continue;
134
+ const isResizing = rsP?.eventId === ev.id;
135
+ const s0 = isResizing ? rsP.start.getTime() : ev.start.getTime();
136
+ const e0 = isResizing ? rsP.end.getTime() : ev.end.getTime();
137
+ if (s0 >= dayEnd || e0 <= day.ms) continue;
138
+ const sMs = Math.max(s0, bandStart);
139
+ const eMs = Math.min(e0, bandEnd);
140
+ if (eMs <= sMs) continue;
141
+ infos.push({ ev, startMs: sMs, endMs: eMs, isResizing, col: 0, totalCols: 1 });
142
+ }
143
+ infos.sort((a, b) => a.startMs - b.startMs || b.endMs - a.endMs);
144
+ const par = infos.map((_, i) => i);
145
+ for (let i = 0; i < infos.length; i++) {
146
+ for (let j = i + 1; j < infos.length; j++) {
147
+ if (infos[j].startMs < infos[i].endMs) par[find(i)] = find(j);
148
+ else break;
149
+ }
150
+ }
151
+ const groups = /* @__PURE__ */ new Map();
152
+ for (let i = 0; i < infos.length; i++) {
153
+ const root = find(i);
154
+ if (!groups.has(root)) groups.set(root, []);
155
+ groups.get(root).push(i);
156
+ }
157
+ for (const [, indices] of groups) {
158
+ const lanes = [];
159
+ for (const idx of indices) {
160
+ const inf = infos[idx];
161
+ let lane = 0;
162
+ for (let r = 0; r < lanes.length; r++) {
163
+ if (lanes[r] <= inf.startMs) {
164
+ lane = r;
165
+ lanes[r] = inf.endMs;
166
+ break;
167
+ }
168
+ lane = r + 1;
117
169
  }
170
+ if (lane >= lanes.length) lanes.push(inf.endMs);
171
+ infos[idx].col = lane;
118
172
  }
119
- days.push({ ms, dayNum, isToday, isPast, isWeekend, isFirstOfMonth, monthLabel: monthLabel2, events: timedEvents, allDaySegments });
173
+ for (const idx of indices) infos[idx].totalCols = lanes.length;
120
174
  }
121
- const startDate = new Date(periodStart);
122
- const showMonth = customDays === 7 ? startDate.getDate() <= 7 : startDate.getDate() <= customDays;
123
- const monthLabel = showMonth ? monthLong(periodStart, locale).toUpperCase() : null;
124
- result.push({ weekStart: periodStart, isCurrent, monthLabel, days });
175
+ map.set(
176
+ day.ms,
177
+ infos.map((inf) => ({
178
+ ev: inf.ev,
179
+ top: ((inf.startMs - day.ms) / HOUR_MS - startHour) * HOUR_H,
180
+ height: Math.max(24, (inf.endMs - inf.startMs) / HOUR_MS * HOUR_H),
181
+ col: inf.col,
182
+ totalCols: inf.totalCols,
183
+ isResizing: inf.isResizing
184
+ }))
185
+ );
125
186
  }
126
- if (hideDays?.length) {
127
- for (const row of result) {
128
- row.days = row.days.filter((d) => {
129
- const isoDay = new Date(d.ms).getDay();
130
- const iso = isoDay === 0 ? 7 : isoDay;
131
- return !hideDays.includes(iso);
132
- });
133
- }
187
+ return map;
188
+ });
189
+ const nowIds = $derived.by(() => {
190
+ const now = clock.tick;
191
+ const s = /* @__PURE__ */ new Set();
192
+ for (const ev of events) {
193
+ if (ev.start.getTime() <= now && ev.end.getTime() > now) s.add(ev.id);
134
194
  }
135
- return result;
195
+ return s;
136
196
  });
137
- function fmtAmPm(d) {
138
- return _fmtTime(d, locale);
139
- }
140
- function fmtNowTime(tick2) {
141
- return _fmtTime(new Date(tick2), locale);
142
- }
143
- const nowFrac = $derived(fractionalHour(clock.tick) / 24);
144
- onMount(async () => {
145
- await tick();
146
- scrollWeekIntoContainer();
197
+ const nowFracHour = $derived((clock.tick - clock.today) / HOUR_MS);
198
+ const nowY = $derived.by(() => {
199
+ if (nowFracHour < startHour || nowFracHour > endHour) return null;
200
+ return (nowFracHour - startHour) * HOUR_H;
147
201
  });
202
+ const weekIsEmpty = $derived(
203
+ !events.some((ev) => ev.start.getTime() < weekEndMs && ev.end.getTime() > weekStartMs)
204
+ );
148
205
  $effect(() => {
149
- const ext = focusDate ? sod(focusDate.getTime()) : clock.today;
150
- if (ext !== lastExternalMs) {
151
- lastExternalMs = ext;
152
- internalFocusMs = ext;
153
- tick().then(() => scrollWeekIntoContainer(ext));
154
- }
206
+ void weekStartMs;
207
+ const el = scrollEl;
208
+ if (!el) return;
209
+ untrack(() => {
210
+ let targetHour;
211
+ if (weekHasToday) {
212
+ const clamped = Math.min(Math.max(nowFracHour, startHour), endHour);
213
+ targetHour = Math.max(startHour, clamped - 1);
214
+ } else {
215
+ targetHour = Math.max(startHour, Math.min(8, endHour - 1));
216
+ }
217
+ el.scrollTop = (targetHour - startHour) * HOUR_H;
218
+ });
155
219
  });
156
- function syncFocusFromScroll() {
157
- if (!el || !viewState) return;
158
- const centerY = el.scrollTop + el.clientHeight / 2;
159
- const rows = el.querySelectorAll("[data-week]");
160
- for (const row of rows) {
161
- if (row.offsetTop + row.offsetHeight >= centerY) {
162
- const ms = Number(row.dataset.week);
163
- if (Number.isFinite(ms) && ms !== lastExternalMs) {
164
- lastExternalMs = ms;
165
- viewState.setFocusDate(new Date(ms));
166
- }
220
+ function colsRect() {
221
+ return colsEl.getBoundingClientRect();
222
+ }
223
+ function pointerDayIndex(clientX) {
224
+ const r = colsRect();
225
+ const n = dayCols.length;
226
+ if (n === 0) return 0;
227
+ const w = r.width / n;
228
+ return Math.max(0, Math.min(n - 1, Math.floor((clientX - r.left) / w)));
229
+ }
230
+ function pointerHour(clientY) {
231
+ return startHour + (clientY - colsRect().top) / HOUR_H;
232
+ }
233
+ function pointerTimeMs(clientX, clientY) {
234
+ const dayMs = dayCols[pointerDayIndex(clientX)]?.ms ?? weekStartMs;
235
+ const hour = Math.min(Math.max(pointerHour(clientY), startHour), endHour);
236
+ return dayMs + hour * HOUR_MS;
237
+ }
238
+ function clampToDayBand(ms, dayMs) {
239
+ return Math.max(dayMs + startHour * HOUR_MS, Math.min(dayMs + endHour * HOUR_MS, ms));
240
+ }
241
+ function isBlockedAt(dayMs, hour) {
242
+ if (!blockedSlots?.length) return false;
243
+ const jsDay = new Date(dayMs).getDay();
244
+ const isoDay = jsDay === 0 ? 7 : jsDay;
245
+ return blockedSlots.some((slot) => {
246
+ if (slot.day && slot.day !== isoDay) return false;
247
+ return hour >= slot.start && hour < slot.end;
248
+ });
249
+ }
250
+ function blockedRangeLabel(dayMs, slotStart, slotEnd) {
251
+ return `${fmtTime(new Date(dayMs + slotStart * HOUR_MS), locale)} \u2013 ${fmtTime(new Date(dayMs + slotEnd * HOUR_MS), locale)}`;
252
+ }
253
+ function statusText(ev) {
254
+ if (ev.status === "cancelled") return ` (${L.cancelled})`;
255
+ if (ev.status === "tentative") return ` (${L.tentative})`;
256
+ if (ev.status === "full") return ` (${L.full})`;
257
+ if (ev.status === "limited") return ` (${L.limited})`;
258
+ return "";
259
+ }
260
+ function ghostForDay(dayMs) {
261
+ if (!drag?.active || !drag.payload) return null;
262
+ const mode = drag.mode;
263
+ if (mode !== "move" && mode !== "create") return null;
264
+ const s = drag.payload.start.getTime();
265
+ const e = drag.payload.end.getTime();
266
+ const bandS = dayMs + startHour * HOUR_MS;
267
+ const bandE = dayMs + endHour * HOUR_MS;
268
+ const cs = Math.max(s, bandS);
269
+ const ce = Math.min(e, bandE);
270
+ if (ce <= cs) return null;
271
+ return {
272
+ top: ((cs - dayMs) / HOUR_MS - startHour) * HOUR_H,
273
+ height: Math.max(12, (ce - cs) / HOUR_MS * HOUR_H),
274
+ start: drag.payload.start,
275
+ end: drag.payload.end,
276
+ create: mode === "create",
277
+ // Only the segment containing the start shows the readout
278
+ showTime: cs === Math.max(s, dayMs)
279
+ };
280
+ }
281
+ const CREATE_THRESHOLD = 4;
282
+ const LONG_PRESS_MS = 350;
283
+ const LONG_PRESS_TOLERANCE = 8;
284
+ let suppressColsClick = false;
285
+ let crStartX = 0;
286
+ let crStartY = 0;
287
+ let crAnchorMs = 0;
288
+ let crDayMs = 0;
289
+ let crStarted = false;
290
+ let longPressTimer = null;
291
+ function blockTouchScroll(e) {
292
+ e.preventDefault();
293
+ }
294
+ function addTouchScrollBlock() {
295
+ window.addEventListener("touchmove", blockTouchScroll, { passive: false });
296
+ }
297
+ function removeTouchScrollBlock() {
298
+ window.removeEventListener("touchmove", blockTouchScroll);
299
+ }
300
+ function clearLongPress() {
301
+ if (longPressTimer !== null) {
302
+ clearTimeout(longPressTimer);
303
+ longPressTimer = null;
304
+ }
305
+ }
306
+ function startColsCreate() {
307
+ if (!drag) return;
308
+ crStarted = true;
309
+ crAnchorMs = clampToDayBand(Math.floor(crAnchorMs / SNAP_MS) * SNAP_MS, crDayMs);
310
+ drag.beginCreate(new Date(crAnchorMs), new Date(crAnchorMs + SNAP_MS));
311
+ addTouchScrollBlock();
312
+ }
313
+ function onColsPointerDown(e) {
314
+ if (e.button !== 0 || !drag || !oneventcreate || readOnly) return;
315
+ if (e.target.closest(".tw-ev, .tw-ghost")) return;
316
+ const day = dayCols[pointerDayIndex(e.clientX)];
317
+ if (!day || day.isDisabled) return;
318
+ if (isBlockedAt(day.ms, Math.min(Math.max(pointerHour(e.clientY), startHour), endHour))) return;
319
+ crStartX = e.clientX;
320
+ crStartY = e.clientY;
321
+ crDayMs = day.ms;
322
+ crAnchorMs = clampToDayBand(
323
+ day.ms + Math.max(pointerHour(e.clientY), startHour) * HOUR_MS,
324
+ day.ms
325
+ );
326
+ crStarted = false;
327
+ if (e.pointerType === "touch") {
328
+ longPressTimer = setTimeout(() => {
329
+ longPressTimer = null;
330
+ startColsCreate();
331
+ }, LONG_PRESS_MS);
332
+ }
333
+ window.addEventListener("pointermove", onColsCreateMove);
334
+ window.addEventListener("pointerup", onColsCreateUp, { once: true });
335
+ window.addEventListener("pointercancel", onColsCreateCancel, { once: true });
336
+ }
337
+ function onColsCreateMove(e) {
338
+ if (!drag) return;
339
+ if (!crStarted) {
340
+ if (longPressTimer !== null) {
341
+ const moved = Math.hypot(e.clientX - crStartX, e.clientY - crStartY);
342
+ if (moved > LONG_PRESS_TOLERANCE) cleanupColsCreate();
167
343
  return;
168
344
  }
345
+ if (e.pointerType === "touch") return;
346
+ if (Math.abs(e.clientY - crStartY) < CREATE_THRESHOLD) return;
347
+ startColsCreate();
348
+ }
349
+ const raw = crDayMs + pointerHour(e.clientY) * HOUR_MS;
350
+ const snapped = clampToDayBand(Math.round(raw / SNAP_MS) * SNAP_MS, crDayMs);
351
+ drag.updatePointer(
352
+ new Date(Math.min(crAnchorMs, snapped)),
353
+ new Date(Math.max(crAnchorMs + SNAP_MS, snapped))
354
+ );
355
+ }
356
+ function cleanupColsCreate() {
357
+ clearLongPress();
358
+ removeTouchScrollBlock();
359
+ window.removeEventListener("pointermove", onColsCreateMove);
360
+ window.removeEventListener("pointerup", onColsCreateUp);
361
+ window.removeEventListener("pointercancel", onColsCreateCancel);
362
+ crStarted = false;
363
+ }
364
+ function onColsCreateUp() {
365
+ if (drag && crStarted) {
366
+ suppressColsClick = true;
367
+ commitDragCtx?.();
368
+ setTimeout(() => {
369
+ suppressColsClick = false;
370
+ }, 0);
169
371
  }
372
+ cleanupColsCreate();
170
373
  }
171
- let extending = false;
172
- function handleUserScroll() {
173
- syncFocusFromScroll();
174
- if (!el || extending) return;
175
- if (el.scrollTop < EDGE_PX) {
176
- extending = true;
177
- const oldHeight = el.scrollHeight;
178
- bufferBefore += EXTEND_BY;
179
- tick().then(() => {
180
- el.scrollTop += el.scrollHeight - oldHeight;
181
- extending = false;
182
- });
183
- } else {
184
- const bottomRemaining = el.scrollHeight - el.clientHeight - el.scrollTop;
185
- if (bottomRemaining < EDGE_PX) {
186
- bufferAfter += EXTEND_BY;
187
- }
374
+ function onColsCreateCancel() {
375
+ if (drag && crStarted) drag.cancel();
376
+ cleanupColsCreate();
377
+ }
378
+ function onColsContextMenu(e) {
379
+ if (crStarted || longPressTimer !== null) e.preventDefault();
380
+ }
381
+ function handleColsClick(e) {
382
+ if (suppressColsClick) {
383
+ suppressColsClick = false;
384
+ return;
188
385
  }
386
+ if (!oneventcreate || readOnly) return;
387
+ if (e.target.closest(".tw-ev, .tw-ghost")) return;
388
+ const day = dayCols[pointerDayIndex(e.clientX)];
389
+ if (!day || day.isDisabled) return;
390
+ const hour = Math.min(Math.max(pointerHour(e.clientY), startHour), endHour);
391
+ if (isBlockedAt(day.ms, hour)) return;
392
+ const startMs = clampToDayBand(
393
+ Math.floor((day.ms + hour * HOUR_MS) / SNAP_MS) * SNAP_MS,
394
+ day.ms
395
+ );
396
+ const durMin = minDuration ?? 60;
397
+ oneventcreate({ start: new Date(startMs), end: new Date(startMs + durMin * 6e4) });
189
398
  }
190
- function handleDayCellClick(ms, e) {
191
- const target = e.target;
192
- if (target.closest(".wg-ev")) return;
193
- if (readOnly || !oneventcreate) return;
194
- if (disabledSet.has(ms)) return;
195
- const durMin = minDuration ? Math.max(60, minDuration) : 60;
196
- const start = new Date(ms + 9 * HOUR_MS);
197
- const end = new Date(start.getTime() + durMin * 6e4);
198
- oneventcreate({ start, end });
199
- }
200
- const DRAG_THRESHOLD = 8;
399
+ const DRAG_THRESHOLD = 5;
201
400
  let evDragStartX = 0;
202
401
  let evDragStartY = 0;
402
+ let evGrabOffsetMs = 0;
203
403
  let evDragStarted = false;
204
- let evDragging = $state(false);
205
- let evDragId = $state(null);
404
+ let evDragMovable = false;
206
405
  let evDragEvent = null;
207
- const dragPreviewEvent = $derived.by(() => {
208
- const payload = drag?.active && drag.mode === "move" ? drag.payload : null;
209
- if (!payload?.eventId) return null;
210
- const ev = events.find((event) => event.id === payload.eventId);
211
- if (!ev) return null;
212
- return { ...ev, start: payload.start, end: payload.end };
213
- });
214
- function isDraggedEvent(eventId) {
215
- return dragPreviewEvent?.id === eventId;
216
- }
217
- function timedEventsForDay(day) {
218
- if (!dragPreviewEvent) return day.events;
219
- return day.events.filter((ev) => ev.id !== dragPreviewEvent.id);
220
- }
221
- const previewKeySnapshot = /* @__PURE__ */ new Map();
222
- function dragPreviewTimedForDay(dayMs) {
223
- const ev = dragPreviewEvent;
224
- if (!ev || isAllDay(ev) || isMultiDay(ev)) return null;
225
- const dayEnd = dayMs + DAY_MS;
226
- const hit = ev.start.getTime() < dayEnd && ev.end.getTime() > dayMs;
227
- if (hit) previewKeySnapshot.set("timed", ev.id);
228
- return hit ? ev : null;
229
- }
230
- function dragPreviewSegmentForDay(dayMs) {
231
- const ev = dragPreviewEvent;
232
- if (!ev || !isAllDay(ev) && !isMultiDay(ev)) return null;
233
- const seg = segmentForDay(ev, dayMs);
234
- if (seg) previewKeySnapshot.set(dayMs, `${ev.id}:${seg.dayIndex}`);
235
- return seg;
236
- }
237
- function getCellWidth() {
238
- const cell = el?.querySelector(".wg-cell");
239
- return cell ? cell.getBoundingClientRect().width : 100;
240
- }
241
- function getRowHeight() {
242
- const row = el?.querySelector(".wg-week");
243
- return row ? row.getBoundingClientRect().height + 24 : 200;
244
- }
245
406
  function onEventPointerDown(e, ev) {
246
- if (e.button !== 0 || !drag || readOnly || ev.data?.readOnly) return;
407
+ if (e.button !== 0) return;
247
408
  e.stopPropagation();
409
+ evDragMovable = !!drag && !readOnly && !ev.data?.readOnly;
248
410
  evDragStartX = e.clientX;
249
411
  evDragStartY = e.clientY;
412
+ evGrabOffsetMs = pointerTimeMs(e.clientX, e.clientY) - ev.start.getTime();
250
413
  evDragStarted = false;
251
- evDragId = ev.id;
252
414
  evDragEvent = ev;
253
- window.addEventListener("pointermove", onEvWindowPointerMove);
254
- window.addEventListener("pointerup", onEvWindowPointerUp, { once: true });
255
- window.addEventListener("pointercancel", onEvWindowPointerCancel, { once: true });
415
+ window.addEventListener("pointermove", onEvMove);
416
+ window.addEventListener("pointerup", onEvUp, { once: true });
417
+ window.addEventListener("pointercancel", onEvCancel, { once: true });
256
418
  }
257
- function onEvWindowPointerMove(e) {
419
+ function onEvMove(e) {
258
420
  const ev = evDragEvent;
259
- if (!drag || !ev || evDragId !== ev.id) return;
260
- const dx = e.clientX - evDragStartX;
261
- const dy = e.clientY - evDragStartY;
262
- if (!evDragStarted && Math.abs(dx) + Math.abs(dy) < DRAG_THRESHOLD) return;
421
+ if (!drag || !ev || !evDragMovable) return;
263
422
  if (!evDragStarted) {
423
+ const moved = Math.abs(e.clientX - evDragStartX) + Math.abs(e.clientY - evDragStartY);
424
+ if (moved < DRAG_THRESHOLD) return;
264
425
  evDragStarted = true;
265
- evDragging = true;
266
426
  drag.beginMove(ev.id, ev.start, ev.end);
267
427
  }
268
- const cellW = getCellWidth();
269
- const rowH = getRowHeight();
270
- const dayOffset = Math.round(dx / cellW);
271
- const weekOffset = Math.round(dy / rowH);
272
- const deltaMs = (dayOffset + weekOffset * 7) * DAY_MS;
273
- drag.updatePointer(
274
- new Date(ev.start.getTime() + deltaMs),
275
- new Date(ev.end.getTime() + deltaMs)
276
- );
428
+ const duration = ev.end.getTime() - ev.start.getTime();
429
+ const raw = pointerTimeMs(e.clientX, e.clientY) - evGrabOffsetMs;
430
+ const snapped = Math.round(raw / SNAP_MS) * SNAP_MS;
431
+ drag.updatePointer(new Date(snapped), new Date(snapped + duration));
277
432
  }
278
433
  function cleanupEvDrag() {
279
- window.removeEventListener("pointermove", onEvWindowPointerMove);
280
- window.removeEventListener("pointerup", onEvWindowPointerUp);
281
- window.removeEventListener("pointercancel", onEvWindowPointerCancel);
434
+ window.removeEventListener("pointermove", onEvMove);
435
+ window.removeEventListener("pointerup", onEvUp);
436
+ window.removeEventListener("pointercancel", onEvCancel);
282
437
  evDragStarted = false;
283
- evDragging = false;
284
- evDragId = null;
438
+ evDragMovable = false;
285
439
  evDragEvent = null;
286
440
  }
287
- function onEvWindowPointerUp() {
288
- if (!drag) {
289
- cleanupEvDrag();
290
- return;
291
- }
292
- if (!evDragStarted) {
293
- if (evDragEvent) oneventclick?.(evDragEvent);
294
- } else {
441
+ function onEvUp() {
442
+ if (!evDragStarted && evDragEvent) {
443
+ oneventclick?.(evDragEvent);
444
+ } else if (evDragStarted && drag) {
445
+ suppressColsClick = true;
295
446
  commitDragCtx?.();
447
+ setTimeout(() => {
448
+ suppressColsClick = false;
449
+ }, 0);
296
450
  }
297
451
  cleanupEvDrag();
298
452
  }
299
- function onEvWindowPointerCancel() {
453
+ function onEvCancel() {
300
454
  if (drag && evDragStarted) drag.cancel();
301
455
  cleanupEvDrag();
302
456
  }
457
+ let rsStartY = 0;
458
+ let rsStarted = false;
459
+ let rsEdge = "end";
460
+ let rsEvent = null;
461
+ function onResizePointerDown(e, ev, edge) {
462
+ if (e.button !== 0 || !drag || readOnly || ev.data?.readOnly) return;
463
+ e.stopPropagation();
464
+ rsStartY = e.clientY;
465
+ rsStarted = false;
466
+ rsEdge = edge;
467
+ rsEvent = ev;
468
+ window.addEventListener("pointermove", onResizeMove);
469
+ window.addEventListener("pointerup", onResizeUp, { once: true });
470
+ window.addEventListener("pointercancel", onResizeCancel, { once: true });
471
+ }
472
+ function onResizeMove(e) {
473
+ const ev = rsEvent;
474
+ if (!drag || !ev) return;
475
+ if (!rsStarted) {
476
+ if (Math.abs(e.clientY - rsStartY) < CREATE_THRESHOLD) return;
477
+ rsStarted = true;
478
+ drag.beginResize(ev.id, rsEdge, ev.start, ev.end);
479
+ addTouchScrollBlock();
480
+ }
481
+ const evDayMs = sod(ev.start.getTime());
482
+ const raw = evDayMs + pointerHour(e.clientY) * HOUR_MS;
483
+ const snapped = clampToDayBand(Math.round(raw / SNAP_MS) * SNAP_MS, evDayMs);
484
+ if (rsEdge === "end") {
485
+ const end = Math.max(snapped, ev.start.getTime() + SNAP_MS);
486
+ drag.updatePointer(ev.start, new Date(end));
487
+ } else {
488
+ const start = Math.min(snapped, ev.end.getTime() - SNAP_MS);
489
+ drag.updatePointer(new Date(start), ev.end);
490
+ }
491
+ }
492
+ function cleanupResize() {
493
+ removeTouchScrollBlock();
494
+ window.removeEventListener("pointermove", onResizeMove);
495
+ window.removeEventListener("pointerup", onResizeUp);
496
+ window.removeEventListener("pointercancel", onResizeCancel);
497
+ rsStarted = false;
498
+ rsEvent = null;
499
+ }
500
+ function onResizeUp() {
501
+ if (drag && rsStarted) {
502
+ suppressColsClick = true;
503
+ commitDragCtx?.();
504
+ setTimeout(() => {
505
+ suppressColsClick = false;
506
+ }, 0);
507
+ } else if (rsEvent && !rsStarted) {
508
+ oneventclick?.(rsEvent);
509
+ }
510
+ cleanupResize();
511
+ }
512
+ function onResizeCancel() {
513
+ if (drag && rsStarted) drag.cancel();
514
+ cleanupResize();
515
+ }
516
+ function onWindowKeydown(e) {
517
+ if (e.key !== "Escape" || !drag?.active) return;
518
+ drag.cancel();
519
+ cleanupColsCreate();
520
+ cleanupEvDrag();
521
+ cleanupResize();
522
+ suppressColsClick = true;
523
+ window.addEventListener(
524
+ "pointerup",
525
+ () => setTimeout(() => {
526
+ suppressColsClick = false;
527
+ }, 0),
528
+ { once: true }
529
+ );
530
+ }
303
531
  </script>
304
532
 
305
- {#snippet allDaySegmentContent(seg: DaySegment)}
306
- {#if seg.isStart}
307
- <span class="wg-ad-title">{seg.ev.title}</span>
308
- {:else}
309
- <span class="wg-ad-cont" aria-hidden="true">◂</span>
310
- <span class="wg-ad-title">{seg.ev.title}</span>
311
- {/if}
312
- {#if !seg.isEnd && seg.totalDays > 1}
313
- <span class="wg-ad-arrow" aria-hidden="true">▸</span>
314
- {/if}
315
- {/snippet}
316
-
317
- {#snippet timedEventContent(ev: TimelineEvent)}
318
- <EventContent event={ev}>
319
- <span class="wg-ev-time">{fmtAmPm(ev.start)}</span>
320
- <span class="wg-ev-title">{ev.title}</span>
321
- {#if ev.location}
322
- <span class="wg-ev-loc">{ev.location}</span>
533
+ {#snippet allDayChip(seg: DaySegment)}
534
+ <button
535
+ type="button"
536
+ class="tw-ad"
537
+ class:tw-ad--start={seg.isStart}
538
+ class:tw-ad--end={seg.isEnd}
539
+ class:tw-ad--mid={!seg.isStart && !seg.isEnd}
540
+ class:tw-ad--selected={selectedEventId === seg.ev.id}
541
+ class:tw-ad--cancelled={seg.ev.status === 'cancelled'}
542
+ style:--ev-color={seg.ev.color ?? 'var(--dt-accent)'}
543
+ aria-label="{seg.ev.title}{seg.totalDays > 1 ? `, ${L.dayNOfTotal(seg.dayIndex, seg.totalDays)}` : `, ${L.allDay}`}{statusText(seg.ev)}"
544
+ onclick={() => oneventclick?.(seg.ev)}
545
+ onpointerenter={() => oneventhover?.(seg.ev)}
546
+ >
547
+ {#if !seg.isStart}
548
+ <span class="tw-ad-cont" aria-hidden="true">◂</span>
549
+ {/if}
550
+ <span class="tw-ad-title">{seg.ev.title}</span>
551
+ {#if seg.totalDays > 1}
552
+ <span class="tw-ad-span" aria-hidden="true">{seg.dayIndex}/{seg.totalDays}</span>
323
553
  {/if}
324
- </EventContent>
554
+ {#if !seg.isEnd && seg.totalDays > 1}
555
+ <span class="tw-ad-arrow" aria-hidden="true">▸</span>
556
+ {/if}
557
+ </button>
325
558
  {/snippet}
326
559
 
327
- <div class="wg" class:wg--auto={autoHeight} style={style || undefined} style:height={autoHeight ? undefined : (height ? `${height}px` : '100%')}>
328
- <div
329
- class="wg-body"
330
- bind:this={el}
331
- onscroll={handleUserScroll}
332
- role="grid"
333
- aria-label={L.multiWeekGrid}
334
- >
335
- {#each weeks as week (week.weekStart)}
336
- <div class="wg-week" class:wg-week--current={week.isCurrent} data-week={week.weekStart}>
337
- <div class="wg-week-body">
338
- <!-- Day columns (header inside each cell) -->
339
- <div class="wg-days">
340
- {#each week.days as day (day.ms)}
341
- {@const visibleAllDaySegments = day.allDaySegments.filter((seg) => !isDraggedEvent(seg.ev.id))}
342
- {@const visibleTimedEvents = timedEventsForDay(day)}
343
- {@const previewTimedEvent = dragPreviewTimedForDay(day.ms)}
344
- {@const previewSegment = dragPreviewSegmentForDay(day.ms)}
345
- <div
346
- class="wg-cell"
347
- class:wg-cell--today={day.isToday}
348
- class:wg-cell--past={day.isPast}
349
- class:wg-cell--weekend={day.isWeekend}
350
- class:wg-cell--disabled={disabledSet.has(day.ms)}
351
- role="gridcell"
352
- tabindex="0"
353
- aria-label="{new Date(day.ms).toLocaleDateString(locale ?? 'en-US', { weekday: 'long', month: 'short', day: 'numeric' })}{day.isToday ? ` (${L.today.toLowerCase()})` : ''}, {L.nEvents(day.events.length)}"
354
- onclick={(e) => handleDayCellClick(day.ms, e)}
355
- onkeydown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); handleDayCellClick(day.ms, e); } }}
356
- >
357
- <!-- Day label in top-right corner -->
358
- <div class="wg-cell-hd" class:wg-cell-hd--today={day.isToday}>
359
- {#if showDates}
360
- <span class="wg-day-num" class:wg-day-num--today={day.isToday}>
361
- {day.dayNum}
362
- </span>
363
- {/if}
364
- <span class="wg-day-wd">{weekdayShort(day.ms, locale)}</span>
560
+ <svelte:window onkeydown={onWindowKeydown} />
561
+
562
+ <div
563
+ class="tw"
564
+ class:tw--auto={autoHeight}
565
+ style={style || undefined}
566
+ style:height={autoHeight ? undefined : (height ? `${height}px` : '100%')}
567
+ role="region"
568
+ aria-label={L.weekAhead}
569
+ >
570
+ <div class="tw-scroll" bind:this={scrollEl}>
571
+ <div class="tw-inner" style:min-width="{innerMinWidth}px">
572
+ <!-- Sticky top: day headers + all-day strip -->
573
+ <div class="tw-top">
574
+ <div class="tw-head">
575
+ <div class="tw-corner" style:width="{GUTTER_W}px" aria-hidden="true"></div>
576
+ {#each dayCols as day (day.ms)}
577
+ <div
578
+ class="tw-hd"
579
+ class:tw-hd--today={day.isToday}
580
+ aria-current={day.isToday ? 'date' : undefined}
581
+ >
582
+ <span class="tw-hd-wd">{weekdayShort(day.ms, locale)}</span>
583
+ {#if showDates}
584
+ <span class="tw-hd-num" class:tw-hd-num--today={day.isToday}>{day.dayNum}</span>
585
+ {/if}
586
+ {#if dayHeaderSnippet}
587
+ <div class="tw-hd-custom">
588
+ {@render dayHeaderSnippet({ date: new Date(day.ms), isToday: day.isToday, dayName: weekdayShort(day.ms, locale) })}
365
589
  </div>
590
+ {/if}
591
+ </div>
592
+ {/each}
593
+ </div>
366
594
 
367
- {#if day.monthLabel && showDates}
368
- <span class="wg-cell-month">{day.monthLabel}</span>
595
+ {#if hasAllDayRow}
596
+ <div class="tw-allday">
597
+ <div class="tw-ad-gutter" style:width="{GUTTER_W}px">
598
+ <span class="tw-ad-gutter-lb">{L.allDay}</span>
599
+ </div>
600
+ {#each dayCols as day (day.ms)}
601
+ {@const segs = allDayByDay.get(day.ms) ?? []}
602
+ {@const isExpanded = adExpanded[day.ms] ?? false}
603
+ {@const shown = isExpanded ? segs : segs.slice(0, ALLDAY_MAX)}
604
+ <div class="tw-ad-cell" class:tw-ad-cell--today={day.isToday}>
605
+ {#each shown as seg (seg.ev.id)}
606
+ {@render allDayChip(seg)}
607
+ {/each}
608
+ {#if segs.length > ALLDAY_MAX}
609
+ <button
610
+ type="button"
611
+ class="tw-ad-more"
612
+ aria-expanded={isExpanded}
613
+ onclick={() => { adExpanded[day.ms] = !isExpanded; }}
614
+ >{isExpanded ? L.showLess : L.nMore(segs.length - ALLDAY_MAX)}</button>
369
615
  {/if}
616
+ </div>
617
+ {/each}
618
+ </div>
619
+ {/if}
620
+ </div>
370
621
 
371
- <!-- Custom day header snippet -->
372
- {#if dayHeaderSnippet}
373
- <div class="wg-cell-custom-header">
374
- {@render dayHeaderSnippet({ date: new Date(day.ms), isToday: day.isToday, dayName: weekdayShort(day.ms, locale) })}
375
- </div>
376
- {/if}
622
+ <!-- Grid body -->
623
+ <div class="tw-body" style:height="{gridHeight}px">
624
+ <!-- Time gutter -->
625
+ <div class="tw-gutter" style:width="{GUTTER_W}px" aria-hidden="true">
626
+ {#each { length: hourCount } as _, i}
627
+ {#if i > 0}
628
+ <span class="tw-gutter-lb" style:top="{i * HOUR_H}px">{fmtH(startHour + i, locale)}</span>
629
+ {/if}
630
+ {/each}
631
+ {#if nowY !== null && weekHasToday}
632
+ <span class="tw-gutter-now" style:top="{nowY}px"></span>
633
+ {/if}
634
+ </div>
377
635
 
378
- <!-- Blocked slots indicator -->
379
- {#if blockedSlots?.length}
380
- {@const jsDay = new Date(day.ms).getDay()}
381
- {@const isoDay = jsDay === 0 ? 7 : jsDay}
382
- {#each blockedSlots as slot}
383
- {#if !slot.day || slot.day === isoDay}
384
- <div class="wg-blocked" aria-label={slot.label || 'Unavailable'}>
385
- {#if slot.label}
386
- <span class="wg-blocked-label">{slot.label}</span>
387
- {/if}
388
- </div>
389
- {/if}
390
- {/each}
391
- {/if}
636
+ <!-- Day columns -->
637
+ <!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_static_element_interactions -->
638
+ <div
639
+ class="tw-cols"
640
+ bind:this={colsEl}
641
+ onclick={handleColsClick}
642
+ onpointerdown={onColsPointerDown}
643
+ oncontextmenu={onColsContextMenu}
644
+ role="presentation"
645
+ >
646
+ <!-- Hour / half-hour guide lines (shared layer behind columns) -->
647
+ <div class="tw-lines" aria-hidden="true">
648
+ {#each { length: hourCount } as _, i}
649
+ <div class="tw-line" style:top="{i * HOUR_H}px"></div>
650
+ <div class="tw-line tw-line--half" style:top="{i * HOUR_H + HOUR_H / 2}px"></div>
651
+ {/each}
652
+ </div>
392
653
 
393
- <!-- All-day / multi-day events -->
394
- {#if visibleAllDaySegments.length > 0 || previewSegment}
395
- <div class="wg-allday">
396
- {#each visibleAllDaySegments as seg (seg.ev.id)}
397
- <div
398
- animate:flip={{ duration: ANIM }}
399
- in:previewReceive={{ key: `${seg.ev.id}:${seg.dayIndex}` }}
400
- out:previewSend={{ key: `${seg.ev.id}:${seg.dayIndex}` }}
401
- class="wg-ad"
402
- class:wg-ad--start={seg.isStart}
403
- class:wg-ad--end={seg.isEnd}
404
- class:wg-ad--mid={!seg.isStart && !seg.isEnd}
405
- class:wg-ad--selected={selectedEventId === seg.ev.id}
406
- style:--ev-color={seg.ev.color ?? 'var(--dt-accent)'}
407
- role="button"
408
- tabindex="0"
409
- aria-label="{seg.ev.title}{seg.totalDays > 1 ? `, ${L.dayNOfTotal(seg.dayIndex, seg.totalDays)}` : `, ${L.allDay}`}"
410
- onpointerdown={(e) => onEventPointerDown(e, seg.ev)}
411
- onkeydown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); e.stopPropagation(); oneventclick?.(seg.ev); } }}
412
- >
413
- {@render allDaySegmentContent(seg)}
414
- </div>
415
- {/each}
416
- {#if previewSegment}
417
- <!-- key by event id + dayIndex: multi-day previews render one segment
418
- per cell, and each pairs with its own real-card counterpart -->
654
+ {#each dayCols as day (day.ms)}
655
+ {@const positioned = layoutByDay.get(day.ms) ?? []}
656
+ {@const ghost = ghostForDay(day.ms)}
657
+ <div
658
+ class="tw-col"
659
+ class:tw-col--today={day.isToday}
660
+ class:tw-col--past={day.isPast}
661
+ class:tw-col--weekend={day.isWeekend}
662
+ class:tw-col--disabled={day.isDisabled}
663
+ data-day={day.ms}
664
+ >
665
+ <!-- Blocked slot overlays -->
666
+ {#if blockedSlots?.length}
667
+ {#each blockedSlots as slot}
668
+ {#if !slot.day || slot.day === day.isoDay}
669
+ {@const s = Math.max(slot.start, startHour)}
670
+ {@const e = Math.min(slot.end, endHour)}
671
+ {#if e > s}
672
+ {@const range = blockedRangeLabel(day.ms, slot.start, slot.end)}
419
673
  <div
420
- class="wg-ad wg-ad--drag-preview"
421
- class:wg-ad--start={previewSegment.isStart}
422
- class:wg-ad--end={previewSegment.isEnd}
423
- class:wg-ad--mid={!previewSegment.isStart && !previewSegment.isEnd}
424
- style:--ev-color={previewSegment.ev.color ?? 'var(--dt-accent)'}
425
- aria-hidden="true"
426
- in:previewReceive={{ key: previewKeySnapshot.get(day.ms) ?? '' }}
427
- out:previewSend={{ key: previewKeySnapshot.get(day.ms) ?? '' }}
674
+ class="tw-blocked"
675
+ style:top="{(s - startHour) * HOUR_H}px"
676
+ style:height="{(e - s) * HOUR_H}px"
677
+ title="{slot.label ? `${slot.label}, ` : ''}{range}"
678
+ aria-label="{slot.label || 'Unavailable'}, {range}"
428
679
  >
429
- {@render allDaySegmentContent(previewSegment)}
680
+ {#if slot.label}
681
+ <span class="tw-blocked-lb">{slot.label}</span>
682
+ {/if}
430
683
  </div>
431
684
  {/if}
432
- </div>
433
- {/if}
685
+ {/if}
686
+ {/each}
687
+ {/if}
434
688
 
435
- <!-- Timed events -->
436
- <div class="wg-cell-events">
437
- {#each visibleTimedEvents.slice(0, MAX_EVENTS_SHOWN) as ev (ev.id)}
438
- <!-- send/receive keyed by event id pair the card with the drag ghost:
439
- drag start morphs card → ghost, drop morphs ghost → placed card -->
440
- <div
441
- animate:flip={{ duration: ANIM }}
442
- in:previewReceive={{ key: ev.id }}
443
- out:previewSend={{ key: ev.id }}
444
- class="wg-ev"
445
- class:wg-ev--selected={selectedEventId === ev.id}
446
- class:wg-ev--current={ev.start.getTime() <= clock.tick && ev.end.getTime() > clock.tick}
447
- class:wg-ev--dragging={evDragging && evDragId === ev.id}
448
- class:wg-ev--readonly={ev.data?.readOnly}
449
- class:wg-ev--cancelled={ev.status === 'cancelled'}
450
- class:wg-ev--tentative={ev.status === 'tentative'}
451
- class:wg-ev--full={ev.status === 'full'}
452
- class:wg-ev--limited={ev.status === 'limited'}
453
- style:--ev-color={ev.color ?? 'var(--dt-accent)'}
454
- role="button"
455
- tabindex="0"
456
- 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})` : ''}"
457
- onpointerdown={(e) => onEventPointerDown(e, ev)}
458
- onpointerenter={() => oneventhover?.(ev)}
459
- onkeydown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); e.stopPropagation(); oneventclick?.(ev); } }}
460
- >
461
- {@render timedEventContent(ev)}
462
- </div>
463
- {/each}
464
- {#if previewTimedEvent}
465
- <div
466
- class="wg-ev wg-ev--drag-preview"
467
- style:--ev-color={previewTimedEvent.color ?? 'var(--dt-accent)'}
689
+ <!-- Timed events -->
690
+ {#each positioned as p (p.ev.id)}
691
+ {@const isCurrent = nowIds.has(p.ev.id)}
692
+ <div
693
+ class="tw-ev"
694
+ class:tw-ev--selected={selectedEventId === p.ev.id}
695
+ class:tw-ev--current={isCurrent}
696
+ class:tw-ev--resizing={p.isResizing}
697
+ class:tw-ev--readonly={p.ev.data?.readOnly}
698
+ class:tw-ev--cancelled={p.ev.status === 'cancelled'}
699
+ class:tw-ev--tentative={p.ev.status === 'tentative'}
700
+ class:tw-ev--full={p.ev.status === 'full'}
701
+ class:tw-ev--limited={p.ev.status === 'limited'}
702
+ class:tw-ev--short={p.height < 44}
703
+ class:tw-ev--compact={p.height < 34}
704
+ style:top="{p.top}px"
705
+ style:height="{p.height}px"
706
+ style:left="calc({(p.col / p.totalCols) * 100}% + 1px)"
707
+ style:width="calc({100 / p.totalCols}% - {p.totalCols > 1 ? 3 : 8}px)"
708
+ style:--ev-color={p.ev.color ?? 'var(--dt-accent)'}
709
+ role="button"
710
+ tabindex="0"
711
+ title={p.ev.title}
712
+ aria-label="{p.ev.title}, {fmtTime(p.ev.start, locale)} {fmtTime(p.ev.end, locale)}, {fmtDuration(p.ev.start, p.ev.end)}{statusText(p.ev)}{isCurrent ? ` (${L.inProgress})` : ''}"
713
+ onpointerdown={(e) => onEventPointerDown(e, p.ev)}
714
+ onpointerenter={() => oneventhover?.(p.ev)}
715
+ onkeydown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); e.stopPropagation(); oneventclick?.(p.ev); } }}
716
+ >
717
+ <div class="tw-ev-stripe" aria-hidden="true"></div>
718
+ <div class="tw-ev-body">
719
+ <EventContent event={p.ev}>
720
+ <span class="tw-ev-time">{fmtTime(p.ev.start, locale)} – {fmtTime(p.ev.end, locale)}</span>
721
+ <span class="tw-ev-title">{p.ev.title}</span>
722
+ {#if p.ev.location && p.height > 56}
723
+ <span class="tw-ev-loc">{p.ev.location}</span>
724
+ {/if}
725
+ </EventContent>
726
+ </div>
727
+ {#if isCurrent}
728
+ <span class="tw-ev-live" aria-hidden="true"></span>
729
+ {/if}
730
+ {#if !readOnly && !p.ev.data?.readOnly}
731
+ <span
732
+ class="tw-ev-handle tw-ev-handle--start"
733
+ aria-hidden="true"
734
+ onpointerdown={(e) => onResizePointerDown(e, p.ev, 'start')}
735
+ ></span>
736
+ <span
737
+ class="tw-ev-handle tw-ev-handle--end"
468
738
  aria-hidden="true"
469
- in:previewReceive={{ key: previewKeySnapshot.get('timed') ?? '' }}
470
- out:previewSend={{ key: previewKeySnapshot.get('timed') ?? '' }}
471
- >
472
- {@render timedEventContent(previewTimedEvent)}
473
- </div>
739
+ onpointerdown={(e) => onResizePointerDown(e, p.ev, 'end')}
740
+ ></span>
474
741
  {/if}
475
- {#if visibleTimedEvents.length > MAX_EVENTS_SHOWN}
476
- <div class="wg-ev-more">{L.nMore(visibleTimedEvents.length - MAX_EVENTS_SHOWN)}</div>
742
+ </div>
743
+ {/each}
744
+
745
+ <!-- Move / create ghost -->
746
+ {#if ghost}
747
+ <div
748
+ class="tw-ghost"
749
+ class:tw-ghost--create={ghost.create}
750
+ style:top="{ghost.top}px"
751
+ style:height="{ghost.height}px"
752
+ style:--ev-color={ghost.create
753
+ ? 'var(--dt-accent, #2563eb)'
754
+ : (movingEvent?.color ?? 'var(--dt-accent, #2563eb)')}
755
+ aria-hidden="true"
756
+ >
757
+ {#if ghost.showTime}
758
+ <span class="tw-ghost-time">
759
+ {fmtTime(ghost.start, locale)} – {fmtTime(ghost.end, locale)}
760
+ </span>
761
+ {#if !ghost.create && movingEvent}
762
+ <span class="tw-ghost-title">{movingEvent.title}</span>
763
+ {/if}
477
764
  {/if}
478
765
  </div>
479
- </div>
480
- {/each}
481
- </div>
766
+ {/if}
767
+
768
+ <!-- Now line (today's column only) -->
769
+ {#if day.isToday && nowY !== null}
770
+ <div class="tw-now" style:top="{nowY}px" aria-label={L.currentTime}>
771
+ <span class="tw-now-dot" aria-hidden="true"></span>
772
+ </div>
773
+ {/if}
774
+ </div>
775
+ {/each}
482
776
  </div>
483
777
  </div>
484
- {/each}
778
+ </div>
485
779
  </div>
486
780
 
781
+ <!-- Empty-week hint — overlaid, pointer events pass through to the grid -->
782
+ {#if weekIsEmpty && ctx.emptySnippet}
783
+ <div class="tw-empty">
784
+ {@render ctx.emptySnippet()}
785
+ </div>
786
+ {/if}
487
787
  </div>
488
788
 
489
789
  <style>
490
790
  /* ─── Container ──────────────────────────────────── */
491
- .wg {
791
+ .tw {
492
792
  position: relative;
493
- overflow: hidden;
494
793
  display: flex;
495
794
  flex-direction: column;
795
+ overflow: hidden;
496
796
  user-select: none;
497
797
  font-variant-numeric: tabular-nums;
798
+ background: var(--dt-bg, #ffffff);
799
+ -webkit-tap-highlight-color: transparent;
498
800
  }
499
- .wg--auto { overflow: visible; }
801
+ .tw--auto { overflow: visible; }
500
802
 
501
- /* ─── Scrollable body ────────────────────────────── */
502
- .wg-body {
803
+ /* ─── Scroll container ───────────────────────────── */
804
+ .tw-scroll {
503
805
  flex: 1;
806
+ min-height: 0;
504
807
  overflow-y: auto;
505
- overflow-x: hidden;
506
- box-sizing: border-box;
808
+ overflow-x: auto;
809
+ overscroll-behavior: contain;
507
810
  scrollbar-width: thin;
508
811
  scrollbar-color: var(--dt-scrollbar, rgba(0, 0, 0, 0.1)) transparent;
509
812
  }
510
- .wg--auto .wg-body { overflow-y: visible; }
511
-
512
- .wg-body::-webkit-scrollbar { width: 4px; }
513
- .wg-body::-webkit-scrollbar-thumb {
813
+ .tw--auto .tw-scroll { overflow-y: visible; }
814
+ .tw-scroll::-webkit-scrollbar { width: 5px; height: 5px; }
815
+ .tw-scroll::-webkit-scrollbar-thumb {
514
816
  background: var(--dt-scrollbar, rgba(0, 0, 0, 0.1));
515
817
  border-radius: 4px;
516
818
  }
517
- .wg-body::-webkit-scrollbar-track { background: transparent; }
819
+ .tw-scroll::-webkit-scrollbar-track { background: transparent; }
518
820
 
519
- /* ─── Week row ───────────────────────────────────── */
520
- .wg-week {
521
- display: flex;
522
- border-radius: 10px;
523
- margin: 12px 8px;
524
- border: 1.5px solid var(--dt-border, rgba(0, 0, 0, 0.08));
525
- overflow: hidden;
526
- }
527
-
528
- .wg-week--current {
529
- background: var(--dt-today-bg, rgba(37, 99, 235, 0.04));
530
- border: 2.5px solid var(--dt-accent, #2563eb);
531
- box-shadow: 0 0 0 1px color-mix(in srgb, var(--dt-accent, #2563eb) 15%, transparent);
532
- }
533
-
534
- /* ─── Week body ──────────────────────────────────── */
535
- .wg-week-body {
536
- flex: 1;
537
- min-width: 0;
821
+ .tw-inner {
538
822
  display: flex;
539
823
  flex-direction: column;
824
+ width: 100%;
540
825
  }
541
826
 
542
- /* ─── Day columns ────────────────────────────────── */
543
- .wg-days {
544
- display: flex;
545
- flex: 1;
546
- }
547
-
548
- .wg-cell {
549
- flex: 1;
550
- position: relative;
551
- min-height: 170px;
552
- padding: 4px 4px 8px;
553
- border-right: 1px solid var(--dt-border, rgba(0, 0, 0, 0.08));
554
- cursor: pointer;
555
- transition: background 0.15s;
556
- }
557
-
558
- .wg-cell:last-child { border-right: none; }
559
- .wg-cell:hover { background: var(--dt-hover, rgba(0, 0, 0, 0.015)); }
560
-
561
- .wg-cell--today { background: var(--dt-today-bg, rgba(37, 99, 235, 0.04)); }
562
- .wg-cell--today:hover { background: color-mix(in srgb, var(--dt-accent, #2563eb) 6%, transparent); }
563
-
564
- /* Dim all cells by default; current week overrides to full brightness */
565
- .wg-cell { opacity: 0.55; }
566
- .wg-cell:hover { opacity: 0.75; }
567
- .wg-week--current .wg-cell { opacity: 1; }
568
- .wg-week--current .wg-cell--past { opacity: 0.8; }
569
- .wg-week--current .wg-cell--past:hover { opacity: 0.9; }
570
-
571
- /* equalDays: when no cells are marked past, all are full brightness */
572
-
573
- .wg-cell--weekend { background: var(--dt-weekend-bg, rgba(0, 0, 0, 0.012)); }
574
-
575
- /* ─── Disabled cell ──────────────────────────────── */
576
- .wg-cell--disabled {
577
- background: repeating-linear-gradient(
578
- 45deg,
579
- transparent,
580
- transparent 6px,
581
- var(--dt-border, rgba(0, 0, 0, 0.08)) 6px,
582
- var(--dt-border, rgba(0, 0, 0, 0.08)) 7px
583
- ) !important;
827
+ /* ─── Sticky top (header + all-day) ──────────────── */
828
+ .tw-top {
829
+ position: sticky;
830
+ top: 0;
831
+ z-index: 30;
832
+ background: var(--dt-bg, #ffffff);
833
+ border-bottom: 1px solid var(--dt-border, rgba(0, 0, 0, 0.08));
584
834
  }
585
835
 
586
- /* ─── Blocked slot indicator ─────────────────────── */
587
- .wg-blocked {
836
+ /* ─── Day header row ─────────────────────────────── */
837
+ .tw-head {
588
838
  display: flex;
589
- align-items: center;
590
- gap: 3px;
591
- padding: 2px 4px;
592
- border-radius: 3px;
593
- background: repeating-linear-gradient(
594
- -45deg,
595
- color-mix(in srgb, var(--dt-text, rgba(0, 0, 0, 0.87)) 4%, transparent),
596
- color-mix(in srgb, var(--dt-text, rgba(0, 0, 0, 0.87)) 4%, transparent) 3px,
597
- transparent 3px,
598
- transparent 6px
599
- );
600
- margin-bottom: 2px;
601
- min-height: 14px;
602
839
  }
603
840
 
604
- .wg-blocked-label {
605
- font: 500 8px/1 var(--dt-sans, system-ui, sans-serif);
606
- color: var(--dt-text-3, rgba(0, 0, 0, 0.38));
607
- text-transform: uppercase;
608
- letter-spacing: 0.04em;
609
- white-space: nowrap;
610
- }
611
-
612
- /* ─── Custom day header ──────────────────────────── */
613
- .wg-cell-custom-header {
614
- padding: 0 4px 2px;
841
+ .tw-corner {
842
+ flex-shrink: 0;
843
+ position: sticky;
844
+ left: 0;
845
+ z-index: 2;
846
+ background: var(--dt-bg, #ffffff);
615
847
  }
616
848
 
617
- /* ─── Cell header (day label top-right) ──────────── */
618
- .wg-cell-hd {
849
+ .tw-hd {
850
+ flex: 1 1 0;
851
+ min-width: 110px;
619
852
  display: flex;
853
+ flex-direction: column;
620
854
  align-items: center;
621
- justify-content: flex-end;
622
- gap: 4px;
623
- padding: 4px 5px 2px 0;
624
- margin-bottom: 2px;
855
+ gap: 2px;
856
+ padding: 8px 4px 6px;
857
+ border-left: 1px solid var(--dt-border, rgba(0, 0, 0, 0.08));
625
858
  }
626
859
 
627
- .wg-day-wd {
628
- font: 400 10px / 1 var(--dt-sans, system-ui, sans-serif);
629
- letter-spacing: 0.04em;
860
+ .tw-hd-wd {
861
+ font: 500 10px/1 var(--dt-sans, system-ui, sans-serif);
862
+ letter-spacing: 0.06em;
630
863
  text-transform: uppercase;
631
864
  color: var(--dt-text-3, rgba(0, 0, 0, 0.38));
632
865
  }
633
-
634
- .wg-week--current .wg-day-wd {
635
- color: var(--dt-text-2, rgba(0, 0, 0, 0.54));
636
- }
637
-
638
- .wg-cell-hd--today .wg-day-wd {
866
+ .tw-hd--today .tw-hd-wd {
639
867
  color: var(--dt-accent, #2563eb);
640
868
  font-weight: 600;
641
869
  }
642
870
 
643
- .wg-day-num {
644
- font: 700 14px / 1 var(--dt-sans, system-ui, sans-serif);
871
+ .tw-hd-num {
872
+ display: inline-flex;
873
+ align-items: center;
874
+ justify-content: center;
875
+ min-width: 26px;
876
+ height: 26px;
877
+ border-radius: 50%;
878
+ font: 600 14px/1 var(--dt-sans, system-ui, sans-serif);
645
879
  color: var(--dt-text, rgba(0, 0, 0, 0.87));
646
880
  }
647
-
648
- .wg-week--current .wg-day-num {
649
- color: var(--dt-text, rgba(0, 0, 0, 0.87));
881
+ .tw-hd-num--today {
882
+ background: var(--dt-accent, #2563eb);
883
+ color: var(--dt-accent-fg, #ffffff);
884
+ font-weight: 700;
650
885
  }
651
886
 
652
- .wg-day-num--today {
653
- color: var(--dt-accent, #2563eb);
654
- font-weight: 900;
887
+ .tw-hd-custom {
888
+ max-width: 100%;
889
+ overflow: hidden;
655
890
  }
656
891
 
657
- /* ─── Month label (top-left, behind events) ─────── */
658
- .wg-cell-month {
659
- position: absolute;
660
- left: 4px;
661
- top: 4px;
662
- writing-mode: vertical-rl;
663
- transform: rotate(180deg);
664
- font: 800 22px / 1 var(--dt-sans, system-ui, sans-serif);
665
- letter-spacing: 0.02em;
666
- text-transform: uppercase;
667
- color: color-mix(in srgb, var(--dt-text, rgba(0, 0, 0, 0.87)) 4%, transparent);
668
- pointer-events: none;
669
- white-space: nowrap;
892
+ /* ─── All-day strip ──────────────────────────────── */
893
+ .tw-allday {
894
+ display: flex;
895
+ border-top: 1px solid var(--dt-border, rgba(0, 0, 0, 0.08));
670
896
  }
671
897
 
672
- .wg-week--current .wg-cell-month {
673
- color: color-mix(in srgb, var(--dt-text, rgba(0, 0, 0, 0.87)) 8%, transparent);
898
+ .tw-ad-gutter {
899
+ flex-shrink: 0;
900
+ position: sticky;
901
+ left: 0;
902
+ z-index: 2;
903
+ background: var(--dt-bg, #ffffff);
904
+ display: flex;
905
+ align-items: flex-start;
906
+ justify-content: flex-end;
907
+ padding: 4px 6px 4px 0;
908
+ }
909
+ .tw-ad-gutter-lb {
910
+ font: 500 10px/1.2 var(--dt-sans, system-ui, sans-serif);
911
+ color: var(--dt-text-3, rgba(0, 0, 0, 0.38));
912
+ text-align: right;
674
913
  }
675
914
 
676
- /* ─── All-day / multi-day events ─────────────────── */
677
- .wg-allday {
915
+ .tw-ad-cell {
916
+ flex: 1 1 0;
917
+ min-width: 110px;
678
918
  display: flex;
679
919
  flex-direction: column;
680
920
  gap: 2px;
681
- margin-bottom: 4px;
921
+ padding: 3px 3px 4px;
922
+ border-left: 1px solid var(--dt-border, rgba(0, 0, 0, 0.08));
682
923
  }
924
+ .tw-ad-cell--today { background: var(--dt-today-bg, rgba(37, 99, 235, 0.04)); }
683
925
 
684
- .wg-ad {
926
+ .tw-ad {
927
+ appearance: none;
685
928
  display: flex;
686
929
  align-items: center;
687
930
  gap: 3px;
688
- padding: 2px 5px;
931
+ padding: 2px 6px;
932
+ min-height: 18px;
933
+ border: none;
689
934
  border-radius: 3px;
690
935
  background: color-mix(in srgb, var(--ev-color) 22%, var(--dt-surface, var(--dt-bg, #ffffff)));
936
+ border-left: 2.5px solid var(--ev-color);
691
937
  cursor: pointer;
692
938
  overflow: hidden;
939
+ text-align: left;
693
940
  transition: background 0.12s;
694
- min-height: 18px;
941
+ -webkit-tap-highlight-color: transparent;
695
942
  }
696
-
697
- .wg-ad--drag-preview {
698
- position: relative;
699
- z-index: 8;
700
- opacity: 0.95;
701
- pointer-events: none;
702
- box-shadow: 0 6px 18px color-mix(in srgb, var(--ev-color) 26%, rgba(0, 0, 0, 0.22));
703
- outline: 1px solid color-mix(in srgb, var(--ev-color) 42%, transparent);
704
- cursor: grabbing;
705
- }
706
-
707
- .wg-ad:hover {
943
+ .tw-ad:hover {
708
944
  background: color-mix(in srgb, var(--ev-color) 32%, var(--dt-surface, var(--dt-bg, #ffffff)));
709
945
  }
710
-
711
- .wg-ad--start {
712
- border-left: 2.5px solid var(--ev-color);
713
- }
714
-
715
- .wg-ad--mid {
716
- border-radius: 0;
946
+ .tw-ad--mid,
947
+ .tw-ad--end:not(.tw-ad--start) {
717
948
  border-left: 1px dashed color-mix(in srgb, var(--ev-color) 40%, transparent);
718
- }
719
-
720
- .wg-ad--end:not(.wg-ad--start) {
721
949
  border-radius: 0 3px 3px 0;
722
- border-left: 1px dashed color-mix(in srgb, var(--ev-color) 40%, transparent);
723
950
  }
724
-
725
- .wg-ad--selected {
951
+ .tw-ad--selected {
726
952
  box-shadow: 0 0 0 1.5px var(--ev-color);
727
953
  }
954
+ .tw-ad--cancelled .tw-ad-title {
955
+ text-decoration: line-through;
956
+ color: var(--dt-text-2, rgba(0, 0, 0, 0.54));
957
+ }
958
+ .tw-ad:focus-visible {
959
+ outline: none;
960
+ box-shadow: 0 0 0 2px var(--dt-accent, #2563eb);
961
+ }
728
962
 
729
- .wg-ad-title {
730
- font: 500 10px / 1.1 var(--dt-sans, system-ui, sans-serif);
963
+ .tw-ad-title {
964
+ font: 500 11px/1.2 var(--dt-sans, system-ui, sans-serif);
731
965
  color: var(--dt-text, rgba(0, 0, 0, 0.87));
732
966
  white-space: nowrap;
733
967
  overflow: hidden;
734
968
  text-overflow: ellipsis;
735
969
  flex: 1;
970
+ min-width: 0;
736
971
  }
737
-
738
- .wg-ad-cont {
739
- font-size: 8px;
740
- color: var(--ev-color);
972
+ .tw-ad-span {
973
+ font: 400 10px/1 var(--dt-sans, system-ui, sans-serif);
974
+ color: var(--dt-text-2, rgba(0, 0, 0, 0.54));
741
975
  flex-shrink: 0;
742
- line-height: 1;
743
976
  }
744
-
745
- .wg-ad-arrow {
746
- font-size: 8px;
977
+ .tw-ad-cont,
978
+ .tw-ad-arrow {
979
+ font-size: 10px;
747
980
  color: var(--ev-color);
748
981
  flex-shrink: 0;
749
- margin-left: auto;
750
982
  line-height: 1;
751
983
  }
984
+ .tw-ad-arrow { margin-left: auto; }
752
985
 
753
- /* ─── Events ─────────────────────────────────────── */
754
- .wg-cell-events {
755
- position: relative;
986
+ .tw-ad-more {
987
+ appearance: none;
988
+ background: none;
989
+ border: none;
990
+ border-radius: 3px;
991
+ text-align: left;
992
+ align-self: flex-start;
993
+ font: 500 10px/1 var(--dt-sans, system-ui, sans-serif);
994
+ color: var(--dt-text-2, rgba(0, 0, 0, 0.54));
995
+ padding: 2px 6px;
996
+ cursor: pointer;
997
+ -webkit-tap-highlight-color: transparent;
998
+ }
999
+ .tw-ad-more:hover { color: var(--dt-text, rgba(0, 0, 0, 0.87)); }
1000
+ .tw-ad-more:focus-visible {
1001
+ outline: none;
1002
+ box-shadow: 0 0 0 2px var(--dt-accent, #2563eb);
1003
+ }
1004
+
1005
+ /* ─── Grid body ──────────────────────────────────── */
1006
+ .tw-body {
756
1007
  display: flex;
757
- flex-direction: column;
758
- gap: 3px;
1008
+ position: relative;
1009
+ }
1010
+
1011
+ /* ─── Time gutter ────────────────────────────────── */
1012
+ .tw-gutter {
1013
+ flex-shrink: 0;
1014
+ position: sticky;
1015
+ left: 0;
1016
+ z-index: 20;
1017
+ background: var(--dt-bg, #ffffff);
1018
+ border-right: 1px solid var(--dt-border, rgba(0, 0, 0, 0.08));
759
1019
  }
760
1020
 
761
- .wg-ev {
1021
+ .tw-gutter-lb {
1022
+ position: absolute;
1023
+ right: 6px;
1024
+ transform: translateY(-50%);
1025
+ font: 500 11px/1 var(--dt-mono, ui-monospace, monospace);
1026
+ color: var(--dt-text-3, rgba(0, 0, 0, 0.38));
1027
+ white-space: nowrap;
1028
+ }
1029
+
1030
+ .tw-gutter-now {
1031
+ position: absolute;
1032
+ right: -3px;
1033
+ width: 6px;
1034
+ height: 6px;
1035
+ border-radius: 50%;
1036
+ background: var(--dt-accent, #2563eb);
1037
+ transform: translateY(-50%);
1038
+ z-index: 2;
1039
+ }
1040
+
1041
+ /* ─── Columns wrapper ────────────────────────────── */
1042
+ .tw-cols {
1043
+ flex: 1;
762
1044
  display: flex;
763
- align-items: center;
764
- flex-wrap: wrap;
765
- gap: 3px 5px;
766
- padding: 3px 6px;
767
- border-radius: 4px;
768
- background: color-mix(in srgb, var(--ev-color) 15%, var(--dt-surface, var(--dt-bg, #ffffff)));
769
- cursor: pointer;
770
- overflow: hidden;
771
- transition: background 0.12s;
1045
+ position: relative;
1046
+ min-width: 0;
772
1047
  }
773
1048
 
774
- .wg-ev:hover {
775
- background: color-mix(in srgb, var(--ev-color) 25%, var(--dt-surface, var(--dt-bg, #ffffff)));
1049
+ /* ─── Guide lines ────────────────────────────────── */
1050
+ .tw-lines {
1051
+ position: absolute;
1052
+ inset: 0;
1053
+ pointer-events: none;
1054
+ /* Above the columns' background washes, below blocked/events/now */
1055
+ z-index: 1;
1056
+ }
1057
+ .tw-line {
1058
+ position: absolute;
1059
+ left: 0;
1060
+ right: 0;
1061
+ height: 1px;
1062
+ background: var(--dt-border, rgba(0, 0, 0, 0.08));
776
1063
  }
1064
+ .tw-line--half { opacity: 0.4; }
777
1065
 
778
- .wg-ev--drag-preview {
1066
+ /* ─── Day column ─────────────────────────────────── */
1067
+ .tw-col {
1068
+ flex: 1 1 0;
1069
+ min-width: 110px;
779
1070
  position: relative;
780
- z-index: 8;
781
- opacity: 0.95;
1071
+ border-left: 1px solid var(--dt-border, rgba(0, 0, 0, 0.08));
1072
+ box-sizing: border-box;
1073
+ }
1074
+ /* The gutter's right border already bounds the first column */
1075
+ .tw-lines + .tw-col { border-left: none; }
1076
+
1077
+ .tw-col--today { background: var(--dt-today-bg, rgba(37, 99, 235, 0.04)); }
1078
+ /* Dim past days with a wash, never a subtree opacity (event contrast) */
1079
+ .tw-col--past {
1080
+ background: color-mix(in srgb, var(--dt-text, rgba(0, 0, 0, 0.87)) 2.5%, transparent);
1081
+ }
1082
+ .tw-col--weekend:not(.tw-col--today):not(.tw-col--past) {
1083
+ background: var(--dt-weekend-bg, rgba(0, 0, 0, 0.012));
1084
+ }
1085
+ .tw-col--disabled {
1086
+ background: repeating-linear-gradient(
1087
+ 45deg,
1088
+ transparent,
1089
+ transparent 6px,
1090
+ var(--dt-border, rgba(0, 0, 0, 0.08)) 6px,
1091
+ var(--dt-border, rgba(0, 0, 0, 0.08)) 7px
1092
+ ) !important;
1093
+ }
1094
+
1095
+ /* ─── Blocked slot overlay ───────────────────────── */
1096
+ .tw-blocked {
1097
+ position: absolute;
1098
+ left: 0;
1099
+ right: 0;
1100
+ z-index: 2;
1101
+ background: repeating-linear-gradient(
1102
+ -45deg,
1103
+ color-mix(in srgb, var(--dt-text, rgba(0, 0, 0, 0.87)) 4%, transparent),
1104
+ color-mix(in srgb, var(--dt-text, rgba(0, 0, 0, 0.87)) 4%, transparent) 4px,
1105
+ transparent 4px,
1106
+ transparent 8px
1107
+ );
782
1108
  pointer-events: none;
783
- background: color-mix(in srgb, var(--ev-color) 28%, var(--dt-surface, var(--dt-bg, #ffffff)));
784
- box-shadow: 0 6px 18px color-mix(in srgb, var(--ev-color) 24%, rgba(0, 0, 0, 0.22));
785
- outline: 1px solid color-mix(in srgb, var(--ev-color) 42%, transparent);
786
- cursor: grabbing;
1109
+ display: flex;
1110
+ align-items: flex-start;
1111
+ justify-content: center;
1112
+ overflow: hidden;
1113
+ }
1114
+ .tw-blocked-lb {
1115
+ font: 500 10px/1 var(--dt-sans, system-ui, sans-serif);
1116
+ color: var(--dt-text-3, rgba(0, 0, 0, 0.38));
1117
+ text-transform: uppercase;
1118
+ letter-spacing: 0.04em;
1119
+ white-space: nowrap;
1120
+ padding-top: 4px;
787
1121
  }
788
1122
 
789
- .wg-ev--selected {
790
- box-shadow: 0 0 0 1.5px var(--ev-color);
1123
+ /* ─── Now line ───────────────────────────────────── */
1124
+ .tw-now {
1125
+ position: absolute;
1126
+ left: 0;
1127
+ right: 0;
1128
+ height: 2px;
1129
+ background: var(--dt-accent, #2563eb);
1130
+ box-shadow: 0 0 6px var(--dt-glow, rgba(37, 99, 235, 0.25));
1131
+ z-index: 12;
1132
+ pointer-events: none;
1133
+ transform: translateY(-1px);
1134
+ }
1135
+ .tw-now-dot {
1136
+ position: absolute;
1137
+ left: -4px;
1138
+ top: -3px;
1139
+ width: 8px;
1140
+ height: 8px;
1141
+ border-radius: 50%;
1142
+ background: var(--dt-accent, #2563eb);
791
1143
  }
792
1144
 
793
- .wg-ev--current {
1145
+ /* ─── Events ─────────────────────────────────────── */
1146
+ .tw-ev {
1147
+ position: absolute;
1148
+ z-index: 6;
1149
+ border-radius: 5px;
1150
+ background: color-mix(in srgb, var(--ev-color) 14%, var(--dt-surface, var(--dt-bg, #ffffff)));
1151
+ display: flex;
1152
+ align-items: stretch;
1153
+ overflow: hidden;
1154
+ cursor: grab;
1155
+ /* Pointer drags move the event, never scroll the grid */
1156
+ touch-action: none;
1157
+ transition: box-shadow 120ms, background 120ms;
1158
+ box-sizing: border-box;
1159
+ min-height: 24px;
1160
+ -webkit-tap-highlight-color: transparent;
1161
+ }
1162
+ .tw-ev:hover {
1163
+ background: color-mix(in srgb, var(--ev-color) 24%, var(--dt-surface, var(--dt-bg, #ffffff)));
1164
+ z-index: 8;
1165
+ }
1166
+ /* Short blocks keep duration-proportional height, but get a 44px
1167
+ transparent hit-slop so clicks/taps still land. */
1168
+ .tw-ev--short { overflow: visible; }
1169
+ .tw-ev--short::after {
1170
+ content: '';
1171
+ position: absolute;
1172
+ left: 0;
1173
+ right: 0;
1174
+ top: 50%;
1175
+ transform: translateY(-50%);
1176
+ height: 44px;
1177
+ }
1178
+ .tw-ev--selected {
1179
+ box-shadow: 0 0 0 2px var(--ev-color),
1180
+ 0 2px 12px color-mix(in srgb, var(--ev-color) 25%, transparent);
1181
+ z-index: 9;
1182
+ }
1183
+ .tw-ev--current {
794
1184
  background: color-mix(in srgb, var(--ev-color) 22%, var(--dt-surface, var(--dt-bg, #ffffff)));
795
1185
  }
796
-
797
- .wg-ev--cancelled {
798
- opacity: 0.5;
1186
+ .tw-ev--resizing {
1187
+ z-index: 50;
1188
+ box-shadow: 0 4px 20px rgba(0, 0, 0, 0.25);
1189
+ cursor: ns-resize;
799
1190
  }
800
- .wg-ev--cancelled .wg-ev-title {
801
- text-decoration: line-through;
1191
+ /* Status treatments: token-level dims + a non-opacity signal
1192
+ (strikethrough / border style) — consistent with the other views. */
1193
+ .tw-ev--cancelled {
1194
+ background: color-mix(in srgb, var(--ev-color) 5%, var(--dt-surface, var(--dt-bg, #ffffff)));
802
1195
  }
803
- .wg-ev--tentative {
804
- opacity: 0.65;
805
- border: 1px dashed color-mix(in srgb, var(--ev-color) 40%, transparent);
1196
+ .tw-ev--cancelled .tw-ev-title {
1197
+ text-decoration: line-through;
1198
+ color: var(--dt-text-2, rgba(0, 0, 0, 0.54));
806
1199
  }
807
- .wg-ev--full {
808
- opacity: 0.55;
1200
+ .tw-ev--cancelled .tw-ev-stripe { opacity: 0.45; /* decorative bar only */ }
1201
+ .tw-ev--tentative {
1202
+ background: color-mix(in srgb, var(--ev-color) 6%, var(--dt-surface, var(--dt-bg, #ffffff)));
1203
+ border: 1px dashed color-mix(in srgb, var(--ev-color) 45%, transparent);
809
1204
  }
810
- .wg-ev--limited {
811
- opacity: 0.65;
812
- border: 1px dashed color-mix(in srgb, var(--ev-color) 40%, transparent);
1205
+ .tw-ev--full {
1206
+ background: color-mix(in srgb, var(--ev-color) 6%, var(--dt-surface, var(--dt-bg, #ffffff)));
1207
+ border: 1px solid color-mix(in srgb, var(--ev-color) 30%, transparent);
813
1208
  }
814
- .wg-ev--readonly {
815
- cursor: default;
1209
+ .tw-ev--full .tw-ev-title { color: var(--dt-text-2, rgba(0, 0, 0, 0.54)); }
1210
+ .tw-ev--limited {
1211
+ background: color-mix(in srgb, var(--ev-color) 8%, var(--dt-surface, var(--dt-bg, #ffffff)));
1212
+ border: 1px dashed color-mix(in srgb, var(--ev-color) 45%, transparent);
816
1213
  }
1214
+ .tw-ev--readonly { cursor: default; }
817
1215
 
818
- .wg-ev-time {
819
- font: 400 10px / 1 var(--dt-sans, system-ui, sans-serif);
820
- color: var(--dt-text-3, rgba(0, 0, 0, 0.38));
1216
+ .tw-ev-stripe {
1217
+ width: 3px;
1218
+ background: var(--ev-color);
821
1219
  flex-shrink: 0;
1220
+ border-radius: 5px 0 0 5px;
1221
+ }
1222
+
1223
+ .tw-ev-body {
1224
+ flex: 1;
1225
+ min-width: 0;
1226
+ padding: 3px 6px;
1227
+ display: flex;
1228
+ flex-direction: column;
1229
+ gap: 1px;
1230
+ overflow: hidden;
1231
+ }
1232
+ /* Compact (< ~35min at default zoom): single inline line "9:00 Title" */
1233
+ .tw-ev--compact .tw-ev-body {
1234
+ flex-direction: row;
1235
+ align-items: center;
1236
+ gap: 4px;
1237
+ padding-top: 1px;
1238
+ padding-bottom: 1px;
1239
+ }
1240
+
1241
+ .tw-ev-time {
1242
+ font: 400 11px/1.1 var(--dt-mono, ui-monospace, monospace);
1243
+ color: var(--dt-text-2, rgba(0, 0, 0, 0.54));
822
1244
  white-space: nowrap;
1245
+ flex-shrink: 0;
823
1246
  }
1247
+ .tw-ev--compact .tw-ev-time { order: 0; }
824
1248
 
825
- .wg-ev-title {
826
- font: 500 12px / 1.1 var(--dt-sans, system-ui, sans-serif);
1249
+ .tw-ev-title {
1250
+ font: 600 12px/1.2 var(--dt-sans, system-ui, sans-serif);
827
1251
  color: var(--dt-text, rgba(0, 0, 0, 0.87));
828
1252
  white-space: nowrap;
829
1253
  overflow: hidden;
830
1254
  text-overflow: ellipsis;
831
1255
  }
832
1256
 
833
- .wg-ev-loc {
834
- font: 400 9px / 1 var(--dt-sans, system-ui, sans-serif);
835
- color: var(--dt-text-3, rgba(0, 0, 0, 0.38));
1257
+ .tw-ev-loc {
1258
+ font: 400 10px/1.2 var(--dt-sans, system-ui, sans-serif);
1259
+ color: var(--dt-text-2, rgba(0, 0, 0, 0.54));
836
1260
  white-space: nowrap;
837
1261
  overflow: hidden;
838
1262
  text-overflow: ellipsis;
839
- flex-shrink: 1;
840
1263
  }
841
1264
 
842
- .wg-ev-more {
843
- font: 500 10px / 1 var(--dt-sans, system-ui, sans-serif);
844
- color: var(--dt-text-3, rgba(0, 0, 0, 0.38));
845
- padding: 2px 8px;
846
- cursor: pointer;
1265
+ .tw-ev-live {
1266
+ position: absolute;
1267
+ top: 4px;
1268
+ right: 4px;
1269
+ width: 6px;
1270
+ height: 6px;
1271
+ border-radius: 50%;
1272
+ background: var(--ev-color, var(--dt-accent));
1273
+ animation: tw-pulse 2s ease-in-out infinite;
1274
+ }
1275
+ @keyframes tw-pulse {
1276
+ 0%, 100% { opacity: 1; }
1277
+ 50% { opacity: 0.4; }
847
1278
  }
848
1279
 
849
- .wg-ev-more:hover {
850
- color: var(--dt-text-2, rgba(0, 0, 0, 0.54));
1280
+ /* ─── Resize handles ─────────────────────────────── */
1281
+ .tw-ev-handle {
1282
+ position: absolute;
1283
+ left: 0;
1284
+ right: 0;
1285
+ height: 8px;
1286
+ z-index: 2;
1287
+ cursor: ns-resize;
1288
+ touch-action: none;
1289
+ }
1290
+ .tw-ev-handle--start { top: 0; }
1291
+ .tw-ev-handle--end { bottom: 0; }
1292
+ /* Hit-slop: ≥20px effective, extending inward so overflow clipping
1293
+ can't cut it off. */
1294
+ .tw-ev-handle::before {
1295
+ content: '';
1296
+ position: absolute;
1297
+ left: 0;
1298
+ right: 0;
1299
+ height: 20px;
1300
+ }
1301
+ .tw-ev-handle--start::before { top: 0; }
1302
+ .tw-ev-handle--end::before { bottom: 0; }
1303
+ /* Short events: shrink the slop so a move-grab area survives */
1304
+ .tw-ev--short .tw-ev-handle::before { height: 12px; }
1305
+ .tw-ev-handle::after {
1306
+ content: '';
1307
+ position: absolute;
1308
+ left: 50%;
1309
+ transform: translateX(-50%);
1310
+ width: 20px;
1311
+ height: 3px;
1312
+ border-radius: 2px;
1313
+ background: var(--ev-color);
1314
+ opacity: 0;
1315
+ transition: opacity 120ms;
1316
+ }
1317
+ .tw-ev-handle--start::after { top: 1px; }
1318
+ .tw-ev-handle--end::after { bottom: 1px; }
1319
+ .tw-ev:hover .tw-ev-handle::after,
1320
+ .tw-ev:focus-within .tw-ev-handle::after,
1321
+ .tw-ev:focus-visible .tw-ev-handle::after,
1322
+ .tw-ev--resizing .tw-ev-handle::after,
1323
+ .tw-ev--selected .tw-ev-handle::after { opacity: 0.55; }
1324
+ /* Coarse pointers can't hover — show the grips persistently */
1325
+ @media (hover: none) {
1326
+ .tw-ev-handle::after { opacity: 0.55; }
851
1327
  }
852
1328
 
853
- /* ─── Focus-visible ──────────────────────────────── */
854
- .wg-cell:focus-visible {
855
- outline: 2px solid var(--dt-accent, #2563eb);
856
- outline-offset: -2px;
1329
+ /* ─── Move / create ghost ────────────────────────── */
1330
+ .tw-ghost {
1331
+ position: absolute;
1332
+ left: 1px;
1333
+ right: 3px;
1334
+ z-index: 40;
1335
+ border-radius: 5px;
1336
+ background: color-mix(in srgb, var(--ev-color) 22%, var(--dt-surface, var(--dt-bg, #ffffff)));
1337
+ outline: 1px solid color-mix(in srgb, var(--ev-color) 45%, transparent);
1338
+ box-shadow: 0 6px 18px color-mix(in srgb, var(--ev-color) 24%, rgba(0, 0, 0, 0.22));
1339
+ display: flex;
1340
+ flex-direction: column;
1341
+ gap: 1px;
1342
+ padding: 3px 6px;
1343
+ overflow: hidden;
1344
+ pointer-events: none;
1345
+ cursor: grabbing;
1346
+ box-sizing: border-box;
1347
+ }
1348
+ .tw-ghost--create {
1349
+ background: color-mix(in srgb, var(--ev-color) 12%, transparent);
1350
+ outline: 1px dashed color-mix(in srgb, var(--ev-color) 60%, transparent);
1351
+ box-shadow: none;
1352
+ }
1353
+ .tw-ghost-time {
1354
+ font: 600 11px/1.1 var(--dt-mono, ui-monospace, monospace);
1355
+ color: var(--ev-color, var(--dt-accent, #2563eb));
1356
+ white-space: nowrap;
1357
+ }
1358
+ .tw-ghost-title {
1359
+ font: 600 12px/1.2 var(--dt-sans, system-ui, sans-serif);
1360
+ color: var(--dt-text, rgba(0, 0, 0, 0.87));
1361
+ white-space: nowrap;
1362
+ overflow: hidden;
1363
+ text-overflow: ellipsis;
857
1364
  }
858
1365
 
859
- .wg-ev:focus-visible {
860
- outline: 2px solid var(--ev-color, var(--dt-accent, #2563eb));
861
- outline-offset: 1px;
1366
+ /* ─── Empty overlay ──────────────────────────────── */
1367
+ .tw-empty {
1368
+ position: absolute;
1369
+ inset: 0;
1370
+ display: flex;
1371
+ align-items: center;
1372
+ justify-content: center;
1373
+ pointer-events: none;
1374
+ z-index: 4;
862
1375
  }
863
1376
 
864
- .wg-ev--dragging {
865
- opacity: 0.6;
866
- cursor: grabbing;
867
- box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
1377
+ /* ─── Focus-visible ──────────────────────────────── */
1378
+ /* box-shadow instead of outline: outlines get clipped by the
1379
+ overflow: hidden scroll container. */
1380
+ .tw-ev:focus-visible {
1381
+ outline: none;
1382
+ box-shadow: 0 0 0 2px var(--dt-accent, #2563eb);
1383
+ z-index: 9;
868
1384
  }
869
- </style>
870
1385
 
1386
+ /* ─── Reduced motion ─────────────────────────────── */
1387
+ @media (prefers-reduced-motion: reduce) {
1388
+ .tw-ev,
1389
+ .tw-ad,
1390
+ .tw-ev-handle::after {
1391
+ transition: none;
1392
+ }
1393
+ .tw-ev-live { animation: none; }
1394
+ }
1395
+ </style>