diff --git a/frontend/app.vue b/frontend/app.vue index ab9cf6b..d0d8594 100644 --- a/frontend/app.vue +++ b/frontend/app.vue @@ -2074,7 +2074,7 @@ onBeforeUnmount(() => { } }); -const calendarView = ref("month"); +const calendarView = ref("year"); const calendarCursor = ref(new Date(new Date().getFullYear(), new Date().getMonth(), 1)); const selectedDateKey = ref(dayKey(new Date())); @@ -2115,118 +2115,112 @@ const calendarViewOptions: { value: CalendarView; label: string }[] = [ { value: "agenda", label: "Agenda" }, ]; -const calendarZoomLevel = computed({ - get() { - if (calendarView.value === "year") return 1; - if (calendarView.value === "month" || calendarView.value === "agenda") return 2; - if (calendarView.value === "week") return 3; - return 4; - }, - set(next) { - const level = Math.max(1, Math.min(4, Number(next) || 2)); - if (level === 1) { - calendarView.value = "year"; - return; - } - if (level === 2) { - calendarView.value = "month"; - return; - } - if (level === 3) { - calendarView.value = "week"; - return; - } - calendarView.value = "day"; - }, +type CalendarTransitionDirection = "in" | "out" | "side"; + +const calendarTransitionDirection = ref("side"); +const calendarHoveredMonthIndex = ref(null); +const calendarHoveredWeekStartKey = ref(""); +const calendarHoveredDayKey = ref(""); +let calendarWheelLockUntil = 0; + +const calendarSceneKey = computed(() => `${calendarView.value}-${calendarRouteToken(calendarView.value)}`); +const calendarTransitionName = computed(() => { + if (calendarTransitionDirection.value === "in") return "calendar-zoom-in"; + if (calendarTransitionDirection.value === "out") return "calendar-zoom-out"; + return "calendar-zoom-side"; }); -const calendarZoomLabel = computed(() => { +const canCalendarZoomIn = computed(() => calendarView.value !== "day"); +const canCalendarZoomOut = computed(() => calendarView.value !== "year"); +const calendarZoomDepthLabel = computed(() => { if (calendarView.value === "day") return "Day"; if (calendarView.value === "week") return "Week"; if (calendarView.value === "month" || calendarView.value === "agenda") return "Month"; - return "Year"; + return "Months"; }); -const calendarPanMode = ref(false); -const calendarCanvasRef = ref(null); -const calendarCanvasScale = ref(1); -const calendarCanvasOffsetX = ref(0); -const calendarCanvasOffsetY = ref(0); -const calendarCanvasDragging = ref(false); -let calendarCanvasPointerId: number | null = null; -let calendarCanvasDragStartX = 0; -let calendarCanvasDragStartY = 0; -let calendarCanvasStartOffsetX = 0; -let calendarCanvasStartOffsetY = 0; - -const calendarCanvasStyle = computed(() => ({ - transform: `translate(${calendarCanvasOffsetX.value}px, ${calendarCanvasOffsetY.value}px) scale(${calendarCanvasScale.value})`, -})); - -function clampCalendarCanvasScale(next: number) { - return Math.max(0.72, Math.min(2.35, Number(next) || 1)); +function setCalendarTransition(direction: CalendarTransitionDirection) { + calendarTransitionDirection.value = direction; } -function resetCalendarCanvas() { - calendarCanvasScale.value = 1; - calendarCanvasOffsetX.value = 0; - calendarCanvasOffsetY.value = 0; - calendarCanvasDragging.value = false; - calendarCanvasPointerId = null; +function resolveMonthAnchor(event?: WheelEvent) { + const target = event?.target as HTMLElement | null; + const monthAttr = target?.closest("[data-calendar-month-index]")?.dataset.calendarMonthIndex; + if (monthAttr) { + const parsed = Number(monthAttr); + if (Number.isFinite(parsed) && parsed >= 0 && parsed <= 11) return parsed; + } + if (calendarHoveredMonthIndex.value !== null) return calendarHoveredMonthIndex.value; + return calendarCursor.value.getMonth(); } -function toggleCalendarPanMode() { - calendarPanMode.value = !calendarPanMode.value; - if (!calendarPanMode.value) { - calendarCanvasDragging.value = false; - calendarCanvasPointerId = null; +function resolveWeekAnchor(event?: WheelEvent) { + const target = event?.target as HTMLElement | null; + const weekKey = target?.closest("[data-calendar-week-start-key]")?.dataset.calendarWeekStartKey; + if (weekKey) return weekKey; + if (calendarHoveredWeekStartKey.value) return calendarHoveredWeekStartKey.value; + if (calendarHoveredDayKey.value) return calendarHoveredDayKey.value; + return selectedDateKey.value; +} + +function resolveDayAnchor(event?: WheelEvent) { + const target = event?.target as HTMLElement | null; + const dayKeyAttr = target?.closest("[data-calendar-day-key]")?.dataset.calendarDayKey; + if (dayKeyAttr) return dayKeyAttr; + if (calendarHoveredDayKey.value) return calendarHoveredDayKey.value; + return selectedDateKey.value; +} + +function zoomInCalendar(event?: Event) { + const wheelEvent = event instanceof WheelEvent ? event : undefined; + if (calendarView.value === "year") { + openYearMonth(resolveMonthAnchor(wheelEvent), "in"); + return; + } + + if (calendarView.value === "month" || calendarView.value === "agenda") { + openWeekView(resolveWeekAnchor(wheelEvent), "in"); + return; + } + + if (calendarView.value === "week") { + openDayView(resolveDayAnchor(wheelEvent), "in"); } } -function onCalendarCanvasWheel(event: WheelEvent) { - const host = calendarCanvasRef.value; - if (!host) return; +function zoomOutCalendar() { + focusedCalendarEventId.value = ""; - const rect = host.getBoundingClientRect(); - const pivotX = event.clientX - rect.left; - const pivotY = event.clientY - rect.top; - const prevScale = calendarCanvasScale.value; - const ratio = event.deltaY < 0 ? 1.08 : 0.92; - const nextScale = clampCalendarCanvasScale(prevScale * ratio); - if (Math.abs(nextScale - prevScale) < 0.0001) return; + if (calendarView.value === "day") { + setCalendarTransition("out"); + calendarView.value = "week"; + return; + } - const contentX = (pivotX - calendarCanvasOffsetX.value) / prevScale; - const contentY = (pivotY - calendarCanvasOffsetY.value) / prevScale; - calendarCanvasScale.value = nextScale; - calendarCanvasOffsetX.value = pivotX - contentX * nextScale; - calendarCanvasOffsetY.value = pivotY - contentY * nextScale; + if (calendarView.value === "week") { + setCalendarTransition("out"); + calendarView.value = "month"; + return; + } + + if (calendarView.value === "month" || calendarView.value === "agenda") { + setCalendarTransition("out"); + calendarView.value = "year"; + } } -function onCalendarCanvasPointerDown(event: PointerEvent) { - if (!calendarPanMode.value || event.button !== 0) return; - const target = event.currentTarget as HTMLElement | null; - if (!target) return; +function onCalendarHierarchyWheel(event: WheelEvent) { + const now = Date.now(); + if (now < calendarWheelLockUntil) return; + if (Math.abs(event.deltaY) < 5) return; + calendarWheelLockUntil = now + 240; - calendarCanvasDragging.value = true; - calendarCanvasPointerId = event.pointerId; - calendarCanvasDragStartX = event.clientX; - calendarCanvasDragStartY = event.clientY; - calendarCanvasStartOffsetX = calendarCanvasOffsetX.value; - calendarCanvasStartOffsetY = calendarCanvasOffsetY.value; - target.setPointerCapture(event.pointerId); -} + if (event.deltaY < 0) { + zoomInCalendar(event); + return; + } -function onCalendarCanvasPointerMove(event: PointerEvent) { - if (!calendarPanMode.value || !calendarCanvasDragging.value || calendarCanvasPointerId !== event.pointerId) return; - calendarCanvasOffsetX.value = calendarCanvasStartOffsetX + (event.clientX - calendarCanvasDragStartX); - calendarCanvasOffsetY.value = calendarCanvasStartOffsetY + (event.clientY - calendarCanvasDragStartY); -} - -function stopCalendarCanvasDrag(event?: PointerEvent) { - if (!calendarCanvasDragging.value) return; - if (event && calendarCanvasPointerId !== event.pointerId) return; - calendarCanvasDragging.value = false; - calendarCanvasPointerId = null; + zoomOutCalendar(); } const monthCells = computed(() => { @@ -2344,6 +2338,7 @@ const selectedDayEvents = computed(() => getEventsByDate(selectedDateKey.value)) function shiftCalendar(step: number) { focusedCalendarEventId.value = ""; + setCalendarTransition("side"); if (calendarView.value === "year") { const next = new Date(calendarCursor.value); next.setFullYear(next.getFullYear() + step); @@ -2371,6 +2366,7 @@ function shiftCalendar(step: number) { function setToday() { focusedCalendarEventId.value = ""; + setCalendarTransition("side"); const now = new Date(); selectedDateKey.value = dayKey(now); calendarCursor.value = new Date(now.getFullYear(), now.getMonth(), 1); @@ -2383,21 +2379,24 @@ function pickDate(key: string) { calendarCursor.value = new Date(d.getFullYear(), d.getMonth(), 1); } -function openDayView(key: string) { +function openDayView(key: string, direction: CalendarTransitionDirection = "in") { pickDate(key); + setCalendarTransition(direction); calendarView.value = "day"; } -function openWeekView(key: string) { +function openWeekView(key: string, direction: CalendarTransitionDirection = "in") { pickDate(key); + setCalendarTransition(direction); calendarView.value = "week"; } -function openYearMonth(monthIndex: number) { +function openYearMonth(monthIndex: number, direction: CalendarTransitionDirection = "in") { focusedCalendarEventId.value = ""; const year = calendarCursor.value.getFullYear(); calendarCursor.value = new Date(year, monthIndex, 1); selectedDateKey.value = dayKey(new Date(year, monthIndex, 1)); + setCalendarTransition(direction); calendarView.value = "month"; } @@ -4083,30 +4082,12 @@ async function decideFeedCard(card: FeedCard, decision: "accepted" | "rejected")
- - - Zoom -
- - - {{ calendarZoomLabel }} - + + +
@@ -4141,186 +4122,190 @@ async function decideFeedCard(card: FeedCard, decision: "accepted" | "rejected")
-
-
-
- Sun - Mon - Tue - Wed - Thu - Fri - Sat -
- -
+
- -
- - - +
+ + + +
+
-
-
-
-
-
-
-

{{ day.label }} {{ day.day }}

+
+
+
+
+

{{ day.label }} {{ day.day }}

+ +
+
+ +

No events

+
+
+
+
+ +
+ +

No events on this day.

+
+ +
+ -
-
+

{{ item.label }}

+

{{ item.count }} events

-

No events

-
-
-
-
+ +
-
- -

No events on this day.

-
- -
- -

{{ item.label }}

-

{{ item.count }} events

- - -
- -
- -
- +
+ +
+ + @@ -5394,21 +5379,14 @@ async function decideFeedCard(card: FeedCard, decision: "accepted" | "rejected") .calendar-content-scroll { height: 100%; + overscroll-behavior: contain; } -.calendar-canvas { +.calendar-scene { + min-height: 100%; min-width: 100%; - transform-origin: 0 0; - transition: transform 120ms ease-out; - will-change: transform; -} - -.calendar-pan-ready { - cursor: grab; -} - -.calendar-pan-active { - cursor: grabbing; + transform-origin: center center; + will-change: transform, opacity; } .calendar-week-grid { @@ -5514,9 +5492,43 @@ async function decideFeedCard(card: FeedCard, decision: "accepted" | "rejected") transform: translate(0, -50%); } -.calendar-zoom-range { - --range-shdw: color-mix(in oklab, var(--color-primary) 72%, white 8%); - --range-bg: color-mix(in oklab, var(--color-base-300) 85%, white 10%); +.calendar-zoom-in-enter-active, +.calendar-zoom-in-leave-active, +.calendar-zoom-out-enter-active, +.calendar-zoom-out-leave-active, +.calendar-zoom-side-enter-active, +.calendar-zoom-side-leave-active { + transition: transform 230ms cubic-bezier(0.2, 0.86, 0.2, 1), opacity 230ms ease; +} + +.calendar-zoom-in-enter-from { + opacity: 0; + transform: scale(0.92) translateY(8px); +} + +.calendar-zoom-in-leave-to { + opacity: 0; + transform: scale(1.07) translateY(-8px); +} + +.calendar-zoom-out-enter-from { + opacity: 0; + transform: scale(1.07) translateY(-8px); +} + +.calendar-zoom-out-leave-to { + opacity: 0; + transform: scale(0.92) translateY(8px); +} + +.calendar-zoom-side-enter-from { + opacity: 0; + transform: translateY(8px); +} + +.calendar-zoom-side-leave-to { + opacity: 0; + transform: translateY(-8px); } @media (max-width: 960px) {