feat(workspace): add hidden contacts filter and remove calendar scene swap

This commit is contained in:
Ruslan Bakiev
2026-02-23 12:38:30 +07:00
parent f076726362
commit aa465f65bd
3 changed files with 107 additions and 40 deletions

View File

@@ -41,6 +41,7 @@ type CalendarView = "day" | "week" | "month" | "year" | "agenda";
type SortMode = "name" | "lastContact";
type PeopleLeftMode = "contacts" | "calendar";
type PeopleSortMode = "name" | "lastContact" | "company" | "country";
type PeopleVisibilityMode = "all" | "hidden";
type DocumentSortMode = "updatedAt" | "title" | "owner";
type FeedCard = {
@@ -2397,7 +2398,6 @@ const calendarZoomOverlay = ref<{ active: boolean } & CalendarRect>({
});
const calendarZoomGhost = ref<CalendarZoomGhost | null>(null);
const calendarZoomBusy = ref(false);
const calendarSceneMasked = ref(false);
const calendarCameraState = ref({
active: false,
left: 0,
@@ -2412,6 +2412,8 @@ let calendarWheelLockUntil = 0;
let calendarZoomPrimeTimer: ReturnType<typeof setTimeout> | null = null;
let calendarZoomPrimeLastAt = 0;
const CALENDAR_ZOOM_DURATION_MS = 2400;
const CALENDAR_ZOOM_FOCUS_MS = 1400;
const CALENDAR_ZOOM_REVEAL_MS = Math.max(500, CALENDAR_ZOOM_DURATION_MS - CALENDAR_ZOOM_FOCUS_MS);
const CALENDAR_ZOOM_PRIME_STEPS = 2;
const CALENDAR_ZOOM_PRIME_MAX_SCALE = 1.05;
const CALENDAR_ZOOM_PRIME_RESET_MS = 900;
@@ -2762,11 +2764,15 @@ function waitCalendarZoomTransition() {
});
}
async function animateCalendarZoomIn(sourceElement: HTMLElement | null, ghost: CalendarZoomGhost, apply: () => void) {
async function animateCalendarZoomIn(
sourceElement: HTMLElement | null,
ghost: CalendarZoomGhost,
apply: () => void,
resolveRevealTarget?: () => HTMLElement | null,
) {
clearCalendarZoomPrime();
calendarZoomBusy.value = true;
clearCalendarZoomOverlay();
calendarSceneMasked.value = false;
try {
calendarZoomGhost.value = ghost;
const fromRect = getElementRectInScene(sourceElement) ?? fallbackZoomOriginRectInScene();
@@ -2790,19 +2796,36 @@ async function animateCalendarZoomIn(sourceElement: HTMLElement | null, ghost: C
left: cameraTarget.left,
top: cameraTarget.top,
scale: cameraTarget.scale,
durationMs: CALENDAR_ZOOM_DURATION_MS,
durationMs: CALENDAR_ZOOM_FOCUS_MS,
};
await waitCalendarCameraTransition();
// Freeze the filled block frame, then swap level while scene is masked.
// This keeps the "zoom into block -> reveal next grid inside" sequence.
calendarSceneMasked.value = true;
await nextAnimationFrame();
apply();
await nextTick();
const revealTargetElement = resolveRevealTarget ? resolveRevealTarget() : sourceElement;
const revealTargetRect = getElementRectInScene(revealTargetElement) ?? fallbackZoomOriginRectInScene();
const revealTarget = revealTargetRect ? cameraTransformForRect(revealTargetRect) : null;
if (revealTarget) {
calendarCameraState.value = {
active: true,
left: revealTarget.left,
top: revealTarget.top,
scale: revealTarget.scale,
durationMs: 0,
};
await nextTick();
await nextAnimationFrame();
}
calendarCameraState.value = {
active: true,
left: 0,
top: 0,
scale: 1,
durationMs: CALENDAR_ZOOM_REVEAL_MS,
};
await waitCalendarCameraTransition();
} finally {
await resetCalendarCamera();
calendarZoomGhost.value = null;
calendarSceneMasked.value = false;
calendarZoomBusy.value = false;
}
}
@@ -2813,15 +2836,11 @@ async function animateCalendarZoomOut(apply: () => void, resolveTarget: () => HT
clearCalendarZoomOverlay();
try {
calendarZoomGhost.value = zoomGhostForCurrentView();
calendarSceneMasked.value = true;
apply();
await nextTick();
const targetRect = getElementRectInScene(resolveTarget()) ?? fallbackZoomOriginRectInScene();
const cameraStart = targetRect ? cameraTransformForRect(targetRect) : null;
if (!cameraStart) {
calendarSceneMasked.value = false;
return;
}
if (!cameraStart) return;
calendarCameraState.value = {
active: true,
left: cameraStart.left,
@@ -2832,8 +2851,6 @@ async function animateCalendarZoomOut(apply: () => void, resolveTarget: () => HT
await nextTick();
await nextAnimationFrame();
calendarSceneRef.value?.getBoundingClientRect();
calendarSceneMasked.value = false;
await nextAnimationFrame();
calendarCameraState.value = {
active: true,
left: 0,
@@ -2845,7 +2862,6 @@ async function animateCalendarZoomOut(apply: () => void, resolveTarget: () => HT
} finally {
await resetCalendarCamera();
calendarZoomGhost.value = null;
calendarSceneMasked.value = false;
calendarZoomBusy.value = false;
}
}
@@ -2895,7 +2911,7 @@ async function zoomInCalendar(event?: Event) {
if (maybePrimeWheelZoom(wheelEvent, calendarPrimeMonthToken(monthIndex))) return;
await animateCalendarZoomIn(sourceElement, zoomGhostForMonth(monthIndex), () => {
openYearMonth(monthIndex);
});
}, () => queryCalendarElement(`[data-calendar-month-index="${monthIndex}"]`));
return;
}
@@ -2914,6 +2930,9 @@ async function zoomInCalendar(event?: Event) {
() => {
openWeekView(anchorDayKey);
},
() =>
queryCalendarElement(`[data-calendar-week-start-key="${rowStartKey}"]`) ??
queryCalendarElement(`[data-calendar-day-key="${anchorDayKey}"]`),
);
return;
}
@@ -2924,7 +2943,7 @@ async function zoomInCalendar(event?: Event) {
if (maybePrimeWheelZoom(wheelEvent, calendarPrimeDayToken(dayAnchor))) return;
await animateCalendarZoomIn(sourceElement, zoomGhostForDay(dayAnchor), () => {
openDayView(dayAnchor);
});
}, () => queryCalendarElement(`[data-calendar-day-key="${dayAnchor}"]`));
}
}
@@ -2935,6 +2954,7 @@ async function zoomToMonth(monthIndex: number) {
() => {
openYearMonth(monthIndex);
},
() => queryCalendarElement(`[data-calendar-month-index="${monthIndex}"]`),
);
}
@@ -3366,6 +3386,7 @@ function openDocumentsTab(push = false) {
const peopleListMode = ref<"contacts" | "deals">("contacts");
const peopleSearch = ref("");
const peopleSortMode = ref<PeopleSortMode>("lastContact");
const peopleVisibilityMode = ref<PeopleVisibilityMode>("all");
const brokenAvatarByContactId = ref<Record<string, boolean>>({});
const peopleSortOptions: Array<{ value: PeopleSortMode; label: string }> = [
{ value: "lastContact", label: "Last contact" },
@@ -3373,6 +3394,10 @@ const peopleSortOptions: Array<{ value: PeopleSortMode; label: string }> = [
{ value: "company", label: "Company" },
{ value: "country", label: "Country" },
];
const peopleVisibilityOptions: Array<{ value: PeopleVisibilityMode; label: string }> = [
{ value: "all", label: "All" },
{ value: "hidden", label: "Hidden" },
];
const selectedDealId = ref(deals.value[0]?.id ?? "");
const selectedDealStepsExpanded = ref(false);
@@ -3412,26 +3437,54 @@ const commThreads = computed(() => {
map.get(item.contact)?.push(item);
}
return contacts.value
.map((contact) => {
const items = map.get(contact.name) ?? [];
const contactById = new Map(contacts.value.map((contact) => [contact.id, contact]));
const inboxesByContactId = new Map<string, ContactInbox[]>();
for (const inbox of contactInboxes.value) {
if (!inboxesByContactId.has(inbox.contactId)) {
inboxesByContactId.set(inbox.contactId, []);
}
inboxesByContactId.get(inbox.contactId)?.push(inbox);
}
const contactIds = new Set<string>([
...contacts.value.map((contact) => contact.id),
...contactInboxes.value.map((inbox) => inbox.contactId),
]);
return [...contactIds]
.map((contactId) => {
const contact = contactById.get(contactId);
const inboxes = inboxesByContactId.get(contactId) ?? [];
const contactName = contact?.name ?? inboxes[0]?.contactName ?? "";
const items = map.get(contactName) ?? [];
const last = items[items.length - 1];
const channels = [...new Set([...contact.channels, ...items.map((item) => item.channel)])] as CommItem["channel"][];
const channels = [
...new Set([
...(contact?.channels ?? []),
...inboxes.map((inbox) => inbox.channel),
...items.map((item) => item.channel),
]),
] as CommItem["channel"][];
const inboxFallbackLast = inboxes
.map((inbox) => inbox.lastMessageAt || inbox.updatedAt)
.filter(Boolean)
.sort()
.at(-1);
return {
id: contact.id,
contact: contact.name,
avatar: contact.avatar,
company: contact.company,
country: contact.country,
location: contact.location,
id: contactId,
contact: contactName,
avatar: contact?.avatar ?? "",
company: contact?.company ?? "",
country: contact?.country ?? "",
location: contact?.location ?? "",
channels,
lastAt: last?.at ?? contact.lastContactAt,
lastAt: last?.at ?? contact?.lastContactAt ?? inboxFallbackLast ?? "",
lastText: last?.text ?? "No messages yet",
items,
};
})
.filter((thread) => thread.items.length > 0)
.filter((thread) => thread.contact)
.sort((a, b) => b.lastAt.localeCompare(a.lastAt));
});
@@ -3442,8 +3495,12 @@ const peopleContactList = computed(() => {
const haystack = [item.contact, item.company, item.country, item.location].join(" ").toLowerCase();
return haystack.includes(query);
});
const byVisibility = list.filter((item) => {
if (peopleVisibilityMode.value === "all") return true;
return threadInboxes(item).some((inbox) => inbox.isHidden);
});
return list.sort((a, b) => {
return byVisibility.sort((a, b) => {
if (peopleSortMode.value === "name") return a.contact.localeCompare(b.contact);
if (peopleSortMode.value === "company") return a.company.localeCompare(b.company);
if (peopleSortMode.value === "country") return a.country.localeCompare(b.country);
@@ -4853,7 +4910,6 @@ async function decideFeedCard(card: FeedCard, decision: "accepted" | "rejected")
:on-calendar-hierarchy-wheel="onCalendarHierarchyWheel"
:set-calendar-scene-ref="setCalendarSceneRef"
:normalized-calendar-view="normalizedCalendarView"
:calendar-scene-masked="calendarSceneMasked"
:calendar-scene-transform-style="calendarSceneTransformStyle"
:on-calendar-scene-mouse-leave="onCalendarSceneMouseLeave"
:calendar-view="calendarView"
@@ -4894,6 +4950,8 @@ async function decideFeedCard(card: FeedCard, decision: "accepted" | "rejected")
:people-search="peopleSearch"
:people-sort-options="peopleSortOptions"
:people-sort-mode="peopleSortMode"
:people-visibility-options="peopleVisibilityOptions"
:people-visibility-mode="peopleVisibilityMode"
:people-contact-list="peopleContactList"
:selected-comm-thread-id="selectedCommThreadId"
:is-review-highlighted-contact="isReviewHighlightedContact"
@@ -4915,6 +4973,7 @@ async function decideFeedCard(card: FeedCard, decision: "accepted" | "rejected")
:on-people-list-mode-change="(mode) => { peopleListMode = mode; }"
:on-people-search-input="(value) => { peopleSearch = value; }"
:on-people-sort-mode-change="(mode) => { peopleSortMode = mode; }"
:on-people-visibility-mode-change="(mode) => { peopleVisibilityMode = mode; }"
/>
<div class="hidden h-12 items-center justify-between gap-2 border-b border-base-300 px-3 md:flex md:col-span-2">

View File

@@ -53,7 +53,6 @@ defineProps<{
onCalendarHierarchyWheel: (event: WheelEvent) => void;
setCalendarSceneRef: (element: HTMLDivElement | null) => void;
normalizedCalendarView: string;
calendarSceneMasked: boolean;
calendarSceneTransformStyle: Record<string, string>;
onCalendarSceneMouseLeave: () => void;
calendarView: string;
@@ -170,7 +169,6 @@ defineProps<{
:class="[
'calendar-scene',
normalizedCalendarView === 'day' ? 'cursor-zoom-out' : 'cursor-zoom-in',
calendarSceneMasked ? 'calendar-scene-hidden' : '',
]"
:style="calendarSceneTransformStyle"
@mouseleave="onCalendarSceneMouseLeave"
@@ -351,10 +349,6 @@ defineProps<{
transform-origin: center center;
}
.calendar-scene-hidden {
visibility: hidden;
}
.calendar-scene.cursor-zoom-in,
.calendar-scene.cursor-zoom-in * {
cursor: zoom-in;

View File

@@ -6,6 +6,8 @@ defineProps<{
peopleSearch: string;
peopleSortOptions: Array<{ value: string; label: string }>;
peopleSortMode: string;
peopleVisibilityOptions: Array<{ value: string; label: string }>;
peopleVisibilityMode: string;
peopleContactList: any[];
selectedCommThreadId: string;
isReviewHighlightedContact: (contactId: string) => boolean;
@@ -26,6 +28,7 @@ defineProps<{
onPeopleListModeChange: (mode: PeopleListMode) => void;
onPeopleSearchInput: (value: string) => void;
onPeopleSortModeChange: (mode: string) => void;
onPeopleVisibilityModeChange: (mode: string) => void;
}>();
function onSearchInput(event: Event) {
@@ -91,6 +94,17 @@ function onSearchInput(event: Event) {
<span>{{ option.label }}</span>
<span v-if="peopleSortMode === option.value"></span>
</button>
<div class="my-1 h-px bg-base-300/70" />
<p class="px-2 pb-1 text-[11px] font-semibold uppercase tracking-wide text-base-content/55">Filter contacts</p>
<button
v-for="option in peopleVisibilityOptions"
:key="`people-visibility-${option.value}`"
class="btn btn-ghost btn-sm w-full justify-between"
@click="onPeopleVisibilityModeChange(option.value)"
>
<span>{{ option.label }}</span>
<span v-if="peopleVisibilityMode === option.value"></span>
</button>
</template>
<p v-else class="px-2 py-1 text-xs text-base-content/60">Deals are sorted by title.</p>
</div>
@@ -192,7 +206,7 @@ function onSearchInput(event: Event) {
</button>
<p v-if="peopleListMode === 'contacts' && peopleContactList.length === 0" class="px-1 py-2 text-xs text-base-content/55">
No contacts found.
{{ peopleVisibilityMode === 'hidden' ? 'No hidden contacts found.' : 'No contacts found.' }}
</p>
<p v-if="peopleListMode === 'deals' && peopleDealList.length === 0" class="px-1 py-2 text-xs text-base-content/55">
No deals found.