goctests0 commited on
Commit
03e0c3e
·
verified ·
1 Parent(s): 7564c8e

Upload 2 files

Browse files
Files changed (2) hide show
  1. static/js/patient.js +139 -23
  2. static/js/provider.js +346 -36
static/js/patient.js CHANGED
@@ -31,6 +31,11 @@
31
  const newConversationBtn = document.getElementById("new-conversation-btn");
32
  const historyCard = document.getElementById("history-card");
33
  const historyList = document.getElementById("history-list");
 
 
 
 
 
34
  const chatThread = document.getElementById("chat-thread");
35
  const waitingCard = document.getElementById("waiting-card");
36
  const toast = document.getElementById("toast");
@@ -405,43 +410,154 @@
405
  });
406
 
407
  // Past messages for this session.
408
- // Deliberately does NOT show the card or a loading skeleton up front:
409
- // a brand new visitor (or anyone right after "Start a new conversation")
410
- // has no history yet, so an optimistic reveal would just flash open and
411
- // immediately hide itself once the empty response came back. The card
412
- // only appears once a response has confirmed there's something to show.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
413
  async function loadHistory() {
414
  try {
415
  const response = await fetch("/history");
416
  const items = await response.json();
 
417
 
418
  if (!items.length) {
419
  historyCard.style.display = "none";
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
420
  return;
421
  }
422
 
423
- historyCard.style.display = "block";
424
- historyList.innerHTML = "";
425
- items.forEach(function (item, index) {
426
- const row = document.createElement("div");
427
- row.className = "result-block list-item-in";
428
- row.style.animationDelay = (index * 40) + "ms";
429
- const answered = Boolean(item.translated_response);
430
- const badgeClass = answered ? "answered" : "waiting";
431
- const badgeLabel = answered ? "Answered" : "Waiting for reply";
432
- const timeLabel = window.formatRelativeTime ? window.formatRelativeTime(item.timestamp) : "";
433
- row.innerHTML =
434
- '<div class="result-label">#' + escapeHtml(item.id) +
435
- ' <span class="status-badge ' + badgeClass + '">' + badgeLabel + "</span>" +
436
- ' <span class="timestamp">' + escapeHtml(timeLabel) + "</span></div>" +
437
- '<div class="result-text">' + escapeHtml(item.input_text) + "</div>";
438
- historyList.appendChild(row);
439
- });
 
 
 
 
 
 
 
 
 
 
440
  } catch (error) {
441
- historyCard.style.display = "none";
442
  }
443
  }
444
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
445
  // Start a new conversation: ends the current session server-side and
446
  // resets the page back to language selection.
447
  newConversationBtn.addEventListener("click", async function () {
 
31
  const newConversationBtn = document.getElementById("new-conversation-btn");
32
  const historyCard = document.getElementById("history-card");
33
  const historyList = document.getElementById("history-list");
34
+ const openHistoryBtn = document.getElementById("open-history-btn");
35
+ const historyBackBtn = document.getElementById("history-back-btn");
36
+ const historyFullList = document.getElementById("history-full-list");
37
+ const historyEmptyText = document.getElementById("history-empty-text");
38
+ const historySearchInput = document.getElementById("history-search");
39
  const chatThread = document.getElementById("chat-thread");
40
  const waitingCard = document.getElementById("waiting-card");
41
  const toast = document.getElementById("toast");
 
410
  });
411
 
412
  // Past messages for this session.
413
+ let cachedHistoryItems = [];
414
+
415
+ function buildHistoryRow(item, index) {
416
+ const row = document.createElement("div");
417
+ row.className = "result-block history-row list-item-in";
418
+ row.style.animationDelay = (index * 40) + "ms";
419
+ row.setAttribute("role", "button");
420
+ row.setAttribute("tabindex", "0");
421
+ const answered = Boolean(item.translated_response);
422
+ const badgeClass = answered ? "answered" : "waiting";
423
+ const badgeLabel = answered ? "Answered" : "Waiting for reply";
424
+ const timeLabel = window.formatRelativeTime ? window.formatRelativeTime(item.timestamp) : "";
425
+ row.innerHTML =
426
+ '<div class="result-label">#' + escapeHtml(item.id) +
427
+ ' <span class="status-badge ' + badgeClass + '">' + badgeLabel + "</span>" +
428
+ ' <span class="timestamp">' + escapeHtml(timeLabel) + "</span></div>" +
429
+ '<div class="result-text">' + escapeHtml(item.input_text) + "</div>";
430
+ row.addEventListener("click", function () {
431
+ restoreInteraction(item.id);
432
+ });
433
+ row.addEventListener("keydown", function (event) {
434
+ if (event.key === "Enter" || event.key === " ") {
435
+ event.preventDefault();
436
+ restoreInteraction(item.id);
437
+ }
438
+ });
439
+ return row;
440
+ }
441
+
442
+ // Deliberately does NOT show the teaser card or a loading skeleton up
443
+ // front: a brand new visitor (or anyone right after "Start a new
444
+ // conversation") has no history yet, so an optimistic reveal would just
445
+ // flash open and immediately hide itself once the empty response came
446
+ // back. The card only appears once a response has confirmed there's
447
+ // something to show.
448
  async function loadHistory() {
449
  try {
450
  const response = await fetch("/history");
451
  const items = await response.json();
452
+ cachedHistoryItems = items;
453
 
454
  if (!items.length) {
455
  historyCard.style.display = "none";
456
+ } else {
457
+ historyCard.style.display = "block";
458
+ historyList.innerHTML = "";
459
+ items.forEach(function (item, index) {
460
+ historyList.appendChild(buildHistoryRow(item, index));
461
+ });
462
+ }
463
+ } catch (error) {
464
+ historyCard.style.display = "none";
465
+ cachedHistoryItems = [];
466
+ }
467
+
468
+ renderFullHistoryList(historySearchInput.value);
469
+ }
470
+
471
+ // Dedicated history view: renders from the cache above (instant), so
472
+ // opening it never flashes empty while a fresh fetch is in flight.
473
+ function renderFullHistoryList(filterText) {
474
+ const query = (filterText || "").trim().toLowerCase();
475
+ const filtered = query
476
+ ? cachedHistoryItems.filter(function (item) {
477
+ return (item.input_text || "").toLowerCase().indexOf(query) !== -1;
478
+ })
479
+ : cachedHistoryItems;
480
+
481
+ historyFullList.innerHTML = "";
482
+
483
+ if (!cachedHistoryItems.length) {
484
+ historyEmptyText.textContent = "You haven't sent any messages yet.";
485
+ historyEmptyText.style.display = "block";
486
+ return;
487
+ }
488
+
489
+ if (!filtered.length) {
490
+ historyEmptyText.textContent = "No messages match your search.";
491
+ historyEmptyText.style.display = "block";
492
+ return;
493
+ }
494
+
495
+ historyEmptyText.style.display = "none";
496
+ filtered.forEach(function (item, index) {
497
+ historyFullList.appendChild(buildHistoryRow(item, index));
498
+ });
499
+ }
500
+
501
+ // Reopens a past consultation (from either history list) into the same
502
+ // step-3 view a brand new message lands on, reusing its chat thread,
503
+ // waiting card, and audio player rather than building a second one.
504
+ async function restoreInteraction(interactionId) {
505
+ clearBanner();
506
+ try {
507
+ const response = await fetch("/interaction/" + interactionId);
508
+ const data = await response.json();
509
+
510
+ if (!response.ok) {
511
+ showError(data.error || "Couldn't load that consultation.");
512
  return;
513
  }
514
 
515
+ stopPolling();
516
+ currentInteractionId = data.id;
517
+ document.getElementById("interaction-id-display").textContent = data.id;
518
+ document.getElementById("heard-text").textContent = data.input_text;
519
+
520
+ chatThread.querySelectorAll(".chat-bubble.from-provider").forEach(function (el) { el.remove(); });
521
+ document.getElementById("response-card").style.display = "none";
522
+
523
+ if (data.translated_response) {
524
+ const bubble = document.createElement("div");
525
+ bubble.className = "chat-bubble from-provider";
526
+ bubble.innerHTML = '<span class="chat-label">Provider</span>' + escapeHtml(data.translated_response);
527
+ chatThread.appendChild(bubble);
528
+ waitingCard.style.display = "none";
529
+
530
+ if (data.audio_url) {
531
+ document.getElementById("response-card").style.display = "block";
532
+ setAudioSource(data.audio_url);
533
+ }
534
+ } else {
535
+ // Still unanswered: resume checking for this one instead of the
536
+ // most recently submitted interaction.
537
+ waitingCard.style.display = "block";
538
+ startPolling();
539
+ }
540
+
541
+ showStep(3);
542
  } catch (error) {
543
+ showError("Couldn't reach the server. Check your connection and try again.");
544
  }
545
  }
546
 
547
+ openHistoryBtn.addEventListener("click", function () {
548
+ clearBanner();
549
+ showStep("history");
550
+ loadHistory();
551
+ });
552
+
553
+ historyBackBtn.addEventListener("click", function () {
554
+ showStep(1);
555
+ });
556
+
557
+ historySearchInput.addEventListener("input", function () {
558
+ renderFullHistoryList(historySearchInput.value);
559
+ });
560
+
561
  // Start a new conversation: ends the current session server-side and
562
  // resets the page back to language selection.
563
  newConversationBtn.addEventListener("click", async function () {
static/js/provider.js CHANGED
@@ -1,18 +1,53 @@
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);
@@ -55,18 +90,107 @@
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"
@@ -78,58 +202,137 @@
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 () {
@@ -139,9 +342,104 @@
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.");
@@ -154,6 +452,16 @@
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();
@@ -163,6 +471,8 @@
163
  return;
164
  }
165
 
 
 
166
  showStep("list");
167
  document.getElementById("detail-placeholder").style.display = "block";
168
  document.getElementById("detail-content").style.display = "none";
 
1
  // TalkToDoc provider page logic.
2
+ // Lists interactions in three tabs (Waiting / Active / Completed), and
3
+ // lets the provider open one to read full context and reply, with a real
4
+ // preview of the translation and speech before actually sending.
5
 
6
  (function () {
7
  let selectedInteractionId = null;
8
+ // The single interaction currently open in the workspace, if any. This
9
+ // is the only honest meaning "Active" can have here: there's no
10
+ // multi-provider login or claim system, so it's a client-side slot,
11
+ // not a backend state. It survives "Back" (so a provider can return to
12
+ // an in-progress reply) and is only cleared once that reply is sent.
13
+ let activeItem = null;
14
+ let currentTab = "waiting";
15
+ let pendingItems = [];
16
+ let completedItems = [];
17
+ let completedLoaded = false;
18
+ // Set on a successful preview, cleared the moment the reply text
19
+ // changes again - only ever valid for the exact text it was generated
20
+ // from, so Send only reuses it when nothing has been edited since.
21
+ let lastPreviewText = null;
22
+ let lastPreviewTranslation = null;
23
 
24
  const statusBanner = document.getElementById("status-banner");
25
  const pendingList = document.getElementById("pending-list");
26
  const emptyPending = document.getElementById("empty-pending");
27
  const queueHeading = document.getElementById("queue-heading");
28
+ const queueSearchInput = document.getElementById("queue-search");
29
  const backToListBtn = document.getElementById("back-to-list");
30
  const submitResponseBtn = document.getElementById("submit-response");
31
+ const previewBtn = document.getElementById("preview-response-btn");
32
+ const previewBlock = document.getElementById("preview-block");
33
+ const previewTextBubble = document.getElementById("preview-text-bubble");
34
+ const responseTextInput = document.getElementById("response-text-input");
35
+ const patientHistoryCard = document.getElementById("patient-history-card");
36
+ const patientHistoryList = document.getElementById("patient-history-list");
37
  const toast = document.getElementById("toast");
38
 
39
+ // Preview audio player (separate instance from the patient page's, same
40
+ // pattern: play/pause/replay/seek/time, no download - a preview isn't
41
+ // the final artifact worth keeping).
42
+ const previewAudioEl = document.getElementById("preview-audio");
43
+ const previewPlayBtn = document.getElementById("preview-audio-play-btn");
44
+ const previewReplayBtn = document.getElementById("preview-audio-replay-btn");
45
+ const previewSeek = document.getElementById("preview-audio-seek");
46
+ const previewCurrentTimeEl = document.getElementById("preview-audio-current-time");
47
+ const previewDurationEl = document.getElementById("preview-audio-duration");
48
+ const previewIconPlay = previewPlayBtn.querySelector(".icon-play");
49
+ const previewIconPause = previewPlayBtn.querySelector(".icon-pause");
50
+
51
  function showStep(stepName) {
52
  document.querySelectorAll(".step").forEach(function (section) {
53
  section.classList.toggle("active", section.dataset.step === stepName);
 
90
  el.dataset.defaultText = el.textContent;
91
  });
92
 
93
+ function capitalize(text) {
94
+ return text ? text.charAt(0).toUpperCase() + text.slice(1) : text;
95
+ }
96
+
97
+ // ---- Queue (Waiting / Active / Completed tabs) ----
98
+
99
+ function getTabItems() {
100
+ if (currentTab === "waiting") {
101
+ return pendingItems.filter(function (item) {
102
+ return !activeItem || item.id !== activeItem.id;
103
+ });
104
+ }
105
+ if (currentTab === "active") {
106
+ return activeItem ? [activeItem] : [];
107
+ }
108
+ return completedItems;
109
+ }
110
+
111
+ function matchesQuery(item, query) {
112
+ if (!query) return true;
113
+ const haystack = (
114
+ (item.detected_language || "") + " " +
115
+ (item.translated_text || "") + " " +
116
+ (item.input_text || "")
117
+ ).toLowerCase();
118
+ return haystack.indexOf(query) !== -1;
119
+ }
120
+
121
+ function emptyStateText(query) {
122
+ if (query) return "No messages match your search.";
123
+ if (currentTab === "waiting") return "Nothing waiting right now.";
124
+ if (currentTab === "active") return "Nothing active right now, open a message below to start on it.";
125
+ return "No completed consultations yet.";
126
+ }
127
+
128
+ function buildQueueRow(item, index) {
129
+ const row = document.createElement("button");
130
+ row.type = "button";
131
+ row.className = "queue-card list-item-in";
132
+ row.style.animationDelay = (index * 50) + "ms";
133
+ const timeLabel = window.formatRelativeTime ? window.formatRelativeTime(item.timestamp) : "";
134
+ const answered = Boolean(item.translated_response);
135
+ const isActive = Boolean(activeItem) && activeItem.id === item.id;
136
+ const badgeClass = answered ? "answered" : "waiting";
137
+ const badgeLabel = answered ? "Answered" : (isActive ? "In progress" : "#" + item.id);
138
+ row.innerHTML =
139
+ '<span class="queue-avatar">' + escapeHtml(item.detected_language.charAt(0).toUpperCase()) + '</span>' +
140
+ '<span class="queue-body">' +
141
+ '<span class="queue-top-row">' +
142
+ '<span class="queue-lang">' + escapeHtml(item.detected_language) + '</span>' +
143
+ '<span class="queue-top-right">' +
144
+ '<span class="timestamp">' + escapeHtml(timeLabel) + '</span>' +
145
+ '<span class="status-badge ' + badgeClass + '">' + escapeHtml(badgeLabel) + '</span>' +
146
+ '</span>' +
147
+ '</span>' +
148
+ '<span class="queue-preview">' + escapeHtml(item.translated_text) + '</span>' +
149
+ '</span>';
150
+ row.addEventListener("click", function () {
151
+ openInteraction(item);
152
+ });
153
+ return row;
154
+ }
155
+
156
+ function renderQueueList() {
157
+ const query = queueSearchInput.value.trim().toLowerCase();
158
+ const items = getTabItems().filter(function (item) {
159
+ return matchesQuery(item, query);
160
+ });
161
+
162
+ pendingList.innerHTML = "";
163
+ const isEmpty = items.length === 0;
164
+ emptyPending.style.display = isEmpty ? "block" : "none";
165
+ emptyPending.textContent = emptyStateText(query);
166
+
167
+ items.forEach(function (item, index) {
168
+ pendingList.appendChild(buildQueueRow(item, index));
169
+ });
170
+ }
171
+
172
+ function updateTabCounts() {
173
+ const waitingCount = pendingItems.filter(function (item) {
174
+ return !activeItem || item.id !== activeItem.id;
175
+ }).length;
176
+ document.getElementById("tab-count-waiting").textContent = waitingCount;
177
+ document.getElementById("tab-count-active").textContent = activeItem ? "1" : "0";
178
+ if (completedLoaded) {
179
+ document.getElementById("tab-count-completed").textContent = completedItems.length;
180
+ }
181
+ }
182
+
183
  async function loadPending() {
184
+ if (currentTab === "waiting") {
185
+ pendingList.innerHTML = '<div class="skeleton-row"></div><div class="skeleton-row"></div><div class="skeleton-row"></div>';
186
+ }
187
 
188
  try {
189
  const response = await fetch("/pending-interactions");
190
  const interactions = await response.json();
191
+ pendingItems = interactions;
192
 
193
+ const newHeadingText = interactions.length === 0
 
 
 
 
194
  ? "All caught up"
195
  : interactions.length === 1
196
  ? "1 message waiting for you"
 
202
  void queueHeading.offsetWidth;
203
  queueHeading.style.animation = "";
204
 
205
+ renderQueueList();
206
+ updateTabCounts();
207
+ } catch (error) {
208
+ showError("Couldn't load pending messages. Check your connection.");
209
+ }
210
+ }
211
+
212
+ async function loadCompleted() {
213
+ pendingList.innerHTML = '<div class="skeleton-row"></div><div class="skeleton-row"></div>';
214
+ try {
215
+ const response = await fetch("/completed-interactions");
216
+ completedItems = await response.json();
217
+ completedLoaded = true;
218
+ renderQueueList();
219
+ updateTabCounts();
220
+ } catch (error) {
221
+ showError("Couldn't load completed messages. Check your connection.");
222
+ }
223
+ }
224
+
225
+ document.querySelectorAll(".queue-tab").forEach(function (tabBtn) {
226
+ tabBtn.addEventListener("click", function () {
227
+ currentTab = tabBtn.dataset.tab;
228
+ document.querySelectorAll(".queue-tab").forEach(function (t) {
229
+ t.classList.toggle("active", t === tabBtn);
230
+ t.setAttribute("aria-selected", t === tabBtn ? "true" : "false");
231
+ });
232
+ if (currentTab === "completed" && !completedLoaded) {
233
+ loadCompleted();
234
+ } else {
235
+ renderQueueList();
236
+ }
237
+ });
238
+ });
239
+
240
+ queueSearchInput.addEventListener("input", function () {
241
+ renderQueueList();
242
+ });
243
+
244
+ // ---- Workspace (respond step) ----
245
+
246
+ async function loadPatientHistoryForWorkspace(item) {
247
+ patientHistoryCard.style.display = "none";
248
+ try {
249
+ const response = await fetch("/patient-history/" + item.user_id);
250
+ const items = await response.json();
251
+ const others = items.filter(function (h) {
252
+ return h.id !== item.id;
253
+ });
254
+
255
+ if (!others.length) return;
256
+
257
+ patientHistoryList.innerHTML = "";
258
+ others.forEach(function (h, index) {
259
+ const row = document.createElement("div");
260
+ row.className = "result-block history-row-static list-item-in";
261
+ row.style.animationDelay = (index * 40) + "ms";
262
+ const answered = Boolean(h.translated_response);
263
+ const badgeClass = answered ? "answered" : "waiting";
264
+ const badgeLabel = answered ? "Answered" : "Waiting for reply";
265
+ const timeLabel = window.formatRelativeTime ? window.formatRelativeTime(h.timestamp) : "";
266
  row.innerHTML =
267
+ '<div class="result-label">#' + escapeHtml(h.id) +
268
+ ' <span class="status-badge ' + badgeClass + '">' + badgeLabel + "</span>" +
269
+ ' <span class="timestamp">' + escapeHtml(timeLabel) + "</span></div>" +
270
+ '<div class="result-text">' + escapeHtml(h.translated_text || h.input_text) + "</div>";
271
+ patientHistoryList.appendChild(row);
 
 
 
 
 
 
 
 
 
 
272
  });
273
+ patientHistoryCard.style.display = "block";
274
  } catch (error) {
275
+ // Supplementary context only, not the critical path - fail quietly
276
+ // rather than blocking the provider from responding.
277
  }
278
  }
279
 
280
+ function invalidatePreview() {
281
+ previewBlock.style.display = "none";
282
+ previewAudioEl.pause();
283
+ lastPreviewText = null;
284
+ lastPreviewTranslation = null;
285
+ }
286
+
287
  function openInteraction(item) {
288
  clearBanner();
289
  selectedInteractionId = item.id;
290
+ activeItem = item;
291
+
292
  document.getElementById("detail-placeholder").style.display = "none";
293
  const detailContent = document.getElementById("detail-content");
294
  detailContent.style.display = "block";
295
  detailContent.classList.remove("list-item-in");
296
  void detailContent.offsetWidth;
297
  detailContent.classList.add("list-item-in");
298
+
299
  document.getElementById("respond-interaction-id").textContent = item.id;
300
  document.getElementById("respond-language-label").textContent =
301
+ "Patient (" + capitalize(item.detected_language) + ")";
302
  document.getElementById("respond-translated-text").textContent = item.translated_text;
303
  document.getElementById("respond-nlu-summary").textContent = item.nlu_summary || "No summary available.";
304
+ document.getElementById("respond-original-language").textContent = capitalize(item.detected_language);
305
+ document.getElementById("respond-original-text").textContent = item.input_text;
306
+
307
+ const timeLabel = window.formatRelativeTime ? window.formatRelativeTime(item.timestamp) : item.timestamp;
308
+ document.getElementById("respond-meta-line").textContent = "Submitted " + timeLabel;
309
+
310
+ const statusBadge = document.getElementById("respond-status-badge");
311
+ const answered = Boolean(item.translated_response);
312
+ statusBadge.className = "status-badge " + (answered ? "answered" : "waiting");
313
+ statusBadge.textContent = answered ? "Answered" : "Waiting for your reply";
314
+
315
+ responseTextInput.value = "";
316
+ invalidatePreview();
317
+ loadPatientHistoryForWorkspace(item);
318
+
319
  showStep("respond");
320
+ renderQueueList();
321
+ updateTabCounts();
322
  }
323
 
324
  document.getElementById("quick-reply-chips").addEventListener("click", function (event) {
325
  const chip = event.target.closest(".chip");
326
  if (!chip) return;
327
+ responseTextInput.value = responseTextInput.value
328
+ ? responseTextInput.value + " " + chip.dataset.text
329
+ : chip.dataset.text;
330
+ responseTextInput.focus();
331
+ invalidatePreview();
332
+ });
333
+
334
+ responseTextInput.addEventListener("input", function () {
335
+ invalidatePreview();
336
  });
337
 
338
  backToListBtn.addEventListener("click", function () {
 
342
  loadPending();
343
  });
344
 
345
+ // ---- Response composer: preview, then send ----
346
+
347
+ function formatAudioTime(totalSeconds) {
348
+ if (!isFinite(totalSeconds) || totalSeconds < 0) return "0:00";
349
+ const minutes = Math.floor(totalSeconds / 60);
350
+ const seconds = Math.floor(totalSeconds % 60);
351
+ return minutes + ":" + String(seconds).padStart(2, "0");
352
+ }
353
+
354
+ function setPreviewAudioSource(url) {
355
+ previewAudioEl.src = url;
356
+ previewSeek.value = 0;
357
+ previewCurrentTimeEl.textContent = "0:00";
358
+ previewDurationEl.textContent = "0:00";
359
+ previewIconPlay.style.display = "inline";
360
+ previewIconPause.style.display = "none";
361
+ }
362
+
363
+ previewPlayBtn.addEventListener("click", function () {
364
+ if (previewAudioEl.paused) {
365
+ previewAudioEl.play();
366
+ } else {
367
+ previewAudioEl.pause();
368
+ }
369
+ });
370
+
371
+ previewReplayBtn.addEventListener("click", function () {
372
+ previewAudioEl.currentTime = 0;
373
+ previewAudioEl.play();
374
+ });
375
+
376
+ previewAudioEl.addEventListener("play", function () {
377
+ previewIconPlay.style.display = "none";
378
+ previewIconPause.style.display = "inline";
379
+ });
380
+
381
+ previewAudioEl.addEventListener("pause", function () {
382
+ previewIconPlay.style.display = "inline";
383
+ previewIconPause.style.display = "none";
384
+ });
385
+
386
+ previewAudioEl.addEventListener("ended", function () {
387
+ previewIconPlay.style.display = "inline";
388
+ previewIconPause.style.display = "none";
389
+ });
390
+
391
+ previewAudioEl.addEventListener("loadedmetadata", function () {
392
+ previewSeek.max = previewAudioEl.duration || 0;
393
+ previewDurationEl.textContent = formatAudioTime(previewAudioEl.duration);
394
+ });
395
+
396
+ previewAudioEl.addEventListener("timeupdate", function () {
397
+ previewSeek.value = previewAudioEl.currentTime;
398
+ previewCurrentTimeEl.textContent = formatAudioTime(previewAudioEl.currentTime);
399
+ });
400
+
401
+ previewSeek.addEventListener("input", function () {
402
+ previewAudioEl.currentTime = Number(previewSeek.value);
403
+ });
404
+
405
+ previewBtn.addEventListener("click", async function () {
406
+ const responseText = responseTextInput.value.trim();
407
+ if (!responseText) {
408
+ showError("Type a reply before previewing.");
409
+ return;
410
+ }
411
+
412
+ clearBanner();
413
+ setButtonLoading(previewBtn, true, "Generating preview...");
414
+
415
+ const formData = new FormData();
416
+ formData.append("interaction_id", selectedInteractionId);
417
+ formData.append("response_text", responseText);
418
+
419
+ try {
420
+ const response = await fetch("/preview-response", { method: "POST", body: formData });
421
+ const data = await response.json();
422
+
423
+ if (!response.ok) {
424
+ showError(data.error || "Couldn't generate a preview.");
425
+ return;
426
+ }
427
+
428
+ previewTextBubble.innerHTML = '<span class="chat-label">Preview</span>' + escapeHtml(data.translated_response);
429
+ setPreviewAudioSource(data.audio_url);
430
+ previewBlock.style.display = "block";
431
+ lastPreviewText = responseText;
432
+ lastPreviewTranslation = data.translated_response;
433
+ } catch (error) {
434
+ showError("Couldn't reach the server. Check your connection and try again.");
435
+ } finally {
436
+ setButtonLoading(previewBtn, false, "Generating preview...");
437
+ }
438
+ });
439
+
440
  submitResponseBtn.addEventListener("click", async function () {
441
  clearBanner();
442
+ const responseText = responseTextInput.value.trim();
443
 
444
  if (!responseText) {
445
  showError("Type a reply before sending.");
 
452
  formData.append("interaction_id", selectedInteractionId);
453
  formData.append("response_text", responseText);
454
 
455
+ // If this exact text was just previewed, tell the backend to reuse
456
+ // that translation + audio instead of generating them again - same
457
+ // result, half the API cost. If the text was edited since the last
458
+ // preview (or there was no preview at all), this is skipped and the
459
+ // backend does the full work, same as before.
460
+ if (lastPreviewText === responseText && lastPreviewTranslation) {
461
+ formData.append("translated_response", lastPreviewTranslation);
462
+ formData.append("reuse_audio", "true");
463
+ }
464
+
465
  try {
466
  const response = await fetch("/provider-response", { method: "POST", body: formData });
467
  const data = await response.json();
 
471
  return;
472
  }
473
 
474
+ activeItem = null;
475
+ completedLoaded = false;
476
  showStep("list");
477
  document.getElementById("detail-placeholder").style.display = "block";
478
  document.getElementById("detail-content").style.display = "none";