import gsap from "gsap"; import { ref, computed, watch, onMounted, onBeforeUnmount, nextTick, type ComputedRef } from "vue"; import { useQuery, useMutation } from "@vue/apollo-composable"; import { CalendarQueryDocument, CreateCalendarEventMutationDocument, ArchiveCalendarEventMutationDocument, } from "~~/graphql/generated"; type CalendarHierarchyView = "year" | "month" | "week" | "day"; export type CalendarView = "day" | "week" | "month" | "year" | "agenda"; export type CalendarEvent = { id: string; title: string; start: string; end: string; contact: string; note: string; isArchived: boolean; createdAt: string; archiveNote: string; archivedAt: string; }; export type EventLifecyclePhase = "scheduled" | "due_soon" | "awaiting_outcome" | "closed"; export function dayKey(date: Date) { const y = date.getFullYear(); const m = String(date.getMonth() + 1).padStart(2, "0"); const d = String(date.getDate()).padStart(2, "0"); return `${y}-${m}-${d}`; } export function formatDay(iso: string) { return new Intl.DateTimeFormat("en-GB", { day: "2-digit", month: "short", year: "numeric", }).format(new Date(iso)); } export function formatTime(iso: string) { return new Intl.DateTimeFormat("en-GB", { hour: "2-digit", minute: "2-digit", }).format(new Date(iso)); } export function formatThreadTime(iso: string) { return new Intl.DateTimeFormat("en-GB", { hour: "2-digit", minute: "2-digit", hour12: false, }) .format(new Date(iso)) .replace(":", "."); } export function formatStamp(iso: string) { return new Intl.DateTimeFormat("en-GB", { day: "2-digit", month: "short", hour: "2-digit", minute: "2-digit", }).format(new Date(iso)); } export function toInputDate(date: Date) { const y = date.getFullYear(); const m = String(date.getMonth() + 1).padStart(2, "0"); const d = String(date.getDate()).padStart(2, "0"); return `${y}-${m}-${d}`; } export function toInputTime(date: Date) { const hh = String(date.getHours()).padStart(2, "0"); const mm = String(date.getMinutes()).padStart(2, "0"); return `${hh}:${mm}`; } export function roundToNextQuarter(date = new Date()) { const d = new Date(date); d.setSeconds(0, 0); const minutes = d.getMinutes(); const rounded = Math.ceil(minutes / 15) * 15; if (rounded >= 60) { d.setHours(d.getHours() + 1, 0, 0, 0); } else { d.setMinutes(rounded, 0, 0); } return d; } export function roundToPrevQuarter(date = new Date()) { const d = new Date(date); d.setSeconds(0, 0); const minutes = d.getMinutes(); const rounded = Math.floor(minutes / 15) * 15; d.setMinutes(rounded, 0, 0); return d; } export function atOffset(days: number, hour: number, minute: number) { const d = new Date(); d.setDate(d.getDate() + days); d.setHours(hour, minute, 0, 0); return d.toISOString(); } export function inMinutes(minutes: number) { const d = new Date(); d.setMinutes(d.getMinutes() + minutes, 0, 0); return d.toISOString(); } export function endAfter(startIso: string, minutes: number) { const d = new Date(startIso); d.setMinutes(d.getMinutes() + minutes); return d.toISOString(); } export function isEventFinalStatus(isArchived: boolean) { return Boolean(isArchived); } export function eventPreDueAt(event: CalendarEvent) { return new Date(new Date(event.start).getTime() - 30 * 60 * 1000).toISOString(); } export function eventDueAt(event: CalendarEvent) { return event.start; } export function eventLifecyclePhase(event: CalendarEvent, nowMs: number): EventLifecyclePhase { if (event.isArchived) return "closed"; const dueMs = new Date(eventDueAt(event)).getTime(); const preDueMs = new Date(eventPreDueAt(event)).getTime(); if (nowMs >= dueMs) return "awaiting_outcome"; if (nowMs >= preDueMs) return "due_soon"; return "scheduled"; } export function eventTimelineAt(event: CalendarEvent, phase: EventLifecyclePhase) { if (phase === "scheduled") return event.createdAt || event.start; if (phase === "due_soon") return eventPreDueAt(event); return eventDueAt(event); } export function eventRelativeLabel(event: CalendarEvent, nowMs: number) { if (event.isArchived) return "Archived"; const diffMs = new Date(event.start).getTime() - nowMs; const minuteMs = 60 * 1000; const hourMs = 60 * minuteMs; const dayMs = 24 * hourMs; const abs = Math.abs(diffMs); if (diffMs >= 0) { if (abs >= dayMs) { const days = Math.round(abs / dayMs); return `Event in ${days} day${days === 1 ? "" : "s"}`; } if (abs >= hourMs) { const hours = Math.round(abs / hourMs); return `Event in ${hours} hour${hours === 1 ? "" : "s"}`; } const minutes = Math.max(1, Math.round(abs / minuteMs)); return `Event in ${minutes} minute${minutes === 1 ? "" : "s"}`; } if (abs >= dayMs) { const days = Math.round(abs / dayMs); return `Overdue by ${days} day${days === 1 ? "" : "s"}`; } if (abs >= hourMs) { const hours = Math.round(abs / hourMs); return `Overdue by ${hours} hour${hours === 1 ? "" : "s"}`; } const minutes = Math.max(1, Math.round(abs / minuteMs)); return `Overdue by ${minutes} minute${minutes === 1 ? "" : "s"}`; } export function eventPhaseToneClass(phase: EventLifecyclePhase) { if (phase === "awaiting_outcome") return "border-warning/50 bg-warning/10"; if (phase === "due_soon") return "border-info/50 bg-info/10"; if (phase === "closed") return "border-success/40 bg-success/10"; return "border-base-300 bg-base-100"; } export function useCalendar(opts: { apolloAuthReady: ComputedRef }) { // --------------------------------------------------------------------------- // Apollo query & mutation // --------------------------------------------------------------------------- const { result: calendarResult, refetch: refetchCalendar } = useQuery( CalendarQueryDocument, null, { enabled: opts.apolloAuthReady }, ); const { mutate: doCreateCalendarEvent } = useMutation(CreateCalendarEventMutationDocument, { refetchQueries: [{ query: CalendarQueryDocument }], }); const { mutate: doArchiveCalendarEvent } = useMutation(ArchiveCalendarEventMutationDocument, { refetchQueries: [{ query: CalendarQueryDocument }], }); // --------------------------------------------------------------------------- // Core state // --------------------------------------------------------------------------- const calendarEvents = ref([]); const calendarView = ref("year"); const calendarCursor = ref(new Date(new Date().getFullYear(), new Date().getMonth(), 1)); const selectedDateKey = ref(dayKey(new Date())); const focusedCalendarEventId = ref(""); const lifecycleNowMs = ref(Date.now()); let lifecycleClock: ReturnType | null = null; onMounted(() => { lifecycleClock = setInterval(() => { lifecycleNowMs.value = Date.now(); }, 15000); }); onBeforeUnmount(() => { if (lifecycleClock) { clearInterval(lifecycleClock); lifecycleClock = null; } if (calendarViewportResizeObserver) { calendarViewportResizeObserver.disconnect(); calendarViewportResizeObserver = null; } calendarKillTweens(); clearCalendarZoomPrime(); }); // --------------------------------------------------------------------------- // Apollo → Ref watcher // --------------------------------------------------------------------------- watch(() => calendarResult.value?.calendar, (v) => { if (v) calendarEvents.value = v as CalendarEvent[]; }, { immediate: true }); // --------------------------------------------------------------------------- // Sorted events & derived computeds // --------------------------------------------------------------------------- const sortedEvents = computed(() => [...calendarEvents.value].sort((a, b) => a.start.localeCompare(b.start))); const focusedCalendarEvent = computed(() => { const id = (focusedCalendarEventId.value ?? "").trim(); if (!id) return null; return sortedEvents.value.find((event) => event.id === id) ?? null; }); const eventsByDate = computed(() => { const map = new Map(); for (const event of sortedEvents.value) { const key = event.start.slice(0, 10); if (!map.has(key)) { map.set(key, []); } map.get(key)?.push(event); } return map; }); function getEventsByDate(key: string) { return eventsByDate.value.get(key) ?? []; } const monthLabel = computed(() => new Intl.DateTimeFormat("en-US", { month: "long", year: "numeric" }).format(calendarCursor.value), ); const calendarViewOptions: { value: CalendarView; label: string }[] = [ { value: "day", label: "Day" }, { value: "week", label: "Week" }, { value: "month", label: "Month" }, { value: "year", label: "Year" }, { value: "agenda", label: "Agenda" }, ]; // --------------------------------------------------------------------------- // Zoom / camera state // --------------------------------------------------------------------------- const calendarContentWrapRef = ref(null); const calendarContentScrollRef = ref(null); const calendarSceneRef = ref(null); const calendarFlyRectRef = ref(null); const calendarFlyVisible = ref(false); const calendarFlyLabelRef = ref(null); const calendarFlyLabelVisible = ref(false); const calendarToolbarLabelRef = ref(null); const calendarViewportHeight = ref(0); const calendarHoveredMonthIndex = ref(null); const calendarHoveredWeekStartKey = ref(""); const calendarHoveredDayKey = ref(""); let calendarViewportResizeObserver: ResizeObserver | null = null; let calendarActiveTweens: gsap.core.Tween[] = []; const CALENDAR_FLY_DURATION = 0.65; const CALENDAR_FADE_DURATION = 0.18; const CALENDAR_EASE = "power3.inOut"; function setCalendarContentWrapRef(element: HTMLElement | null) { calendarContentWrapRef.value = element; } function setCalendarContentScrollRef(element: HTMLElement | null) { if (calendarViewportResizeObserver) { calendarViewportResizeObserver.disconnect(); calendarViewportResizeObserver = null; } calendarContentScrollRef.value = element; if (element && typeof ResizeObserver !== "undefined") { calendarViewportResizeObserver = new ResizeObserver(() => { calendarViewportHeight.value = Math.max(0, Math.round(element.clientHeight)); }); calendarViewportResizeObserver.observe(element); calendarViewportHeight.value = Math.max(0, Math.round(element.clientHeight)); } } function setCalendarSceneRef(element: HTMLElement | null) { calendarSceneRef.value = element; } function setCalendarFlyRectRef(element: HTMLDivElement | null) { calendarFlyRectRef.value = element; } function setCalendarFlyLabelRef(element: HTMLDivElement | null) { calendarFlyLabelRef.value = element; } function setCalendarToolbarLabelRef(element: HTMLDivElement | null) { calendarToolbarLabelRef.value = element; } function setCalendarHoveredMonthIndex(value: number | null) { calendarHoveredMonthIndex.value = value; } function setCalendarHoveredWeekStartKey(value: string) { calendarHoveredWeekStartKey.value = value; } function setCalendarHoveredDayKey(value: string) { calendarHoveredDayKey.value = value; } function onCalendarSceneMouseLeave() { calendarHoveredMonthIndex.value = null; calendarHoveredWeekStartKey.value = ""; calendarHoveredDayKey.value = ""; clearCalendarZoomPrime(); } function calendarTweenTo(target: gsap.TweenTarget, vars: gsap.TweenVars): Promise { return new Promise((resolve) => { const t = gsap.to(target, { ...vars, onComplete: () => { calendarActiveTweens = calendarActiveTweens.filter((tw) => tw !== t); resolve(); }, }); calendarActiveTweens.push(t); }); } function calendarKillTweens() { for (const t of calendarActiveTweens) t.kill(); calendarActiveTweens = []; } const calendarZoomBusy = ref(false); const calendarZoomPrimeToken = ref(""); const calendarZoomPrimeScale = ref(1); const calendarZoomPrimeTicks = ref(0); let calendarWheelLockUntil = 0; let calendarZoomPrimeTimer: ReturnType | null = null; let calendarZoomPrimeLastAt = 0; const CALENDAR_ZOOM_PRIME_STEPS = 2; const CALENDAR_ZOOM_PRIME_MAX_SCALE = 1.05; const CALENDAR_ZOOM_PRIME_RESET_MS = 900; const calendarZoomOrder: CalendarHierarchyView[] = ["year", "month", "week", "day"]; const normalizedCalendarView = computed(() => calendarView.value === "agenda" ? "month" : calendarView.value, ); const calendarZoomLevelIndex = computed(() => Math.max(0, calendarZoomOrder.indexOf(normalizedCalendarView.value))); function clearCalendarZoomPrime() { if (calendarZoomPrimeTimer) { clearTimeout(calendarZoomPrimeTimer); calendarZoomPrimeTimer = null; } calendarZoomPrimeToken.value = ""; calendarZoomPrimeScale.value = 1; calendarZoomPrimeTicks.value = 0; calendarZoomPrimeLastAt = 0; } function calendarPrimeMonthToken(monthIndex: number) { return `year-month-${monthIndex}`; } function calendarPrimeWeekToken(startKey: string) { return `month-week-${startKey}`; } function calendarPrimeDayToken(key: string) { return `week-day-${key}`; } function calendarPrimeStyle(token: string) { if (calendarZoomPrimeToken.value !== token) return undefined; return { transform: `scale(${calendarZoomPrimeScale.value})`, }; } function maybePrimeWheelZoom(event: WheelEvent | undefined, token: string) { if (!event || event.deltaY >= 0) return false; const now = Date.now(); if (calendarZoomPrimeToken.value !== token || now - calendarZoomPrimeLastAt > CALENDAR_ZOOM_PRIME_RESET_MS) { calendarZoomPrimeTicks.value = 0; } calendarZoomPrimeToken.value = token; calendarZoomPrimeTicks.value += 1; calendarZoomPrimeLastAt = now; if (calendarZoomPrimeTicks.value <= CALENDAR_ZOOM_PRIME_STEPS) { const ratio = calendarZoomPrimeTicks.value / CALENDAR_ZOOM_PRIME_STEPS; calendarZoomPrimeScale.value = 1 + (CALENDAR_ZOOM_PRIME_MAX_SCALE - 1) * ratio; if (calendarZoomPrimeTimer) clearTimeout(calendarZoomPrimeTimer); calendarZoomPrimeTimer = setTimeout(() => { clearCalendarZoomPrime(); }, CALENDAR_ZOOM_PRIME_RESET_MS); return true; } clearCalendarZoomPrime(); return false; } function queryCalendarElement(selector: string) { return calendarContentWrapRef.value?.querySelector(selector) ?? null; } function weekRowStartForDate(key: string) { const date = new Date(`${key}T00:00:00`); date.setDate(date.getDate() - date.getDay()); return dayKey(date); } function isoWeekNumber(dateString: string): number { const d = new Date(`${dateString}T00:00:00`); const t = new Date(d.getTime()); t.setDate(t.getDate() + 3 - ((t.getDay() + 6) % 7)); const y = new Date(t.getFullYear(), 0, 4); return 1 + Math.round(((t.getTime() - y.getTime()) / 86400000 - 3 + ((y.getDay() + 6) % 7)) / 7); } function nextAnimationFrame() { return new Promise((resolve) => { requestAnimationFrame(() => resolve()); }); } // --------------------------------------------------------------------------- // GSAP animation helpers // --------------------------------------------------------------------------- function cloneElementStyleToFlyRect(source: HTMLElement, flyEl: HTMLElement) { const s = getComputedStyle(source); flyEl.style.borderColor = s.borderColor; flyEl.style.borderWidth = s.borderWidth; flyEl.style.borderStyle = s.borderStyle; flyEl.style.backgroundColor = s.backgroundColor; flyEl.style.borderRadius = s.borderRadius; flyEl.style.boxShadow = s.boxShadow; } function resetFlyRectStyle(flyEl: HTMLElement) { flyEl.style.borderColor = ""; flyEl.style.borderWidth = ""; flyEl.style.borderStyle = ""; flyEl.style.backgroundColor = ""; flyEl.style.borderRadius = ""; flyEl.style.boxShadow = ""; flyEl.innerHTML = ""; } function extractSourceLabel(sourceElement: HTMLElement, viewBefore: string): string { if (viewBefore === "year") { const p = sourceElement.querySelector("p"); return p?.textContent?.trim() ?? ""; } if (viewBefore === "month" || viewBefore === "agenda") { const wn = sourceElement.querySelector(".calendar-week-number"); return wn ? `Week ${wn.textContent?.trim()}` : ""; } if (viewBefore === "week") { const p = sourceElement.querySelector("p"); return p?.textContent?.trim() ?? ""; } return ""; } function findSourceTitleElement(sourceElement: HTMLElement, viewBefore: string): HTMLElement | null { if (viewBefore === "year") { return sourceElement.parentElement?.querySelector(".calendar-card-title") ?? null; } if (viewBefore === "month" || viewBefore === "agenda") { return sourceElement.querySelector(".calendar-week-number") ?? null; } if (viewBefore === "week") { return sourceElement.parentElement?.querySelector(".calendar-card-title") ?? null; } return null; } function resetFlyLabelStyle(el: HTMLElement) { el.textContent = ""; el.style.fontWeight = ""; el.style.color = ""; el.style.fontSize = ""; } function buildFlyRectSkeletonContent(): string { return `
`; } // --------------------------------------------------------------------------- // GSAP zoom animations // --------------------------------------------------------------------------- async function animateCalendarFlipTransition( _sourceElement: HTMLElement | null, apply: () => void, resolveTarget: () => HTMLElement | null, ) { clearCalendarZoomPrime(); calendarZoomBusy.value = true; calendarKillTweens(); const flyEl = calendarFlyRectRef.value; const wrapEl = calendarContentWrapRef.value; const sceneEl = calendarSceneRef.value; const flyLabelEl = calendarFlyLabelRef.value; const toolbarLabelEl = calendarToolbarLabelRef.value; console.warn("[CALENDAR ZOOM-OUT] refs:", { flyEl: !!flyEl, wrapEl: !!wrapEl, sceneEl: !!sceneEl, flyLabelEl: !!flyLabelEl, toolbarLabelEl: !!toolbarLabelEl }); if (!flyEl || !wrapEl) { console.warn("[CALENDAR ZOOM-OUT] SKIPPED — missing ref:", { flyEl: !!flyEl, wrapEl: !!wrapEl }); apply(); calendarZoomBusy.value = false; return; } try { console.warn("[CALENDAR ZOOM-OUT] ANIMATING..."); const wrapRect = wrapEl.getBoundingClientRect(); const flyLabelText = calendarPeriodLabel.value; // 1. Fade out current scene if (sceneEl) { await calendarTweenTo(sceneEl, { opacity: 0, duration: CALENDAR_FADE_DURATION, ease: "power2.in" }); } // 2. Position fly rect at full viewport gsap.set(flyEl, { left: 0, top: 0, width: wrapRect.width, height: wrapRect.height, opacity: 1 }); flyEl.style.borderRadius = "0.75rem"; flyEl.style.borderWidth = "1px"; flyEl.style.borderStyle = "solid"; flyEl.style.borderColor = "color-mix(in oklab, var(--color-base-300) 100%, transparent)"; flyEl.style.backgroundColor = "color-mix(in oklab, var(--color-base-100) 100%, transparent)"; flyEl.style.boxShadow = ""; flyEl.innerHTML = buildFlyRectSkeletonContent(); calendarFlyVisible.value = true; // 3. Position flying label at toolbar let flyLabelReady = false; if (flyLabelEl && toolbarLabelEl && flyLabelText) { const sectionRect = (flyLabelEl.offsetParent as HTMLElement | null)?.getBoundingClientRect(); if (sectionRect) { const toolbarStyle = getComputedStyle(toolbarLabelEl); const toolbarTextRange = document.createRange(); toolbarTextRange.selectNodeContents(toolbarLabelEl); const toolbarTextRect = toolbarTextRange.getBoundingClientRect(); flyLabelEl.textContent = flyLabelText; flyLabelEl.style.fontWeight = toolbarStyle.fontWeight; flyLabelEl.style.color = toolbarStyle.color; gsap.set(flyLabelEl, { left: toolbarTextRect.left - sectionRect.left, top: toolbarTextRect.top - sectionRect.top, fontSize: parseFloat(toolbarStyle.fontSize), opacity: 1, }); toolbarLabelEl.style.opacity = "0"; calendarFlyLabelVisible.value = true; flyLabelReady = true; } } // 4. Switch to parent view apply(); await nextTick(); await nextAnimationFrame(); // 5. Find target element const targetElement = resolveTarget(); const targetRect = targetElement?.getBoundingClientRect() ?? null; const viewAfter = calendarView.value; if (targetElement && targetRect && targetRect.width >= 2 && targetRect.height >= 2) { cloneElementStyleToFlyRect(targetElement, flyEl); targetElement.style.opacity = "0"; const tgtLeft = targetRect.left - wrapRect.left; const tgtTop = targetRect.top - wrapRect.top; const targetTitleEl = findSourceTitleElement(targetElement, viewAfter); let flyLabelPromise: Promise | null = null; if (flyLabelReady && flyLabelEl && targetTitleEl) { const sectionRect = (flyLabelEl.offsetParent as HTMLElement | null)?.getBoundingClientRect(); if (sectionRect) { const titleRect = targetTitleEl.getBoundingClientRect(); const titleStyle = getComputedStyle(targetTitleEl); targetTitleEl.style.opacity = "0"; flyLabelPromise = calendarTweenTo(flyLabelEl, { left: titleRect.left - sectionRect.left, top: titleRect.top - sectionRect.top, fontSize: parseFloat(titleStyle.fontSize), color: titleStyle.color, duration: CALENDAR_FLY_DURATION, ease: CALENDAR_EASE, }); } } // 6. Animate fly rect → target (concurrent with label) const flyRectPromise = calendarTweenTo(flyEl, { left: tgtLeft, top: tgtTop, width: targetRect.width, height: targetRect.height, duration: CALENDAR_FLY_DURATION, ease: CALENDAR_EASE, }); await Promise.all([flyRectPromise, flyLabelPromise].filter(Boolean)); targetElement.style.opacity = ""; if (targetTitleEl) targetTitleEl.style.opacity = ""; } // 7. Cleanup calendarFlyLabelVisible.value = false; if (flyLabelEl) resetFlyLabelStyle(flyLabelEl); if (toolbarLabelEl) toolbarLabelEl.style.opacity = ""; calendarFlyVisible.value = false; resetFlyRectStyle(flyEl); if (sceneEl) { gsap.set(sceneEl, { opacity: 0 }); await calendarTweenTo(sceneEl, { opacity: 1, duration: 0.25, ease: "power2.out" }); } } finally { calendarFlyVisible.value = false; calendarFlyLabelVisible.value = false; resetFlyRectStyle(flyEl); if (flyLabelEl) resetFlyLabelStyle(flyLabelEl); if (toolbarLabelEl) toolbarLabelEl.style.opacity = ""; calendarZoomBusy.value = false; } } async function animateCalendarZoomIntoSource( sourceElement: HTMLElement | null, apply: () => void, ) { clearCalendarZoomPrime(); calendarZoomBusy.value = true; calendarKillTweens(); const flyEl = calendarFlyRectRef.value; const wrapEl = calendarContentWrapRef.value; const scrollEl = calendarContentScrollRef.value; const sceneEl = calendarSceneRef.value; const flyLabelEl = calendarFlyLabelRef.value; const toolbarLabelEl = calendarToolbarLabelRef.value; console.warn("[CALENDAR ZOOM-IN] refs:", { sourceElement: !!sourceElement, flyEl: !!flyEl, wrapEl: !!wrapEl, scrollEl: !!scrollEl, sceneEl: !!sceneEl, flyLabelEl: !!flyLabelEl, toolbarLabelEl: !!toolbarLabelEl }); if (!sourceElement || !flyEl || !wrapEl || !scrollEl) { console.warn("[CALENDAR ZOOM-IN] SKIPPED — missing ref:", { sourceElement: !!sourceElement, flyEl: !!flyEl, wrapEl: !!wrapEl, scrollEl: !!scrollEl }); apply(); calendarZoomBusy.value = false; return; } try { console.warn("[CALENDAR ZOOM-IN] ANIMATING..."); const wrapRect = wrapEl.getBoundingClientRect(); const sourceRect = sourceElement.getBoundingClientRect(); if (sourceRect.width < 2 || sourceRect.height < 2) { apply(); return; } // 1. Find source title element and extract label const viewBefore = calendarView.value; const labelText = extractSourceLabel(sourceElement, viewBefore); const sourceTitleEl = findSourceTitleElement(sourceElement, viewBefore); // 2. Fade out siblings (cards + external titles + week numbers) const allFadable = Array.from( sceneEl?.querySelectorAll(".calendar-hover-targetable, .calendar-card-title, .calendar-week-number") ?? [], ); const siblings = allFadable.filter( (el) => el !== sourceElement && el !== sourceTitleEl && !sourceElement.contains(el) && !el.contains(sourceElement), ); await calendarTweenTo(siblings, { opacity: 0, duration: CALENDAR_FADE_DURATION, ease: "power2.in" }); // 3. Fade out source children const sourceChildren = Array.from(sourceElement.children) as HTMLElement[]; await calendarTweenTo(sourceChildren, { opacity: 0, duration: 0.12, ease: "power2.in" }); // 4. Clone source style to fly-rect, inject skeleton cloneElementStyleToFlyRect(sourceElement, flyEl); flyEl.innerHTML = buildFlyRectSkeletonContent(); const srcLeft = sourceRect.left - wrapRect.left; const srcTop = sourceRect.top - wrapRect.top; gsap.set(flyEl, { left: srcLeft, top: srcTop, width: sourceRect.width, height: sourceRect.height, opacity: 1 }); // 5. Swap: hide source, show fly-rect sourceElement.style.opacity = "0"; calendarFlyVisible.value = true; // 6. Setup flying label: source title → toolbar let flyLabelPromise: Promise | null = null; if (flyLabelEl && toolbarLabelEl && sourceTitleEl && labelText) { const sectionRect = (flyLabelEl.offsetParent as HTMLElement | null)?.getBoundingClientRect(); if (sectionRect) { const srcTitleRect = sourceTitleEl.getBoundingClientRect(); const srcTitleStyle = getComputedStyle(sourceTitleEl); const toolbarStyle = getComputedStyle(toolbarLabelEl); flyLabelEl.textContent = labelText; flyLabelEl.style.fontWeight = srcTitleStyle.fontWeight; flyLabelEl.style.color = srcTitleStyle.color; gsap.set(flyLabelEl, { left: srcTitleRect.left - sectionRect.left, top: srcTitleRect.top - sectionRect.top, fontSize: parseFloat(srcTitleStyle.fontSize), opacity: 1, }); sourceTitleEl.style.opacity = "0"; toolbarLabelEl.style.opacity = "0"; calendarFlyLabelVisible.value = true; await nextAnimationFrame(); const endFontSize = parseFloat(toolbarStyle.fontSize); const toolbarTextRange = document.createRange(); toolbarTextRange.selectNodeContents(toolbarLabelEl); const toolbarTextRect = toolbarTextRange.getBoundingClientRect(); flyLabelPromise = calendarTweenTo(flyLabelEl, { left: toolbarTextRect.left - sectionRect.left, top: toolbarTextRect.top - sectionRect.top, fontSize: endFontSize, color: toolbarStyle.color, duration: CALENDAR_FLY_DURATION, ease: CALENDAR_EASE, }); } } // 7. Animate fly-rect to viewport (concurrent with label) const flyRectPromise = calendarTweenTo(flyEl, { left: 0, top: 0, width: wrapRect.width, height: wrapRect.height, duration: CALENDAR_FLY_DURATION, ease: CALENDAR_EASE, }); await Promise.all([flyRectPromise, flyLabelPromise].filter(Boolean)); // 8. Cleanup flying label calendarFlyLabelVisible.value = false; if (flyLabelEl) resetFlyLabelStyle(flyLabelEl); if (toolbarLabelEl) toolbarLabelEl.style.opacity = ""; if (sourceTitleEl) sourceTitleEl.style.opacity = ""; // 9. Switch view apply(); await nextTick(); // 10. Hide fly-rect, fade in calendarFlyVisible.value = false; resetFlyRectStyle(flyEl); if (sceneEl) { gsap.set(sceneEl, { opacity: 0 }); await calendarTweenTo(sceneEl, { opacity: 1, duration: 0.25, ease: "power2.out" }); } // 11. Restore sourceElement.style.opacity = ""; for (const child of sourceChildren) child.style.opacity = ""; for (const el of siblings) el.style.opacity = ""; } finally { calendarFlyVisible.value = false; calendarFlyLabelVisible.value = false; resetFlyRectStyle(flyEl); if (flyLabelEl) resetFlyLabelStyle(flyLabelEl); if (toolbarLabelEl) toolbarLabelEl.style.opacity = ""; calendarZoomBusy.value = false; } } function resolveMonthAnchor(event?: WheelEvent) { const target = event?.target as HTMLElement | null; const monthAttr = target?.closest("[data-calendar-month-index]")?.dataset.calendarMonthIndex; if (monthAttr) { const parsed = Number(monthAttr); if (Number.isFinite(parsed) && parsed >= 0 && parsed <= 11) return parsed; } if (calendarHoveredMonthIndex.value !== null) return calendarHoveredMonthIndex.value; return calendarCursor.value.getMonth(); } function fallbackMonthGridAnchorKey() { if (monthCells.value.some((cell) => cell.key === selectedDateKey.value)) return selectedDateKey.value; const middle = dayKey(new Date(calendarCursor.value.getFullYear(), calendarCursor.value.getMonth(), 15)); if (monthCells.value.some((cell) => cell.key === middle)) return middle; return monthCells.value.find((cell) => cell.inMonth)?.key ?? monthCells.value[0]?.key ?? selectedDateKey.value; } function resolveWeekAnchor(event?: WheelEvent) { const target = event?.target as HTMLElement | null; const weekKey = target?.closest("[data-calendar-week-start-key]")?.dataset.calendarWeekStartKey; if (weekKey) return weekKey; if (calendarHoveredWeekStartKey.value) return calendarHoveredWeekStartKey.value; if (calendarHoveredDayKey.value) return calendarHoveredDayKey.value; return fallbackMonthGridAnchorKey(); } function resolveDayAnchor(event?: WheelEvent) { const target = event?.target as HTMLElement | null; const dayKeyAttr = target?.closest("[data-calendar-day-key]")?.dataset.calendarDayKey; if (dayKeyAttr) return dayKeyAttr; if (calendarHoveredDayKey.value) return calendarHoveredDayKey.value; return weekDays.value[0]?.key ?? selectedDateKey.value; } async function zoomInCalendar(event?: Event) { const wheelEvent = event instanceof WheelEvent ? event : undefined; if (calendarView.value === "year") { const monthIndex = resolveMonthAnchor(wheelEvent); const sourceElement = queryCalendarElement(`[data-calendar-month-index="${monthIndex}"]`) ?? queryCalendarElement("[data-calendar-month-index]"); if (maybePrimeWheelZoom(wheelEvent, calendarPrimeMonthToken(monthIndex))) return; await animateCalendarZoomIntoSource(sourceElement, () => { openYearMonth(monthIndex); }); return; } if (calendarView.value === "month" || calendarView.value === "agenda") { const anchorDayKey = resolveWeekAnchor(wheelEvent); const rowStartKey = weekRowStartForDate(anchorDayKey); const sourceElement = queryCalendarElement(`[data-calendar-layer="month"] [data-calendar-week-start-key="${rowStartKey}"]`) ?? queryCalendarElement(`[data-calendar-layer="month"] [data-calendar-day-key="${anchorDayKey}"]`) ?? queryCalendarElement(`[data-calendar-layer="month"] [data-calendar-week-start-key]`) ?? queryCalendarElement(`[data-calendar-layer="month"] [data-calendar-day-key]`); if (maybePrimeWheelZoom(wheelEvent, calendarPrimeWeekToken(rowStartKey))) return; await animateCalendarZoomIntoSource(sourceElement, () => { openWeekView(anchorDayKey); }); return; } if (calendarView.value === "week") { const dayAnchor = resolveDayAnchor(wheelEvent); const sourceElement = queryCalendarElement(`[data-calendar-layer="week"] [data-calendar-day-key="${dayAnchor}"]`) ?? queryCalendarElement(`[data-calendar-layer="week"] [data-calendar-day-key]`); if (maybePrimeWheelZoom(wheelEvent, calendarPrimeDayToken(dayAnchor))) return; await animateCalendarZoomIntoSource(sourceElement, () => { openDayView(dayAnchor); }); } } async function zoomToMonth(monthIndex: number) { const el = queryCalendarElement(`[data-calendar-month-index="${monthIndex}"]`); console.warn("[CALENDAR] zoomToMonth called, monthIndex:", monthIndex, "sourceEl:", el, "wrapRef:", calendarContentWrapRef.value); await animateCalendarZoomIntoSource(el, () => { openYearMonth(monthIndex); }); } async function zoomOutCalendar() { focusedCalendarEventId.value = ""; clearCalendarZoomPrime(); if (calendarView.value === "day") { const targetDayKey = selectedDateKey.value; await animateCalendarFlipTransition( queryCalendarElement(`[data-calendar-month-index="${calendarCursor.value.getMonth()}"]`), () => { calendarView.value = "week"; }, () => queryCalendarElement(`[data-calendar-layer="week"] [data-calendar-day-key="${targetDayKey}"]`) ?? queryCalendarElement(`[data-calendar-layer="week"] [data-calendar-day-key]`), ); return; } if (calendarView.value === "week") { const targetRowKey = weekRowStartForDate(selectedDateKey.value); await animateCalendarFlipTransition( queryCalendarElement(`[data-calendar-month-index="${calendarCursor.value.getMonth()}"]`), () => { calendarView.value = "month"; }, () => queryCalendarElement(`[data-calendar-layer="month"] [data-calendar-week-start-key="${targetRowKey}"]`) ?? queryCalendarElement(`[data-calendar-layer="month"] [data-calendar-day-key="${selectedDateKey.value}"]`) ?? queryCalendarElement(`[data-calendar-layer="month"] [data-calendar-week-start-key]`) ?? queryCalendarElement(`[data-calendar-layer="month"] [data-calendar-day-key]`), ); return; } if (calendarView.value === "month" || calendarView.value === "agenda") { const targetMonthIndex = calendarCursor.value.getMonth(); await animateCalendarFlipTransition( queryCalendarElement(`[data-calendar-month-index="${targetMonthIndex}"]`), () => { calendarView.value = "year"; }, () => queryCalendarElement(`[data-calendar-month-index="${targetMonthIndex}"]`), ); } } function onCalendarHierarchyWheel(event: WheelEvent) { const now = Date.now(); if (calendarZoomBusy.value) return; if (now < calendarWheelLockUntil) return; if (Math.abs(event.deltaY) < 5) return; calendarWheelLockUntil = now + 140; if (event.deltaY < 0) { void zoomInCalendar(event); return; } void zoomOutCalendar(); } async function setCalendarZoomLevel(targetView: CalendarHierarchyView) { let currentIndex = calendarZoomOrder.indexOf(normalizedCalendarView.value); const targetIndex = calendarZoomOrder.indexOf(targetView); if (currentIndex < 0 || targetIndex < 0 || currentIndex === targetIndex) return; while (currentIndex !== targetIndex) { if (targetIndex > currentIndex) { await zoomInCalendar(); } else { await zoomOutCalendar(); } currentIndex = calendarZoomOrder.indexOf(normalizedCalendarView.value); } } function onCalendarZoomSliderInput(event: Event) { const value = Number((event.target as HTMLInputElement | null)?.value ?? NaN); if (!Number.isFinite(value)) return; const targetIndex = Math.max(0, Math.min(3, Math.round(value))); const targetView = calendarZoomOrder[targetIndex]; if (!targetView) return; void setCalendarZoomLevel(targetView); } // --------------------------------------------------------------------------- // Month cells, rows, week days // --------------------------------------------------------------------------- const monthCells = computed(() => { const year = calendarCursor.value.getFullYear(); const month = calendarCursor.value.getMonth(); const first = new Date(year, month, 1); const start = new Date(year, month, 1 - first.getDay()); return Array.from({ length: 42 }, (_, index) => { const d = new Date(start); d.setDate(start.getDate() + index); const key = dayKey(d); return { key, day: d.getDate(), inMonth: d.getMonth() === month, events: getEventsByDate(key), }; }); }); const monthRows = computed(() => { const rows: Array<{ key: string; startKey: string; weekNumber: number; cells: typeof monthCells.value }> = []; for (let index = 0; index < monthCells.value.length; index += 7) { const cells = monthCells.value.slice(index, index + 7); if (!cells.length) continue; const startKey = cells[0]?.key ?? selectedDateKey.value; rows.push({ key: `${cells[0]?.key ?? index}-week-row`, startKey, weekNumber: isoWeekNumber(startKey), cells, }); } return rows; }); function monthCellHasFocusedEvent(events: CalendarEvent[]) { const id = focusedCalendarEventId.value.trim(); if (!id) return false; return events.some((event) => event.id === id); } function monthCellEvents(events: CalendarEvent[]) { const id = focusedCalendarEventId.value.trim(); if (!id) return events.slice(0, 2); const focused = events.find((event) => event.id === id); if (!focused) return events.slice(0, 2); const rest = events.filter((event) => event.id !== id).slice(0, 1); return [focused, ...rest]; } const weekDays = computed(() => { const base = new Date(`${selectedDateKey.value}T00:00:00`); const mondayOffset = (base.getDay() + 6) % 7; const monday = new Date(base); monday.setDate(base.getDate() - mondayOffset); return Array.from({ length: 7 }, (_, index) => { const d = new Date(monday); d.setDate(monday.getDate() + index); const key = dayKey(d); return { key, label: new Intl.DateTimeFormat("en-US", { weekday: "short" }).format(d), day: d.getDate(), events: getEventsByDate(key), }; }); }); const calendarPeriodLabel = computed(() => { if (calendarView.value === "month") { return monthLabel.value; } if (calendarView.value === "year") { return String(calendarCursor.value.getFullYear()); } if (calendarView.value === "week") { const first = weekDays.value[0]; const last = weekDays.value[weekDays.value.length - 1]; if (!first || !last) return ""; return `${formatDay(`${first.key}T00:00:00`)} - ${formatDay(`${last.key}T00:00:00`)}`; } if (calendarView.value === "day") { return formatDay(`${selectedDateKey.value}T00:00:00`); } return `Agenda · ${monthLabel.value}`; }); const yearMonths = computed(() => { const year = calendarCursor.value.getFullYear(); return Array.from({ length: 12 }, (_, monthIndex) => { const monthStart = new Date(year, monthIndex, 1); const monthEnd = new Date(year, monthIndex + 1, 1); const items = sortedEvents.value.filter((event) => { const d = new Date(event.start); return d >= monthStart && d < monthEnd; }); return { monthIndex, label: new Intl.DateTimeFormat("en-US", { month: "long" }).format(monthStart), count: items.length, first: items[0], }; }); }); const selectedDayEvents = computed(() => getEventsByDate(selectedDateKey.value)); // --------------------------------------------------------------------------- // Navigation helpers // --------------------------------------------------------------------------- function shiftCalendar(step: number) { focusedCalendarEventId.value = ""; if (calendarView.value === "year") { const next = new Date(calendarCursor.value); next.setFullYear(next.getFullYear() + step); calendarCursor.value = new Date(next.getFullYear(), next.getMonth(), 1); const selected = new Date(`${selectedDateKey.value}T00:00:00`); selected.setFullYear(selected.getFullYear() + step); selectedDateKey.value = dayKey(selected); return; } if (calendarView.value === "month" || calendarView.value === "agenda") { const next = new Date(calendarCursor.value); next.setMonth(next.getMonth() + step); calendarCursor.value = new Date(next.getFullYear(), next.getMonth(), 1); return; } const current = new Date(`${selectedDateKey.value}T00:00:00`); const days = calendarView.value === "week" ? 7 : 1; current.setDate(current.getDate() + days * step); selectedDateKey.value = dayKey(current); calendarCursor.value = new Date(current.getFullYear(), current.getMonth(), 1); } function setToday() { focusedCalendarEventId.value = ""; const now = new Date(); selectedDateKey.value = dayKey(now); calendarCursor.value = new Date(now.getFullYear(), now.getMonth(), 1); } function pickDate(key: string) { focusedCalendarEventId.value = ""; selectedDateKey.value = key; const d = new Date(`${key}T00:00:00`); calendarCursor.value = new Date(d.getFullYear(), d.getMonth(), 1); } function openDayView(key: string) { pickDate(key); calendarView.value = "day"; } function openWeekView(key: string) { pickDate(key); calendarView.value = "week"; } function openYearMonth(monthIndex: number) { focusedCalendarEventId.value = ""; const year = calendarCursor.value.getFullYear(); calendarCursor.value = new Date(year, monthIndex, 1); selectedDateKey.value = dayKey(new Date(year, monthIndex, 1)); calendarView.value = "month"; } function formatYearMonthFirst(item: { first?: CalendarEvent }) { if (!item.first) return ""; return `${formatDay(item.first.start)} · ${item.first.title}`; } // --------------------------------------------------------------------------- // Event creation // --------------------------------------------------------------------------- const commEventForm = ref({ startDate: "", startTime: "", durationMinutes: 30, }); const commEventMode = ref<"planned" | "logged">("planned"); const commEventSaving = ref(false); const commEventError = ref(""); function setDefaultCommEventForm(mode: "planned" | "logged") { const start = mode === "planned" ? roundToNextQuarter(new Date(Date.now() + 15 * 60 * 1000)) : roundToPrevQuarter(new Date(Date.now() - 30 * 60 * 1000)); commEventForm.value = { startDate: toInputDate(start), startTime: toInputTime(start), durationMinutes: 30, }; } function buildCommEventTitle(text: string, mode: "planned" | "logged", contact: string) { const cleaned = text.replace(/\s+/g, " ").trim(); if (cleaned) { const sentence = cleaned.split(/[.!?\n]/)[0]?.trim() ?? ""; if (sentence) return sentence.slice(0, 120); } return mode === "logged" ? `Отчёт по контакту ${contact}` : `Событие с ${contact}`; } function openCommEventModal(mode: "planned" | "logged", hasThread: boolean) { if (!hasThread) return; commEventMode.value = mode; setDefaultCommEventForm(mode); commEventError.value = ""; } function closeCommEventModal() { if (commEventSaving.value) return; commEventError.value = ""; } async function createCommEvent(contactName: string, draftText: string) { if (!contactName || commEventSaving.value) return; const note = draftText.trim(); const title = buildCommEventTitle(note, commEventMode.value, contactName); const duration = Number(commEventForm.value.durationMinutes || 0); if (!note) { commEventError.value = "Текст события обязателен"; return; } if (!commEventForm.value.startDate || !commEventForm.value.startTime) { commEventError.value = "Date and time are required"; return; } const start = new Date(`${commEventForm.value.startDate}T${commEventForm.value.startTime}:00`); if (Number.isNaN(start.getTime())) { commEventError.value = "Invalid date or time"; return; } const safeDuration = Number.isFinite(duration) && duration > 0 ? duration : 30; const end = new Date(start); end.setMinutes(end.getMinutes() + safeDuration); commEventSaving.value = true; commEventError.value = ""; try { const res = await doCreateCalendarEvent({ input: { title, start: start.toISOString(), end: end.toISOString(), contact: contactName, note, archived: commEventMode.value === "logged", archiveNote: commEventMode.value === "logged" ? note : undefined, }, }); if (res?.data?.createCalendarEvent) { calendarEvents.value = [res.data.createCalendarEvent as CalendarEvent, ...calendarEvents.value]; } selectedDateKey.value = dayKey(start); calendarCursor.value = new Date(start.getFullYear(), start.getMonth(), 1); commEventError.value = ""; return true; } catch (error: any) { commEventError.value = String(error?.message ?? error ?? "Failed to create event"); return false; } finally { commEventSaving.value = false; } } // --------------------------------------------------------------------------- // Event archival // --------------------------------------------------------------------------- const eventCloseOpen = ref>({}); const eventCloseDraft = ref>({}); const eventCloseSaving = ref>({}); const eventCloseError = ref>({}); function canManuallyCloseEvent(entry: { kind: string; event?: CalendarEvent; phase?: string }) { if (entry.kind !== "eventLifecycle" || !entry.event) return false; return !isEventFinalStatus(entry.event.isArchived); } function isEventCloseOpen(eventId: string) { return Boolean(eventCloseOpen.value[eventId]); } function toggleEventClose(eventId: string) { const next = !eventCloseOpen.value[eventId]; eventCloseOpen.value = { ...eventCloseOpen.value, [eventId]: next }; if (next && !eventCloseDraft.value[eventId]) { eventCloseDraft.value = { ...eventCloseDraft.value, [eventId]: "" }; } if (!next && eventCloseError.value[eventId]) { eventCloseError.value = { ...eventCloseError.value, [eventId]: "" }; } } async function archiveEventManually(event: CalendarEvent) { const eventId = event.id; const archiveNote = String(eventCloseDraft.value[eventId] ?? "").trim(); if (eventCloseSaving.value[eventId]) return; eventCloseSaving.value = { ...eventCloseSaving.value, [eventId]: true }; eventCloseError.value = { ...eventCloseError.value, [eventId]: "" }; try { await doArchiveCalendarEvent({ input: { id: eventId, archiveNote: archiveNote || undefined, }, }); eventCloseOpen.value = { ...eventCloseOpen.value, [eventId]: false }; eventCloseDraft.value = { ...eventCloseDraft.value, [eventId]: "" }; } catch (error: any) { eventCloseError.value = { ...eventCloseError.value, [eventId]: String(error?.message ?? error ?? "Failed to archive event") }; } finally { eventCloseSaving.value = { ...eventCloseSaving.value, [eventId]: false }; } } return { // Core state calendarEvents, calendarView, calendarCursor, selectedDateKey, focusedCalendarEventId, lifecycleNowMs, // Computeds sortedEvents, focusedCalendarEvent, eventsByDate, getEventsByDate, monthLabel, calendarViewOptions, monthCells, monthRows, monthCellHasFocusedEvent, monthCellEvents, weekDays, calendarPeriodLabel, yearMonths, selectedDayEvents, // Zoom / camera calendarContentWrapRef, calendarContentScrollRef, calendarSceneRef, calendarFlyRectRef, calendarFlyVisible, calendarFlyLabelRef, calendarFlyLabelVisible, calendarToolbarLabelRef, calendarViewportHeight, calendarHoveredMonthIndex, calendarHoveredWeekStartKey, calendarHoveredDayKey, calendarZoomBusy, calendarZoomPrimeToken, calendarZoomPrimeScale, calendarZoomPrimeTicks, normalizedCalendarView, calendarZoomLevelIndex, calendarZoomOrder, // Zoom / camera setters setCalendarContentWrapRef, setCalendarContentScrollRef, setCalendarSceneRef, setCalendarFlyRectRef, setCalendarFlyLabelRef, setCalendarToolbarLabelRef, setCalendarHoveredMonthIndex, setCalendarHoveredWeekStartKey, setCalendarHoveredDayKey, onCalendarSceneMouseLeave, clearCalendarZoomPrime, calendarKillTweens, calendarPrimeMonthToken, calendarPrimeWeekToken, calendarPrimeDayToken, calendarPrimeStyle, maybePrimeWheelZoom, queryCalendarElement, weekRowStartForDate, // Zoom animations zoomInCalendar, zoomToMonth, zoomOutCalendar, onCalendarHierarchyWheel, setCalendarZoomLevel, onCalendarZoomSliderInput, // Navigation shiftCalendar, setToday, pickDate, openDayView, openWeekView, openYearMonth, formatYearMonthFirst, // Event creation commEventForm, commEventMode, commEventSaving, commEventError, createCommEvent, openCommEventModal, closeCommEventModal, setDefaultCommEventForm, buildCommEventTitle, // Event archival eventCloseOpen, eventCloseDraft, eventCloseSaving, eventCloseError, canManuallyCloseEvent, isEventCloseOpen, toggleEventClose, archiveEventManually, // Refetch refetchCalendar, }; }