calendar: unify zoom-in pipeline with ghost card text

This commit is contained in:
Ruslan Bakiev
2026-02-23 08:44:03 +07:00
parent 24c771d143
commit 05324de216

View File

@@ -32,7 +32,7 @@ type CalendarView = "day" | "week" | "month" | "year" | "agenda";
type SortMode = "name" | "lastContact"; type SortMode = "name" | "lastContact";
type PeopleLeftMode = "contacts" | "calendar"; type PeopleLeftMode = "contacts" | "calendar";
type PeopleSortMode = "name" | "lastContact" | "company" | "country"; type PeopleSortMode = "name" | "lastContact" | "company" | "country";
type DocumentSortMode = "updatedAt" | "title" | "owner" | "type"; type DocumentSortMode = "updatedAt" | "title" | "owner";
type FeedCard = { type FeedCard = {
id: string; id: string;
@@ -2274,6 +2274,10 @@ const calendarViewOptions: { value: CalendarView; label: string }[] = [
type CalendarHierarchyView = "year" | "month" | "week" | "day"; type CalendarHierarchyView = "year" | "month" | "week" | "day";
type CalendarRect = { left: number; top: number; width: number; height: number }; type CalendarRect = { left: number; top: number; width: number; height: number };
type CalendarZoomGhost = {
title: string;
subtitle?: string;
};
const calendarContentWrapRef = ref<HTMLElement | null>(null); const calendarContentWrapRef = ref<HTMLElement | null>(null);
const calendarHoveredMonthIndex = ref<number | null>(null); const calendarHoveredMonthIndex = ref<number | null>(null);
@@ -2286,13 +2290,13 @@ const calendarZoomOverlay = ref<{ active: boolean } & CalendarRect>({
width: 0, width: 0,
height: 0, height: 0,
}); });
const calendarZoomGhost = ref<CalendarZoomGhost | null>(null);
const calendarZoomBusy = ref(false); const calendarZoomBusy = ref(false);
const calendarSceneMasked = ref(false); const calendarSceneMasked = ref(false);
const calendarZoomPrimeToken = ref(""); const calendarZoomPrimeToken = ref("");
const calendarZoomPrimeScale = ref(1); const calendarZoomPrimeScale = ref(1);
const calendarZoomPrimeTicks = ref(0); const calendarZoomPrimeTicks = ref(0);
let calendarWheelLockUntil = 0; let calendarWheelLockUntil = 0;
let calendarZoomOverlayTimer: ReturnType<typeof setTimeout> | null = null;
let calendarZoomPrimeTimer: ReturnType<typeof setTimeout> | null = null; let calendarZoomPrimeTimer: ReturnType<typeof setTimeout> | null = null;
let calendarZoomPrimeLastAt = 0; let calendarZoomPrimeLastAt = 0;
const CALENDAR_ZOOM_DURATION_MS = 2400; const CALENDAR_ZOOM_DURATION_MS = 2400;
@@ -2313,10 +2317,11 @@ const calendarZoomOverlayStyle = computed(() => ({
})); }));
function clearCalendarZoomOverlay() { function clearCalendarZoomOverlay() {
if (calendarZoomOverlayTimer) { calendarZoomOverlay.value = {
clearTimeout(calendarZoomOverlayTimer); ...calendarZoomOverlay.value,
calendarZoomOverlayTimer = null; active: false,
} };
calendarZoomGhost.value = null;
} }
function clearCalendarZoomPrime() { function clearCalendarZoomPrime() {
@@ -2407,6 +2412,71 @@ function weekRowStartForDate(key: string) {
return dayKey(date); 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) { function primeCalendarRect(rect: CalendarRect) {
clearCalendarZoomOverlay(); clearCalendarZoomOverlay();
calendarZoomOverlay.value = { calendarZoomOverlay.value = {
@@ -2428,12 +2498,6 @@ function morphCalendarRect(toRect: CalendarRect) {
height: toRect.height, height: toRect.height,
}; };
}); });
calendarZoomOverlayTimer = setTimeout(() => {
calendarZoomOverlay.value = {
...calendarZoomOverlay.value,
active: false,
};
}, CALENDAR_ZOOM_DURATION_MS + 40);
} }
function waitCalendarZoom() { function waitCalendarZoom() {
@@ -2442,13 +2506,7 @@ function waitCalendarZoom() {
}); });
} }
function waitMs(ms: number) { async function animateCalendarZoomIn(sourceElement: HTMLElement | null, ghost: CalendarZoomGhost, apply: () => void) {
return new Promise<void>((resolve) => {
setTimeout(() => resolve(), ms);
});
}
async function animateCalendarZoomIn(sourceElement: HTMLElement | null, apply: () => void) {
const fromRect = getElementRectInCalendar(sourceElement); const fromRect = getElementRectInCalendar(sourceElement);
const viewportRect = getCalendarViewportRect(); const viewportRect = getCalendarViewportRect();
if (!fromRect || !viewportRect) { if (!fromRect || !viewportRect) {
@@ -2460,14 +2518,15 @@ async function animateCalendarZoomIn(sourceElement: HTMLElement | null, apply: (
calendarZoomBusy.value = true; calendarZoomBusy.value = true;
try { try {
primeCalendarRect(fromRect); primeCalendarRect(fromRect);
morphCalendarRect(viewportRect); calendarZoomGhost.value = ghost;
const switchLag = 260;
await waitMs(Math.max(0, CALENDAR_ZOOM_DURATION_MS - switchLag));
calendarSceneMasked.value = true; calendarSceneMasked.value = true;
await nextTick();
morphCalendarRect(viewportRect);
await waitCalendarZoom();
apply(); apply();
await nextTick(); await nextTick();
await waitMs(switchLag);
} finally { } finally {
clearCalendarZoomOverlay();
calendarSceneMasked.value = false; calendarSceneMasked.value = false;
calendarZoomBusy.value = false; calendarZoomBusy.value = false;
} }
@@ -2484,6 +2543,7 @@ async function animateCalendarZoomOut(apply: () => void, resolveTarget: () => HT
calendarZoomBusy.value = true; calendarZoomBusy.value = true;
try { try {
primeCalendarRect(viewportRect); primeCalendarRect(viewportRect);
calendarZoomGhost.value = zoomGhostForCurrentView();
calendarSceneMasked.value = true; calendarSceneMasked.value = true;
await nextTick(); await nextTick();
apply(); apply();
@@ -2499,6 +2559,7 @@ async function animateCalendarZoomOut(apply: () => void, resolveTarget: () => HT
morphCalendarRect(targetRect); morphCalendarRect(targetRect);
await waitCalendarZoom(); await waitCalendarZoom();
} finally { } finally {
clearCalendarZoomOverlay();
calendarSceneMasked.value = false; calendarSceneMasked.value = false;
calendarZoomBusy.value = false; calendarZoomBusy.value = false;
} }
@@ -2538,7 +2599,7 @@ async function zoomInCalendar(event?: Event) {
const monthIndex = resolveMonthAnchor(wheelEvent); const monthIndex = resolveMonthAnchor(wheelEvent);
const sourceElement = queryCalendarElement(`[data-calendar-month-index="${monthIndex}"]`); const sourceElement = queryCalendarElement(`[data-calendar-month-index="${monthIndex}"]`);
if (maybePrimeWheelZoom(wheelEvent, calendarPrimeMonthToken(monthIndex))) return; if (maybePrimeWheelZoom(wheelEvent, calendarPrimeMonthToken(monthIndex))) return;
await animateCalendarZoomIn(sourceElement, () => { await animateCalendarZoomIn(sourceElement, zoomGhostForMonth(monthIndex), () => {
openYearMonth(monthIndex); openYearMonth(monthIndex);
}); });
return; return;
@@ -2553,6 +2614,7 @@ async function zoomInCalendar(event?: Event) {
if (maybePrimeWheelZoom(wheelEvent, calendarPrimeWeekToken(rowStartKey))) return; if (maybePrimeWheelZoom(wheelEvent, calendarPrimeWeekToken(rowStartKey))) return;
await animateCalendarZoomIn( await animateCalendarZoomIn(
sourceElement, sourceElement,
zoomGhostForWeek(rowStartKey),
() => { () => {
openWeekView(anchorDayKey); openWeekView(anchorDayKey);
}, },
@@ -2564,16 +2626,20 @@ async function zoomInCalendar(event?: Event) {
const dayAnchor = resolveDayAnchor(wheelEvent); const dayAnchor = resolveDayAnchor(wheelEvent);
const sourceElement = queryCalendarElement(`[data-calendar-day-key="${dayAnchor}"]`); const sourceElement = queryCalendarElement(`[data-calendar-day-key="${dayAnchor}"]`);
if (maybePrimeWheelZoom(wheelEvent, calendarPrimeDayToken(dayAnchor))) return; if (maybePrimeWheelZoom(wheelEvent, calendarPrimeDayToken(dayAnchor))) return;
await animateCalendarZoomIn(sourceElement, () => { await animateCalendarZoomIn(sourceElement, zoomGhostForDay(dayAnchor), () => {
openDayView(dayAnchor); openDayView(dayAnchor);
}); });
} }
} }
async function zoomToMonth(monthIndex: number) { async function zoomToMonth(monthIndex: number) {
await animateCalendarZoomIn(queryCalendarElement(`[data-calendar-month-index="${monthIndex}"]`), () => { await animateCalendarZoomIn(
openYearMonth(monthIndex); queryCalendarElement(`[data-calendar-month-index="${monthIndex}"]`),
}); zoomGhostForMonth(monthIndex),
() => {
openYearMonth(monthIndex);
},
);
} }
async function zoomOutCalendar() { async function zoomOutCalendar() {
@@ -2950,7 +3016,6 @@ const documentSortOptions: Array<{ value: DocumentSortMode; label: string }> = [
{ value: "updatedAt", label: "Updated" }, { value: "updatedAt", label: "Updated" },
{ value: "title", label: "Title" }, { value: "title", label: "Title" },
{ value: "owner", label: "Owner" }, { value: "owner", label: "Owner" },
{ value: "type", label: "Type" },
]; ];
const filteredDocuments = computed(() => { const filteredDocuments = computed(() => {
@@ -2965,7 +3030,6 @@ const filteredDocuments = computed(() => {
.sort((a, b) => { .sort((a, b) => {
if (documentSortMode.value === "title") return a.title.localeCompare(b.title); if (documentSortMode.value === "title") return a.title.localeCompare(b.title);
if (documentSortMode.value === "owner") return a.owner.localeCompare(b.owner); 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); return b.updatedAt.localeCompare(a.updatedAt);
}); });
@@ -3153,10 +3217,8 @@ const commEventForm = ref({
durationMinutes: 30, durationMinutes: 30,
}); });
const commDocumentForm = ref<{ const commDocumentForm = ref<{
type: WorkspaceDocument["type"];
title: string; title: string;
}>({ }>({
type: "Template",
title: "", title: "",
}); });
const eventCloseOpen = ref<Record<string, boolean>>({}); const eventCloseOpen = ref<Record<string, boolean>>({});
@@ -3184,7 +3246,7 @@ watch(selectedCommThreadId, () => {
commComposerMode.value = "message"; commComposerMode.value = "message";
commQuickMenuOpen.value = false; commQuickMenuOpen.value = false;
commEventError.value = ""; commEventError.value = "";
commDocumentForm.value = { type: "Template", title: "" }; commDocumentForm.value = { title: "" };
eventCloseOpen.value = {}; eventCloseOpen.value = {};
eventCloseDraft.value = {}; eventCloseDraft.value = {};
eventCloseSaving.value = {}; eventCloseSaving.value = {};
@@ -3953,7 +4015,6 @@ function setDefaultCommEventForm(mode: "planned" | "logged") {
function setDefaultCommDocumentForm() { function setDefaultCommDocumentForm() {
commDocumentForm.value = { commDocumentForm.value = {
type: "Template",
title: "", title: "",
}; };
} }
@@ -4089,7 +4150,6 @@ async function createCommDocument() {
const res = await gqlFetch<{ createWorkspaceDocument: WorkspaceDocument }>(createWorkspaceDocumentMutation, { const res = await gqlFetch<{ createWorkspaceDocument: WorkspaceDocument }>(createWorkspaceDocumentMutation, {
input: { input: {
title, title,
type: commDocumentForm.value.type,
owner: authDisplayName.value, owner: authDisplayName.value,
scope, scope,
summary, summary,
@@ -4876,7 +4936,12 @@ async function decideFeedCard(card: FeedCard, decision: "accepted" | "rejected")
v-if="calendarZoomOverlay.active" v-if="calendarZoomOverlay.active"
class="calendar-zoom-overlay" class="calendar-zoom-overlay"
:style="calendarZoomOverlayStyle" :style="calendarZoomOverlayStyle"
/> >
<div v-if="calendarZoomGhost" class="calendar-zoom-overlay-content">
<p class="calendar-zoom-overlay-title">{{ calendarZoomGhost.title }}</p>
<p v-if="calendarZoomGhost.subtitle" class="calendar-zoom-overlay-subtitle">{{ calendarZoomGhost.subtitle }}</p>
</div>
</div>
</div> </div>
</section> </section>
@@ -5626,16 +5691,6 @@ async function decideFeedCard(card: FeedCard, decision: "accepted" | "rejected")
:disabled="commEventSaving" :disabled="commEventSaving"
placeholder="Document title (optional)" placeholder="Document title (optional)"
> >
<select
v-model="commDocumentForm.type"
class="select select-bordered select-xs h-7 min-h-7"
:disabled="commEventSaving"
>
<option value="Regulation">Regulation</option>
<option value="Playbook">Playbook</option>
<option value="Policy">Policy</option>
<option value="Template">Template</option>
</select>
</div> </div>
<p v-if="commEventError && commComposerMode !== 'message'" class="comm-event-error text-xs text-error"> <p v-if="commEventError && commComposerMode !== 'message'" class="comm-event-error text-xs text-error">
@@ -5764,7 +5819,6 @@ async function decideFeedCard(card: FeedCard, decision: "accepted" | "rejected")
> >
<div class="flex items-start justify-between gap-2"> <div class="flex items-start justify-between gap-2">
<p class="min-w-0 flex-1 truncate text-xs font-semibold">{{ doc.title }}</p> <p class="min-w-0 flex-1 truncate text-xs font-semibold">{{ doc.title }}</p>
<span class="shrink-0 text-[10px] text-base-content/55">{{ doc.type }}</span>
</div> </div>
<p class="mt-0.5 line-clamp-2 text-[11px] text-base-content/70">{{ doc.summary }}</p> <p class="mt-0.5 line-clamp-2 text-[11px] text-base-content/70">{{ doc.summary }}</p>
<div class="mt-1 flex items-center justify-between gap-2"> <div class="mt-1 flex items-center justify-between gap-2">
@@ -5909,7 +5963,6 @@ async function decideFeedCard(card: FeedCard, decision: "accepted" | "rejected")
> >
<div class="flex items-start justify-between gap-2"> <div class="flex items-start justify-between gap-2">
<p class="min-w-0 flex-1 truncate text-xs font-semibold">{{ doc.title }}</p> <p class="min-w-0 flex-1 truncate text-xs font-semibold">{{ doc.title }}</p>
<span class="shrink-0 text-[10px] text-base-content/55">{{ doc.type }}</span>
</div> </div>
<p class="mt-0.5 truncate text-[11px] text-base-content/75">{{ formatDocumentScope(doc.scope) }}</p> <p class="mt-0.5 truncate text-[11px] text-base-content/75">{{ formatDocumentScope(doc.scope) }}</p>
<p class="mt-0.5 line-clamp-2 text-[11px] text-base-content/70">{{ doc.summary }}</p> <p class="mt-0.5 line-clamp-2 text-[11px] text-base-content/70">{{ doc.summary }}</p>
@@ -5926,7 +5979,7 @@ async function decideFeedCard(card: FeedCard, decision: "accepted" | "rejected")
<div class="border-b border-base-300 pb-2"> <div class="border-b border-base-300 pb-2">
<p class="font-medium">{{ selectedDocument.title }}</p> <p class="font-medium">{{ selectedDocument.title }}</p>
<p class="text-xs text-base-content/60"> <p class="text-xs text-base-content/60">
{{ selectedDocument.type }} · {{ formatDocumentScope(selectedDocument.scope) }} · {{ selectedDocument.owner }} {{ formatDocumentScope(selectedDocument.scope) }} · {{ selectedDocument.owner }}
</p> </p>
<p class="mt-1 text-sm text-base-content/80">{{ selectedDocument.summary }}</p> <p class="mt-1 text-sm text-base-content/80">{{ selectedDocument.summary }}</p>
</div> </div>
@@ -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: 3px solid color-mix(in oklab, var(--color-primary) 64%, transparent);
border-radius: 12px; border-radius: 12px;
background: color-mix(in oklab, var(--color-primary) 14%, transparent); background: color-mix(in oklab, var(--color-primary) 14%, transparent);
overflow: hidden;
pointer-events: none; pointer-events: none;
transition: transition:
left 2400ms cubic-bezier(0.16, 0.86, 0.18, 1), 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); 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) { @media (max-width: 960px) {
.calendar-content-wrap { .calendar-content-wrap {
padding-left: 32px; padding-left: 32px;