diff --git a/frontend/app.vue b/frontend/app.vue index d0d8594..a60e22b 100644 --- a/frontend/app.vue +++ b/frontend/app.vue @@ -2072,6 +2072,7 @@ onBeforeUnmount(() => { clearInterval(lifecycleClock); lifecycleClock = null; } + clearCalendarZoomOverlay(); }); const calendarView = ref("year"); @@ -2115,32 +2116,154 @@ const calendarViewOptions: { value: CalendarView; label: string }[] = [ { value: "agenda", label: "Agenda" }, ]; -type CalendarTransitionDirection = "in" | "out" | "side"; +type CalendarHierarchyView = "year" | "month" | "week" | "day"; +type CalendarRect = { left: number; top: number; width: number; height: number }; -const calendarTransitionDirection = ref("side"); +const calendarContentWrapRef = ref(null); const calendarHoveredMonthIndex = ref(null); const calendarHoveredWeekStartKey = ref(""); const calendarHoveredDayKey = ref(""); +const calendarZoomOverlay = ref<{ active: boolean } & CalendarRect>({ + active: false, + left: 0, + top: 0, + width: 0, + height: 0, +}); let calendarWheelLockUntil = 0; +let calendarZoomOverlayTimer: ReturnType | null = null; +const CALENDAR_ZOOM_DURATION_MS = 180; +const calendarZoomStops: Array<{ view: CalendarHierarchyView; label: string }> = [ + { view: "year", label: "Year" }, + { view: "month", label: "Month" }, + { view: "week", label: "Week" }, + { view: "day", label: "Day" }, +]; +const calendarZoomOrder: CalendarHierarchyView[] = ["year", "month", "week", "day"]; -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 normalizedCalendarView = computed(() => + calendarView.value === "agenda" ? "month" : calendarView.value, +); +const calendarZoomLevelIndex = computed(() => + Math.max(0, calendarZoomStops.findIndex((stop) => stop.view === normalizedCalendarView.value)), +); +const calendarZoomOverlayStyle = computed(() => ({ + left: `${calendarZoomOverlay.value.left}px`, + top: `${calendarZoomOverlay.value.top}px`, + width: `${calendarZoomOverlay.value.width}px`, + height: `${calendarZoomOverlay.value.height}px`, +})); -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 "Months"; -}); +function clearCalendarZoomOverlay() { + if (calendarZoomOverlayTimer) { + clearTimeout(calendarZoomOverlayTimer); + calendarZoomOverlayTimer = null; + } +} -function setCalendarTransition(direction: CalendarTransitionDirection) { - calendarTransitionDirection.value = direction; +function queryCalendarElement(selector: string) { + return calendarContentWrapRef.value?.querySelector(selector) ?? null; +} + +function getCalendarViewportRect(): CalendarRect | null { + const wrapRect = calendarContentWrapRef.value?.getBoundingClientRect(); + if (!wrapRect) return null; + return { + left: 0, + top: 0, + width: Math.max(24, wrapRect.width), + height: Math.max(24, wrapRect.height), + }; +} + +function getElementRectInCalendar(element: HTMLElement | null): CalendarRect | null { + if (!element) return null; + const wrapRect = calendarContentWrapRef.value?.getBoundingClientRect(); + if (!wrapRect) return null; + const rect = element.getBoundingClientRect(); + const left = Math.max(0, rect.left - wrapRect.left); + const top = Math.max(0, rect.top - wrapRect.top); + const width = Math.max(24, Math.min(rect.width, wrapRect.width - left)); + const height = Math.max(24, Math.min(rect.height, wrapRect.height - top)); + return { left, top, width, height }; +} + +function weekRowStartForDate(key: string) { + const date = new Date(`${key}T00:00:00`); + date.setDate(date.getDate() - date.getDay()); + return dayKey(date); +} + +function primeCalendarRect(rect: CalendarRect) { + clearCalendarZoomOverlay(); + calendarZoomOverlay.value = { + active: true, + left: rect.left, + top: rect.top, + width: rect.width, + height: rect.height, + }; +} + +function morphCalendarRect(toRect: CalendarRect) { + requestAnimationFrame(() => { + calendarZoomOverlay.value = { + active: true, + left: toRect.left, + top: toRect.top, + width: toRect.width, + height: toRect.height, + }; + }); + calendarZoomOverlayTimer = setTimeout(() => { + calendarZoomOverlay.value = { + ...calendarZoomOverlay.value, + active: false, + }; + }, CALENDAR_ZOOM_DURATION_MS + 40); +} + +function waitCalendarZoom() { + return new Promise((resolve) => { + setTimeout(() => resolve(), CALENDAR_ZOOM_DURATION_MS); + }); +} + +async function animateCalendarZoomIn(sourceElement: HTMLElement | null, apply: () => void) { + const fromRect = getElementRectInCalendar(sourceElement); + const viewportRect = getCalendarViewportRect(); + if (!fromRect || !viewportRect) { + apply(); + return; + } + + primeCalendarRect(fromRect); + apply(); + await nextTick(); + morphCalendarRect(viewportRect); + await waitCalendarZoom(); +} + +async function animateCalendarZoomOut(apply: () => void, resolveTarget: () => HTMLElement | null) { + const viewportRect = getCalendarViewportRect(); + if (!viewportRect) { + apply(); + return; + } + + primeCalendarRect(viewportRect); + apply(); + await nextTick(); + const targetRect = getElementRectInCalendar(resolveTarget()); + if (!targetRect) { + calendarZoomOverlay.value = { + ...calendarZoomOverlay.value, + active: false, + }; + return; + } + morphCalendarRect(targetRect); + await waitCalendarZoom(); } function resolveMonthAnchor(event?: WheelEvent) { @@ -2171,41 +2294,78 @@ function resolveDayAnchor(event?: WheelEvent) { return selectedDateKey.value; } -function zoomInCalendar(event?: Event) { +async function zoomInCalendar(event?: Event) { const wheelEvent = event instanceof WheelEvent ? event : undefined; if (calendarView.value === "year") { - openYearMonth(resolveMonthAnchor(wheelEvent), "in"); + const monthIndex = resolveMonthAnchor(wheelEvent); + await animateCalendarZoomIn(queryCalendarElement(`[data-calendar-month-index="${monthIndex}"]`), () => { + openYearMonth(monthIndex); + }); return; } if (calendarView.value === "month" || calendarView.value === "agenda") { - openWeekView(resolveWeekAnchor(wheelEvent), "in"); + const anchorDayKey = resolveWeekAnchor(wheelEvent); + const rowStartKey = weekRowStartForDate(anchorDayKey); + await animateCalendarZoomIn( + queryCalendarElement(`[data-calendar-week-start-key="${rowStartKey}"]`) ?? + queryCalendarElement(`[data-calendar-day-key="${anchorDayKey}"]`), + () => { + openWeekView(anchorDayKey); + }, + ); return; } if (calendarView.value === "week") { - openDayView(resolveDayAnchor(wheelEvent), "in"); + const dayAnchor = resolveDayAnchor(wheelEvent); + await animateCalendarZoomIn(queryCalendarElement(`[data-calendar-day-key="${dayAnchor}"]`), () => { + openDayView(dayAnchor); + }); } } -function zoomOutCalendar() { +async function zoomToMonth(monthIndex: number) { + await animateCalendarZoomIn(queryCalendarElement(`[data-calendar-month-index="${monthIndex}"]`), () => { + openYearMonth(monthIndex); + }); +} + +async function zoomOutCalendar() { focusedCalendarEventId.value = ""; if (calendarView.value === "day") { - setCalendarTransition("out"); - calendarView.value = "week"; + const targetDayKey = selectedDateKey.value; + await animateCalendarZoomOut( + () => { + calendarView.value = "week"; + }, + () => queryCalendarElement(`[data-calendar-day-key="${targetDayKey}"]`), + ); return; } if (calendarView.value === "week") { - setCalendarTransition("out"); - calendarView.value = "month"; + const targetRowKey = weekRowStartForDate(selectedDateKey.value); + await animateCalendarZoomOut( + () => { + calendarView.value = "month"; + }, + () => + queryCalendarElement(`[data-calendar-week-start-key="${targetRowKey}"]`) ?? + queryCalendarElement(`[data-calendar-day-key="${selectedDateKey.value}"]`), + ); return; } if (calendarView.value === "month" || calendarView.value === "agenda") { - setCalendarTransition("out"); - calendarView.value = "year"; + const targetMonthIndex = calendarCursor.value.getMonth(); + await animateCalendarZoomOut( + () => { + calendarView.value = "year"; + }, + () => queryCalendarElement(`[data-calendar-month-index="${targetMonthIndex}"]`), + ); } } @@ -2216,11 +2376,36 @@ function onCalendarHierarchyWheel(event: WheelEvent) { calendarWheelLockUntil = now + 240; if (event.deltaY < 0) { - zoomInCalendar(event); + void zoomInCalendar(event); return; } - zoomOutCalendar(); + 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 sliderStep = Math.max(0, Math.min(3, Math.round(value))); + const targetIndex = 3 - sliderStep; + const targetView = calendarZoomOrder[targetIndex]; + if (!targetView) return; + void setCalendarZoomLevel(targetView); } const monthCells = computed(() => { @@ -2338,7 +2523,6 @@ 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); @@ -2366,7 +2550,6 @@ 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); @@ -2379,24 +2562,21 @@ function pickDate(key: string) { calendarCursor.value = new Date(d.getFullYear(), d.getMonth(), 1); } -function openDayView(key: string, direction: CalendarTransitionDirection = "in") { +function openDayView(key: string) { pickDate(key); - setCalendarTransition(direction); calendarView.value = "day"; } -function openWeekView(key: string, direction: CalendarTransitionDirection = "in") { +function openWeekView(key: string) { pickDate(key); - setCalendarTransition(direction); calendarView.value = "week"; } -function openYearMonth(monthIndex: number, direction: CalendarTransitionDirection = "in") { +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)); - setCalendarTransition(direction); calendarView.value = "month"; } @@ -4072,7 +4252,7 @@ async function decideFeedCard(card: FeedCard, decision: "accepted" | "rejected") v-if="contextPickerEnabled" class="context-scope-label" >{{ contextScopeLabel('calendar') }} -
+
@@ -4081,14 +4261,6 @@ async function decideFeedCard(card: FeedCard, decision: "accepted" | "rejected") {{ calendarPeriodLabel }}
-
- - - -
{{ focusedCalendarEvent.note || "No note" }}

-
+
- -
- -
+ + +
+ + +
+
+
Sun @@ -4149,17 +4340,6 @@ async function decideFeedCard(card: FeedCard, decision: "accepted" | "rejected") :data-calendar-week-start-key="row.startKey" @mouseenter="calendarHoveredWeekStartKey = row.startKey" > -

{{ item.label }}

{{ item.count }} events

-
- -
-
- +
+
+
+
+
@@ -5374,7 +5525,7 @@ async function decideFeedCard(card: FeedCard, decision: "accepted" | "rejected") .calendar-content-wrap { position: relative; padding-left: 40px; - padding-right: 40px; + padding-right: 128px; } .calendar-content-scroll { @@ -5386,7 +5537,16 @@ async function decideFeedCard(card: FeedCard, decision: "accepted" | "rejected") min-height: 100%; min-width: 100%; transform-origin: center center; - will-change: transform, opacity; +} + +.calendar-scene.cursor-zoom-in, +.calendar-scene.cursor-zoom-in * { + cursor: zoom-in; +} + +.calendar-scene.cursor-zoom-out, +.calendar-scene.cursor-zoom-out * { + cursor: zoom-out; } .calendar-week-grid { @@ -5424,117 +5584,102 @@ async function decideFeedCard(card: FeedCard, decision: "accepted" | "rejected") } .calendar-side-nav-right { - right: 4px; + right: 56px; } -.calendar-hover-jump { +.calendar-zoom-slider-shell { position: absolute; - top: 4px; - right: 4px; - z-index: 3; - display: inline-flex; + top: 50%; + right: 8px; + z-index: 5; + transform: translateY(-50%); + display: flex; align-items: center; - justify-content: center; - min-width: 26px; - width: 26px; - height: 26px; - padding: 0; - border-radius: 8px; - border: 1px solid color-mix(in oklab, var(--color-primary) 35%, transparent); - background: color-mix(in oklab, var(--color-base-100) 86%, transparent); - color: color-mix(in oklab, var(--color-base-content) 78%, transparent); - opacity: 0; - pointer-events: none; - transition: opacity 120ms ease, transform 120ms ease, background-color 120ms ease; - transform: translateY(-2px); + gap: 8px; + border: 1px solid color-mix(in oklab, var(--color-base-content) 16%, transparent); + border-radius: 12px; + background: color-mix(in oklab, var(--color-base-100) 90%, transparent); + padding: 8px 7px; +} + +.calendar-zoom-slider { + width: 124px; + height: 16px; + margin: 0; + transform: rotate(-90deg); + transform-origin: center; + accent-color: color-mix(in oklab, var(--color-primary) 82%, transparent); cursor: pointer; } -.group:hover > .calendar-hover-jump, -.group:focus-within > .calendar-hover-jump { - opacity: 1; - pointer-events: auto; - transform: translateY(0); +.calendar-zoom-slider:focus-visible { + outline: 2px solid color-mix(in oklab, var(--color-primary) 52%, transparent); + outline-offset: 2px; } -.calendar-hover-jump:focus-visible { - opacity: 1; - pointer-events: auto; - transform: translateY(0); - outline: 2px solid color-mix(in oklab, var(--color-primary) 58%, transparent); - outline-offset: 1px; +.calendar-zoom-slider::-webkit-slider-runnable-track { + height: 4px; + border-radius: 999px; + background: color-mix(in oklab, var(--color-base-content) 26%, transparent); } -.calendar-hover-jump:hover { - background: color-mix(in oklab, var(--color-primary) 12%, var(--color-base-100)); +.calendar-zoom-slider::-webkit-slider-thumb { + -webkit-appearance: none; + margin-top: -5px; + width: 14px; + height: 14px; + border-radius: 999px; + border: 1px solid color-mix(in oklab, var(--color-primary) 42%, transparent); + background: color-mix(in oklab, var(--color-primary) 86%, var(--color-base-100)); } -.calendar-hover-jump-week, -.calendar-hover-jump-row { - top: 8px; - right: 10px; +.calendar-zoom-slider::-moz-range-track { + height: 4px; + border-radius: 999px; + background: color-mix(in oklab, var(--color-base-content) 26%, transparent); } -.calendar-hover-jump-month { - top: 8px; - right: 8px; +.calendar-zoom-slider::-moz-range-thumb { + width: 14px; + height: 14px; + border-radius: 999px; + border: 1px solid color-mix(in oklab, var(--color-primary) 42%, transparent); + background: color-mix(in oklab, var(--color-primary) 86%, var(--color-base-100)); } -.calendar-hover-jump-row { - top: 50%; - right: -14px; - transform: translate(6px, -50%); +.calendar-zoom-slider-labels { + display: flex; + flex-direction: column; + justify-content: space-between; + height: 124px; + font-size: 10px; + line-height: 1; + color: color-mix(in oklab, var(--color-base-content) 56%, transparent); } -.group:hover > .calendar-hover-jump-row, -.group:focus-within > .calendar-hover-jump-row, -.calendar-hover-jump-row:focus-visible { - transform: translate(0, -50%); +.calendar-zoom-slider-label-active { + color: color-mix(in oklab, var(--color-primary) 88%, var(--color-base-content)); + font-weight: 700; } -.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); +.calendar-zoom-overlay { + position: absolute; + z-index: 6; + border: 2px solid color-mix(in oklab, var(--color-primary) 60%, transparent); + border-radius: 12px; + background: color-mix(in oklab, var(--color-primary) 12%, transparent); + pointer-events: none; + transition: + left 180ms cubic-bezier(0.2, 0.85, 0.25, 1), + top 180ms cubic-bezier(0.2, 0.85, 0.25, 1), + width 180ms cubic-bezier(0.2, 0.85, 0.25, 1), + height 180ms cubic-bezier(0.2, 0.85, 0.25, 1); } @media (max-width: 960px) { .calendar-content-wrap { padding-left: 32px; - padding-right: 32px; + padding-right: 104px; } .calendar-week-grid { @@ -5546,6 +5691,25 @@ async function decideFeedCard(card: FeedCard, decision: "accepted" | "rejected") width: 24px; height: 24px; } + + .calendar-side-nav-right { + right: 44px; + } + + .calendar-zoom-slider-shell { + right: 4px; + padding: 7px 6px; + gap: 6px; + } + + .calendar-zoom-slider { + width: 102px; + } + + .calendar-zoom-slider-labels { + height: 102px; + font-size: 9px; + } } .pilot-shell {