TalkToDoc / static /js /provider.js
goctests0's picture
Upload 2 files
c0530e9 verified
Raw
History Blame Contribute Delete
22 kB
// 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 = '<div class="status-banner error">' + escapeHtml(message) + "</div>";
}
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 =
'<span class="queue-avatar" aria-hidden="true">' + escapeHtml(item.detected_language.charAt(0).toUpperCase()) + '</span>' +
'<span class="queue-body">' +
'<span class="queue-top-row">' +
'<span class="queue-lang">' + escapeHtml(item.detected_language) + '</span>' +
'<span class="queue-top-right">' +
'<span class="timestamp">' + escapeHtml(timeLabel) + '</span>' +
'<span class="status-badge ' + badgeClass + '" aria-hidden="true">' + escapeHtml(badgeLabel) + '</span>' +
'</span>' +
'</span>' +
'<span class="queue-preview">' + escapeHtml(item.translated_text) + '</span>' +
'</span>';
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 = '<div class="skeleton-row"></div><div class="skeleton-row"></div><div class="skeleton-row"></div>';
}
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 = '<div class="skeleton-row"></div><div class="skeleton-row"></div>';
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 =
'<div class="result-label">#' + escapeHtml(h.id) +
' <span class="status-badge ' + badgeClass + '">' + badgeLabel + "</span>" +
' <span class="timestamp">' + escapeHtml(timeLabel) + "</span></div>" +
'<div class="result-text">' + escapeHtml(h.translated_text || h.input_text) + "</div>";
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 = '<span class="chat-label">Preview</span>' + 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();
})();