diff --git a/Frontend/app.vue b/Frontend/app.vue index 8a01075..a77ba65 100644 --- a/Frontend/app.vue +++ b/Frontend/app.vue @@ -210,11 +210,37 @@ function eventTimelineAt(event: CalendarEvent, phase: EventLifecyclePhase) { return eventDueAt(event); } -function eventPhaseLabel(phase: EventLifecyclePhase) { - if (phase === "scheduled") return "Scheduled"; - if (phase === "due_soon") return "Starts in <30 min"; - if (phase === "awaiting_outcome") return "Awaiting outcome"; - return "Closed"; +function eventRelativeLabel(event: CalendarEvent, nowMs: number) { + if (event.isArchived) return "Archived"; + const diffMs = new Date(event.start).getTime() - nowMs; + const minuteMs = 60 * 1000; + const hourMs = 60 * minuteMs; + const dayMs = 24 * hourMs; + const abs = Math.abs(diffMs); + + if (diffMs >= 0) { + if (abs >= dayMs) { + const days = Math.round(abs / dayMs); + return `Event in ${days} day${days === 1 ? "" : "s"}`; + } + if (abs >= hourMs) { + const hours = Math.round(abs / hourMs); + return `Event in ${hours} hour${hours === 1 ? "" : "s"}`; + } + const minutes = Math.max(1, Math.round(abs / minuteMs)); + return `Event in ${minutes} minute${minutes === 1 ? "" : "s"}`; + } + + if (abs >= dayMs) { + const days = Math.round(abs / dayMs); + return `Overdue by ${days} day${days === 1 ? "" : "s"}`; + } + if (abs >= hourMs) { + const hours = Math.round(abs / hourMs); + return `Overdue by ${hours} hour${hours === 1 ? "" : "s"}`; + } + const minutes = Math.max(1, Math.round(abs / minuteMs)); + return `Overdue by ${minutes} minute${minutes === 1 ? "" : "s"}`; } function eventPhaseToneClass(phase: EventLifecyclePhase) { @@ -1014,16 +1040,20 @@ async function decodeAudioBlobToPcm16(blob: Blob) { } } +async function transcribeAudioBlob(blob: Blob) { + const payload = await decodeAudioBlobToPcm16(blob); + const result = await $fetch<{ text?: string }>("/api/pilot-transcribe", { + method: "POST", + body: payload, + }); + return String(result?.text ?? "").trim(); +} + async function transcribeRecordedPilotAudio(blob: Blob) { pilotMicError.value = null; pilotTranscribing.value = true; try { - const payload = await decodeAudioBlobToPcm16(blob); - const result = await $fetch<{ text?: string }>("/api/pilot-transcribe", { - method: "POST", - body: payload, - }); - const text = String(result?.text ?? "").trim(); + const text = await transcribeAudioBlob(blob); if (!text) { pilotMicError.value = "Не удалось распознать речь, попробуйте еще раз."; return null; @@ -1219,6 +1249,7 @@ onBeforeUnmount(() => { if (pilotRecording.value) { stopPilotRecording("fill"); } + stopEventArchiveRecording(); destroyAllCommCallWaves(); void stopPilotMeter(); if (pilotWaveSurfer) { @@ -1662,23 +1693,30 @@ const commPinnedOnly = ref(false); const commDraft = ref(""); const commSending = ref(false); const commRecording = ref(false); -const commEventModalOpen = ref(false); +const commComposerMode = ref<"message" | "planned" | "logged">("message"); const commEventSaving = ref(false); const commEventError = ref(""); const commEventMode = ref<"planned" | "logged">("planned"); const commEventForm = ref({ - title: "", startDate: "", startTime: "", durationMinutes: 30, - note: "", }); const eventCloseOpen = ref>({}); const eventCloseDraft = ref>({}); const eventCloseSaving = ref>({}); const eventCloseError = ref>({}); +const eventArchiveRecordingById = ref>({}); +const eventArchiveTranscribingById = ref>({}); +const eventArchiveMicErrorById = ref>({}); +let eventArchiveMediaRecorder: MediaRecorder | null = null; +let eventArchiveRecorderStream: MediaStream | null = null; +let eventArchiveRecorderMimeType = "audio/webm"; +let eventArchiveChunks: Blob[] = []; +let eventArchiveTargetEventId = ""; watch(selectedCommThreadId, () => { + stopEventArchiveRecording(); destroyAllCommCallWaves(); callTranscriptOpen.value = {}; callTranscriptLoading.value = {}; @@ -1686,12 +1724,15 @@ watch(selectedCommThreadId, () => { callTranscriptError.value = {}; commPinnedOnly.value = false; commDraft.value = ""; - commEventModalOpen.value = false; + commComposerMode.value = "message"; commEventError.value = ""; eventCloseOpen.value = {}; eventCloseDraft.value = {}; eventCloseSaving.value = {}; eventCloseError.value = {}; + eventArchiveRecordingById.value = {}; + eventArchiveTranscribingById.value = {}; + eventArchiveMicErrorById.value = {}; const fallback = selectedCommThread.value?.channels.find((channel) => channel !== "Phone") ?? "Telegram"; commSendChannel.value = fallback; }); @@ -1872,6 +1913,91 @@ function toggleEventClose(eventId: string) { } } +function isEventArchiveRecording(eventId: string) { + return Boolean(eventArchiveRecordingById.value[eventId]); +} + +function isEventArchiveTranscribing(eventId: string) { + return Boolean(eventArchiveTranscribingById.value[eventId]); +} + +async function startEventArchiveRecording(eventId: string) { + if (eventArchiveMediaRecorder || isEventArchiveTranscribing(eventId)) return; + eventArchiveMicErrorById.value = { ...eventArchiveMicErrorById.value, [eventId]: "" }; + try { + const stream = await navigator.mediaDevices.getUserMedia({ audio: true }); + const preferredMime = "audio/webm;codecs=opus"; + const recorder = MediaRecorder.isTypeSupported(preferredMime) + ? new MediaRecorder(stream, { mimeType: preferredMime }) + : new MediaRecorder(stream); + + eventArchiveRecorderStream = stream; + eventArchiveRecorderMimeType = recorder.mimeType || "audio/webm"; + eventArchiveMediaRecorder = recorder; + eventArchiveChunks = []; + eventArchiveTargetEventId = eventId; + eventArchiveRecordingById.value = { ...eventArchiveRecordingById.value, [eventId]: true }; + + recorder.ondataavailable = (event: BlobEvent) => { + if (event.data?.size) eventArchiveChunks.push(event.data); + }; + + recorder.onstop = async () => { + const targetId = eventArchiveTargetEventId; + eventArchiveRecordingById.value = { ...eventArchiveRecordingById.value, [targetId]: false }; + eventArchiveMediaRecorder = null; + eventArchiveTargetEventId = ""; + if (eventArchiveRecorderStream) { + eventArchiveRecorderStream.getTracks().forEach((track) => track.stop()); + eventArchiveRecorderStream = null; + } + + const audioBlob = new Blob(eventArchiveChunks, { type: eventArchiveRecorderMimeType }); + eventArchiveChunks = []; + if (!targetId || audioBlob.size === 0) return; + eventArchiveTranscribingById.value = { ...eventArchiveTranscribingById.value, [targetId]: true }; + try { + const text = await transcribeAudioBlob(audioBlob); + if (!text) { + eventArchiveMicErrorById.value = { ...eventArchiveMicErrorById.value, [targetId]: "Could not recognize speech" }; + return; + } + const previous = String(eventCloseDraft.value[targetId] ?? "").trim(); + const merged = previous ? `${previous} ${text}` : text; + eventCloseDraft.value = { ...eventCloseDraft.value, [targetId]: merged }; + } catch (error: any) { + eventArchiveMicErrorById.value = { + ...eventArchiveMicErrorById.value, + [targetId]: String(error?.data?.message ?? error?.message ?? "Voice transcription failed"), + }; + } finally { + eventArchiveTranscribingById.value = { ...eventArchiveTranscribingById.value, [targetId]: false }; + } + }; + + recorder.start(); + } catch { + eventArchiveMicErrorById.value = { ...eventArchiveMicErrorById.value, [eventId]: "No microphone access" }; + } +} + +function stopEventArchiveRecording() { + if (!eventArchiveMediaRecorder || eventArchiveMediaRecorder.state === "inactive") return; + eventArchiveMediaRecorder.stop(); +} + +function toggleEventArchiveRecording(eventId: string) { + if (!pilotMicSupported.value) { + eventArchiveMicErrorById.value = { ...eventArchiveMicErrorById.value, [eventId]: "Recording is not supported in this browser" }; + return; + } + if (isEventArchiveRecording(eventId)) { + stopEventArchiveRecording(); + return; + } + void startEventArchiveRecording(eventId); +} + async function archiveEventManually(event: CalendarEvent) { const eventId = event.id; const archiveNote = String(eventCloseDraft.value[eventId] ?? "").trim(); @@ -2234,13 +2360,10 @@ function setDefaultCommEventForm(mode: "planned" | "logged") { const start = mode === "planned" ? roundToNextQuarter(new Date(Date.now() + 15 * 60 * 1000)) : roundToPrevQuarter(new Date(Date.now() - 30 * 60 * 1000)); - const titleSeed = selectedCommThread.value?.contact?.split(" ")[0] ?? "Contact"; commEventForm.value = { - title: mode === "planned" ? `Follow-up with ${titleSeed}` : `Call note with ${titleSeed}`, startDate: toInputDate(start), startTime: toInputTime(start), durationMinutes: 30, - note: "", }; } @@ -2249,22 +2372,38 @@ function openCommEventModal(mode: "planned" | "logged") { commEventMode.value = mode; setDefaultCommEventForm(mode); commEventError.value = ""; - commEventModalOpen.value = true; + commComposerMode.value = mode; } function closeCommEventModal() { if (commEventSaving.value) return; - commEventModalOpen.value = false; + commComposerMode.value = "message"; + commEventError.value = ""; +} + +function commComposerPlaceholder() { + if (commComposerMode.value === "planned") return "Опиши, что нужно запланировать..."; + if (commComposerMode.value === "logged") return "Опиши итог/отчёт по прошедшему событию..."; + return "Type a message..."; +} + +function buildCommEventTitle(text: string, mode: "planned" | "logged", contact: string) { + const cleaned = text.replace(/\s+/g, " ").trim(); + if (cleaned) { + const sentence = cleaned.split(/[.!?\n]/)[0]?.trim() ?? ""; + if (sentence) return sentence.slice(0, 120); + } + return mode === "logged" ? `Отчёт по контакту ${contact}` : `Событие с ${contact}`; } async function createCommEvent() { if (!selectedCommThread.value || commEventSaving.value) return; - const title = commEventForm.value.title.trim(); - const note = commEventForm.value.note.trim(); + const note = commDraft.value.trim(); + const title = buildCommEventTitle(note, commEventMode.value, selectedCommThread.value.contact); const duration = Number(commEventForm.value.durationMinutes || 0); - if (!title) { - commEventError.value = "Event title is required"; + if (!note) { + commEventError.value = "Текст события обязателен"; return; } @@ -2300,7 +2439,9 @@ async function createCommEvent() { calendarEvents.value = [res.createCalendarEvent, ...calendarEvents.value]; selectedDateKey.value = dayKey(start); calendarCursor.value = new Date(start.getFullYear(), start.getMonth(), 1); - commEventModalOpen.value = false; + commDraft.value = ""; + commComposerMode.value = "message"; + commEventError.value = ""; } catch (error: any) { commEventError.value = String(error?.message ?? error ?? "Failed to create event"); } finally { @@ -2342,7 +2483,11 @@ function toggleCommRecording() { function handleCommComposerEnter(event: KeyboardEvent) { if (event.shiftKey) return; event.preventDefault(); - void sendCommMessage(); + if (commComposerMode.value === "message") { + void sendCommMessage(); + return; + } + void createCommEvent(); } async function executeFeedAction(card: FeedCard) { @@ -3416,8 +3561,8 @@ async function decideFeedCard(card: FeedCard, decision: "accepted" | "rejected")
-

{{ eventPhaseLabel(entry.phase) }}

-

{{ formatDay(entry.event.start) }} · {{ formatTime(entry.event.start) }}

+

{{ formatDay(entry.event.start) }} · {{ formatTime(entry.event.start) }}

+

{{ eventRelativeLabel(entry.event, lifecycleNowMs) }}

{{ entry.event.note || entry.event.title }}

Archive note: {{ entry.event.archiveNote }}

@@ -3434,6 +3579,22 @@ async function decideFeedCard(card: FeedCard, decision: "accepted" | "rejected") rows="3" placeholder="Archive note (optional)" /> +
+ +
+

{{ eventArchiveMicErrorById[entry.event.id] }}

{{ eventCloseError[entry.event.id] }}

+
+