script.js 50 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243
  1. document.addEventListener('DOMContentLoaded', () => {
  2. const chatForm = document.getElementById('chat-form');
  3. const userInput = document.getElementById('user-input');
  4. const chatContainer = document.getElementById('chat-container');
  5. const sendBtn = document.getElementById('send-btn');
  6. const clearChatBtn = document.getElementById('clear-chat');
  7. let chatHistory = []; // Store conversation history
  8. // Auto-resize textarea
  9. userInput.addEventListener('input', function() {
  10. this.style.height = 'auto';
  11. this.style.height = (this.scrollHeight > 150 ? 150 : this.scrollHeight) + 'px';
  12. if (this.value.trim() === '') {
  13. sendBtn.disabled = true;
  14. } else {
  15. sendBtn.disabled = false;
  16. }
  17. });
  18. // Handle Enter key (Shift+Enter for new line)
  19. userInput.addEventListener('keydown', function(e) {
  20. if (e.key === 'Enter' && !e.shiftKey) {
  21. e.preventDefault();
  22. if (!sendBtn.disabled) {
  23. chatForm.requestSubmit();
  24. }
  25. }
  26. });
  27. clearChatBtn.addEventListener('click', async () => {
  28. if (confirm('Are you sure you want to clear the chat history?')) {
  29. try {
  30. const token = localStorage.getItem('localFoodToken');
  31. console.log("Clear Chat clicked. Token exists?", !!token);
  32. if (token) {
  33. console.log("Sending DELETE request to /api/chat/history...");
  34. const res = await fetch('/api/chat/history', {
  35. method: 'DELETE',
  36. headers: {
  37. 'Authorization': `Bearer ${token}`
  38. }
  39. });
  40. console.log("DELETE request finished with status:", res.status);
  41. } else {
  42. console.warn("No localFoodToken found in localStorage. Skipping backend deletion.");
  43. }
  44. } catch (err) {
  45. console.error("Failed to clear chat history on server:", err);
  46. }
  47. chatHistory = [];
  48. chatContainer.innerHTML = '';
  49. addMessage('system', 'Hello! I am LocalFoodAI, your completely local nutrition and menu assistant. How can I help you today?');
  50. }
  51. });
  52. chatForm.addEventListener('submit', async (e) => {
  53. e.preventDefault();
  54. const message = userInput.value.trim();
  55. if (!message) return;
  56. // Reset input
  57. userInput.value = '';
  58. userInput.style.height = 'auto';
  59. sendBtn.disabled = true;
  60. // Add user message to UI
  61. addMessage('user', message);
  62. chatHistory.push({ role: 'user', content: message });
  63. // Add loading indicator
  64. const loadingId = addTypingIndicator();
  65. try {
  66. const token = localStorage.getItem('localFoodToken');
  67. // Fetch response from backend
  68. const response = await fetch('/chat', {
  69. method: 'POST',
  70. headers: {
  71. 'Content-Type': 'application/json',
  72. 'Authorization': `Bearer ${token}`
  73. },
  74. body: JSON.stringify({ messages: chatHistory })
  75. });
  76. if (response.status === 401) {
  77. setLoggedOutState();
  78. addMessage('system', 'Your session has expired. Please log in again.');
  79. return;
  80. }
  81. if (!response.ok) {
  82. throw new Error(`HTTP error! status: ${response.status}`);
  83. }
  84. // Remove loading indicator
  85. removeElement(loadingId);
  86. // Create new bot message container
  87. const botMessageId = 'msg-' + Date.now();
  88. const botContentEl = addMessage('system', '', botMessageId);
  89. // Add stopwatch timer for thinking phase
  90. const timerSpan = document.createElement('div');
  91. timerSpan.style.cssText = "font-size: 0.8em; color: #888; font-family: monospace; position: absolute; right: 15px; top: 12px;";
  92. timerSpan.textContent = "0s";
  93. const msgDiv = document.getElementById(botMessageId);
  94. msgDiv.style.position = 'relative';
  95. msgDiv.appendChild(timerSpan);
  96. let seconds = 0;
  97. let timerInterval = setInterval(() => {
  98. seconds++;
  99. timerSpan.textContent = `${seconds}s`;
  100. }, 1000);
  101. let botFullResponse = '';
  102. // Handle Server-Sent Events (Streaming)
  103. const reader = response.body.getReader();
  104. const decoder = new TextDecoder('utf-8');
  105. let done = false;
  106. while (!done) {
  107. const { value, done: readerDone } = await reader.read();
  108. done = readerDone;
  109. if (value) {
  110. const chunk = decoder.decode(value, { stream: true });
  111. // Split the chunk by double newline to get individual SSE messages
  112. const lines = chunk.split('\n\n');
  113. for (const line of lines) {
  114. if (line.startsWith('data: ')) {
  115. const dataStr = line.substring(6);
  116. if (dataStr.trim() === '') continue;
  117. try {
  118. const data = JSON.parse(dataStr);
  119. if (data.error) {
  120. botContentEl.innerHTML += `<br><span style="color:#f85149">Error: ${data.error}</span>`;
  121. } else if (data.content !== undefined) {
  122. botFullResponse += data.content;
  123. // Basic text to HTML conversion
  124. botContentEl.innerHTML = formatText(botFullResponse);
  125. chatContainer.scrollTop = chatContainer.scrollHeight;
  126. // Check if AI finished thinking
  127. if (data.is_thinking === false && timerInterval) {
  128. clearInterval(timerInterval);
  129. timerInterval = null;
  130. timerSpan.textContent = `Thought for ${seconds}s`;
  131. }
  132. }
  133. } catch (err) {
  134. console.error('Error parsing SSE data:', err, dataStr);
  135. }
  136. }
  137. }
  138. }
  139. }
  140. // Save bot response to history once complete
  141. chatHistory.push({ role: 'assistant', content: botFullResponse });
  142. } catch (error) {
  143. console.error('Chat error:', error);
  144. removeElement(loadingId);
  145. addMessage('system', 'Sorry, there was an error communicating with the local LLM. Make sure the server and Ollama are running.');
  146. } finally {
  147. sendBtn.disabled = false;
  148. userInput.focus();
  149. }
  150. });
  151. function addMessage(role, content, id = null) {
  152. const msgDiv = document.createElement('div');
  153. msgDiv.className = `message ${role}`;
  154. if (id) msgDiv.id = id;
  155. const avatarDiv = document.createElement('div');
  156. avatarDiv.className = 'avatar';
  157. avatarDiv.textContent = role === 'user' ? '👤' : '🤖';
  158. const contentDiv = document.createElement('div');
  159. contentDiv.className = 'message-content';
  160. contentDiv.innerHTML = formatText(content);
  161. msgDiv.appendChild(avatarDiv);
  162. msgDiv.appendChild(contentDiv);
  163. chatContainer.appendChild(msgDiv);
  164. chatContainer.scrollTop = chatContainer.scrollHeight;
  165. return contentDiv;
  166. }
  167. function addTypingIndicator() {
  168. const id = 'typing-' + Date.now();
  169. const msgDiv = document.createElement('div');
  170. msgDiv.className = 'message system';
  171. msgDiv.id = id;
  172. const avatarDiv = document.createElement('div');
  173. avatarDiv.className = 'avatar';
  174. avatarDiv.textContent = '🤖';
  175. const contentDiv = document.createElement('div');
  176. contentDiv.className = 'message-content typing-indicator';
  177. contentDiv.innerHTML = `
  178. <div style="display: flex; align-items: center; gap: 8px;">
  179. <div style="display: flex; gap: 3px; align-items: center; height: 20px;">
  180. <div class="typing-dot"></div>
  181. <div class="typing-dot"></div>
  182. <div class="typing-dot"></div>
  183. </div>
  184. <span id="${id}-timer" style="font-size: 0.85em; color: #888; font-family: monospace;">0s</span>
  185. </div>
  186. `;
  187. msgDiv.appendChild(avatarDiv);
  188. msgDiv.appendChild(contentDiv);
  189. chatContainer.appendChild(msgDiv);
  190. chatContainer.scrollTop = chatContainer.scrollHeight;
  191. let seconds = 0;
  192. const intervalId = setInterval(() => {
  193. seconds++;
  194. const span = document.getElementById(`${id}-timer`);
  195. if (span) {
  196. span.textContent = `${seconds}s`;
  197. } else {
  198. clearInterval(intervalId);
  199. }
  200. }, 1000);
  201. return id;
  202. }
  203. function removeElement(id) {
  204. const el = document.getElementById(id);
  205. if (el) el.remove();
  206. }
  207. function formatText(text) {
  208. if (!text) return '';
  209. if (typeof marked !== 'undefined') {
  210. return marked.parse(text, { breaks: true });
  211. }
  212. // Fallback just in case marked doesn't load
  213. let formatted = text
  214. .replace(/&/g, "&amp;")
  215. .replace(/</g, "&lt;")
  216. .replace(/>/g, "&gt;")
  217. .replace(/\n/g, "<br>")
  218. .replace(/\*\*(.*?)\*\*/g, "<strong>$1</strong>")
  219. .replace(/\*(.*?)\*/g, "<em>$1</em>")
  220. .replace(/`(.*?)`/g, "<code style='background:rgba(255,255,255,0.1);padding:2px 4px;border-radius:4px'>$1</code>");
  221. return formatted;
  222. }
  223. // Authentication & Session Logic
  224. const authScreen = document.getElementById('auth-screen');
  225. const chatApp = document.getElementById('chat-app');
  226. const loginForm = document.getElementById('login-form');
  227. const registerForm = document.getElementById('register-form');
  228. const showRegisterLink = document.getElementById('show-register');
  229. const showLoginLink = document.getElementById('show-login');
  230. const loginError = document.getElementById('login-error');
  231. const regError = document.getElementById('reg-error');
  232. const regSuccess = document.getElementById('reg-success');
  233. const logoutBtn = document.getElementById('nav-logout-btn');
  234. const userGreeting = document.getElementById('user-greeting');
  235. // Check session on load
  236. const savedUser = localStorage.getItem('localFoodUser');
  237. const savedToken = localStorage.getItem('localFoodToken');
  238. if (savedUser && savedToken) {
  239. setLoggedInState(savedUser, savedToken);
  240. }
  241. async function setLoggedInState(username, token) {
  242. localStorage.setItem('localFoodUser', username);
  243. localStorage.setItem('localFoodToken', token);
  244. userGreeting.textContent = `Welcome, ${username}`;
  245. authScreen.classList.add('fade-out');
  246. setTimeout(() => {
  247. authScreen.style.display = 'none';
  248. chatApp.style.display = 'flex';
  249. chatApp.classList.add('fade-in');
  250. userInput.focus();
  251. }, 500);
  252. // Load persisted chat history and macro targets from the server
  253. await loadChatHistory();
  254. await loadMacroTargets();
  255. }
  256. async function loadChatHistory() {
  257. const token = localStorage.getItem('localFoodToken');
  258. if (!token) return;
  259. try {
  260. const response = await fetch('/api/chat/history', {
  261. headers: { 'Authorization': `Bearer ${token}` }
  262. });
  263. if (response.status === 401) {
  264. setLoggedOutState();
  265. return;
  266. }
  267. if (response.ok) {
  268. const data = await response.json();
  269. if (data.history && data.history.length > 0) {
  270. // Clear initial welcome message if we have real history
  271. chatContainer.innerHTML = '';
  272. chatHistory = []; // Reset local state
  273. data.history.forEach(msg => {
  274. addMessage(msg.role, msg.content);
  275. chatHistory.push({ role: msg.role, content: msg.content });
  276. });
  277. }
  278. }
  279. } catch (err) {
  280. console.error("Failed to load chat history:", err);
  281. }
  282. }
  283. async function loadMacroTargets() {
  284. const token = localStorage.getItem('localFoodToken');
  285. if (!token) return;
  286. const dashboard = document.getElementById('macro-dashboard');
  287. try {
  288. const response = await fetch('/api/macros/targets', {
  289. headers: { 'Authorization': `Bearer ${token}` }
  290. });
  291. if (response.ok) {
  292. const data = await response.json();
  293. document.getElementById('macro-calories').textContent = `${data.calories} kcal`;
  294. document.getElementById('macro-protein').textContent = `${data.protein_g} g`;
  295. document.getElementById('macro-carbs').textContent = `${data.carbs_g} g`;
  296. document.getElementById('macro-fat').textContent = `${data.fat_g} g`;
  297. if (dashboard) dashboard.style.display = 'flex';
  298. }
  299. } catch (err) {
  300. console.error('Failed to load macro targets:', err);
  301. }
  302. }
  303. async function setLoggedOutState() {
  304. const token = localStorage.getItem('localFoodToken');
  305. if (token) {
  306. try {
  307. await fetch('/api/logout', {
  308. method: 'POST',
  309. headers: { 'Authorization': `Bearer ${token}` }
  310. });
  311. } catch (err) {
  312. console.error("Error during logout:", err);
  313. }
  314. }
  315. // Clear chat memory and interface so the next user doesn't see old messages
  316. chatHistory = [];
  317. chatContainer.innerHTML = '';
  318. addMessage('system', 'Hello! I am LocalFoodAI, your completely local nutrition and menu assistant. How can I help you today?');
  319. localStorage.removeItem('localFoodUser');
  320. localStorage.removeItem('localFoodToken');
  321. chatApp.style.display = 'none';
  322. chatApp.classList.remove('fade-in');
  323. const dashboard = document.getElementById('macro-dashboard');
  324. if (dashboard) dashboard.style.display = 'none';
  325. authScreen.style.display = 'block';
  326. setTimeout(() => {
  327. authScreen.classList.remove('fade-out');
  328. }, 50);
  329. loginForm.reset();
  330. registerForm.reset();
  331. loginError.textContent = '';
  332. regError.textContent = '';
  333. }
  334. logoutBtn.addEventListener('click', () => {
  335. setLoggedOutState();
  336. });
  337. // Toggles
  338. showRegisterLink.addEventListener('click', (e) => {
  339. e.preventDefault();
  340. loginForm.style.display = 'none';
  341. registerForm.style.display = 'block';
  342. });
  343. showLoginLink.addEventListener('click', (e) => {
  344. e.preventDefault();
  345. registerForm.style.display = 'none';
  346. loginForm.style.display = 'block';
  347. });
  348. // Login Submission
  349. loginForm.addEventListener('submit', async (e) => {
  350. e.preventDefault();
  351. loginError.textContent = '';
  352. const submitBtn = document.getElementById('login-submit-btn');
  353. submitBtn.disabled = true;
  354. const username = document.getElementById('login-username').value;
  355. const password = document.getElementById('login-password').value;
  356. try {
  357. const response = await fetch('/api/login', {
  358. method: 'POST',
  359. headers: { 'Content-Type': 'application/json' },
  360. body: JSON.stringify({ username, password })
  361. });
  362. const data = await response.json();
  363. if (!response.ok) {
  364. loginError.textContent = data.detail || 'Login failed.';
  365. } else {
  366. setLoggedInState(data.username, data.token);
  367. }
  368. } catch (err) {
  369. loginError.textContent = 'Server error. Is the backend running?';
  370. } finally {
  371. submitBtn.disabled = false;
  372. }
  373. });
  374. // Registration Submission
  375. registerForm.addEventListener('submit', async (e) => {
  376. e.preventDefault();
  377. regError.textContent = '';
  378. regSuccess.textContent = '';
  379. const submitBtn = document.getElementById('reg-submit-btn');
  380. submitBtn.disabled = true;
  381. const username = document.getElementById('reg-username').value;
  382. const password = document.getElementById('reg-password').value;
  383. const confirmInfo = document.getElementById('reg-confirm').value;
  384. if (password !== confirmInfo) {
  385. regError.textContent = "Passwords do not match.";
  386. submitBtn.disabled = false;
  387. return;
  388. }
  389. try {
  390. const response = await fetch('/api/register', {
  391. method: 'POST',
  392. headers: { 'Content-Type': 'application/json' },
  393. body: JSON.stringify({ username, password })
  394. });
  395. const data = await response.json();
  396. if (!response.ok) {
  397. regError.textContent = data.detail || 'Registration failed.';
  398. } else {
  399. regSuccess.textContent = 'Account created! Logging in...';
  400. setTimeout(() => {
  401. setLoggedInState(data.username, data.token);
  402. }, 1000);
  403. }
  404. } catch (err) {
  405. regError.textContent = 'Server error. Please try again later.';
  406. } finally {
  407. submitBtn.disabled = false;
  408. }
  409. });
  410. // --- Food Search Module Logic ---
  411. const searchInput = document.getElementById('food-search-input');
  412. const searchDropdown = document.getElementById('search-results-dropdown');
  413. const clearSearchBtn = document.getElementById('clear-search-btn');
  414. // --- Meal Builder State & UI (US-10 Task #46) ---
  415. let currentMealItems = [];
  416. let editingMealId = null;
  417. const mealBuilder = document.getElementById('meal-builder');
  418. const mealContent = document.getElementById('meal-content');
  419. const mealItemsList = document.getElementById('meal-items-list');
  420. const mealItemCount = document.getElementById('meal-item-count');
  421. const toggleMealBtn = document.getElementById('toggle-meal-btn');
  422. const emptyMealMsg = document.getElementById('empty-meal-msg');
  423. const mealBuilderFooter = document.getElementById('meal-builder-footer');
  424. const generateRecipeBtn = document.getElementById('generate-recipe-btn');
  425. const toggleMealBuilder = () => {
  426. const isCollapsed = mealContent.classList.contains('collapsed');
  427. if (isCollapsed) {
  428. mealContent.classList.remove('collapsed');
  429. toggleMealBtn.style.transform = 'rotate(180deg)';
  430. } else {
  431. mealContent.classList.add('collapsed');
  432. toggleMealBtn.style.transform = 'rotate(0deg)';
  433. }
  434. };
  435. const addItemToMeal = (food) => {
  436. // Duplicate Handling: Merge by updating grams
  437. const existingItem = currentMealItems.find(item => item.id === food.id);
  438. if (existingItem) {
  439. existingItem.amount += 100;
  440. // Directly update the DOM input for this item to avoid re-rendering the whole list
  441. const inputEl = document.querySelector(`.meal-weight-input[data-id="${food.id}"]`);
  442. if (inputEl) {
  443. inputEl.value = existingItem.amount;
  444. // Animate the row slightly to show it was updated
  445. const rowEl = inputEl.closest('.meal-item-row');
  446. if (rowEl) {
  447. rowEl.style.transform = 'scale(1.02)';
  448. rowEl.style.background = 'rgba(255, 255, 255, 0.1)';
  449. setTimeout(() => {
  450. rowEl.style.transform = '';
  451. rowEl.style.background = 'rgba(255, 255, 255, 0.05)';
  452. }, 200);
  453. }
  454. }
  455. } else {
  456. currentMealItems.push({
  457. id: food.id,
  458. name: food.name,
  459. amount: 100,
  460. base_macros: {
  461. calories: food.calories,
  462. protein_g: food.protein_g,
  463. fat_g: food.fat_g,
  464. carbs_g: food.carbs_g
  465. }
  466. });
  467. renderMealItems(); // Only re-render if it's a new item
  468. }
  469. // Ensure builder is expanded when adding an item
  470. if (mealContent.classList.contains('collapsed')) {
  471. toggleMealBuilder();
  472. }
  473. // Trigger calculation
  474. calculateMealTotals();
  475. };
  476. const removeItemFromMeal = (id) => {
  477. currentMealItems = currentMealItems.filter(item => item.id !== id);
  478. renderMealItems();
  479. calculateMealTotals();
  480. };
  481. let calculateAbortController = null;
  482. const calculateMealTotals = async () => {
  483. const activeItems = currentMealItems.filter(item => item.amount > 0);
  484. // DOM Elements
  485. const totalsBanner = document.getElementById('meal-totals-banner');
  486. const totalWeightEl = document.getElementById('meal-total-weight');
  487. const totalCalEl = document.getElementById('meal-total-cal');
  488. const totalProEl = document.getElementById('meal-total-pro');
  489. const totalFatEl = document.getElementById('meal-total-fat');
  490. const totalCarbEl = document.getElementById('meal-total-carb');
  491. if (activeItems.length === 0) {
  492. if (totalsBanner) totalsBanner.classList.remove('has-error');
  493. if (totalWeightEl) totalWeightEl.textContent = '0g';
  494. if (totalCalEl) totalCalEl.textContent = '0';
  495. if (totalProEl) totalProEl.textContent = '0';
  496. if (totalFatEl) totalFatEl.textContent = '0';
  497. if (totalCarbEl) totalCarbEl.textContent = '0';
  498. const saveBtn = document.getElementById('save-meal-btn');
  499. if (saveBtn) saveBtn.style.display = 'none';
  500. return;
  501. }
  502. // Race Condition Protection: Abort previous request
  503. if (calculateAbortController) {
  504. calculateAbortController.abort();
  505. }
  506. calculateAbortController = new AbortController();
  507. try {
  508. const token = localStorage.getItem('localFoodToken');
  509. const payload = {
  510. items: activeItems.map(item => ({
  511. food_id: item.id,
  512. amount_g: item.amount
  513. }))
  514. };
  515. const response = await fetch('/api/meal/calculate', {
  516. method: 'POST',
  517. headers: {
  518. 'Content-Type': 'application/json',
  519. 'Authorization': `Bearer ${token}`
  520. },
  521. body: JSON.stringify(payload),
  522. signal: calculateAbortController.signal
  523. });
  524. if (response.ok) {
  525. const data = await response.json();
  526. if (totalsBanner) totalsBanner.classList.remove('has-error');
  527. // Update UI with animation for "alive" feel
  528. if (totalWeightEl) totalWeightEl.textContent = `${data.total_weight_g}g`;
  529. if (totalCalEl) animateValue(totalCalEl, data.macros.calories);
  530. if (totalProEl) animateValue(totalProEl, data.macros.protein_g);
  531. if (totalFatEl) animateValue(totalFatEl, data.macros.fat_g);
  532. if (totalCarbEl) animateValue(totalCarbEl, data.macros.carbs_g);
  533. // Show Save button (Sprint 8)
  534. const saveBtn = document.getElementById('save-meal-btn');
  535. if (saveBtn) saveBtn.style.display = 'flex';
  536. } else {
  537. if (totalsBanner) totalsBanner.classList.add('has-error');
  538. }
  539. } catch (err) {
  540. if (err.name === 'AbortError') return; // Ignore expected aborts
  541. console.error('Failed to calculate meal totals:', err);
  542. if (totalsBanner) totalsBanner.classList.add('has-error');
  543. }
  544. };
  545. // Helper for smooth number transitions
  546. const animateValue = (el, targetValue) => {
  547. const startValue = parseFloat(el.textContent) || 0;
  548. const duration = 400;
  549. const startTime = performance.now();
  550. const update = (currentTime) => {
  551. const elapsed = currentTime - startTime;
  552. const progress = Math.min(elapsed / duration, 1);
  553. // Ease out quad
  554. const ease = progress * (2 - progress);
  555. const currentValue = startValue + (targetValue - startValue) * ease;
  556. el.textContent = targetValue % 1 === 0 ? Math.round(currentValue) : currentValue.toFixed(1);
  557. if (progress < 1) {
  558. requestAnimationFrame(update);
  559. } else {
  560. el.textContent = targetValue;
  561. }
  562. };
  563. requestAnimationFrame(update);
  564. };
  565. const debouncedCalculate = debounce(calculateMealTotals, 300);
  566. const updateItemAmount = (id, newAmount) => {
  567. const item = currentMealItems.find(item => item.id === id);
  568. if (item) {
  569. item.amount = parseInt(newAmount) || 0;
  570. debouncedCalculate();
  571. }
  572. };
  573. const renderMealItems = () => {
  574. mealItemsList.innerHTML = '';
  575. mealItemCount.textContent = `${currentMealItems.length} item${currentMealItems.length !== 1 ? 's' : ''}`;
  576. if (currentMealItems.length === 0) {
  577. mealItemsList.appendChild(emptyMealMsg);
  578. mealBuilderFooter.style.display = 'none';
  579. return;
  580. }
  581. mealBuilderFooter.style.display = 'flex';
  582. currentMealItems.forEach(item => {
  583. const row = document.createElement('div');
  584. row.className = 'meal-item-row';
  585. row.innerHTML = `
  586. <div class="meal-item-name" title="${item.name}">${item.name}</div>
  587. <div class="meal-item-controls">
  588. <div class="weight-input-wrapper">
  589. <input type="number" value="${item.amount}" min="0" max="5000" data-id="${item.id}" class="meal-weight-input">
  590. <span class="weight-unit">g</span>
  591. </div>
  592. <button class="remove-item-btn" data-id="${item.id}" title="Remove item">
  593. <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 6h18M19 6v14a2 2 0 01-2 2H7a2 2 0 01-2-2V6m3 0V4a2 2 0 012-2h4a2 2 0 012 2v2"></path></svg>
  594. </button>
  595. </div>
  596. `;
  597. // Event Listeners for rows
  598. row.querySelector('.meal-weight-input').addEventListener('input', (e) => {
  599. updateItemAmount(item.id, e.target.value);
  600. });
  601. row.querySelector('.remove-item-btn').addEventListener('click', () => {
  602. removeItemFromMeal(item.id);
  603. });
  604. mealItemsList.appendChild(row);
  605. });
  606. };
  607. if (toggleMealBtn) {
  608. toggleMealBtn.addEventListener('click', toggleMealBuilder);
  609. }
  610. if (generateRecipeBtn) {
  611. generateRecipeBtn.addEventListener('click', () => {
  612. const activeItems = currentMealItems.filter(item => item.amount > 0);
  613. if (activeItems.length === 0) return;
  614. const itemStrings = activeItems.map(item => `${item.name} (${item.amount}g)`);
  615. const promptText = `Give me a recipe that contains: ${itemStrings.join(', ')}`;
  616. userInput.value = promptText;
  617. // UI Feedback
  618. userInput.focus();
  619. userInput.style.height = 'auto';
  620. userInput.style.height = userInput.scrollHeight + 'px';
  621. sendBtn.disabled = false;
  622. // Smoothly scroll to the bottom to see the chat input
  623. window.scrollTo({ top: document.body.scrollHeight, behavior: 'smooth' });
  624. });
  625. }
  626. // Also toggle when clicking the header
  627. document.querySelector('.meal-builder-header').addEventListener('click', (e) => {
  628. if (!e.target.closest('#toggle-meal-btn')) {
  629. toggleMealBuilder();
  630. }
  631. });
  632. function debounce(func, delay) {
  633. let timeout;
  634. return function(...args) {
  635. clearTimeout(timeout);
  636. timeout = setTimeout(() => func(...args), delay);
  637. };
  638. }
  639. const performSearch = async (query) => {
  640. if (!query) {
  641. searchDropdown.style.display = 'none';
  642. return;
  643. }
  644. searchDropdown.style.display = 'block';
  645. searchDropdown.innerHTML = '<div class="search-loading">Searching local database...</div>';
  646. try {
  647. const token = localStorage.getItem('localFoodToken');
  648. const response = await fetch(`/api/food/search?q=${encodeURIComponent(query)}`, {
  649. headers: { 'Authorization': `Bearer ${token}` }
  650. });
  651. if (response.status === 401) {
  652. setLoggedOutState();
  653. addMessage('system', 'Your session has expired. Please log in again to use the food search.');
  654. searchDropdown.style.display = 'none';
  655. return;
  656. }
  657. if (!response.ok) {
  658. throw new Error(`HTTP error! status: ${response.status}`);
  659. }
  660. const data = await response.json();
  661. if (data.results && data.results.length > 0) {
  662. searchDropdown.innerHTML = '';
  663. data.results.forEach(item => {
  664. const foodEl = document.createElement('div');
  665. foodEl.className = 'food-item';
  666. const badgeStr = item.category === "Sourced Ingredient" ? "Raw Ingredient" : item.category;
  667. foodEl.innerHTML = `
  668. <div class="food-item-header">
  669. <span class="food-name">${item.name}</span>
  670. <span class="food-badge">${badgeStr}</span>
  671. </div>
  672. <div class="food-macros">
  673. <div class="macro-tag">Cal: <span>${item.calories}</span></div>
  674. <div class="macro-tag">Pro: <span>${item.protein_g}g</span></div>
  675. <div class="macro-tag">Fat: <span>${item.fat_g}g</span></div>
  676. <div class="macro-tag">Carb: <span>${item.carbs_g}g</span></div>
  677. </div>
  678. <div class="food-item-footer">
  679. <button class="add-meal-btn" data-id="${item.id}">➕ Add to Meal</button>
  680. <button class="details-btn" data-id="${item.id}">📊 Details</button>
  681. </div>
  682. <div class="details-panel" id="details-${item.id}"></div>
  683. `;
  684. // Click on the header/name area auto-fills the chat
  685. foodEl.querySelector('.food-item-header').addEventListener('click', (e) => {
  686. e.stopPropagation();
  687. userInput.value = `Can you build a meal around ${item.name} (${item.calories} cal, ${item.protein_g}g protein)?`;
  688. userInput.focus();
  689. userInput.style.height = 'auto'; // Reset
  690. sendBtn.disabled = false;
  691. searchDropdown.style.display = 'none';
  692. searchInput.value = '';
  693. clearSearchBtn.style.display = 'none';
  694. });
  695. // Click on the add to meal button
  696. foodEl.querySelector('.add-meal-btn').addEventListener('click', (e) => {
  697. e.stopPropagation();
  698. addItemToMeal(item);
  699. // Optional: provide visual feedback
  700. const btn = e.target;
  701. const originalText = btn.textContent;
  702. btn.textContent = '✅ Added';
  703. btn.style.background = '#22c55e';
  704. setTimeout(() => {
  705. btn.textContent = originalText;
  706. btn.style.background = '';
  707. }, 1000);
  708. });
  709. // Click on the details button toggles the panel
  710. foodEl.querySelector('.details-btn').addEventListener('click', (e) => {
  711. e.stopPropagation();
  712. const panel = document.getElementById(`details-${item.id}`);
  713. toggleFoodDetails(item.id, panel, e.target);
  714. });
  715. searchDropdown.appendChild(foodEl);
  716. });
  717. } else {
  718. searchDropdown.innerHTML = '<div class="search-empty">No matching foods found.</div>';
  719. }
  720. } catch (error) {
  721. console.error('Search error:', error);
  722. searchDropdown.innerHTML = '<div class="search-empty">Service currently unavailable. Please try again.</div>';
  723. }
  724. };
  725. const toggleFoodDetails = async (foodId, panel, btn) => {
  726. const isExpanded = panel.classList.contains('expanded');
  727. if (isExpanded) {
  728. panel.classList.remove('expanded');
  729. btn.textContent = '📊 Details';
  730. return;
  731. }
  732. // If not loaded yet, fetch from API
  733. if (panel.innerHTML.trim() === '' || panel.innerHTML.includes('Loading')) {
  734. console.log(`[UI] Fetching details for food ${foodId}...`);
  735. panel.innerHTML = '<div class="search-loading">Loading details...</div>';
  736. panel.classList.add('expanded'); // Show loading state
  737. try {
  738. const token = localStorage.getItem('localFoodToken');
  739. const response = await fetch(`/api/food/${foodId}`, {
  740. headers: { 'Authorization': `Bearer ${token}` }
  741. });
  742. if (!response.ok) throw new Error("Failed to fetch details");
  743. const data = await response.json();
  744. renderFoodDetails(panel, data);
  745. } catch (err) {
  746. console.error(err);
  747. panel.innerHTML = '<div class="search-empty">Error loading details.</div>';
  748. }
  749. } else {
  750. panel.classList.add('expanded');
  751. }
  752. btn.textContent = '✖ Close';
  753. };
  754. const renderFoodDetails = (panel, data) => {
  755. panel.innerHTML = `
  756. <div class="nutrient-section">
  757. <div class="nutrient-section-title">Extended Nutrition</div>
  758. <div class="nutrient-grid">
  759. <div class="nutrient-item"><span class="nutrient-label">Fiber</span><span class="nutrient-value">${data.extended.fiber_g}g</span></div>
  760. <div class="nutrient-item"><span class="nutrient-label">Sugar</span><span class="nutrient-value">${data.extended.sugar_g}g</span></div>
  761. <div class="nutrient-item"><span class="nutrient-label">Cholesterol</span><span class="nutrient-value">${data.extended.cholesterol_mg}mg</span></div>
  762. </div>
  763. </div>
  764. <div class="nutrient-section">
  765. <div class="nutrient-section-title">Vitamins</div>
  766. <div class="nutrient-grid">
  767. <div class="nutrient-item"><span class="nutrient-label">Vitamin A</span><span class="nutrient-value">${data.vitamins.vitamin_a_iu}IU</span></div>
  768. <div class="nutrient-item"><span class="nutrient-label">Vitamin C</span><span class="nutrient-value">${data.vitamins.vitamin_c_mg}mg</span></div>
  769. </div>
  770. </div>
  771. <div class="nutrient-section">
  772. <div class="nutrient-section-title">Minerals</div>
  773. <div class="nutrient-grid">
  774. <div class="nutrient-item"><span class="nutrient-label">Calcium</span><span class="nutrient-value">${data.minerals.calcium_mg}mg</span></div>
  775. <div class="nutrient-item"><span class="nutrient-label">Iron</span><span class="nutrient-value">${data.minerals.iron_mg}mg</span></div>
  776. <div class="nutrient-item"><span class="nutrient-label">Potassium</span><span class="nutrient-value">${data.minerals.potassium_mg}mg</span></div>
  777. <div class="nutrient-item"><span class="nutrient-label">Sodium</span><span class="nutrient-value">${data.minerals.sodium_mg}mg</span></div>
  778. </div>
  779. </div>
  780. `;
  781. };
  782. const handleSearchInput = debounce((e) => {
  783. const query = e.target.value.trim();
  784. if (query.length > 0) {
  785. clearSearchBtn.style.display = 'block';
  786. performSearch(query);
  787. } else {
  788. clearSearchBtn.style.display = 'none';
  789. searchDropdown.style.display = 'none';
  790. }
  791. }, 300);
  792. if (searchInput) {
  793. searchInput.addEventListener('input', handleSearchInput);
  794. }
  795. if (clearSearchBtn) {
  796. clearSearchBtn.addEventListener('click', () => {
  797. searchInput.value = '';
  798. clearSearchBtn.style.display = 'none';
  799. searchDropdown.style.display = 'none';
  800. searchInput.focus();
  801. });
  802. }
  803. document.addEventListener('click', (e) => {
  804. if (!e.target.closest('#food-search-module')) {
  805. if (searchDropdown) searchDropdown.style.display = 'none';
  806. }
  807. });
  808. // Initialize state
  809. sendBtn.disabled = true;
  810. // --- Saved Meals Logic (Sprint 8) ---
  811. const saveMealModal = document.getElementById('save-meal-modal');
  812. const openSaveModalBtn = document.getElementById('save-meal-btn');
  813. const cancelSaveBtn = document.getElementById('cancel-save-btn');
  814. const confirmSaveBtn = document.getElementById('confirm-save-btn');
  815. const mealNameInput = document.getElementById('meal-name-input');
  816. const saveMealError = document.getElementById('save-meal-error');
  817. if (openSaveModalBtn) {
  818. openSaveModalBtn.addEventListener('click', (e) => {
  819. e.stopPropagation();
  820. if (currentMealItems.length === 0) return;
  821. saveMealModal.style.display = 'flex';
  822. if (!editingMealId) {
  823. mealNameInput.value = '';
  824. }
  825. saveMealError.textContent = '';
  826. mealNameInput.focus();
  827. });
  828. }
  829. const closeSaveModal = () => {
  830. saveMealModal.style.display = 'none';
  831. };
  832. if (cancelSaveBtn) cancelSaveBtn.addEventListener('click', closeSaveModal);
  833. if (confirmSaveBtn) {
  834. confirmSaveBtn.addEventListener('click', async () => {
  835. const name = mealNameInput.value.trim();
  836. if (!name) {
  837. saveMealError.textContent = 'Please enter a name for your meal.';
  838. return;
  839. }
  840. confirmSaveBtn.disabled = true;
  841. confirmSaveBtn.textContent = editingMealId ? 'Updating...' : 'Saving...';
  842. try {
  843. const token = localStorage.getItem('localFoodToken');
  844. const isUpdating = !!editingMealId;
  845. const method = isUpdating ? 'PUT' : 'POST';
  846. const url = isUpdating ? `/api/meals/${editingMealId}` : '/api/meals';
  847. const response = await fetch(url, {
  848. method: method,
  849. headers: {
  850. 'Content-Type': 'application/json',
  851. 'Authorization': `Bearer ${token}`
  852. },
  853. body: JSON.stringify({
  854. name: name,
  855. items: currentMealItems.map(item => ({
  856. food_id: item.id,
  857. amount_g: item.amount
  858. }))
  859. })
  860. });
  861. if (response.ok) {
  862. closeSaveModal();
  863. const actionWord = isUpdating ? 'updated' : 'saved';
  864. addMessage('system', `✅ Successfully ${actionWord} "${name}"!`);
  865. if (isUpdating) {
  866. editingMealId = null;
  867. openSaveModalBtn.textContent = 'Save Meal';
  868. confirmSaveBtn.textContent = 'Save to Dashboard';
  869. currentMealItems = [];
  870. renderMealItems();
  871. calculateMealTotals();
  872. }
  873. // Trigger a refresh of the dashboard if it's open
  874. if (window.refreshDashboard) window.refreshDashboard();
  875. } else {
  876. const data = await response.json();
  877. saveMealError.textContent = data.detail || 'Failed to save meal.';
  878. }
  879. } catch (err) {
  880. console.error('Save meal error:', err);
  881. saveMealError.textContent = 'Server error. Please try again.';
  882. } finally {
  883. confirmSaveBtn.disabled = false;
  884. confirmSaveBtn.textContent = editingMealId ? 'Update Meal' : 'Save to Dashboard';
  885. }
  886. });
  887. }
  888. // Close modal on outside click
  889. window.addEventListener('click', (e) => {
  890. if (e.target === saveMealModal) {
  891. closeSaveModal();
  892. }
  893. });
  894. // --- Dashboard Logic (Sprint 8) ---
  895. const dashboardOverlay = document.getElementById('dashboard-overlay');
  896. const openDashboardBtn = document.getElementById('nav-dashboard-btn');
  897. const closeDashboardBtn = document.getElementById('close-dashboard-btn');
  898. const dashboardGrid = document.getElementById('dashboard-grid');
  899. const dashboardEmptyMsg = document.getElementById('dashboard-empty-msg');
  900. const toggleDashboard = (show) => {
  901. if (show) {
  902. dashboardOverlay.style.display = 'flex';
  903. loadSavedMeals();
  904. } else {
  905. dashboardOverlay.style.display = 'none';
  906. }
  907. };
  908. if (openDashboardBtn) openDashboardBtn.addEventListener('click', () => toggleDashboard(true));
  909. if (closeDashboardBtn) closeDashboardBtn.addEventListener('click', () => toggleDashboard(false));
  910. async function loadSavedMeals() {
  911. dashboardGrid.innerHTML = '<div class="search-loading">Loading your meals...</div>';
  912. dashboardEmptyMsg.style.display = 'none';
  913. try {
  914. const token = localStorage.getItem('localFoodToken');
  915. const response = await fetch('/api/meals', {
  916. headers: { 'Authorization': `Bearer ${token}` }
  917. });
  918. if (response.ok) {
  919. const data = await response.json();
  920. renderDashboard(data.meals);
  921. } else {
  922. dashboardGrid.innerHTML = '<div class="error-text">Failed to load meals.</div>';
  923. }
  924. } catch (err) {
  925. console.error('Dashboard load error:', err);
  926. dashboardGrid.innerHTML = '<div class="error-text">Connection error.</div>';
  927. }
  928. }
  929. function renderDashboard(meals) {
  930. dashboardGrid.innerHTML = '';
  931. if (!meals || meals.length === 0) {
  932. dashboardEmptyMsg.style.display = 'block';
  933. return;
  934. }
  935. meals.forEach((meal, index) => {
  936. const card = document.createElement('div');
  937. card.className = 'meal-card';
  938. card.setAttribute('data-meal-id', meal.id);
  939. card.style.animationDelay = `${index * 0.07}s`;
  940. const dateStr = new Date(meal.created_at).toLocaleDateString(undefined, {
  941. month: 'short', day: 'numeric', year: 'numeric'
  942. });
  943. card.innerHTML = `
  944. <div class="meal-card-header">
  945. <span class="meal-card-name">${meal.name}</span>
  946. <span class="meal-card-date">${dateStr}</span>
  947. </div>
  948. <div class="meal-card-macros">
  949. <div class="card-macro kcal">
  950. <span class="card-macro-val">${Math.round(meal.total_calories)}</span>
  951. <span class="card-macro-lbl">kcal</span>
  952. </div>
  953. <div class="card-macro protein">
  954. <span class="card-macro-val">${Math.round(meal.total_protein)}g</span>
  955. <span class="card-macro-lbl">Protein</span>
  956. </div>
  957. <div class="card-macro carbs">
  958. <span class="card-macro-val">${Math.round(meal.total_carbs)}g</span>
  959. <span class="card-macro-lbl">Carbs</span>
  960. </div>
  961. <div class="card-macro fat">
  962. <span class="card-macro-val">${Math.round(meal.total_fat)}g</span>
  963. <span class="card-macro-lbl">Fat</span>
  964. </div>
  965. </div>
  966. <div class="meal-card-actions">
  967. <button class="edit-meal-btn" data-id="${meal.id}" data-name="${meal.name}" title="Rename Meal">
  968. <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M11 4H4a2 2 0 00-2 2v14a2 2 0 002 2h14a2 2 0 002-2v-7"></path><path d="M18.5 2.5a2.121 2.121 0 013 3L12 15l-4 1 1-4 9.5-9.5z"></path></svg>
  969. </button>
  970. <button class="delete-meal-btn" data-id="${meal.id}" title="Delete Meal">
  971. <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 6h18M19 6v14a2 2 0 01-2 2H7a2 2 0 01-2-2V6m3 0V4a2 2 0 012-2h4a2 2 0 012 2v2"></path></svg>
  972. </button>
  973. </div>
  974. `;
  975. // Attach listeners to new buttons
  976. card.querySelector('.edit-meal-btn').addEventListener('click', (e) => {
  977. e.stopPropagation();
  978. editMealInBuilder(meal);
  979. });
  980. card.querySelector('.delete-meal-btn').addEventListener('click', (e) => {
  981. e.stopPropagation();
  982. if (confirm(`Are you sure you want to delete "${meal.name}"?`)) {
  983. deleteMeal(meal.id);
  984. }
  985. });
  986. dashboardGrid.appendChild(card);
  987. });
  988. }
  989. function editMealInBuilder(meal) {
  990. editingMealId = meal.id;
  991. currentMealItems = [];
  992. if (meal.items && meal.items.length > 0) {
  993. meal.items.forEach(item => {
  994. currentMealItems.push({
  995. id: item.id,
  996. name: item.name,
  997. amount: item.amount_g,
  998. base_macros: {
  999. calories: item.calories,
  1000. protein_g: item.protein_g,
  1001. fat_g: item.fat_g,
  1002. carbs_g: item.carbs_g
  1003. }
  1004. });
  1005. });
  1006. }
  1007. renderMealItems();
  1008. calculateMealTotals();
  1009. toggleDashboard(false);
  1010. if (mealContent.classList.contains('collapsed')) {
  1011. toggleMealBuilder();
  1012. }
  1013. mealNameInput.value = meal.name;
  1014. confirmSaveBtn.textContent = 'Update Meal';
  1015. openSaveModalBtn.textContent = 'Update Meal';
  1016. }
  1017. async function renameMeal(id, newName) {
  1018. try {
  1019. const token = localStorage.getItem('localFoodToken');
  1020. const response = await fetch(`/api/meals/${id}`, {
  1021. method: 'PUT',
  1022. headers: {
  1023. 'Content-Type': 'application/json',
  1024. 'Authorization': `Bearer ${token}`
  1025. },
  1026. body: JSON.stringify({ name: newName })
  1027. });
  1028. if (response.ok) {
  1029. const card = document.querySelector(`.meal-card[data-meal-id="${id}"]`);
  1030. if (card) {
  1031. card.querySelector('.meal-card-name').textContent = newName;
  1032. const editBtn = card.querySelector('.edit-meal-btn');
  1033. if (editBtn) editBtn.dataset.name = newName;
  1034. }
  1035. } else {
  1036. alert('Failed to rename meal.');
  1037. }
  1038. } catch (err) {
  1039. console.error('Rename error:', err);
  1040. }
  1041. }
  1042. async function deleteMeal(id) {
  1043. try {
  1044. const token = localStorage.getItem('localFoodToken');
  1045. const response = await fetch(`/api/meals/${id}`, {
  1046. method: 'DELETE',
  1047. headers: { 'Authorization': `Bearer ${token}` }
  1048. });
  1049. if (response.ok) {
  1050. const card = document.querySelector(`.meal-card[data-meal-id="${id}"]`);
  1051. if (card) {
  1052. card.style.transition = 'all 0.3s ease';
  1053. card.style.opacity = '0';
  1054. card.style.transform = 'scale(0.9)';
  1055. setTimeout(() => {
  1056. card.remove();
  1057. if (document.querySelectorAll('.meal-card').length === 0) {
  1058. dashboardEmptyMsg.style.display = 'block';
  1059. }
  1060. }, 300);
  1061. }
  1062. } else {
  1063. alert('Failed to delete meal.');
  1064. }
  1065. } catch (err) {
  1066. console.error('Delete error:', err);
  1067. }
  1068. }
  1069. // Attach refresh helper
  1070. window.refreshDashboard = loadSavedMeals;
  1071. });