Refine event card timing text and add voice archive note

This commit is contained in:
Ruslan Bakiev
2026-02-19 17:30:00 +07:00
parent e5de1b8753
commit 895867d710

View File

@@ -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<Record<string, boolean>>({});
const eventCloseDraft = ref<Record<string, string>>({});
const eventCloseSaving = ref<Record<string, boolean>>({});
const eventCloseError = ref<Record<string, string>>({});
const eventArchiveRecordingById = ref<Record<string, boolean>>({});
const eventArchiveTranscribingById = ref<Record<string, boolean>>({});
const eventArchiveMicErrorById = ref<Record<string, string>>({});
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")
<div v-else-if="entry.kind === 'eventLifecycle'" class="flex justify-center">
<article class="w-full max-w-[460px] rounded-xl border p-3 text-center" :class="eventPhaseToneClass(entry.phase)">
<p class="text-[11px] uppercase tracking-wide text-base-content/65">{{ eventPhaseLabel(entry.phase) }}</p>
<p class="mt-1 text-xs text-base-content/65">{{ formatDay(entry.event.start) }} · {{ formatTime(entry.event.start) }}</p>
<p class="text-xs text-base-content/65">{{ formatDay(entry.event.start) }} · {{ formatTime(entry.event.start) }}</p>
<p class="mt-1 text-xs text-base-content/75">{{ eventRelativeLabel(entry.event, lifecycleNowMs) }}</p>
<p class="mt-1 text-sm text-base-content/90">{{ entry.event.note || entry.event.title }}</p>
<p v-if="entry.event.archiveNote" class="mt-2 text-xs text-base-content/70">Archive note: {{ entry.event.archiveNote }}</p>
@@ -3434,6 +3579,22 @@ async function decideFeedCard(card: FeedCard, decision: "accepted" | "rejected")
rows="3"
placeholder="Archive note (optional)"
/>
<div class="flex justify-between gap-2">
<button
class="btn btn-xs btn-outline"
:disabled="isEventArchiveTranscribing(entry.event.id)"
@click="toggleEventArchiveRecording(entry.event.id)"
>
{{
isEventArchiveTranscribing(entry.event.id)
? "Transcribing..."
: isEventArchiveRecording(entry.event.id)
? "Stop mic"
: "Voice note"
}}
</button>
</div>
<p v-if="eventArchiveMicErrorById[entry.event.id]" class="text-xs text-error">{{ eventArchiveMicErrorById[entry.event.id] }}</p>
<p v-if="eventCloseError[entry.event.id]" class="text-xs text-error">{{ eventCloseError[entry.event.id] }}</p>
<div class="flex justify-end">
<button
@@ -3529,15 +3690,70 @@ async function decideFeedCard(card: FeedCard, decision: "accepted" | "rejected")
</div>
<div class="comm-input-wrap">
<div class="comm-input-shell">
<div v-if="commComposerMode !== 'message'" class="mb-2 flex items-center justify-between gap-2">
<p class="text-xs font-medium text-base-content/75">
{{ commComposerMode === "logged" ? "Log past event" : "Plan event" }}
</p>
<button
class="btn btn-ghost btn-xs h-6 min-h-6 px-2"
:disabled="commEventSaving"
@click="closeCommEventModal"
>
Cancel
</button>
</div>
<textarea
v-model="commDraft"
class="comm-input-textarea"
placeholder="Type a message..."
:disabled="commSending"
:placeholder="commComposerPlaceholder()"
:disabled="commSending || commEventSaving"
@keydown.enter="handleCommComposerEnter"
/>
<div class="comm-input-channel dropdown dropdown-top not-prose">
<div
v-if="commComposerMode !== 'message'"
class="mb-2 grid grid-cols-1 gap-2 sm:grid-cols-3"
>
<label class="form-control">
<span class="label-text text-[11px]">Date</span>
<input
v-model="commEventForm.startDate"
type="date"
class="input input-bordered input-sm"
:disabled="commEventSaving"
>
</label>
<label class="form-control">
<span class="label-text text-[11px]">Time</span>
<input
v-model="commEventForm.startTime"
type="time"
class="input input-bordered input-sm"
:disabled="commEventSaving"
>
</label>
<label class="form-control">
<span class="label-text text-[11px]">Duration</span>
<select
v-model.number="commEventForm.durationMinutes"
class="select select-bordered select-sm"
:disabled="commEventSaving"
>
<option :value="15">15 min</option>
<option :value="30">30 min</option>
<option :value="45">45 min</option>
<option :value="60">60 min</option>
<option :value="90">90 min</option>
</select>
</label>
</div>
<p v-if="commEventError && commComposerMode !== 'message'" class="mb-2 text-xs text-error">
{{ commEventError }}
</p>
<div v-if="commComposerMode === 'message'" class="comm-input-channel dropdown dropdown-top not-prose">
<button
tabindex="0"
class="btn btn-ghost btn-xs h-7 min-h-7 px-1 text-xs font-medium"
@@ -3563,7 +3779,7 @@ async function decideFeedCard(card: FeedCard, decision: "accepted" | "rejected")
<button
class="btn btn-xs btn-circle border border-base-300 bg-base-100 text-base-content/80 hover:bg-base-200"
:class="commRecording ? 'comm-mic-active' : ''"
:disabled="commSending"
:disabled="commSending || commEventSaving"
title="Voice input"
@click="toggleCommRecording"
>
@@ -3574,9 +3790,9 @@ async function decideFeedCard(card: FeedCard, decision: "accepted" | "rejected")
<button
class="btn btn-sm btn-circle border-0 bg-[#5865f2] text-white hover:bg-[#4752c4]"
:disabled="commSending || !commDraft.trim()"
:title="`Send via ${commSendChannel}`"
@click="sendCommMessage"
:disabled="commSending || commEventSaving || !commDraft.trim()"
:title="commComposerMode === 'message' ? `Send via ${commSendChannel}` : (commComposerMode === 'logged' ? 'Save log event' : 'Create event')"
@click="commComposerMode === 'message' ? sendCommMessage() : createCommEvent()"
>
<svg viewBox="0 0 24 24" class="h-4 w-4 fill-current" :class="commSending ? 'opacity-50' : ''">
<path d="M4.5 19.5 21 12 4.5 4.5l.02 5.84L15 12l-10.48 1.66z" />
@@ -3586,68 +3802,6 @@ async function decideFeedCard(card: FeedCard, decision: "accepted" | "rejected")
</div>
</div>
</div>
<div v-if="commEventModalOpen" class="comm-event-modal">
<div class="comm-event-modal-card">
<div class="mb-3 flex items-center justify-between gap-2">
<p class="text-sm font-semibold">{{ commEventMode === "logged" ? "Log past event" : "Plan new event" }}</p>
<button class="btn btn-ghost btn-xs" :disabled="commEventSaving" @click="closeCommEventModal">Close</button>
</div>
<p class="mb-2 text-xs text-base-content/70">
Contact: <span class="font-medium text-base-content">{{ selectedCommThread.contact }}</span>
</p>
<label class="form-control mb-2">
<span class="label-text text-xs">Title</span>
<input
v-model="commEventForm.title"
type="text"
class="input input-bordered input-sm"
:placeholder="commEventMode === 'logged' ? 'Call recap / Meeting notes' : 'Meeting / Call / Follow-up'"
>
</label>
<div class="mb-2 grid grid-cols-1 gap-2 sm:grid-cols-3">
<label class="form-control">
<span class="label-text text-xs">Date</span>
<input v-model="commEventForm.startDate" type="date" class="input input-bordered input-sm">
</label>
<label class="form-control">
<span class="label-text text-xs">Time</span>
<input v-model="commEventForm.startTime" type="time" class="input input-bordered input-sm">
</label>
<label class="form-control">
<span class="label-text text-xs">Duration</span>
<select v-model.number="commEventForm.durationMinutes" class="select select-bordered select-sm">
<option :value="15">15 min</option>
<option :value="30">30 min</option>
<option :value="45">45 min</option>
<option :value="60">60 min</option>
<option :value="90">90 min</option>
</select>
</label>
</div>
<label class="form-control">
<span class="label-text text-xs">Note</span>
<textarea
v-model="commEventForm.note"
class="textarea textarea-bordered min-h-[84px] text-sm"
placeholder="Agenda, context, who joins..."
/>
</label>
<p v-if="commEventError" class="mt-2 text-xs text-error">{{ commEventError }}</p>
<div class="mt-3 flex items-center justify-end gap-2">
<button class="btn btn-ghost btn-sm" :disabled="commEventSaving" @click="closeCommEventModal">Cancel</button>
<button class="btn btn-sm" :disabled="commEventSaving" @click="createCommEvent">
{{ commEventSaving ? "Saving..." : (commEventMode === "logged" ? "Save log" : "Create event") }}
</button>
</div>
</div>
</div>
</div>
<div v-else class="flex h-full items-center justify-center text-sm text-base-content/60">