Files
clientsflow/frontend/app/composables/useCalendar.ts
Ruslan Bakiev a4d8d81de9 refactor: decompose CrmWorkspaceApp.vue into 15 composables
Split the 6000+ line monolithic component into modular composables:
- crm-types.ts: shared types and utility functions
- useAuth, useContacts, useContactInboxes, useCalendar, useDeals,
  useDocuments, useFeed, useTimeline, usePilotChat, useCallAudio,
  usePins, useChangeReview, useCrmRealtime, useWorkspaceRouting
CrmWorkspaceApp.vue is now a thin orchestrator (~2500 lines) that
wires composables together with glue code, keeping template and
styles intact.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 15:05:01 +07:00

1130 lines
40 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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<boolean> }) {
// ---------------------------------------------------------------------------
// 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<CalendarEvent[]>([]);
const calendarView = ref<CalendarView>("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<typeof setInterval> | 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<string, CalendarEvent[]>();
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<HTMLElement | null>(null);
const calendarContentScrollRef = ref<HTMLElement | null>(null);
const calendarSceneRef = ref<HTMLElement | null>(null);
const calendarViewportHeight = ref(0);
const calendarHoveredMonthIndex = ref<number | null>(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<typeof setTimeout> | 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<CalendarHierarchyView>(() =>
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<HTMLElement>(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<void>((resolve) => {
requestAnimationFrame(() => resolve());
});
}
function waitForTransformTransition(element: HTMLElement) {
return new Promise<void>((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<HTMLElement>(".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<HTMLElement>("[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<HTMLElement>("[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<HTMLElement>("[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<Record<string, boolean>>({});
const eventCloseDraft = ref<Record<string, string>>({});
const eventCloseSaving = ref<Record<string, boolean>>({});
const eventCloseError = ref<Record<string, string>>({});
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,
};
}