Files
clientsflow/frontend/app/composables/useCalendar.ts
Ruslan Bakiev c229bdee23 fix(calendar): restore GSAP fly-rect + fly-label animation in useCalendar composable
The refactoring in a4d8d81 moved calendar logic into useCalendar.ts but
used the old CSS-transform animation code instead of the GSAP-based
flying rect + flying label implementation. This restores:

- GSAP-based animateCalendarZoomIntoSource and animateCalendarFlipTransition
- Flying label that animates from card title → toolbar on zoom-in and back
- Clone-and-swap pattern with skeleton content in fly-rect (no text)
- Fly-rect/fly-label refs and setters now live in the composable
- isoWeekNumber() and weekNumber field on monthRows
- Sibling card titles and week numbers faded during zoom
- Removed old CSS-transform camera state and calendarSceneTransformStyle

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

1430 lines
49 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 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<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;
}
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<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 calendarFlyRectRef = ref<HTMLDivElement | null>(null);
const calendarFlyVisible = ref(false);
const calendarFlyLabelRef = ref<HTMLDivElement | null>(null);
const calendarFlyLabelVisible = ref(false);
const calendarToolbarLabelRef = ref<HTMLDivElement | null>(null);
const calendarViewportHeight = ref(0);
const calendarHoveredMonthIndex = ref<number | null>(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<void> {
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<typeof setTimeout> | 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<CalendarHierarchyView>(() =>
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<HTMLElement>(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<void>((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<HTMLElement>(".calendar-card-title") ?? null;
}
if (viewBefore === "month" || viewBefore === "agenda") {
return sourceElement.querySelector<HTMLElement>(".calendar-week-number") ?? null;
}
if (viewBefore === "week") {
return sourceElement.parentElement?.querySelector<HTMLElement>(".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 `<div class="calendar-fly-content"><div class="calendar-fly-skeleton">
<div class="calendar-fly-skeleton-line" style="width:70%"></div>
<div class="calendar-fly-skeleton-line" style="width:45%"></div>
<div class="calendar-fly-skeleton-line" style="width:60%"></div>
</div></div>`;
}
// ---------------------------------------------------------------------------
// 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;
if (!flyEl || !wrapEl) {
apply();
calendarZoomBusy.value = false;
return;
}
try {
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<void> | 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;
if (!sourceElement || !flyEl || !wrapEl || !scrollEl) {
apply();
calendarZoomBusy.value = false;
return;
}
try {
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<HTMLElement>(".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<void> | 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<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; 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<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,
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,
};
}