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"; import type { CalendarEvent, CalendarView } from "~/composables/crm-types"; import { dayKey, formatDay, formatTime, toInputDate, toInputTime, roundToNextQuarter, roundToPrevQuarter, isEventFinalStatus, } from "~/composables/crm-types"; type CalendarHierarchyView = "year" | "month" | "week" | "day"; type CalendarRect = { left: number; top: number; width: number; height: number }; 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; } 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 calendarViewportHeight = ref(0); const calendarHoveredMonthIndex = ref(null); const calendarHoveredWeekStartKey = ref(""); const calendarHoveredDayKey = ref(""); let calendarViewportResizeObserver: ResizeObserver | null = null; 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 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(); } const calendarZoomBusy = ref(false); const calendarCameraState = ref({ active: false, left: 0, top: 0, scale: 1, durationMs: 0, }); 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_DURATION_MS = 2400; 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))); const calendarSceneTransformStyle = computed(() => { if (!calendarCameraState.value.active) return undefined; return { transform: `translate(${calendarCameraState.value.left}px, ${calendarCameraState.value.top}px) scale(${calendarCameraState.value.scale})`, transformOrigin: "0 0", transition: calendarCameraState.value.durationMs > 0 ? `transform ${calendarCameraState.value.durationMs}ms cubic-bezier(0.16, 0.86, 0.18, 1)` : "none", willChange: "transform", }; }); 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 getCalendarViewportRect(): CalendarRect | null { const wrapRect = calendarContentWrapRef.value?.getBoundingClientRect(); if (!wrapRect) return null; return { left: 0, top: 0, width: Math.max(24, wrapRect.width), height: Math.max(24, wrapRect.height), }; } function getCalendarCameraViewportRect() { const viewport = calendarContentScrollRef.value?.getBoundingClientRect(); if (!viewport) return null; return { width: Math.max(24, viewport.width), height: Math.max(24, viewport.height), }; } function getElementRectInCalendar(element: HTMLElement | null): CalendarRect | null { if (!element) return null; const wrapRect = calendarContentWrapRef.value?.getBoundingClientRect(); if (!wrapRect) return null; const rect = element.getBoundingClientRect(); const left = Math.max(0, Math.min(rect.left - wrapRect.left, wrapRect.width)); const top = Math.max(0, Math.min(rect.top - wrapRect.top, wrapRect.height)); const right = Math.max(0, Math.min(rect.right - wrapRect.left, wrapRect.width)); const bottom = Math.max(0, Math.min(rect.bottom - wrapRect.top, wrapRect.height)); const visibleWidth = right - left; const visibleHeight = bottom - top; if (visibleWidth < 2 || visibleHeight < 2) return null; const width = Math.min(Math.max(24, visibleWidth), wrapRect.width - left); const height = Math.min(Math.max(24, visibleHeight), wrapRect.height - top); return { left, top, width, height }; } function getElementRectInScene(element: HTMLElement | null): CalendarRect | null { if (!element) return null; const sceneRect = calendarSceneRef.value?.getBoundingClientRect(); if (!sceneRect) return null; const rect = element.getBoundingClientRect(); const left = rect.left - sceneRect.left; const top = rect.top - sceneRect.top; const width = Math.max(24, rect.width); const height = Math.max(24, rect.height); return { left, top, width, height }; } function fallbackZoomOriginRectInScene(): CalendarRect | null { const viewport = getCalendarCameraViewportRect(); const scroll = calendarContentScrollRef.value; if (!viewport || !scroll) return null; const width = Math.max(96, Math.round(viewport.width * 0.28)); const height = Math.max(64, Math.round(viewport.height * 0.24)); return { left: scroll.scrollLeft + Math.max(0, (viewport.width - width) / 2), top: scroll.scrollTop + Math.max(0, (viewport.height - height) / 2), width, height, }; } function weekRowStartForDate(key: string) { const date = new Date(`${key}T00:00:00`); date.setDate(date.getDate() - date.getDay()); return dayKey(date); } function nextAnimationFrame() { return new Promise((resolve) => { requestAnimationFrame(() => resolve()); }); } function waitForTransformTransition(element: HTMLElement) { return new Promise((resolve) => { let settled = false; const finish = () => { if (settled) return; settled = true; element.removeEventListener("transitionend", onTransitionEnd); clearTimeout(fallbackTimer); resolve(); }; const onTransitionEnd = (event: TransitionEvent) => { if (event.target !== element) return; if (event.propertyName !== "transform") return; finish(); }; const fallbackTimer = setTimeout(() => finish(), CALENDAR_ZOOM_DURATION_MS + 160); element.addEventListener("transitionend", onTransitionEnd); }); } function fadeOutCalendarSiblings(sourceElement: HTMLElement) { const scene = calendarSceneRef.value; if (!scene) return () => {}; const targets = Array.from(scene.querySelectorAll(".calendar-hover-targetable")); const siblings = targets.filter((element) => { if (element === sourceElement) return false; if (sourceElement.contains(element)) return false; if (element.contains(sourceElement)) return false; return true; }); const snapshots = siblings.map((element) => ({ element, opacity: element.style.opacity, pointerEvents: element.style.pointerEvents, transition: element.style.transition, })); for (const { element } of snapshots) { element.style.transition = "opacity 180ms ease"; element.style.opacity = "0"; element.style.pointerEvents = "none"; } return () => { for (const snapshot of snapshots) { snapshot.element.style.opacity = snapshot.opacity; snapshot.element.style.pointerEvents = snapshot.pointerEvents; snapshot.element.style.transition = snapshot.transition; } }; } function isRenderableRect(rect: DOMRect | null) { return Boolean(rect && rect.width >= 2 && rect.height >= 2); } async function animateCalendarFlipTransition( sourceElement: HTMLElement | null, apply: () => void, resolveTarget: () => HTMLElement | null, ) { clearCalendarZoomPrime(); calendarZoomBusy.value = true; let restoreSiblings = () => {}; let animatedElement: HTMLElement | null = null; let snapshot: { transform: string; transition: string; transformOrigin: string; willChange: string; zIndex: string; } | null = null; try { const sourceRect = sourceElement?.getBoundingClientRect() ?? null; apply(); await nextTick(); const targetElement = resolveTarget(); const targetRect = targetElement?.getBoundingClientRect() ?? null; if (!targetElement || !isRenderableRect(sourceRect) || !isRenderableRect(targetRect)) return; restoreSiblings = fadeOutCalendarSiblings(targetElement); animatedElement = targetElement; snapshot = { transform: targetElement.style.transform, transition: targetElement.style.transition, transformOrigin: targetElement.style.transformOrigin, willChange: targetElement.style.willChange, zIndex: targetElement.style.zIndex, }; const dx = sourceRect!.left - targetRect!.left; const dy = sourceRect!.top - targetRect!.top; const sx = Math.max(0.01, sourceRect!.width / targetRect!.width); const sy = Math.max(0.01, sourceRect!.height / targetRect!.height); targetElement.style.transformOrigin = "top left"; targetElement.style.willChange = "transform"; targetElement.style.zIndex = "24"; targetElement.style.transition = "none"; targetElement.style.transform = `translate3d(${dx}px, ${dy}px, 0px) scale(${sx}, ${sy})`; targetElement.getBoundingClientRect(); await nextAnimationFrame(); targetElement.style.transition = `transform ${CALENDAR_ZOOM_DURATION_MS}ms cubic-bezier(0.16, 0.86, 0.18, 1)`; targetElement.style.transform = "translate3d(0px, 0px, 0px) scale(1, 1)"; await waitForTransformTransition(targetElement); } finally { if (animatedElement && snapshot) { animatedElement.style.transform = snapshot.transform; animatedElement.style.transition = snapshot.transition; animatedElement.style.transformOrigin = snapshot.transformOrigin; animatedElement.style.willChange = snapshot.willChange; animatedElement.style.zIndex = snapshot.zIndex; } restoreSiblings(); calendarZoomBusy.value = false; } } async function animateCalendarZoomIntoSource( sourceElement: HTMLElement | null, apply: () => void, ) { clearCalendarZoomPrime(); calendarZoomBusy.value = true; let restoreSiblings = () => {}; let snapshot: { transform: string; transition: string; transformOrigin: string; willChange: string; zIndex: string; } | null = null; try { const viewportRect = calendarContentScrollRef.value?.getBoundingClientRect() ?? null; const sourceRect = sourceElement?.getBoundingClientRect() ?? null; if (!sourceElement || !isRenderableRect(viewportRect) || !isRenderableRect(sourceRect)) { apply(); return; } restoreSiblings = fadeOutCalendarSiblings(sourceElement); snapshot = { transform: sourceElement.style.transform, transition: sourceElement.style.transition, transformOrigin: sourceElement.style.transformOrigin, willChange: sourceElement.style.willChange, zIndex: sourceElement.style.zIndex, }; const dx = viewportRect!.left - sourceRect!.left; const dy = viewportRect!.top - sourceRect!.top; const sx = Math.max(0.01, viewportRect!.width / sourceRect!.width); const sy = Math.max(0.01, viewportRect!.height / sourceRect!.height); sourceElement.style.transformOrigin = "top left"; sourceElement.style.willChange = "transform"; sourceElement.style.zIndex = "24"; sourceElement.style.transition = "none"; sourceElement.style.transform = "translate3d(0px, 0px, 0px) scale(1, 1)"; sourceElement.getBoundingClientRect(); await nextAnimationFrame(); sourceElement.style.transition = `transform ${CALENDAR_ZOOM_DURATION_MS}ms cubic-bezier(0.16, 0.86, 0.18, 1)`; sourceElement.style.transform = `translate3d(${dx}px, ${dy}px, 0px) scale(${sx}, ${sy})`; await waitForTransformTransition(sourceElement); apply(); await nextTick(); await nextAnimationFrame(); } finally { if (sourceElement && snapshot) { sourceElement.style.transform = snapshot.transform; sourceElement.style.transition = snapshot.transition; sourceElement.style.transformOrigin = snapshot.transformOrigin; sourceElement.style.willChange = snapshot.willChange; sourceElement.style.zIndex = snapshot.zIndex; } restoreSiblings(); 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) { await animateCalendarZoomIntoSource(queryCalendarElement(`[data-calendar-month-index="${monthIndex}"]`), () => { 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; 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; rows.push({ key: `${cells[0]?.key ?? index}-week-row`, startKey: cells[0]?.key ?? selectedDateKey.value, 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, calendarViewportHeight, calendarHoveredMonthIndex, calendarHoveredWeekStartKey, calendarHoveredDayKey, calendarZoomBusy, calendarCameraState, calendarZoomPrimeToken, calendarZoomPrimeScale, calendarZoomPrimeTicks, normalizedCalendarView, calendarZoomLevelIndex, calendarSceneTransformStyle, calendarZoomOrder, // Zoom / camera setters setCalendarContentWrapRef, setCalendarContentScrollRef, setCalendarSceneRef, setCalendarHoveredMonthIndex, setCalendarHoveredWeekStartKey, setCalendarHoveredDayKey, onCalendarSceneMouseLeave, clearCalendarZoomPrime, calendarPrimeMonthToken, calendarPrimeWeekToken, calendarPrimeDayToken, calendarPrimeStyle, maybePrimeWheelZoom, queryCalendarElement, getCalendarViewportRect, getCalendarCameraViewportRect, getElementRectInCalendar, getElementRectInScene, fallbackZoomOriginRectInScene, 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, }; }