TalkToDoc / static /js /patient.js
goctests0's picture
Upload patient.js
1d69108 verified
Raw
History Blame Contribute Delete
21.8 kB
// TalkToDoc patient page logic.
// Handles the three-step flow: language selection, input (text, quick
// chips, or voice), and checking for the provider's response.
(function () {
let selectedLanguage = null;
let mediaRecorder = null;
let recordedChunks = [];
let recordedBlob = null;
let currentInteractionId = null;
let recordingTimerInterval = null;
let recordingSeconds = 0;
const statusBanner = document.getElementById("status-banner");
const languageGrid = document.getElementById("language-grid");
const toStep2Btn = document.getElementById("to-step-2");
const backToStep1Btn = document.getElementById("back-to-step-1");
const submitInputBtn = document.getElementById("submit-input");
const recordBtn = document.getElementById("record-btn");
const stopRecordingBtn = document.getElementById("stop-recording-btn");
const inputPanel = document.getElementById("input-panel");
const listeningPanel = document.getElementById("listening-panel");
const transcribingPanel = document.getElementById("transcribing-panel");
const reviewPanel = document.getElementById("review-panel");
const reviewText = document.getElementById("review-text");
const recordAgainBtn = document.getElementById("record-again-btn");
const recordTimer = document.getElementById("record-timer");
const recordStatus = document.getElementById("record-status");
const symptomChips = document.getElementById("symptom-chips");
const checkResponseBtn = document.getElementById("check-response-btn");
const newConversationBtn = document.getElementById("new-conversation-btn");
const historyCard = document.getElementById("history-card");
const historyList = document.getElementById("history-list");
const openHistoryBtn = document.getElementById("open-history-btn");
const historyBackBtn = document.getElementById("history-back-btn");
const historyFullList = document.getElementById("history-full-list");
const historyEmptyText = document.getElementById("history-empty-text");
const historySearchInput = document.getElementById("history-search");
const chatThread = document.getElementById("chat-thread");
const waitingCard = document.getElementById("waiting-card");
const toast = document.getElementById("toast");
// Audio player controls (for the provider's spoken reply)
const responseAudioEl = document.getElementById("response-audio");
const audioPlayBtn = document.getElementById("audio-play-btn");
const audioReplayBtn = document.getElementById("audio-replay-btn");
const audioSeek = document.getElementById("audio-seek");
const audioCurrentTimeEl = document.getElementById("audio-current-time");
const audioDurationEl = document.getElementById("audio-duration");
const audioDownloadBtn = document.getElementById("audio-download-btn");
const iconPlay = audioPlayBtn.querySelector(".icon-play");
const iconPause = audioPlayBtn.querySelector(".icon-pause");
// Auto-refresh for step 3: polls for the provider's reply in the
// background so the patient doesn't have to keep tapping "Check now".
let pollTimer = null;
function showStep(stepNumber) {
document.querySelectorAll(".step").forEach(function (section) {
section.classList.toggle("active", section.dataset.step === String(stepNumber));
});
}
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 setSubmitLabel(text) {
const label = submitInputBtn.querySelector(".btn-label");
label.textContent = text;
label.dataset.defaultText = text;
}
// Step 1: language selection
languageGrid.addEventListener("click", function (event) {
const option = event.target.closest(".language-option");
if (!option) return;
languageGrid.querySelectorAll(".language-option").forEach(function (el) {
el.setAttribute("aria-pressed", "false");
});
option.setAttribute("aria-pressed", "true");
selectedLanguage = option.dataset.language;
toStep2Btn.disabled = false;
});
toStep2Btn.addEventListener("click", function () {
showStep(2);
});
backToStep1Btn.addEventListener("click", function () {
reviewPanel.style.display = "none";
transcribingPanel.style.display = "none";
listeningPanel.style.display = "none";
inputPanel.style.display = "block";
recordedBlob = null;
setSubmitLabel("Send to provider");
showStep(1);
});
// Quick symptom chips: fill the textarea, patient can still edit before sending
symptomChips.addEventListener("click", function (event) {
const chip = event.target.closest(".chip");
if (!chip) return;
const textarea = document.getElementById("patient-text");
textarea.value = textarea.value ? textarea.value + ". " + chip.dataset.text : chip.dataset.text;
textarea.focus();
});
function formatTimer(totalSeconds) {
const minutes = Math.floor(totalSeconds / 60);
const seconds = totalSeconds % 60;
return minutes + ":" + String(seconds).padStart(2, "0");
}
function startRecording() {
recordBtn.classList.add("tapped");
setTimeout(function () { recordBtn.classList.remove("tapped"); }, 500);
navigator.mediaDevices.getUserMedia({ audio: true }).then(function (stream) {
recordedChunks = [];
mediaRecorder = new MediaRecorder(stream);
mediaRecorder.addEventListener("dataavailable", function (event) {
if (event.data.size > 0) recordedChunks.push(event.data);
});
mediaRecorder.addEventListener("stop", function () {
recordedBlob = new Blob(recordedChunks, { type: "audio/webm" });
stream.getTracks().forEach(function (track) { track.stop(); });
clearInterval(recordingTimerInterval);
listeningPanel.style.display = "none";
transcribeRecording();
});
mediaRecorder.start();
recordingSeconds = 0;
recordTimer.textContent = "\u00A00:00";
inputPanel.style.display = "none";
listeningPanel.style.display = "block";
recordingTimerInterval = setInterval(function () {
recordingSeconds += 1;
recordTimer.textContent = "\u00A0" + formatTimer(recordingSeconds);
}, 1000);
}).catch(function () {
showError("Couldn't access your microphone. You can type your message instead.");
});
}
recordBtn.addEventListener("click", startRecording);
stopRecordingBtn.addEventListener("click", function () {
if (mediaRecorder && mediaRecorder.state === "recording") {
mediaRecorder.stop();
}
});
async function transcribeRecording() {
clearBanner();
transcribingPanel.style.display = "block";
const formData = new FormData();
formData.append("language", selectedLanguage);
formData.append("audio", recordedBlob, "recording.webm");
try {
const response = await fetch("/transcribe", { method: "POST", body: formData });
const data = await response.json();
transcribingPanel.style.display = "none";
if (!response.ok) {
showError(data.error || "Couldn't hear that clearly, try again or type your message instead.");
recordedBlob = null;
inputPanel.style.display = "block";
return;
}
reviewText.value = data.patient_text;
reviewPanel.style.display = "block";
setSubmitLabel("Confirm and send");
reviewText.focus();
} catch (error) {
transcribingPanel.style.display = "none";
recordedBlob = null;
inputPanel.style.display = "block";
showError("Couldn't reach the server. Check your connection and try again.");
}
}
recordAgainBtn.addEventListener("click", function () {
reviewPanel.style.display = "none";
recordedBlob = null;
inputPanel.style.display = "block";
recordStatus.textContent = "";
setSubmitLabel("Send to provider");
});
// Step 2 submit
submitInputBtn.addEventListener("click", async function () {
clearBanner();
const isReviewing = reviewPanel.style.display === "block";
const text = isReviewing
? reviewText.value.trim()
: document.getElementById("patient-text").value.trim();
if (!text) {
showError(isReviewing ? "Your message can't be empty." : "Type a message, tap a quick option, or record your voice first.");
return;
}
setButtonLoading(submitInputBtn, true, "Sending...");
const formData = new FormData();
formData.append("language", selectedLanguage);
formData.append("text", text);
try {
const response = await fetch("/patient-input", { method: "POST", body: formData });
const data = await response.json();
if (!response.ok) {
showError(data.error || "Something went wrong, please try again.");
return;
}
currentInteractionId = data.interaction_id;
document.getElementById("interaction-id-display").textContent = currentInteractionId;
document.getElementById("heard-text").textContent = data.patient_text;
// Reset step 3 to just the patient's own message, in case this
// page is reused for a second message later in the same session.
chatThread.querySelectorAll(".chat-bubble.from-provider").forEach(function (el) { el.remove(); });
document.getElementById("response-card").style.display = "none";
waitingCard.style.display = "block";
showStep(3);
loadHistory();
startPolling();
} catch (error) {
showError("Couldn't reach the server. Check your connection and try again.");
} finally {
setButtonLoading(submitInputBtn, false, "Sending...");
}
});
// Step 3: check for response (shared by the manual button and the
// background poll below, so there's exactly one place that decides
// what "answered" means and what happens next).
async function checkForResponse(isManual) {
if (!currentInteractionId) return;
if (isManual) {
clearBanner();
setButtonLoading(checkResponseBtn, true, "Checking...");
}
try {
const response = await fetch("/interaction/" + currentInteractionId);
const data = await response.json();
if (data.translated_response) {
stopPolling();
const bubble = document.createElement("div");
bubble.className = "chat-bubble from-provider list-item-in";
bubble.innerHTML = '<span class="chat-label">Provider</span>' + escapeHtml(data.translated_response);
chatThread.appendChild(bubble);
waitingCard.style.display = "none";
if (data.audio_url) {
document.getElementById("response-card").style.display = "block";
setAudioSource(data.audio_url);
}
// Keep the history list's "Waiting for reply" / "Answered" badge
// in sync now, rather than only on the next full page load.
loadHistory();
} else if (isManual) {
showError("No reply yet, try again in a moment.");
}
} catch (error) {
// A background poll fails silently and just retries next interval;
// only a manual tap surfaces a connection error.
if (isManual) showError("Couldn't reach the server. Check your connection and try again.");
} finally {
if (isManual) setButtonLoading(checkResponseBtn, false, "Checking...");
}
}
checkResponseBtn.addEventListener("click", function () {
checkForResponse(true);
});
function startPolling() {
stopPolling();
pollTimer = setInterval(function () { checkForResponse(false); }, 15000);
}
function stopPolling() {
if (pollTimer) {
clearInterval(pollTimer);
pollTimer = null;
}
}
// Don't burn the patient's data plan polling in the background if they've
// switched away to another tab or app; catch back up when they return.
document.addEventListener("visibilitychange", function () {
if (document.hidden) {
stopPolling();
} else if (currentInteractionId && waitingCard.style.display !== "none") {
checkForResponse(false);
startPolling();
}
});
// Audio player for the provider's spoken reply.
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 setAudioSource(url) {
responseAudioEl.src = url;
audioDownloadBtn.href = url;
audioSeek.value = 0;
audioCurrentTimeEl.textContent = "0:00";
audioDurationEl.textContent = "0:00";
iconPlay.style.display = "inline";
iconPause.style.display = "none";
}
audioPlayBtn.addEventListener("click", function () {
if (responseAudioEl.paused) {
responseAudioEl.play();
} else {
responseAudioEl.pause();
}
});
audioReplayBtn.addEventListener("click", function () {
responseAudioEl.currentTime = 0;
responseAudioEl.play();
});
responseAudioEl.addEventListener("play", function () {
iconPlay.style.display = "none";
iconPause.style.display = "inline";
});
responseAudioEl.addEventListener("pause", function () {
iconPlay.style.display = "inline";
iconPause.style.display = "none";
});
responseAudioEl.addEventListener("ended", function () {
iconPlay.style.display = "inline";
iconPause.style.display = "none";
});
responseAudioEl.addEventListener("loadedmetadata", function () {
audioSeek.max = responseAudioEl.duration || 0;
audioDurationEl.textContent = formatAudioTime(responseAudioEl.duration);
});
responseAudioEl.addEventListener("timeupdate", function () {
audioSeek.value = responseAudioEl.currentTime;
audioCurrentTimeEl.textContent = formatAudioTime(responseAudioEl.currentTime);
});
audioSeek.addEventListener("input", function () {
responseAudioEl.currentTime = Number(audioSeek.value);
});
// Past messages for this session.
let cachedHistoryItems = [];
function buildHistoryRow(item, index) {
const row = document.createElement("div");
row.className = "result-block history-row list-item-in";
row.style.animationDelay = (index * 40) + "ms";
row.setAttribute("role", "button");
row.setAttribute("tabindex", "0");
const answered = Boolean(item.translated_response);
const badgeClass = answered ? "answered" : "waiting";
const badgeLabel = answered ? "Answered" : "Waiting for reply";
const timeLabel = window.formatRelativeTime ? window.formatRelativeTime(item.timestamp) : "";
row.innerHTML =
'<div class="result-label">#' + escapeHtml(item.id) +
' <span class="status-badge ' + badgeClass + '">' + badgeLabel + "</span>" +
' <span class="timestamp">' + escapeHtml(timeLabel) + "</span></div>" +
'<div class="result-text">' + escapeHtml(item.input_text) + "</div>";
row.addEventListener("click", function () {
restoreInteraction(item.id);
});
row.addEventListener("keydown", function (event) {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
restoreInteraction(item.id);
}
});
return row;
}
// Deliberately does NOT show the teaser card or a loading skeleton up
// front: a brand new visitor (or anyone right after "Start a new
// conversation") has no history yet, so an optimistic reveal would just
// flash open and immediately hide itself once the empty response came
// back. The card only appears once a response has confirmed there's
// something to show.
async function loadHistory() {
try {
const response = await fetch("/history");
const items = await response.json();
cachedHistoryItems = items;
if (!items.length) {
historyCard.style.display = "none";
} else {
historyCard.style.display = "block";
historyList.innerHTML = "";
items.forEach(function (item, index) {
historyList.appendChild(buildHistoryRow(item, index));
});
}
} catch (error) {
historyCard.style.display = "none";
cachedHistoryItems = [];
}
renderFullHistoryList(historySearchInput.value);
}
// Dedicated history view: renders from the cache above (instant), so
// opening it never flashes empty while a fresh fetch is in flight.
function renderFullHistoryList(filterText) {
const query = (filterText || "").trim().toLowerCase();
const filtered = query
? cachedHistoryItems.filter(function (item) {
return (item.input_text || "").toLowerCase().indexOf(query) !== -1;
})
: cachedHistoryItems;
historyFullList.innerHTML = "";
if (!cachedHistoryItems.length) {
historyEmptyText.textContent = "You haven't sent any messages yet.";
historyEmptyText.style.display = "block";
return;
}
if (!filtered.length) {
historyEmptyText.textContent = "No messages match your search.";
historyEmptyText.style.display = "block";
return;
}
historyEmptyText.style.display = "none";
filtered.forEach(function (item, index) {
historyFullList.appendChild(buildHistoryRow(item, index));
});
}
// Reopens a past consultation (from either history list) into the same
// step-3 view a brand new message lands on, reusing its chat thread,
// waiting card, and audio player rather than building a second one.
async function restoreInteraction(interactionId) {
clearBanner();
try {
const response = await fetch("/interaction/" + interactionId);
const data = await response.json();
if (!response.ok) {
showError(data.error || "Couldn't load that consultation.");
return;
}
stopPolling();
currentInteractionId = data.id;
document.getElementById("interaction-id-display").textContent = data.id;
document.getElementById("heard-text").textContent = data.input_text;
chatThread.querySelectorAll(".chat-bubble.from-provider").forEach(function (el) { el.remove(); });
document.getElementById("response-card").style.display = "none";
if (data.translated_response) {
const bubble = document.createElement("div");
bubble.className = "chat-bubble from-provider";
bubble.innerHTML = '<span class="chat-label">Provider</span>' + escapeHtml(data.translated_response);
chatThread.appendChild(bubble);
waitingCard.style.display = "none";
if (data.audio_url) {
document.getElementById("response-card").style.display = "block";
setAudioSource(data.audio_url);
}
} else {
// Still unanswered: resume checking for this one instead of the
// most recently submitted interaction.
waitingCard.style.display = "block";
startPolling();
}
showStep(3);
} catch (error) {
showError("Couldn't reach the server. Check your connection and try again.");
}
}
openHistoryBtn.addEventListener("click", function () {
clearBanner();
showStep("history");
loadHistory();
});
historyBackBtn.addEventListener("click", function () {
showStep(1);
});
historySearchInput.addEventListener("input", function () {
renderFullHistoryList(historySearchInput.value);
});
// Start a new conversation: ends the current session server-side and
// resets the page back to language selection.
newConversationBtn.addEventListener("click", async function () {
stopPolling();
try {
await fetch("/end-session", { method: "POST" });
} catch (error) {
// Even if this fails, still reset the page locally.
}
selectedLanguage = null;
recordedBlob = null;
currentInteractionId = null;
cachedHistoryItems = [];
document.getElementById("patient-text").value = "";
document.getElementById("response-card").style.display = "none";
waitingCard.style.display = "block";
responseAudioEl.pause();
responseAudioEl.removeAttribute("src");
reviewPanel.style.display = "none";
transcribingPanel.style.display = "none";
listeningPanel.style.display = "none";
inputPanel.style.display = "block";
historyCard.style.display = "none";
setSubmitLabel("Send to provider");
languageGrid.querySelectorAll(".language-option").forEach(function (el) {
el.setAttribute("aria-pressed", "false");
});
toStep2Btn.disabled = true;
clearBanner();
showStep(1);
// Do not call loadHistory() here. end-session clears the server-side
// session so a /history fetch immediately after always returns empty.
// History reloads naturally after the next message is submitted.
showToast("New conversation started");
});
loadHistory();
})();