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 }}
-
-
- Wheel to zoom
- {{ calendarZoomDepthLabel }}
-
-
-
-
{{ focusedCalendarEvent.note || "No note" }}
-
+
-
-
-
-
+
→
+
+
+
+
+
+ {{ item.label }}
+
+
+
+
+
Sun
@@ -4149,17 +4340,6 @@ async function decideFeedCard(card: FeedCard, decision: "accepted" | "rejected")
:data-calendar-week-start-key="row.startKey"
@mouseenter="calendarHoveredWeekStartKey = row.startKey"
>
-
-
-
-
-
+
+
+
+
+
@@ -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 {