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