diff --git a/frontend/app.vue b/frontend/app.vue index faf9952..85725e7 100644 --- a/frontend/app.vue +++ b/frontend/app.vue @@ -32,7 +32,7 @@ type CalendarView = "day" | "week" | "month" | "year" | "agenda"; type SortMode = "name" | "lastContact"; type PeopleLeftMode = "contacts" | "calendar"; type PeopleSortMode = "name" | "lastContact" | "company" | "country"; -type DocumentSortMode = "updatedAt" | "title" | "owner" | "type"; +type DocumentSortMode = "updatedAt" | "title" | "owner"; type FeedCard = { id: string; @@ -2274,6 +2274,10 @@ const calendarViewOptions: { value: CalendarView; label: string }[] = [ type CalendarHierarchyView = "year" | "month" | "week" | "day"; type CalendarRect = { left: number; top: number; width: number; height: number }; +type CalendarZoomGhost = { + title: string; + subtitle?: string; +}; const calendarContentWrapRef = ref(null); const calendarHoveredMonthIndex = ref(null); @@ -2286,13 +2290,13 @@ const calendarZoomOverlay = ref<{ active: boolean } & CalendarRect>({ width: 0, height: 0, }); +const calendarZoomGhost = ref(null); const calendarZoomBusy = ref(false); const calendarSceneMasked = ref(false); const calendarZoomPrimeToken = ref(""); const calendarZoomPrimeScale = ref(1); const calendarZoomPrimeTicks = ref(0); let calendarWheelLockUntil = 0; -let calendarZoomOverlayTimer: ReturnType | null = null; let calendarZoomPrimeTimer: ReturnType | null = null; let calendarZoomPrimeLastAt = 0; const CALENDAR_ZOOM_DURATION_MS = 2400; @@ -2313,10 +2317,11 @@ const calendarZoomOverlayStyle = computed(() => ({ })); function clearCalendarZoomOverlay() { - if (calendarZoomOverlayTimer) { - clearTimeout(calendarZoomOverlayTimer); - calendarZoomOverlayTimer = null; - } + calendarZoomOverlay.value = { + ...calendarZoomOverlay.value, + active: false, + }; + calendarZoomGhost.value = null; } function clearCalendarZoomPrime() { @@ -2407,6 +2412,71 @@ function weekRowStartForDate(key: string) { return dayKey(date); } +function zoomGhostForMonth(monthIndex: number): CalendarZoomGhost { + const item = yearMonths.value.find((entry) => entry.monthIndex === monthIndex); + if (!item) { + return { + title: new Intl.DateTimeFormat("en-US", { month: "long" }).format(new Date(calendarCursor.value.getFullYear(), monthIndex, 1)), + subtitle: "", + }; + } + return { + title: item.label, + subtitle: `${item.count} events`, + }; +} + +function zoomGhostForWeek(startKey: string): CalendarZoomGhost { + const start = new Date(`${startKey}T00:00:00`); + const end = new Date(start); + end.setDate(start.getDate() + 6); + const row = monthRows.value.find((item) => item.startKey === startKey); + const count = row ? row.cells.reduce((sum, cell) => sum + cell.events.length, 0) : 0; + return { + title: `${formatDay(`${dayKey(start)}T00:00:00`)} - ${formatDay(`${dayKey(end)}T00:00:00`)}`, + subtitle: `${count} events`, + }; +} + +function zoomGhostForDay(dayKeyValue: string): CalendarZoomGhost { + const day = weekDays.value.find((entry) => entry.key === dayKeyValue); + if (!day) { + return { + title: formatDay(`${dayKeyValue}T00:00:00`), + subtitle: `${getEventsByDate(dayKeyValue).length} events`, + }; + } + return { + title: `${day.label} ${day.day}`, + subtitle: `${day.events.length} events`, + }; +} + +function zoomGhostForCurrentView(): CalendarZoomGhost { + if (calendarView.value === "day") { + return { + title: formatDay(`${selectedDateKey.value}T00:00:00`), + subtitle: `${selectedDayEvents.value.length} events`, + }; + } + if (calendarView.value === "week") { + return { + title: calendarPeriodLabel.value, + subtitle: `${weekDays.value.reduce((sum, day) => sum + day.events.length, 0)} events`, + }; + } + if (calendarView.value === "month" || calendarView.value === "agenda") { + return { + title: monthLabel.value, + subtitle: `${monthCells.value.reduce((sum, cell) => sum + cell.events.length, 0)} events`, + }; + } + return { + title: String(calendarCursor.value.getFullYear()), + subtitle: `${sortedEvents.value.filter((event) => new Date(event.start).getFullYear() === calendarCursor.value.getFullYear()).length} events`, + }; +} + function primeCalendarRect(rect: CalendarRect) { clearCalendarZoomOverlay(); calendarZoomOverlay.value = { @@ -2428,12 +2498,6 @@ function morphCalendarRect(toRect: CalendarRect) { height: toRect.height, }; }); - calendarZoomOverlayTimer = setTimeout(() => { - calendarZoomOverlay.value = { - ...calendarZoomOverlay.value, - active: false, - }; - }, CALENDAR_ZOOM_DURATION_MS + 40); } function waitCalendarZoom() { @@ -2442,13 +2506,7 @@ function waitCalendarZoom() { }); } -function waitMs(ms: number) { - return new Promise((resolve) => { - setTimeout(() => resolve(), ms); - }); -} - -async function animateCalendarZoomIn(sourceElement: HTMLElement | null, apply: () => void) { +async function animateCalendarZoomIn(sourceElement: HTMLElement | null, ghost: CalendarZoomGhost, apply: () => void) { const fromRect = getElementRectInCalendar(sourceElement); const viewportRect = getCalendarViewportRect(); if (!fromRect || !viewportRect) { @@ -2460,14 +2518,15 @@ async function animateCalendarZoomIn(sourceElement: HTMLElement | null, apply: ( calendarZoomBusy.value = true; try { primeCalendarRect(fromRect); - morphCalendarRect(viewportRect); - const switchLag = 260; - await waitMs(Math.max(0, CALENDAR_ZOOM_DURATION_MS - switchLag)); + calendarZoomGhost.value = ghost; calendarSceneMasked.value = true; + await nextTick(); + morphCalendarRect(viewportRect); + await waitCalendarZoom(); apply(); await nextTick(); - await waitMs(switchLag); } finally { + clearCalendarZoomOverlay(); calendarSceneMasked.value = false; calendarZoomBusy.value = false; } @@ -2484,6 +2543,7 @@ async function animateCalendarZoomOut(apply: () => void, resolveTarget: () => HT calendarZoomBusy.value = true; try { primeCalendarRect(viewportRect); + calendarZoomGhost.value = zoomGhostForCurrentView(); calendarSceneMasked.value = true; await nextTick(); apply(); @@ -2499,6 +2559,7 @@ async function animateCalendarZoomOut(apply: () => void, resolveTarget: () => HT morphCalendarRect(targetRect); await waitCalendarZoom(); } finally { + clearCalendarZoomOverlay(); calendarSceneMasked.value = false; calendarZoomBusy.value = false; } @@ -2538,7 +2599,7 @@ async function zoomInCalendar(event?: Event) { const monthIndex = resolveMonthAnchor(wheelEvent); const sourceElement = queryCalendarElement(`[data-calendar-month-index="${monthIndex}"]`); if (maybePrimeWheelZoom(wheelEvent, calendarPrimeMonthToken(monthIndex))) return; - await animateCalendarZoomIn(sourceElement, () => { + await animateCalendarZoomIn(sourceElement, zoomGhostForMonth(monthIndex), () => { openYearMonth(monthIndex); }); return; @@ -2553,6 +2614,7 @@ async function zoomInCalendar(event?: Event) { if (maybePrimeWheelZoom(wheelEvent, calendarPrimeWeekToken(rowStartKey))) return; await animateCalendarZoomIn( sourceElement, + zoomGhostForWeek(rowStartKey), () => { openWeekView(anchorDayKey); }, @@ -2564,16 +2626,20 @@ async function zoomInCalendar(event?: Event) { const dayAnchor = resolveDayAnchor(wheelEvent); const sourceElement = queryCalendarElement(`[data-calendar-day-key="${dayAnchor}"]`); if (maybePrimeWheelZoom(wheelEvent, calendarPrimeDayToken(dayAnchor))) return; - await animateCalendarZoomIn(sourceElement, () => { + await animateCalendarZoomIn(sourceElement, zoomGhostForDay(dayAnchor), () => { openDayView(dayAnchor); }); } } async function zoomToMonth(monthIndex: number) { - await animateCalendarZoomIn(queryCalendarElement(`[data-calendar-month-index="${monthIndex}"]`), () => { - openYearMonth(monthIndex); - }); + await animateCalendarZoomIn( + queryCalendarElement(`[data-calendar-month-index="${monthIndex}"]`), + zoomGhostForMonth(monthIndex), + () => { + openYearMonth(monthIndex); + }, + ); } async function zoomOutCalendar() { @@ -2950,7 +3016,6 @@ const documentSortOptions: Array<{ value: DocumentSortMode; label: string }> = [ { value: "updatedAt", label: "Updated" }, { value: "title", label: "Title" }, { value: "owner", label: "Owner" }, - { value: "type", label: "Type" }, ]; const filteredDocuments = computed(() => { @@ -2965,7 +3030,6 @@ const filteredDocuments = computed(() => { .sort((a, b) => { if (documentSortMode.value === "title") return a.title.localeCompare(b.title); if (documentSortMode.value === "owner") return a.owner.localeCompare(b.owner); - if (documentSortMode.value === "type") return a.type.localeCompare(b.type); return b.updatedAt.localeCompare(a.updatedAt); }); @@ -3153,10 +3217,8 @@ const commEventForm = ref({ durationMinutes: 30, }); const commDocumentForm = ref<{ - type: WorkspaceDocument["type"]; title: string; }>({ - type: "Template", title: "", }); const eventCloseOpen = ref>({}); @@ -3184,7 +3246,7 @@ watch(selectedCommThreadId, () => { commComposerMode.value = "message"; commQuickMenuOpen.value = false; commEventError.value = ""; - commDocumentForm.value = { type: "Template", title: "" }; + commDocumentForm.value = { title: "" }; eventCloseOpen.value = {}; eventCloseDraft.value = {}; eventCloseSaving.value = {}; @@ -3953,7 +4015,6 @@ function setDefaultCommEventForm(mode: "planned" | "logged") { function setDefaultCommDocumentForm() { commDocumentForm.value = { - type: "Template", title: "", }; } @@ -4089,7 +4150,6 @@ async function createCommDocument() { const res = await gqlFetch<{ createWorkspaceDocument: WorkspaceDocument }>(createWorkspaceDocumentMutation, { input: { title, - type: commDocumentForm.value.type, owner: authDisplayName.value, scope, summary, @@ -4876,7 +4936,12 @@ async function decideFeedCard(card: FeedCard, decision: "accepted" | "rejected") v-if="calendarZoomOverlay.active" class="calendar-zoom-overlay" :style="calendarZoomOverlayStyle" - /> + > +
+

{{ calendarZoomGhost.title }}

+

{{ calendarZoomGhost.subtitle }}

+
+ @@ -5626,16 +5691,6 @@ async function decideFeedCard(card: FeedCard, decision: "accepted" | "rejected") :disabled="commEventSaving" placeholder="Document title (optional)" > -

@@ -5764,7 +5819,6 @@ async function decideFeedCard(card: FeedCard, decision: "accepted" | "rejected") >

{{ doc.title }}

- {{ doc.type }}

{{ doc.summary }}

@@ -5909,7 +5963,6 @@ async function decideFeedCard(card: FeedCard, decision: "accepted" | "rejected") >

{{ doc.title }}

- {{ doc.type }}

{{ formatDocumentScope(doc.scope) }}

{{ doc.summary }}

@@ -5926,7 +5979,7 @@ async function decideFeedCard(card: FeedCard, decision: "accepted" | "rejected")

{{ selectedDocument.title }}

- {{ selectedDocument.type }} · {{ formatDocumentScope(selectedDocument.scope) }} · {{ selectedDocument.owner }} + {{ formatDocumentScope(selectedDocument.scope) }} · {{ selectedDocument.owner }}

{{ selectedDocument.summary }}

@@ -6227,6 +6280,7 @@ async function decideFeedCard(card: FeedCard, decision: "accepted" | "rejected") border: 3px solid color-mix(in oklab, var(--color-primary) 64%, transparent); border-radius: 12px; background: color-mix(in oklab, var(--color-primary) 14%, transparent); + overflow: hidden; pointer-events: none; transition: left 2400ms cubic-bezier(0.16, 0.86, 0.18, 1), @@ -6235,6 +6289,32 @@ async function decideFeedCard(card: FeedCard, decision: "accepted" | "rejected") height 2400ms cubic-bezier(0.16, 0.86, 0.18, 1); } +.calendar-zoom-overlay-content { + position: absolute; + inset: 0; + display: flex; + flex-direction: column; + align-items: flex-start; + justify-content: flex-start; + gap: 4px; + padding: 10px; + color: color-mix(in oklab, var(--color-base-content) 86%, transparent); +} + +.calendar-zoom-overlay-title { + margin: 0; + font-size: 14px; + font-weight: 700; + line-height: 1.2; +} + +.calendar-zoom-overlay-subtitle { + margin: 0; + font-size: 11px; + line-height: 1.2; + opacity: 0.74; +} + @media (max-width: 960px) { .calendar-content-wrap { padding-left: 32px;