feat: add unified client timeline query
This commit is contained in:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user