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 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<HTMLElement | null>(null);
const calendarHoveredMonthIndex = ref<number | null>(null);
@@ -2286,13 +2290,13 @@ const calendarZoomOverlay = ref<{ active: boolean } & CalendarRect>({
width: 0,
height: 0,
});
const calendarZoomGhost = ref<CalendarZoomGhost | null>(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<typeof setTimeout> | null = null;
let calendarZoomPrimeTimer: ReturnType<typeof setTimeout> | 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<void>((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<Record<string, boolean>>({});
@@ -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"
/>
>
<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>
</section>
@@ -5626,16 +5691,6 @@ async function decideFeedCard(card: FeedCard, decision: "accepted" | "rejected")
:disabled="commEventSaving"
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>
<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">
<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>
<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">
@@ -5909,7 +5963,6 @@ async function decideFeedCard(card: FeedCard, decision: "accepted" | "rejected")
>
<div class="flex items-start justify-between gap-2">
<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>
<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>
@@ -5926,7 +5979,7 @@ async function decideFeedCard(card: FeedCard, decision: "accepted" | "rejected")
<div class="border-b border-base-300 pb-2">
<p class="font-medium">{{ selectedDocument.title }}</p>
<p class="text-xs text-base-content/60">
{{ selectedDocument.type }} · {{ formatDocumentScope(selectedDocument.scope) }} · {{ selectedDocument.owner }}
{{ formatDocumentScope(selectedDocument.scope) }} · {{ selectedDocument.owner }}
</p>
<p class="mt-1 text-sm text-base-content/80">{{ selectedDocument.summary }}</p>
</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-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;