// TalkToDoc provider page logic. // Lists interactions in three tabs (Waiting / Active / Completed), and // lets the provider open one to read full context and reply, with a real // preview of the translation and speech before actually sending. (function () { let selectedInteractionId = null; let activeItem = null; let currentTab = "waiting"; let pendingItems = []; let completedItems = []; let completedLoaded = false; // Set on a successful preview, cleared the moment the reply text // changes again - only ever valid for the exact text it was generated // from, so Send only reuses it when nothing has been edited since. let lastPreviewText = null; let lastPreviewTranslation = null; // Completed tab filters — client-side only, no new API calls needed let completedLanguageFilter = "all"; let completedSortAsc = false; // false = newest first (default) const statusBanner = document.getElementById("status-banner"); const pendingList = document.getElementById("pending-list"); const emptyPending = document.getElementById("empty-pending"); const emptyPendingTitle = document.getElementById("empty-pending-title"); const emptyPendingBody = document.getElementById("empty-pending-body"); const queueHeading = document.getElementById("queue-heading"); const queueSearchInput = document.getElementById("queue-search"); const completedControls = document.getElementById("completed-controls"); const sortToggleBtn = document.getElementById("sort-toggle-btn"); const sortLabel = document.getElementById("sort-label"); const sortIconAsc = document.getElementById("sort-icon-asc"); const sortIconDesc = document.getElementById("sort-icon-desc"); const backToListBtn = document.getElementById("back-to-list"); const backToListReadonlyBtn = document.getElementById("back-to-list-readonly"); const submitResponseBtn = document.getElementById("submit-response"); const previewBtn = document.getElementById("preview-response-btn"); const previewBlock = document.getElementById("preview-block"); const previewTextBubble = document.getElementById("preview-text-bubble"); const responseTextInput = document.getElementById("response-text-input"); const patientHistoryCard = document.getElementById("patient-history-card"); const patientHistoryList = document.getElementById("patient-history-list"); const composeArea = document.getElementById("compose-area"); const readonlyArea = document.getElementById("readonly-area"); const respondProviderBubble = document.getElementById("respond-provider-bubble"); const respondProviderReplyText = document.getElementById("respond-provider-reply-text"); const toast = document.getElementById("toast"); // Preview audio player const previewAudioEl = document.getElementById("preview-audio"); const previewPlayBtn = document.getElementById("preview-audio-play-btn"); const previewReplayBtn = document.getElementById("preview-audio-replay-btn"); const previewSeek = document.getElementById("preview-audio-seek"); const previewCurrentTimeEl = document.getElementById("preview-audio-current-time"); const previewDurationEl = document.getElementById("preview-audio-duration"); const previewIconPlay = previewPlayBtn.querySelector(".icon-play"); const previewIconPause = previewPlayBtn.querySelector(".icon-pause"); function showStep(stepName) { document.querySelectorAll(".step").forEach(function (section) { section.classList.toggle("active", section.dataset.step === stepName); }); } function showError(message) { statusBanner.innerHTML = '
' + escapeHtml(message) + "
"; } function clearBanner() { statusBanner.innerHTML = ""; } function escapeHtml(text) { const div = document.createElement("div"); div.textContent = text == null ? "" : String(text); return div.innerHTML; } function showToast(message) { toast.textContent = message; toast.classList.add("visible"); setTimeout(function () { toast.classList.remove("visible"); }, 2200); } function setButtonLoading(button, isLoading, loadingText) { const spinner = button.querySelector(".btn-spinner"); const icon = button.querySelector(".btn-icon"); const label = button.querySelector(".btn-label"); button.disabled = isLoading; if (spinner) spinner.style.display = isLoading ? "inline-block" : "none"; if (icon) icon.style.display = isLoading ? "none" : "inline-flex"; if (label && loadingText) label.textContent = isLoading ? loadingText : label.dataset.defaultText; } document.querySelectorAll(".btn-label").forEach(function (el) { el.dataset.defaultText = el.textContent; }); function capitalize(text) { return text ? text.charAt(0).toUpperCase() + text.slice(1) : text; } // ---- Queue (Waiting / Active / Completed tabs) ---- function getTabItems() { if (currentTab === "waiting") { return pendingItems.filter(function (item) { return !activeItem || item.id !== activeItem.id; }); } if (currentTab === "active") { return activeItem ? [activeItem] : []; } // Completed tab: apply language filter then sort let items = completedItems; if (completedLanguageFilter !== "all") { items = items.filter(function (item) { return (item.detected_language || "").toLowerCase() === completedLanguageFilter; }); } if (completedSortAsc) { items = items.slice().sort(function (a, b) { return a.timestamp < b.timestamp ? -1 : 1; }); } return items; } function matchesQuery(item, query) { if (!query) return true; const haystack = ( (item.detected_language || "") + " " + (item.translated_text || "") + " " + (item.input_text || "") ).toLowerCase(); return haystack.indexOf(query) !== -1; } function buildQueueRow(item, index) { const row = document.createElement("button"); row.type = "button"; row.className = "queue-card list-item-in"; row.style.animationDelay = (index * 50) + "ms"; const timeLabel = window.formatRelativeTime ? window.formatRelativeTime(item.timestamp) : ""; const answered = Boolean(item.translated_response); const isActive = Boolean(activeItem) && activeItem.id === item.id; const badgeClass = answered ? "answered" : "waiting"; const badgeLabel = answered ? "Answered" : (isActive ? "In progress" : "#" + item.id); // Descriptive aria-label so screen readers announce "Yoruba, just now, // [preview text]" rather than the meaningless default "button". const ariaLabel = [ capitalize(item.detected_language), timeLabel, answered ? "Answered" : "Waiting for reply", item.translated_text ].filter(Boolean).join(", "); row.setAttribute("aria-label", ariaLabel); row.innerHTML = '' + '' + '' + '' + escapeHtml(item.detected_language) + '' + '' + '' + escapeHtml(timeLabel) + '' + '' + '' + '' + '' + escapeHtml(item.translated_text) + '' + ''; row.addEventListener("click", function () { openInteraction(item); }); return row; } function renderQueueList() { const query = queueSearchInput.value.trim().toLowerCase(); const items = getTabItems().filter(function (item) { return matchesQuery(item, query); }); // Show completed-controls (language filter + sort) only on the // Completed tab — filtering a short live Waiting/Active queue // adds friction rather than value. completedControls.style.display = currentTab === "completed" ? "block" : "none"; pendingList.innerHTML = ""; const isEmpty = items.length === 0; emptyPending.style.display = isEmpty ? "block" : "none"; if (isEmpty) { const hasFilter = query || (currentTab === "completed" && completedLanguageFilter !== "all"); if (hasFilter) { emptyPendingTitle.textContent = "No matches"; emptyPendingBody.textContent = "Try a different search or filter."; } else if (currentTab === "waiting") { emptyPendingTitle.textContent = "All caught up"; emptyPendingBody.textContent = "Nothing waiting right now."; } else if (currentTab === "active") { emptyPendingTitle.textContent = "Nothing active"; emptyPendingBody.textContent = "Open a message from the Waiting tab to start on it."; } else { emptyPendingTitle.textContent = "No completed consultations"; emptyPendingBody.textContent = "Completed replies will appear here."; } return; } items.forEach(function (item, index) { pendingList.appendChild(buildQueueRow(item, index)); }); } function updateTabCounts() { const waitingCount = pendingItems.filter(function (item) { return !activeItem || item.id !== activeItem.id; }).length; document.getElementById("tab-count-waiting").textContent = waitingCount; document.getElementById("tab-count-active").textContent = activeItem ? "1" : "0"; if (completedLoaded) { document.getElementById("tab-count-completed").textContent = completedItems.length; } } async function loadPending() { if (currentTab === "waiting") { pendingList.innerHTML = '
'; } try { const response = await fetch("/pending-interactions"); const interactions = await response.json(); pendingItems = interactions; const newHeadingText = interactions.length === 0 ? "All caught up" : interactions.length === 1 ? "1 message waiting for you" : interactions.length + " messages waiting for you"; queueHeading.textContent = newHeadingText; queueHeading.style.animation = "none"; void queueHeading.offsetWidth; queueHeading.style.animation = ""; renderQueueList(); updateTabCounts(); } catch (error) { showError("Couldn't load pending messages. Check your connection."); } } async function loadCompleted() { pendingList.innerHTML = '
'; try { const response = await fetch("/completed-interactions"); completedItems = await response.json(); completedLoaded = true; renderQueueList(); updateTabCounts(); } catch (error) { showError("Couldn't load completed messages. Check your connection."); } } document.querySelectorAll(".queue-tab").forEach(function (tabBtn) { tabBtn.addEventListener("click", function () { currentTab = tabBtn.dataset.tab; document.querySelectorAll(".queue-tab").forEach(function (t) { t.classList.toggle("active", t === tabBtn); t.setAttribute("aria-selected", t === tabBtn ? "true" : "false"); }); if (currentTab === "completed" && !completedLoaded) { loadCompleted(); } else { renderQueueList(); } }); }); queueSearchInput.addEventListener("input", function () { renderQueueList(); }); // Language filter chips (completed tab only) — set initial aria-pressed document.querySelectorAll("#language-filter-chips .filter-chip").forEach(function (c) { c.setAttribute("aria-pressed", c.classList.contains("active") ? "true" : "false"); }); document.getElementById("language-filter-chips").addEventListener("click", function (event) { const chip = event.target.closest(".filter-chip"); if (!chip) return; completedLanguageFilter = chip.dataset.filter; document.querySelectorAll("#language-filter-chips .filter-chip").forEach(function (c) { const active = c === chip; c.classList.toggle("active", active); c.setAttribute("aria-pressed", active ? "true" : "false"); }); renderQueueList(); }); // Sort toggle (completed tab only) sortToggleBtn.addEventListener("click", function () { completedSortAsc = !completedSortAsc; sortLabel.textContent = completedSortAsc ? "Oldest first" : "Newest first"; sortIconAsc.style.display = completedSortAsc ? "inline" : "none"; sortIconDesc.style.display = completedSortAsc ? "none" : "inline"; renderQueueList(); }); // ---- Workspace ---- async function loadPatientHistoryForWorkspace(item) { patientHistoryCard.style.display = "none"; try { const response = await fetch("/patient-history/" + item.user_id); const items = await response.json(); const others = items.filter(function (h) { return h.id !== item.id; }); if (!others.length) return; patientHistoryList.innerHTML = ""; others.forEach(function (h, index) { const row = document.createElement("div"); row.className = "result-block history-row-static list-item-in"; row.style.animationDelay = (index * 40) + "ms"; const answered = Boolean(h.translated_response); const badgeClass = answered ? "answered" : "waiting"; const badgeLabel = answered ? "Answered" : "Waiting for reply"; const timeLabel = window.formatRelativeTime ? window.formatRelativeTime(h.timestamp) : ""; row.innerHTML = '
#' + escapeHtml(h.id) + ' ' + badgeLabel + "" + ' ' + escapeHtml(timeLabel) + "
" + '
' + escapeHtml(h.translated_text || h.input_text) + "
"; patientHistoryList.appendChild(row); }); patientHistoryCard.style.display = "block"; } catch (error) { // Supplementary context only — fail silently rather than blocking // the provider from responding to the patient. } } function invalidatePreview() { previewBlock.style.display = "none"; previewAudioEl.pause(); lastPreviewText = null; lastPreviewTranslation = null; } function openInteraction(item) { clearBanner(); selectedInteractionId = item.id; activeItem = item; document.getElementById("detail-placeholder").style.display = "none"; const detailContent = document.getElementById("detail-content"); detailContent.style.display = "block"; detailContent.classList.remove("list-item-in"); void detailContent.offsetWidth; detailContent.classList.add("list-item-in"); document.getElementById("respond-interaction-id").textContent = item.id; document.getElementById("respond-language-label").textContent = "Patient (" + capitalize(item.detected_language) + ")"; document.getElementById("respond-translated-text").textContent = item.translated_text; document.getElementById("respond-nlu-summary").textContent = item.nlu_summary || "No summary available."; document.getElementById("respond-original-language").textContent = capitalize(item.detected_language); document.getElementById("respond-original-text").textContent = item.input_text; const timeLabel = window.formatRelativeTime ? window.formatRelativeTime(item.timestamp) : item.timestamp; document.getElementById("respond-meta-line").textContent = "Submitted " + timeLabel; const answered = Boolean(item.translated_response); const statusBadge = document.getElementById("respond-status-badge"); statusBadge.className = "status-badge " + (answered ? "answered" : "waiting"); statusBadge.textContent = answered ? "Answered" : "Waiting for your reply"; // Completed consultations open read-only: show the provider's reply // in the thread and hide the composer entirely. Unanswered ones open // the composer as normal. if (answered) { respondProviderBubble.style.display = "block"; respondProviderReplyText.textContent = item.translated_response; composeArea.style.display = "none"; readonlyArea.style.display = "block"; } else { respondProviderBubble.style.display = "none"; composeArea.style.display = "block"; readonlyArea.style.display = "none"; } responseTextInput.value = ""; invalidatePreview(); loadPatientHistoryForWorkspace(item); showStep("respond"); renderQueueList(); updateTabCounts(); } document.getElementById("quick-reply-chips").addEventListener("click", function (event) { const chip = event.target.closest(".chip"); if (!chip) return; responseTextInput.value = responseTextInput.value ? responseTextInput.value + " " + chip.dataset.text : chip.dataset.text; responseTextInput.focus(); invalidatePreview(); }); responseTextInput.addEventListener("input", function () { invalidatePreview(); }); function goBackToList() { document.getElementById("detail-placeholder").style.display = "block"; document.getElementById("detail-content").style.display = "none"; showStep("list"); loadPending(); } backToListBtn.addEventListener("click", goBackToList); backToListReadonlyBtn.addEventListener("click", goBackToList); // ---- Response composer: preview then send ---- function formatAudioTime(totalSeconds) { if (!isFinite(totalSeconds) || totalSeconds < 0) return "0:00"; const minutes = Math.floor(totalSeconds / 60); const seconds = Math.floor(totalSeconds % 60); return minutes + ":" + String(seconds).padStart(2, "0"); } function setPreviewAudioSource(url) { previewAudioEl.src = url; previewSeek.value = 0; previewCurrentTimeEl.textContent = "0:00"; previewDurationEl.textContent = "0:00"; previewIconPlay.style.display = "inline"; previewIconPause.style.display = "none"; } previewPlayBtn.addEventListener("click", function () { if (previewAudioEl.paused) { previewAudioEl.play(); } else { previewAudioEl.pause(); } }); previewReplayBtn.addEventListener("click", function () { previewAudioEl.currentTime = 0; previewAudioEl.play(); }); previewAudioEl.addEventListener("play", function () { previewIconPlay.style.display = "none"; previewIconPause.style.display = "inline"; }); previewAudioEl.addEventListener("pause", function () { previewIconPlay.style.display = "inline"; previewIconPause.style.display = "none"; }); previewAudioEl.addEventListener("ended", function () { previewIconPlay.style.display = "inline"; previewIconPause.style.display = "none"; }); previewAudioEl.addEventListener("loadedmetadata", function () { previewSeek.max = previewAudioEl.duration || 0; previewDurationEl.textContent = formatAudioTime(previewAudioEl.duration); }); previewAudioEl.addEventListener("timeupdate", function () { previewSeek.value = previewAudioEl.currentTime; previewCurrentTimeEl.textContent = formatAudioTime(previewAudioEl.currentTime); }); previewSeek.addEventListener("input", function () { previewAudioEl.currentTime = Number(previewSeek.value); }); previewBtn.addEventListener("click", async function () { const responseText = responseTextInput.value.trim(); if (!responseText) { showError("Type a reply before previewing."); return; } clearBanner(); setButtonLoading(previewBtn, true, "Generating preview..."); const formData = new FormData(); formData.append("interaction_id", selectedInteractionId); formData.append("response_text", responseText); try { const response = await fetch("/preview-response", { method: "POST", body: formData }); const data = await response.json(); if (!response.ok) { showError(data.error || "Couldn't generate a preview."); return; } previewTextBubble.innerHTML = 'Preview' + escapeHtml(data.translated_response); setPreviewAudioSource(data.audio_url); previewBlock.style.display = "block"; lastPreviewText = responseText; lastPreviewTranslation = data.translated_response; } catch (error) { showError("Couldn't reach the server. Check your connection and try again."); } finally { setButtonLoading(previewBtn, false, "Generating preview..."); } }); submitResponseBtn.addEventListener("click", async function () { clearBanner(); const responseText = responseTextInput.value.trim(); if (!responseText) { showError("Type a reply before sending."); return; } setButtonLoading(submitResponseBtn, true, "Sending..."); const formData = new FormData(); formData.append("interaction_id", selectedInteractionId); formData.append("response_text", responseText); // Reuse the already-computed preview translation + audio if the text // hasn't changed since the last preview — saves one translate + TTS call. if (lastPreviewText === responseText && lastPreviewTranslation) { formData.append("translated_response", lastPreviewTranslation); formData.append("reuse_audio", "true"); } try { const response = await fetch("/provider-response", { method: "POST", body: formData }); const data = await response.json(); if (!response.ok) { showError(data.error || "Something went wrong, please try again."); return; } activeItem = null; completedLoaded = false; goBackToList(); showToast("Reply sent to patient"); } catch (error) { showError("Couldn't reach the server. Check your connection and try again."); } finally { setButtonLoading(submitResponseBtn, false, "Sending..."); } }); loadPending(); })();