1438 lines
50 KiB
TypeScript
1438 lines
50 KiB
TypeScript
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;
|
||
|
||
console.warn("[CALENDAR ZOOM-OUT] refs:", { flyEl: !!flyEl, wrapEl: !!wrapEl, sceneEl: !!sceneEl, flyLabelEl: !!flyLabelEl, toolbarLabelEl: !!toolbarLabelEl });
|
||
if (!flyEl || !wrapEl) {
|
||
console.warn("[CALENDAR ZOOM-OUT] SKIPPED — missing ref:", { flyEl: !!flyEl, wrapEl: !!wrapEl });
|
||
apply();
|
||
calendarZoomBusy.value = false;
|
||
return;
|
||
}
|
||
|
||
try {
|
||
console.warn("[CALENDAR ZOOM-OUT] ANIMATING...");
|
||
const wrapRect = wrapEl.getBoundingClientRect();
|
||
const flyLabelText = calendarPeriodLabel.value;
|
||
|
||
// 1. Fade out current scene
|
||
if (sceneEl) {
|
||
await calendarTweenTo(sceneEl, { opacity: 0, duration: CALENDAR_FADE_DURATION, ease: "power2.in" });
|
||
}
|
||
|
||
// 2. Position fly rect at full viewport
|
||
gsap.set(flyEl, { left: 0, top: 0, width: wrapRect.width, height: wrapRect.height, opacity: 1 });
|
||
flyEl.style.borderRadius = "0.75rem";
|
||
flyEl.style.borderWidth = "1px";
|
||
flyEl.style.borderStyle = "solid";
|
||
flyEl.style.borderColor = "color-mix(in oklab, var(--color-base-300) 100%, transparent)";
|
||
flyEl.style.backgroundColor = "color-mix(in oklab, var(--color-base-100) 100%, transparent)";
|
||
flyEl.style.boxShadow = "";
|
||
flyEl.innerHTML = buildFlyRectSkeletonContent();
|
||
calendarFlyVisible.value = true;
|
||
|
||
// 3. Position flying label at toolbar
|
||
let flyLabelReady = false;
|
||
if (flyLabelEl && toolbarLabelEl && flyLabelText) {
|
||
const sectionRect = (flyLabelEl.offsetParent as HTMLElement | null)?.getBoundingClientRect();
|
||
if (sectionRect) {
|
||
const toolbarStyle = getComputedStyle(toolbarLabelEl);
|
||
const toolbarTextRange = document.createRange();
|
||
toolbarTextRange.selectNodeContents(toolbarLabelEl);
|
||
const toolbarTextRect = toolbarTextRange.getBoundingClientRect();
|
||
flyLabelEl.textContent = flyLabelText;
|
||
flyLabelEl.style.fontWeight = toolbarStyle.fontWeight;
|
||
flyLabelEl.style.color = toolbarStyle.color;
|
||
gsap.set(flyLabelEl, {
|
||
left: toolbarTextRect.left - sectionRect.left,
|
||
top: toolbarTextRect.top - sectionRect.top,
|
||
fontSize: parseFloat(toolbarStyle.fontSize),
|
||
opacity: 1,
|
||
});
|
||
toolbarLabelEl.style.opacity = "0";
|
||
calendarFlyLabelVisible.value = true;
|
||
flyLabelReady = true;
|
||
}
|
||
}
|
||
|
||
// 4. Switch to parent view
|
||
apply();
|
||
await nextTick();
|
||
await nextAnimationFrame();
|
||
|
||
// 5. Find target element
|
||
const targetElement = resolveTarget();
|
||
const targetRect = targetElement?.getBoundingClientRect() ?? null;
|
||
const viewAfter = calendarView.value;
|
||
|
||
if (targetElement && targetRect && targetRect.width >= 2 && targetRect.height >= 2) {
|
||
cloneElementStyleToFlyRect(targetElement, flyEl);
|
||
targetElement.style.opacity = "0";
|
||
|
||
const tgtLeft = targetRect.left - wrapRect.left;
|
||
const tgtTop = targetRect.top - wrapRect.top;
|
||
|
||
const targetTitleEl = findSourceTitleElement(targetElement, viewAfter);
|
||
let flyLabelPromise: Promise<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;
|
||
|
||
console.warn("[CALENDAR ZOOM-IN] refs:", { sourceElement: !!sourceElement, flyEl: !!flyEl, wrapEl: !!wrapEl, scrollEl: !!scrollEl, sceneEl: !!sceneEl, flyLabelEl: !!flyLabelEl, toolbarLabelEl: !!toolbarLabelEl });
|
||
if (!sourceElement || !flyEl || !wrapEl || !scrollEl) {
|
||
console.warn("[CALENDAR ZOOM-IN] SKIPPED — missing ref:", { sourceElement: !!sourceElement, flyEl: !!flyEl, wrapEl: !!wrapEl, scrollEl: !!scrollEl });
|
||
apply();
|
||
calendarZoomBusy.value = false;
|
||
return;
|
||
}
|
||
|
||
try {
|
||
console.warn("[CALENDAR ZOOM-IN] ANIMATING...");
|
||
const wrapRect = wrapEl.getBoundingClientRect();
|
||
const sourceRect = sourceElement.getBoundingClientRect();
|
||
if (sourceRect.width < 2 || sourceRect.height < 2) {
|
||
apply();
|
||
return;
|
||
}
|
||
|
||
// 1. Find source title element and extract label
|
||
const viewBefore = calendarView.value;
|
||
const labelText = extractSourceLabel(sourceElement, viewBefore);
|
||
const sourceTitleEl = findSourceTitleElement(sourceElement, viewBefore);
|
||
|
||
// 2. Fade out siblings (cards + external titles + week numbers)
|
||
const allFadable = Array.from(
|
||
sceneEl?.querySelectorAll<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) {
|
||
const el = queryCalendarElement(`[data-calendar-month-index="${monthIndex}"]`);
|
||
console.warn("[CALENDAR] zoomToMonth called, monthIndex:", monthIndex, "sourceEl:", el, "wrapRef:", calendarContentWrapRef.value);
|
||
await animateCalendarZoomIntoSource(el, () => {
|
||
openYearMonth(monthIndex);
|
||
});
|
||
}
|
||
|
||
async function zoomOutCalendar() {
|
||
focusedCalendarEventId.value = "";
|
||
clearCalendarZoomPrime();
|
||
|
||
if (calendarView.value === "day") {
|
||
const targetDayKey = selectedDateKey.value;
|
||
await animateCalendarFlipTransition(
|
||
queryCalendarElement(`[data-calendar-month-index="${calendarCursor.value.getMonth()}"]`),
|
||
() => {
|
||
calendarView.value = "week";
|
||
},
|
||
() =>
|
||
queryCalendarElement(`[data-calendar-layer="week"] [data-calendar-day-key="${targetDayKey}"]`) ??
|
||
queryCalendarElement(`[data-calendar-layer="week"] [data-calendar-day-key]`),
|
||
);
|
||
return;
|
||
}
|
||
|
||
if (calendarView.value === "week") {
|
||
const targetRowKey = weekRowStartForDate(selectedDateKey.value);
|
||
await animateCalendarFlipTransition(
|
||
queryCalendarElement(`[data-calendar-month-index="${calendarCursor.value.getMonth()}"]`),
|
||
() => {
|
||
calendarView.value = "month";
|
||
},
|
||
() =>
|
||
queryCalendarElement(`[data-calendar-layer="month"] [data-calendar-week-start-key="${targetRowKey}"]`) ??
|
||
queryCalendarElement(`[data-calendar-layer="month"] [data-calendar-day-key="${selectedDateKey.value}"]`) ??
|
||
queryCalendarElement(`[data-calendar-layer="month"] [data-calendar-week-start-key]`) ??
|
||
queryCalendarElement(`[data-calendar-layer="month"] [data-calendar-day-key]`),
|
||
);
|
||
return;
|
||
}
|
||
|
||
if (calendarView.value === "month" || calendarView.value === "agenda") {
|
||
const targetMonthIndex = calendarCursor.value.getMonth();
|
||
await animateCalendarFlipTransition(
|
||
queryCalendarElement(`[data-calendar-month-index="${targetMonthIndex}"]`),
|
||
() => {
|
||
calendarView.value = "year";
|
||
},
|
||
() => queryCalendarElement(`[data-calendar-month-index="${targetMonthIndex}"]`),
|
||
);
|
||
}
|
||
}
|
||
|
||
function onCalendarHierarchyWheel(event: WheelEvent) {
|
||
const now = Date.now();
|
||
if (calendarZoomBusy.value) return;
|
||
if (now < calendarWheelLockUntil) return;
|
||
if (Math.abs(event.deltaY) < 5) return;
|
||
calendarWheelLockUntil = now + 140;
|
||
|
||
if (event.deltaY < 0) {
|
||
void zoomInCalendar(event);
|
||
return;
|
||
}
|
||
|
||
void zoomOutCalendar();
|
||
}
|
||
|
||
async function setCalendarZoomLevel(targetView: CalendarHierarchyView) {
|
||
let currentIndex = calendarZoomOrder.indexOf(normalizedCalendarView.value);
|
||
const targetIndex = calendarZoomOrder.indexOf(targetView);
|
||
if (currentIndex < 0 || targetIndex < 0 || currentIndex === targetIndex) return;
|
||
|
||
while (currentIndex !== targetIndex) {
|
||
if (targetIndex > currentIndex) {
|
||
await zoomInCalendar();
|
||
} else {
|
||
await zoomOutCalendar();
|
||
}
|
||
currentIndex = calendarZoomOrder.indexOf(normalizedCalendarView.value);
|
||
}
|
||
}
|
||
|
||
function onCalendarZoomSliderInput(event: Event) {
|
||
const value = Number((event.target as HTMLInputElement | null)?.value ?? NaN);
|
||
if (!Number.isFinite(value)) return;
|
||
const targetIndex = Math.max(0, Math.min(3, Math.round(value)));
|
||
const targetView = calendarZoomOrder[targetIndex];
|
||
if (!targetView) return;
|
||
void setCalendarZoomLevel(targetView);
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Month cells, rows, week days
|
||
// ---------------------------------------------------------------------------
|
||
const monthCells = computed(() => {
|
||
const year = calendarCursor.value.getFullYear();
|
||
const month = calendarCursor.value.getMonth();
|
||
const first = new Date(year, month, 1);
|
||
const start = new Date(year, month, 1 - first.getDay());
|
||
|
||
return Array.from({ length: 42 }, (_, index) => {
|
||
const d = new Date(start);
|
||
d.setDate(start.getDate() + index);
|
||
const key = dayKey(d);
|
||
|
||
return {
|
||
key,
|
||
day: d.getDate(),
|
||
inMonth: d.getMonth() === month,
|
||
events: getEventsByDate(key),
|
||
};
|
||
});
|
||
});
|
||
|
||
const monthRows = computed(() => {
|
||
const rows: Array<{ key: string; startKey: string; weekNumber: number; cells: typeof monthCells.value }> = [];
|
||
for (let index = 0; index < monthCells.value.length; index += 7) {
|
||
const cells = monthCells.value.slice(index, index + 7);
|
||
if (!cells.length) continue;
|
||
const startKey = cells[0]?.key ?? selectedDateKey.value;
|
||
rows.push({
|
||
key: `${cells[0]?.key ?? index}-week-row`,
|
||
startKey,
|
||
weekNumber: isoWeekNumber(startKey),
|
||
cells,
|
||
});
|
||
}
|
||
return rows;
|
||
});
|
||
|
||
function monthCellHasFocusedEvent(events: CalendarEvent[]) {
|
||
const id = focusedCalendarEventId.value.trim();
|
||
if (!id) return false;
|
||
return events.some((event) => event.id === id);
|
||
}
|
||
|
||
function monthCellEvents(events: CalendarEvent[]) {
|
||
const id = focusedCalendarEventId.value.trim();
|
||
if (!id) return events.slice(0, 2);
|
||
const focused = events.find((event) => event.id === id);
|
||
if (!focused) return events.slice(0, 2);
|
||
const rest = events.filter((event) => event.id !== id).slice(0, 1);
|
||
return [focused, ...rest];
|
||
}
|
||
|
||
const weekDays = computed(() => {
|
||
const base = new Date(`${selectedDateKey.value}T00:00:00`);
|
||
const mondayOffset = (base.getDay() + 6) % 7;
|
||
const monday = new Date(base);
|
||
monday.setDate(base.getDate() - mondayOffset);
|
||
|
||
return Array.from({ length: 7 }, (_, index) => {
|
||
const d = new Date(monday);
|
||
d.setDate(monday.getDate() + index);
|
||
const key = dayKey(d);
|
||
|
||
return {
|
||
key,
|
||
label: new Intl.DateTimeFormat("en-US", { weekday: "short" }).format(d),
|
||
day: d.getDate(),
|
||
events: getEventsByDate(key),
|
||
};
|
||
});
|
||
});
|
||
|
||
const calendarPeriodLabel = computed(() => {
|
||
if (calendarView.value === "month") {
|
||
return monthLabel.value;
|
||
}
|
||
|
||
if (calendarView.value === "year") {
|
||
return String(calendarCursor.value.getFullYear());
|
||
}
|
||
|
||
if (calendarView.value === "week") {
|
||
const first = weekDays.value[0];
|
||
const last = weekDays.value[weekDays.value.length - 1];
|
||
if (!first || !last) return "";
|
||
return `${formatDay(`${first.key}T00:00:00`)} - ${formatDay(`${last.key}T00:00:00`)}`;
|
||
}
|
||
|
||
if (calendarView.value === "day") {
|
||
return formatDay(`${selectedDateKey.value}T00:00:00`);
|
||
}
|
||
|
||
return `Agenda · ${monthLabel.value}`;
|
||
});
|
||
|
||
const yearMonths = computed(() => {
|
||
const year = calendarCursor.value.getFullYear();
|
||
return Array.from({ length: 12 }, (_, monthIndex) => {
|
||
const monthStart = new Date(year, monthIndex, 1);
|
||
const monthEnd = new Date(year, monthIndex + 1, 1);
|
||
const items = sortedEvents.value.filter((event) => {
|
||
const d = new Date(event.start);
|
||
return d >= monthStart && d < monthEnd;
|
||
});
|
||
|
||
return {
|
||
monthIndex,
|
||
label: new Intl.DateTimeFormat("en-US", { month: "long" }).format(monthStart),
|
||
count: items.length,
|
||
first: items[0],
|
||
};
|
||
});
|
||
});
|
||
|
||
const selectedDayEvents = computed(() => getEventsByDate(selectedDateKey.value));
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Navigation helpers
|
||
// ---------------------------------------------------------------------------
|
||
function shiftCalendar(step: number) {
|
||
focusedCalendarEventId.value = "";
|
||
if (calendarView.value === "year") {
|
||
const next = new Date(calendarCursor.value);
|
||
next.setFullYear(next.getFullYear() + step);
|
||
calendarCursor.value = new Date(next.getFullYear(), next.getMonth(), 1);
|
||
const selected = new Date(`${selectedDateKey.value}T00:00:00`);
|
||
selected.setFullYear(selected.getFullYear() + step);
|
||
selectedDateKey.value = dayKey(selected);
|
||
return;
|
||
}
|
||
|
||
if (calendarView.value === "month" || calendarView.value === "agenda") {
|
||
const next = new Date(calendarCursor.value);
|
||
next.setMonth(next.getMonth() + step);
|
||
calendarCursor.value = new Date(next.getFullYear(), next.getMonth(), 1);
|
||
return;
|
||
}
|
||
|
||
const current = new Date(`${selectedDateKey.value}T00:00:00`);
|
||
const days = calendarView.value === "week" ? 7 : 1;
|
||
current.setDate(current.getDate() + days * step);
|
||
|
||
selectedDateKey.value = dayKey(current);
|
||
calendarCursor.value = new Date(current.getFullYear(), current.getMonth(), 1);
|
||
}
|
||
|
||
function setToday() {
|
||
focusedCalendarEventId.value = "";
|
||
const now = new Date();
|
||
selectedDateKey.value = dayKey(now);
|
||
calendarCursor.value = new Date(now.getFullYear(), now.getMonth(), 1);
|
||
}
|
||
|
||
function pickDate(key: string) {
|
||
focusedCalendarEventId.value = "";
|
||
selectedDateKey.value = key;
|
||
const d = new Date(`${key}T00:00:00`);
|
||
calendarCursor.value = new Date(d.getFullYear(), d.getMonth(), 1);
|
||
}
|
||
|
||
function openDayView(key: string) {
|
||
pickDate(key);
|
||
calendarView.value = "day";
|
||
}
|
||
|
||
function openWeekView(key: string) {
|
||
pickDate(key);
|
||
calendarView.value = "week";
|
||
}
|
||
|
||
function openYearMonth(monthIndex: number) {
|
||
focusedCalendarEventId.value = "";
|
||
const year = calendarCursor.value.getFullYear();
|
||
calendarCursor.value = new Date(year, monthIndex, 1);
|
||
selectedDateKey.value = dayKey(new Date(year, monthIndex, 1));
|
||
calendarView.value = "month";
|
||
}
|
||
|
||
function formatYearMonthFirst(item: { first?: CalendarEvent }) {
|
||
if (!item.first) return "";
|
||
return `${formatDay(item.first.start)} · ${item.first.title}`;
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Event creation
|
||
// ---------------------------------------------------------------------------
|
||
const commEventForm = ref({
|
||
startDate: "",
|
||
startTime: "",
|
||
durationMinutes: 30,
|
||
});
|
||
const commEventMode = ref<"planned" | "logged">("planned");
|
||
const commEventSaving = ref(false);
|
||
const commEventError = ref("");
|
||
|
||
function setDefaultCommEventForm(mode: "planned" | "logged") {
|
||
const start = mode === "planned"
|
||
? roundToNextQuarter(new Date(Date.now() + 15 * 60 * 1000))
|
||
: roundToPrevQuarter(new Date(Date.now() - 30 * 60 * 1000));
|
||
commEventForm.value = {
|
||
startDate: toInputDate(start),
|
||
startTime: toInputTime(start),
|
||
durationMinutes: 30,
|
||
};
|
||
}
|
||
|
||
function buildCommEventTitle(text: string, mode: "planned" | "logged", contact: string) {
|
||
const cleaned = text.replace(/\s+/g, " ").trim();
|
||
if (cleaned) {
|
||
const sentence = cleaned.split(/[.!?\n]/)[0]?.trim() ?? "";
|
||
if (sentence) return sentence.slice(0, 120);
|
||
}
|
||
return mode === "logged" ? `Отчёт по контакту ${contact}` : `Событие с ${contact}`;
|
||
}
|
||
|
||
function openCommEventModal(mode: "planned" | "logged", hasThread: boolean) {
|
||
if (!hasThread) return;
|
||
commEventMode.value = mode;
|
||
setDefaultCommEventForm(mode);
|
||
commEventError.value = "";
|
||
}
|
||
|
||
function closeCommEventModal() {
|
||
if (commEventSaving.value) return;
|
||
commEventError.value = "";
|
||
}
|
||
|
||
async function createCommEvent(contactName: string, draftText: string) {
|
||
if (!contactName || commEventSaving.value) return;
|
||
|
||
const note = draftText.trim();
|
||
const title = buildCommEventTitle(note, commEventMode.value, contactName);
|
||
const duration = Number(commEventForm.value.durationMinutes || 0);
|
||
if (!note) {
|
||
commEventError.value = "Текст события обязателен";
|
||
return;
|
||
}
|
||
|
||
if (!commEventForm.value.startDate || !commEventForm.value.startTime) {
|
||
commEventError.value = "Date and time are required";
|
||
return;
|
||
}
|
||
|
||
const start = new Date(`${commEventForm.value.startDate}T${commEventForm.value.startTime}:00`);
|
||
if (Number.isNaN(start.getTime())) {
|
||
commEventError.value = "Invalid date or time";
|
||
return;
|
||
}
|
||
|
||
const safeDuration = Number.isFinite(duration) && duration > 0 ? duration : 30;
|
||
const end = new Date(start);
|
||
end.setMinutes(end.getMinutes() + safeDuration);
|
||
|
||
commEventSaving.value = true;
|
||
commEventError.value = "";
|
||
try {
|
||
const res = await doCreateCalendarEvent({
|
||
input: {
|
||
title,
|
||
start: start.toISOString(),
|
||
end: end.toISOString(),
|
||
contact: contactName,
|
||
note,
|
||
archived: commEventMode.value === "logged",
|
||
archiveNote: commEventMode.value === "logged" ? note : undefined,
|
||
},
|
||
});
|
||
if (res?.data?.createCalendarEvent) {
|
||
calendarEvents.value = [res.data.createCalendarEvent as CalendarEvent, ...calendarEvents.value];
|
||
}
|
||
selectedDateKey.value = dayKey(start);
|
||
calendarCursor.value = new Date(start.getFullYear(), start.getMonth(), 1);
|
||
commEventError.value = "";
|
||
return true;
|
||
} catch (error: any) {
|
||
commEventError.value = String(error?.message ?? error ?? "Failed to create event");
|
||
return false;
|
||
} finally {
|
||
commEventSaving.value = false;
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Event archival
|
||
// ---------------------------------------------------------------------------
|
||
const eventCloseOpen = ref<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,
|
||
};
|
||
}
|