feat: add unified client timeline query

This commit is contained in:
Ruslan Bakiev
2026-02-23 10:48:21 +07:00
parent c9e4c3172e
commit 4b9682e447
5 changed files with 660 additions and 54 deletions

View File

@@ -3,6 +3,7 @@ import { nextTick, onBeforeUnmount, onMounted } from "vue";
import meQuery from "~~/graphql/operations/me.graphql?raw";
import chatMessagesQuery from "~~/graphql/operations/chat-messages.graphql?raw";
import dashboardQuery from "~~/graphql/operations/dashboard.graphql?raw";
import getClientTimelineQuery from "~~/graphql/operations/get-client-timeline.graphql?raw";
import loginMutation from "~~/graphql/operations/login.graphql?raw";
import logoutMutation from "~~/graphql/operations/logout.graphql?raw";
import logPilotNoteMutation from "~~/graphql/operations/log-pilot-note.graphql?raw";
@@ -145,6 +146,18 @@ type WorkspaceDocument = {
body: string;
};
type ClientTimelineItem = {
id: string;
contactId: string;
contentType: "message" | "calendar_event" | "document" | "recommendation" | string;
contentId: string;
datetime: string;
message?: CommItem | null;
calendarEvent?: CalendarEvent | null;
recommendation?: FeedCard | null;
document?: WorkspaceDocument | null;
};
const selectedTab = ref<TabId>("communications");
const peopleLeftMode = ref<PeopleLeftMode>("contacts");
@@ -319,6 +332,7 @@ const calendarEvents = ref<CalendarEvent[]>([]);
const commItems = ref<CommItem[]>([]);
const contactInboxes = ref<ContactInbox[]>([]);
const clientTimelineItems = ref<ClientTimelineItem[]>([]);
const commPins = ref<CommPin[]>([]);
@@ -516,6 +530,7 @@ let crmRealtimeReconnectTimer: ReturnType<typeof setTimeout> | null = null;
let crmRealtimeRefreshTimer: ReturnType<typeof setTimeout> | null = null;
let crmRealtimeRefreshInFlight = false;
let crmRealtimeReconnectAttempt = 0;
let clientTimelineRequestToken = 0;
watch(
() => pilotLiveLogs.value.length,
@@ -873,6 +888,7 @@ async function bootstrapSession() {
stopCrmRealtime();
pilotMessages.value = [];
chatConversations.value = [];
clientTimelineItems.value = [];
telegramConnectStatus.value = "not_connected";
telegramConnections.value = [];
telegramConnectUrl.value = "";
@@ -887,6 +903,7 @@ async function bootstrapSession() {
authMe.value = null;
pilotMessages.value = [];
chatConversations.value = [];
clientTimelineItems.value = [];
telegramConnectStatus.value = "not_connected";
telegramConnections.value = [];
telegramConnectUrl.value = "";
@@ -959,6 +976,7 @@ async function logout() {
livePilotAssistantText.value = "";
pilotChat.messages = [];
chatConversations.value = [];
clientTimelineItems.value = [];
telegramConnectStatus.value = "not_connected";
telegramConnections.value = [];
telegramConnectUrl.value = "";
@@ -997,6 +1015,33 @@ async function refreshCrmData() {
...c,
channels: Array.from(byName.get(c.name) ?? []),
}));
await refreshSelectedClientTimeline();
}
async function loadClientTimeline(contactId: string, limit = 500) {
const normalizedContactId = String(contactId ?? "").trim();
if (!normalizedContactId) {
clientTimelineItems.value = [];
return;
}
const requestToken = ++clientTimelineRequestToken;
const data = await gqlFetch<{ getClientTimeline: ClientTimelineItem[] }>(getClientTimelineQuery, {
contactId: normalizedContactId,
limit,
});
if (requestToken !== clientTimelineRequestToken) return;
clientTimelineItems.value = data.getClientTimeline ?? [];
}
async function refreshSelectedClientTimeline() {
const contactId = String(selectedCommThreadId.value ?? "").trim();
if (!contactId) {
clientTimelineItems.value = [];
return;
}
await loadClientTimeline(contactId);
}
function clearCrmRealtimeReconnectTimer() {
@@ -3487,8 +3532,14 @@ watch(selectedCommThreadId, () => {
eventArchiveRecordingById.value = {};
eventArchiveTranscribingById.value = {};
eventArchiveMicErrorById.value = {};
clientTimelineRequestToken += 1;
const preferred = selectedCommThread.value?.channels.find((channel) => channel !== "Phone") ?? "";
commSendChannel.value = preferred;
if (!selectedCommThread.value) {
clientTimelineItems.value = [];
return;
}
void refreshSelectedClientTimeline().catch(() => undefined);
});
const commSendChannelOptions = computed<CommItem["channel"][]>(() => {
@@ -3502,14 +3553,6 @@ const visibleThreadItems = computed(() => {
return selectedCommThread.value.items;
});
const selectedThreadRecommendation = computed(() => {
if (!selectedCommThread.value) return null;
const cards = feedCards.value
.filter((card) => card.contact === selectedCommThread.value?.contact)
.sort((a, b) => a.at.localeCompare(b.at));
return cards[cards.length - 1] ?? null;
});
const selectedCommPins = computed(() => {
if (!selectedCommThread.value) return [];
return commPins.value.filter((item) => item.contact === selectedCommThread.value?.contact);
@@ -3536,67 +3579,68 @@ const hiddenContactInboxes = computed(() =>
);
const selectedCommLifecycleEvents = computed(() => {
if (!selectedCommThread.value) return [];
const nowMs = lifecycleNowMs.value;
return sortedEvents.value
.filter((event) => event.contact === selectedCommThread.value?.contact)
.map((event) => {
return clientTimelineItems.value
.filter((entry) => entry.contentType === "calendar_event" && entry.calendarEvent)
.map((entry) => {
const event = entry.calendarEvent as CalendarEvent;
const phase = eventLifecyclePhase(event, nowMs);
return {
event,
phase,
timelineAt: eventTimelineAt(event, phase),
timelineAt: entry.datetime,
};
})
.sort((a, b) => a.timelineAt.localeCompare(b.timelineAt))
.slice(-12);
.sort((a, b) => a.timelineAt.localeCompare(b.timelineAt));
});
const threadStreamItems = computed(() => {
const messageRows = visibleThreadItems.value.map((item) => ({
id: `comm-${item.id}`,
at: item.at,
kind: item.kind,
item,
})).sort((a, b) => a.at.localeCompare(b.at));
const rows = clientTimelineItems.value
.map((entry) => {
if (entry.contentType === "message" && entry.message) {
return {
id: entry.id,
at: entry.datetime,
kind: entry.message.kind,
item: entry.message,
};
}
const centeredRows: Array<
| {
id: string;
at: string;
kind: "eventLifecycle";
event: CalendarEvent;
phase: EventLifecyclePhase;
}
| {
id: string;
at: string;
kind: "recommendation";
card: FeedCard;
}
> = [];
if (entry.contentType === "calendar_event" && entry.calendarEvent) {
const phase = eventLifecyclePhase(entry.calendarEvent, lifecycleNowMs.value);
return {
id: entry.id,
at: entry.datetime,
kind: "eventLifecycle" as const,
event: entry.calendarEvent,
phase,
};
}
for (const entry of selectedCommLifecycleEvents.value) {
centeredRows.push({
id: `event-${entry.event.id}`,
at: entry.timelineAt,
kind: "eventLifecycle",
event: entry.event,
phase: entry.phase,
});
}
if (entry.contentType === "recommendation" && entry.recommendation) {
return {
id: entry.id,
at: entry.datetime,
kind: "recommendation" as const,
card: entry.recommendation,
};
}
if (selectedThreadRecommendation.value) {
centeredRows.push({
id: `rec-${selectedThreadRecommendation.value.id}`,
at: selectedThreadRecommendation.value.at,
kind: "recommendation",
card: selectedThreadRecommendation.value,
});
}
if (entry.contentType === "document" && entry.document) {
return {
id: entry.id,
at: entry.datetime,
kind: "document" as const,
document: entry.document,
};
}
return [...messageRows, ...centeredRows].sort((a, b) => a.at.localeCompare(b.at));
return null;
})
.filter((entry) => entry !== null) as Array<any>;
return rows.sort((a, b) => a.at.localeCompare(b.at));
});
watch(
@@ -5979,6 +6023,17 @@ async function decideFeedCard(card: FeedCard, decision: "accepted" | "rejected")
</article>
</div>
<div v-else-if="entry.kind === 'document'" class="flex justify-center">
<article class="w-full max-w-[460px] rounded-xl border border-base-300 bg-base-100 p-3 text-left">
<p class="text-xs text-base-content/65">Document · {{ formatStamp(entry.at) }}</p>
<p class="mt-1 text-sm font-semibold text-base-content">{{ entry.document.title }}</p>
<p class="mt-1 text-xs text-base-content/70">
{{ formatDocumentScope(entry.document.scope) }} · {{ entry.document.owner }}
</p>
<p class="mt-2 text-sm text-base-content/85">{{ entry.document.summary }}</p>
</article>
</div>
<div v-else-if="entry.kind === 'recommendation'" class="flex justify-center">
<article class="w-full max-w-[460px] rounded-xl border border-base-300 bg-base-100 p-3">
<p class="text-sm">{{ entry.card.text }}</p>