Glitche commited on
Commit
29944ea
·
verified ·
1 Parent(s): de5b315

Upload 3 files

Browse files
Files changed (3) hide show
  1. static/js/patient.js +300 -0
  2. static/js/provider.js +179 -0
  3. static/js/shared.js +42 -0
static/js/patient.js ADDED
@@ -0,0 +1,300 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // TalkToDoc patient page logic.
2
+ // Handles the three-step flow: language selection, input (text, quick
3
+ // chips, or voice), and checking for the provider's response.
4
+
5
+ (function () {
6
+ let selectedLanguage = null;
7
+ let mediaRecorder = null;
8
+ let recordedChunks = [];
9
+ let recordedBlob = null;
10
+ let currentInteractionId = null;
11
+ let recordingTimerInterval = null;
12
+ let recordingSeconds = 0;
13
+
14
+ const statusBanner = document.getElementById("status-banner");
15
+ const languageGrid = document.getElementById("language-grid");
16
+ const toStep2Btn = document.getElementById("to-step-2");
17
+ const backToStep1Btn = document.getElementById("back-to-step-1");
18
+ const submitInputBtn = document.getElementById("submit-input");
19
+ const recordBtn = document.getElementById("record-btn");
20
+ const stopRecordingBtn = document.getElementById("stop-recording-btn");
21
+ const inputPanel = document.getElementById("input-panel");
22
+ const listeningPanel = document.getElementById("listening-panel");
23
+ const recordTimer = document.getElementById("record-timer");
24
+ const recordStatus = document.getElementById("record-status");
25
+ const symptomChips = document.getElementById("symptom-chips");
26
+ const checkResponseBtn = document.getElementById("check-response-btn");
27
+ const newConversationBtn = document.getElementById("new-conversation-btn");
28
+ const historyCard = document.getElementById("history-card");
29
+ const historyList = document.getElementById("history-list");
30
+ const chatThread = document.getElementById("chat-thread");
31
+ const waitingText = document.getElementById("waiting-text");
32
+ const toast = document.getElementById("toast");
33
+
34
+ function showStep(stepNumber) {
35
+ document.querySelectorAll(".step").forEach(function (section) {
36
+ section.classList.toggle("active", section.dataset.step === String(stepNumber));
37
+ });
38
+ }
39
+
40
+ function showError(message) {
41
+ statusBanner.innerHTML = '<div class="status-banner error">' + escapeHtml(message) + "</div>";
42
+ }
43
+
44
+ function clearBanner() {
45
+ statusBanner.innerHTML = "";
46
+ }
47
+
48
+ function escapeHtml(text) {
49
+ const div = document.createElement("div");
50
+ div.textContent = text == null ? "" : String(text);
51
+ return div.innerHTML;
52
+ }
53
+
54
+ function showToast(message) {
55
+ toast.textContent = message;
56
+ toast.classList.add("visible");
57
+ setTimeout(function () {
58
+ toast.classList.remove("visible");
59
+ }, 2200);
60
+ }
61
+
62
+ function setButtonLoading(button, isLoading, loadingText) {
63
+ const spinner = button.querySelector(".btn-spinner");
64
+ const icon = button.querySelector(".btn-icon");
65
+ const label = button.querySelector(".btn-label");
66
+ button.disabled = isLoading;
67
+ if (spinner) spinner.style.display = isLoading ? "inline-block" : "none";
68
+ if (icon) icon.style.display = isLoading ? "none" : "inline-flex";
69
+ if (label && loadingText) label.textContent = isLoading ? loadingText : label.dataset.defaultText;
70
+ }
71
+
72
+ document.querySelectorAll(".btn-label").forEach(function (el) {
73
+ el.dataset.defaultText = el.textContent;
74
+ });
75
+
76
+ // Step 1: language selection
77
+ languageGrid.addEventListener("click", function (event) {
78
+ const option = event.target.closest(".language-option");
79
+ if (!option) return;
80
+
81
+ languageGrid.querySelectorAll(".language-option").forEach(function (el) {
82
+ el.setAttribute("aria-pressed", "false");
83
+ });
84
+ option.setAttribute("aria-pressed", "true");
85
+ selectedLanguage = option.dataset.language;
86
+ toStep2Btn.disabled = false;
87
+ });
88
+
89
+ toStep2Btn.addEventListener("click", function () {
90
+ showStep(2);
91
+ });
92
+
93
+ backToStep1Btn.addEventListener("click", function () {
94
+ showStep(1);
95
+ });
96
+
97
+ // Quick symptom chips: fill the textarea, patient can still edit before sending
98
+ symptomChips.addEventListener("click", function (event) {
99
+ const chip = event.target.closest(".chip");
100
+ if (!chip) return;
101
+ const textarea = document.getElementById("patient-text");
102
+ textarea.value = textarea.value ? textarea.value + ". " + chip.dataset.text : chip.dataset.text;
103
+ textarea.focus();
104
+ });
105
+
106
+ function formatTimer(totalSeconds) {
107
+ const minutes = Math.floor(totalSeconds / 60);
108
+ const seconds = totalSeconds % 60;
109
+ return minutes + ":" + String(seconds).padStart(2, "0");
110
+ }
111
+
112
+ function startRecording() {
113
+ recordBtn.classList.add("tapped");
114
+ setTimeout(function () { recordBtn.classList.remove("tapped"); }, 500);
115
+
116
+ navigator.mediaDevices.getUserMedia({ audio: true }).then(function (stream) {
117
+ recordedChunks = [];
118
+ mediaRecorder = new MediaRecorder(stream);
119
+
120
+ mediaRecorder.addEventListener("dataavailable", function (event) {
121
+ if (event.data.size > 0) recordedChunks.push(event.data);
122
+ });
123
+
124
+ mediaRecorder.addEventListener("stop", function () {
125
+ recordedBlob = new Blob(recordedChunks, { type: "audio/webm" });
126
+ stream.getTracks().forEach(function (track) { track.stop(); });
127
+ clearInterval(recordingTimerInterval);
128
+ inputPanel.style.display = "block";
129
+ listeningPanel.style.display = "none";
130
+ recordStatus.textContent = "Recording captured, ready to send.";
131
+ });
132
+
133
+ mediaRecorder.start();
134
+ recordingSeconds = 0;
135
+ recordTimer.textContent = "\u00A00:00";
136
+ inputPanel.style.display = "none";
137
+ listeningPanel.style.display = "block";
138
+ recordingTimerInterval = setInterval(function () {
139
+ recordingSeconds += 1;
140
+ recordTimer.textContent = "\u00A0" + formatTimer(recordingSeconds);
141
+ }, 1000);
142
+ }).catch(function () {
143
+ showError("Couldn't access your microphone. You can type your message instead.");
144
+ });
145
+ }
146
+
147
+ recordBtn.addEventListener("click", startRecording);
148
+
149
+ stopRecordingBtn.addEventListener("click", function () {
150
+ if (mediaRecorder && mediaRecorder.state === "recording") {
151
+ mediaRecorder.stop();
152
+ }
153
+ });
154
+
155
+ // Step 2 submit
156
+ submitInputBtn.addEventListener("click", async function () {
157
+ clearBanner();
158
+ const text = document.getElementById("patient-text").value.trim();
159
+
160
+ if (!text && !recordedBlob) {
161
+ showError("Type a message, tap a quick option, or record your voice first.");
162
+ return;
163
+ }
164
+
165
+ setButtonLoading(submitInputBtn, true, "Sending...");
166
+
167
+ const formData = new FormData();
168
+ formData.append("language", selectedLanguage);
169
+ if (recordedBlob) {
170
+ formData.append("audio", recordedBlob, "recording.webm");
171
+ } else {
172
+ formData.append("text", text);
173
+ }
174
+
175
+ try {
176
+ const response = await fetch("/patient-input", { method: "POST", body: formData });
177
+ const data = await response.json();
178
+
179
+ if (!response.ok) {
180
+ showError(data.error || "Something went wrong, please try again.");
181
+ return;
182
+ }
183
+
184
+ currentInteractionId = data.interaction_id;
185
+ document.getElementById("interaction-id-display").textContent = currentInteractionId;
186
+ document.getElementById("heard-text").textContent = data.patient_text;
187
+
188
+ // Reset step 3 to just the patient's own message, in case this
189
+ // page is reused for a second message later in the same session.
190
+ chatThread.querySelectorAll(".chat-bubble.from-provider").forEach(function (el) { el.remove(); });
191
+ document.getElementById("response-card").style.display = "none";
192
+ waitingText.style.display = "block";
193
+ checkResponseBtn.style.display = "inline-flex";
194
+
195
+ showStep(3);
196
+ loadHistory();
197
+ } catch (error) {
198
+ showError("Couldn't reach the server. Check your connection and try again.");
199
+ } finally {
200
+ setButtonLoading(submitInputBtn, false, "Sending...");
201
+ }
202
+ });
203
+
204
+ // Step 3: check for response
205
+ checkResponseBtn.addEventListener("click", async function () {
206
+ if (!currentInteractionId) return;
207
+
208
+ clearBanner();
209
+ setButtonLoading(checkResponseBtn, true, "Checking...");
210
+
211
+ try {
212
+ const response = await fetch("/interaction/" + currentInteractionId);
213
+ const data = await response.json();
214
+
215
+ if (data.translated_response) {
216
+ const bubble = document.createElement("div");
217
+ bubble.className = "chat-bubble from-provider list-item-in";
218
+ bubble.innerHTML = '<span class="chat-label">Provider</span>' + escapeHtml(data.translated_response);
219
+ chatThread.appendChild(bubble);
220
+
221
+ waitingText.style.display = "none";
222
+ checkResponseBtn.style.display = "none";
223
+
224
+ if (data.audio_url) {
225
+ document.getElementById("response-card").style.display = "block";
226
+ document.getElementById("response-audio").src = data.audio_url;
227
+ }
228
+ } else {
229
+ showError("No reply yet, try again in a moment.");
230
+ }
231
+ } catch (error) {
232
+ showError("Couldn't reach the server. Check your connection and try again.");
233
+ } finally {
234
+ setButtonLoading(checkResponseBtn, false, "Checking...");
235
+ }
236
+ });
237
+
238
+ // Past messages for this session
239
+ async function loadHistory() {
240
+ historyCard.style.display = "block";
241
+ historyList.innerHTML =
242
+ '<div class="skeleton-row"></div><div class="skeleton-row"></div>';
243
+
244
+ try {
245
+ const response = await fetch("/history");
246
+ const items = await response.json();
247
+
248
+ if (!items.length) {
249
+ historyCard.style.display = "none";
250
+ return;
251
+ }
252
+
253
+ historyList.innerHTML = "";
254
+ items.forEach(function (item, index) {
255
+ const row = document.createElement("div");
256
+ row.className = "result-block list-item-in";
257
+ row.style.animationDelay = (index * 40) + "ms";
258
+ const answered = Boolean(item.translated_response);
259
+ const badgeClass = answered ? "answered" : "waiting";
260
+ const badgeLabel = answered ? "Answered" : "Waiting for reply";
261
+ const timeLabel = window.formatRelativeTime ? window.formatRelativeTime(item.timestamp) : "";
262
+ row.innerHTML =
263
+ '<div class="result-label">#' + escapeHtml(item.id) +
264
+ ' <span class="status-badge ' + badgeClass + '">' + badgeLabel + "</span>" +
265
+ ' <span class="timestamp">' + escapeHtml(timeLabel) + "</span></div>" +
266
+ '<div class="result-text">' + escapeHtml(item.input_text) + "</div>";
267
+ historyList.appendChild(row);
268
+ });
269
+ historyCard.style.display = "block";
270
+ } catch (error) {
271
+ historyCard.style.display = "none";
272
+ }
273
+ }
274
+
275
+ // Start a new conversation: ends the current session server-side and
276
+ // resets the page back to language selection.
277
+ newConversationBtn.addEventListener("click", async function () {
278
+ try {
279
+ await fetch("/end-session", { method: "POST" });
280
+ } catch (error) {
281
+ // Even if this fails, still reset the page locally.
282
+ }
283
+
284
+ selectedLanguage = null;
285
+ recordedBlob = null;
286
+ currentInteractionId = null;
287
+ document.getElementById("patient-text").value = "";
288
+ document.getElementById("response-card").style.display = "none";
289
+ languageGrid.querySelectorAll(".language-option").forEach(function (el) {
290
+ el.setAttribute("aria-pressed", "false");
291
+ });
292
+ toStep2Btn.disabled = true;
293
+ clearBanner();
294
+ showStep(1);
295
+ loadHistory();
296
+ showToast("New conversation started");
297
+ });
298
+
299
+ loadHistory();
300
+ })();
static/js/provider.js ADDED
@@ -0,0 +1,179 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // TalkToDoc provider page logic.
2
+ // Lists interactions waiting for a response, and lets the provider reply
3
+ // to a selected one.
4
+
5
+ (function () {
6
+ let selectedInteractionId = null;
7
+
8
+ const statusBanner = document.getElementById("status-banner");
9
+ const pendingList = document.getElementById("pending-list");
10
+ const emptyPending = document.getElementById("empty-pending");
11
+ const queueHeading = document.getElementById("queue-heading");
12
+ const backToListBtn = document.getElementById("back-to-list");
13
+ const submitResponseBtn = document.getElementById("submit-response");
14
+ const toast = document.getElementById("toast");
15
+
16
+ function showStep(stepName) {
17
+ document.querySelectorAll(".step").forEach(function (section) {
18
+ section.classList.toggle("active", section.dataset.step === stepName);
19
+ });
20
+ }
21
+
22
+ function showError(message) {
23
+ statusBanner.innerHTML = '<div class="status-banner error">' + escapeHtml(message) + "</div>";
24
+ }
25
+
26
+ function clearBanner() {
27
+ statusBanner.innerHTML = "";
28
+ }
29
+
30
+ function escapeHtml(text) {
31
+ const div = document.createElement("div");
32
+ div.textContent = text == null ? "" : String(text);
33
+ return div.innerHTML;
34
+ }
35
+
36
+ function showToast(message) {
37
+ toast.textContent = message;
38
+ toast.classList.add("visible");
39
+ setTimeout(function () {
40
+ toast.classList.remove("visible");
41
+ }, 2200);
42
+ }
43
+
44
+ function setButtonLoading(button, isLoading, loadingText) {
45
+ const spinner = button.querySelector(".btn-spinner");
46
+ const icon = button.querySelector(".btn-icon");
47
+ const label = button.querySelector(".btn-label");
48
+ button.disabled = isLoading;
49
+ if (spinner) spinner.style.display = isLoading ? "inline-block" : "none";
50
+ if (icon) icon.style.display = isLoading ? "none" : "inline-flex";
51
+ if (label && loadingText) label.textContent = isLoading ? loadingText : label.dataset.defaultText;
52
+ }
53
+
54
+ document.querySelectorAll(".btn-label").forEach(function (el) {
55
+ el.dataset.defaultText = el.textContent;
56
+ });
57
+
58
+ async function loadPending() {
59
+ pendingList.innerHTML = '<div class="skeleton-row"></div><div class="skeleton-row"></div><div class="skeleton-row"></div>';
60
+
61
+ try {
62
+ const response = await fetch("/pending-interactions");
63
+ const interactions = await response.json();
64
+
65
+ pendingList.innerHTML = "";
66
+ const isEmpty = interactions.length === 0;
67
+ emptyPending.style.display = isEmpty ? "block" : "none";
68
+
69
+ const newHeadingText = isEmpty
70
+ ? "All caught up"
71
+ : interactions.length === 1
72
+ ? "1 message waiting for you"
73
+ : interactions.length + " messages waiting for you";
74
+ queueHeading.textContent = newHeadingText;
75
+ // Restart the count-change animation, changing textContent alone
76
+ // does not replay a CSS animation on its own.
77
+ queueHeading.style.animation = "none";
78
+ void queueHeading.offsetWidth;
79
+ queueHeading.style.animation = "";
80
+
81
+ interactions.forEach(function (item, index) {
82
+ const row = document.createElement("button");
83
+ row.type = "button";
84
+ row.className = "queue-card list-item-in";
85
+ row.style.animationDelay = (index * 50) + "ms";
86
+ const timeLabel = window.formatRelativeTime ? window.formatRelativeTime(item.timestamp) : "";
87
+ row.innerHTML =
88
+ '<span class="queue-avatar">' + escapeHtml(item.detected_language.charAt(0).toUpperCase()) + '</span>' +
89
+ '<span class="queue-body">' +
90
+ '<span class="queue-top-row">' +
91
+ '<span class="queue-lang">' + escapeHtml(item.detected_language) + '</span>' +
92
+ '<span class="queue-top-right">' +
93
+ '<span class="timestamp">' + escapeHtml(timeLabel) + '</span>' +
94
+ '<span class="status-badge waiting">#' + escapeHtml(item.id) + '</span>' +
95
+ '</span>' +
96
+ '</span>' +
97
+ '<span class="queue-preview">' + escapeHtml(item.translated_text) + '</span>' +
98
+ '</span>';
99
+ row.addEventListener("click", function () {
100
+ openInteraction(item);
101
+ });
102
+ pendingList.appendChild(row);
103
+ });
104
+ } catch (error) {
105
+ showError("Couldn't load pending messages. Check your connection.");
106
+ }
107
+ }
108
+
109
+ function openInteraction(item) {
110
+ clearBanner();
111
+ selectedInteractionId = item.id;
112
+ document.getElementById("detail-placeholder").style.display = "none";
113
+ const detailContent = document.getElementById("detail-content");
114
+ detailContent.style.display = "block";
115
+ detailContent.classList.remove("list-item-in");
116
+ void detailContent.offsetWidth;
117
+ detailContent.classList.add("list-item-in");
118
+ document.getElementById("respond-interaction-id").textContent = item.id;
119
+ document.getElementById("respond-language-label").textContent =
120
+ "Patient (" + item.detected_language.charAt(0).toUpperCase() + item.detected_language.slice(1) + ")";
121
+ document.getElementById("respond-translated-text").textContent = item.translated_text;
122
+ document.getElementById("respond-nlu-summary").textContent = item.nlu_summary || "No summary available.";
123
+ document.getElementById("response-text-input").value = "";
124
+ showStep("respond");
125
+ }
126
+
127
+ document.getElementById("quick-reply-chips").addEventListener("click", function (event) {
128
+ const chip = event.target.closest(".chip");
129
+ if (!chip) return;
130
+ const textarea = document.getElementById("response-text-input");
131
+ textarea.value = textarea.value ? textarea.value + " " + chip.dataset.text : chip.dataset.text;
132
+ textarea.focus();
133
+ });
134
+
135
+ backToListBtn.addEventListener("click", function () {
136
+ document.getElementById("detail-placeholder").style.display = "block";
137
+ document.getElementById("detail-content").style.display = "none";
138
+ showStep("list");
139
+ loadPending();
140
+ });
141
+
142
+ submitResponseBtn.addEventListener("click", async function () {
143
+ clearBanner();
144
+ const responseText = document.getElementById("response-text-input").value.trim();
145
+
146
+ if (!responseText) {
147
+ showError("Type a reply before sending.");
148
+ return;
149
+ }
150
+
151
+ setButtonLoading(submitResponseBtn, true, "Sending...");
152
+
153
+ const formData = new FormData();
154
+ formData.append("interaction_id", selectedInteractionId);
155
+ formData.append("response_text", responseText);
156
+
157
+ try {
158
+ const response = await fetch("/provider-response", { method: "POST", body: formData });
159
+ const data = await response.json();
160
+
161
+ if (!response.ok) {
162
+ showError(data.error || "Something went wrong, please try again.");
163
+ return;
164
+ }
165
+
166
+ showStep("list");
167
+ document.getElementById("detail-placeholder").style.display = "block";
168
+ document.getElementById("detail-content").style.display = "none";
169
+ loadPending();
170
+ showToast("Reply sent to patient");
171
+ } catch (error) {
172
+ showError("Couldn't reach the server. Check your connection and try again.");
173
+ } finally {
174
+ setButtonLoading(submitResponseBtn, false, "Sending...");
175
+ }
176
+ });
177
+
178
+ loadPending();
179
+ })();
static/js/shared.js ADDED
@@ -0,0 +1,42 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // TalkToDoc shared page logic: offline/online banner and relative time
2
+ // formatting, used by both the patient and provider pages.
3
+
4
+ (function () {
5
+ function setupOfflineBanner() {
6
+ const banner = document.createElement("div");
7
+ banner.className = "offline-banner";
8
+ banner.id = "offline-banner";
9
+ banner.setAttribute("role", "status");
10
+ banner.setAttribute("aria-live", "polite");
11
+ banner.innerHTML = '<span class="offline-dot"></span><span>You\'re offline. We\'ll keep trying to reconnect.</span>';
12
+
13
+ const main = document.querySelector(".app-main");
14
+ if (main) main.insertBefore(banner, main.firstChild);
15
+
16
+ function updateStatus() {
17
+ banner.classList.toggle("visible", !navigator.onLine);
18
+ }
19
+
20
+ window.addEventListener("online", updateStatus);
21
+ window.addEventListener("offline", updateStatus);
22
+ updateStatus();
23
+ }
24
+
25
+ document.addEventListener("DOMContentLoaded", setupOfflineBanner);
26
+
27
+ // Turns an ISO timestamp into a short relative label, e.g. "2m ago".
28
+ window.formatRelativeTime = function (isoString) {
29
+ if (!isoString) return "";
30
+ const then = new Date(isoString).getTime();
31
+ const now = Date.now();
32
+ const diffSeconds = Math.max(0, Math.floor((now - then) / 1000));
33
+
34
+ if (diffSeconds < 60) return "just now";
35
+ const diffMinutes = Math.floor(diffSeconds / 60);
36
+ if (diffMinutes < 60) return diffMinutes + (diffMinutes === 1 ? "m ago" : "m ago");
37
+ const diffHours = Math.floor(diffMinutes / 60);
38
+ if (diffHours < 24) return diffHours + "h ago";
39
+ const diffDays = Math.floor(diffHours / 24);
40
+ return diffDays + (diffDays === 1 ? "d ago" : "d ago");
41
+ };
42
+ })();