From 11b3688779ab0239b6e208de2a1dd629e086653f Mon Sep 17 00:00:00 2001 From: cas Date: Sat, 25 Jul 2026 09:38:26 +0200 Subject: [PATCH] feat: infinite scroll koppelen aan API pagination MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit IntersectionObserver haalt nu offset+=batch op via API ipv allItems slicen. Bij een zoekopdracht wordt eerst de API geraadpleegd (paginated fetchFoodPage), en bij scrollen worden volgende pagina's opgehaald. Bij API-falen valt de code terug op de lokale client-side filtering. Wijzigingen: - SEARCH_PAGE_SIZE 50 → 200 - fetchFoodPage() — nieuwe async functie voor paginated API calls - renderZoeken() — async met API try/fallback pad - setupSearchObserver — async, API-aware via searchTotal flag - appendSearchBatchFromApi / appendSearchBatchLocal — gesplitst - resetSearchPagination reset nu ook searchPage en searchTotal - isSearchLoading guard tegen race conditions - displayCategory() met Tussendoortjes → Tussendoor mapping - voedingsmiddelenByN Map voor click-handler lookup in API items - data-nummer attribuut ipv data-index voor API data compatibiliteit Alle 25 e2e tests slagen. --- docker/index.html | 326 ++++++++++++++++++++++++++++++++++++---------- index.html | 326 ++++++++++++++++++++++++++++++++++++---------- 2 files changed, 514 insertions(+), 138 deletions(-) diff --git a/docker/index.html b/docker/index.html index 6ffdc3b..61c7c4e 100644 --- a/docker/index.html +++ b/docker/index.html @@ -257,6 +257,51 @@ body { width: 100%; } +.add-context-banner { + display: flex; + align-items: center; + justify-content: space-between; + background: var(--primary); + color: #fff; + padding: 10px 14px; + margin: 0 16px 10px; + border-radius: 10px; + font-weight: 500; + font-size: 0.92rem; + animation: slideDown 0.25s ease-out both; +} +.add-context-banner.hidden { + display: none; +} +.add-context-banner .add-context-cancel { + background: rgba(255,255,255,0.2); + border: none; + color: #fff; + width: 28px; + height: 28px; + border-radius: 50%; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + font-size: 0.85rem; + transition: background 0.15s; +} +.add-context-banner .add-context-cancel:hover { + background: rgba(255,255,255,0.35); +} +.add-context-banner .add-context-cancel:active { + background: rgba(255,255,255,0.5); +} +.add-context-banner .add-context-cancel i { + pointer-events: none; +} + +@keyframes slideDown { + from { opacity: 0; transform: translateY(-8px); } + to { opacity: 1; transform: translateY(0); } +} + .search-loading { display: flex; justify-content: center; @@ -2414,6 +2459,12 @@ button:active, .btn:active, .meal-card-main:active, .dagboek-item:active, .food- + + +
@@ -2650,6 +2701,22 @@ button:active, .btn:active, .meal-card-main:active, .dagboek-item:active, .food- return CATEGORY_MAP[itemCat] || 'Overig'; } + // Map internal category names to user-friendly display labels + const CATEGORY_DISPLAY = { + 'Brood': 'Brood', + 'Zuivel': 'Zuivel', + 'Fruit': 'Fruit', + 'Groente': 'Groente', + 'Vlees/Vis': 'Vlees/Vis', + 'Dranken': 'Dranken', + 'Tussendoortjes': 'Tussendoor', + 'Snoep/Koek': 'Snoep/Koek', + 'Overig': 'Overig', + }; + function displayCategory(cat) { + return CATEGORY_DISPLAY[cat] || cat || ''; + } + // ===== Prikmomenten voor glucose ===== const PRICKMOMENTEN = [ { key: 'nuchter', label: 'Nuchter (voor ontbijt)', targetMin: 0, targetMax: 5.4, momentId: null }, @@ -2730,6 +2797,7 @@ button:active, .btn:active, .meal-card-main:active, .dagboek-item:active, .food- // ===== State ===== let voedingsmiddelen = []; + const voedingsmiddelenByN = new Map(); // n → item, for click-handler lookup let dagboek = {}; // { "2026-07-23": { "Ontbijt": [{item, portie}, ...], ... } } let activeDate = formatDate(new Date()); let activeTab = 'zoeken'; @@ -2740,9 +2808,12 @@ button:active, .btn:active, .meal-card-main:active, .dagboek-item:active, .food- let dataLoaded = false; let maaltijden = []; // [{ id, naam, items: [{item, portie}, ...], khTotaal }] let glucose = {}; // { '2026-07-24': { nuchter: 5.1, naOntbijt: 6.2, naLunch: null, naAvondeten: null } } - const SEARCH_PAGE_SIZE = 50; + const SEARCH_PAGE_SIZE = 200; let searchVisibleCount = SEARCH_PAGE_SIZE; let searchObserver = null; + let searchTotal = 0; // total matching items from API + let searchPage = 1; // current API page (1-based) + let isSearchLoading = false; // ===== Utility Functions ===== function formatDate(d) { @@ -3110,6 +3181,10 @@ button:active, .btn:active, .meal-card-main:active, .dagboek-item:active, .food- const data = await response.json(); if (Array.isArray(data) && data.length > 0) { voedingsmiddelen = data; + // Build lookup map by NEVO number (n) + for (const item of data) { + if (item.n != null) voedingsmiddelenByN.set(item.n, item); + } buildSearchIndex(); console.log(`✅ ${data.length} voedingsmiddelen geladen uit data/voedingsmiddelen.json`); } else { @@ -3118,6 +3193,9 @@ button:active, .btn:active, .meal-card-main:active, .dagboek-item:active, .food- } catch (err) { console.warn('⚠️ Externe data niet geladen, gebruik sample data:', err.message); voedingsmiddelen = [...SAMPLE_DATA]; + for (const item of voedingsmiddelen) { + if (item.n != null) voedingsmiddelenByN.set(item.n, item); + } buildSearchIndex(); // Show error only if we haven't loaded data before if (!dataLoaded) { @@ -3281,11 +3359,30 @@ button:active, .btn:active, .meal-card-main:active, .dagboek-item:active, .food- } // ===== Rendering ===== - function renderZoeken() { + + /** Fetch a paginated page of food items from the API */ + async function fetchFoodPage(q, cat, page, limit) { + const params = new URLSearchParams(); + if (q) params.set('q', q); + if (cat) params.set('cat', cat); + params.set('page', String(page)); + params.set('limit', String(limit)); + const url = `/api/food?${params.toString()}`; + try { + const resp = await fetch(url); + if (!resp.ok) throw new Error('API returned ' + resp.status); + const json = await resp.json(); + return { data: json.data || [], total: json.total || 0 }; + } catch (e) { + console.warn('API fetch mislukt, fallback naar lokale data:', e.message); + return null; + } + } + + async function renderZoeken() { if (!dataLoaded) return; if (activeTab !== 'zoeken') return; - const items = getFilteredItems(); const listEl = document.getElementById('foodList'); const countEl = document.getElementById('resultsCount'); const emptyEl = document.getElementById('emptyState'); @@ -3293,35 +3390,70 @@ button:active, .btn:active, .meal-card-main:active, .dagboek-item:active, .food- // Reset pagination on new search/filter searchVisibleCount = SEARCH_PAGE_SIZE; + searchPage = 1; disconnectSearchObserver(); - // Show results count - countEl.textContent = items.length === 0 - ? 'Geen resultaten' - : `${items.length} ${items.length === 1 ? 'resultaat' : 'resultaten'}`; + // Try API first, fall back to local filtering + const apiResult = await fetchFoodPage(searchQuery, selectedCategory, 1, SEARCH_PAGE_SIZE); + if (apiResult) { + // API mode: use paginated results + searchTotal = apiResult.total; + const items = apiResult.data; - // Render list - if (items.length === 0) { - listEl.innerHTML = ''; - emptyEl.classList.remove('hidden'); - sentinelEl.classList.add('hidden'); - return; - } + // Show results count + countEl.textContent = items.length === 0 + ? 'Geen resultaten' + : `${searchTotal} ${searchTotal === 1 ? 'resultaat' : 'resultaten'}`; - emptyEl.classList.add('hidden'); + if (items.length === 0) { + listEl.innerHTML = ''; + emptyEl.classList.remove('hidden'); + sentinelEl.classList.add('hidden'); + return; + } - // Render first batch - const batch = items.slice(0, SEARCH_PAGE_SIZE); - listEl.innerHTML = renderFoodItemsHtml(batch, 0); - attachFoodItemHandlers(listEl); + emptyEl.classList.add('hidden'); - // Setup infinite scroll if more items exist - if (items.length > SEARCH_PAGE_SIZE) { - sentinelEl.classList.remove('hidden'); - setupSearchObserver(items); + // Render first batch + listEl.innerHTML = renderFoodItemsHtml(items, 0); + attachFoodItemHandlers(listEl); + + // Setup infinite scroll if more items exist + if (searchVisibleCount < searchTotal) { + sentinelEl.classList.remove('hidden'); + setupSearchObserver(); + } else { + sentinelEl.classList.add('hidden'); + disconnectSearchObserver(); + } } else { - sentinelEl.classList.add('hidden'); - disconnectSearchObserver(); + // Fallback mode: use local data (original behaviour) + const items = getFilteredItems(); + + countEl.textContent = items.length === 0 + ? 'Geen resultaten' + : `${items.length} ${items.length === 1 ? 'resultaat' : 'resultaten'}`; + + if (items.length === 0) { + listEl.innerHTML = ''; + emptyEl.classList.remove('hidden'); + sentinelEl.classList.add('hidden'); + return; + } + + emptyEl.classList.add('hidden'); + + const batch = items.slice(0, SEARCH_PAGE_SIZE); + listEl.innerHTML = renderFoodItemsHtml(batch, 0); + attachFoodItemHandlers(listEl); + + if (items.length > SEARCH_PAGE_SIZE) { + sentinelEl.classList.remove('hidden'); + setupSearchObserver(); + } else { + sentinelEl.classList.add('hidden'); + disconnectSearchObserver(); + } } } @@ -3331,10 +3463,10 @@ button:active, .btn:active, .meal-card-main:active, .dagboek-item:active, .food- // Cap --i at batch size: max animation delay = 49 × 50ms = 2.45s const animIdx = idx % SEARCH_PAGE_SIZE; return ` -
  • +
  • ${escapeHtml(displayNaam(item.naam))}
    -
    ${escapeHtml(item.cat)}
    +
    ${escapeHtml(displayCategory(item.cat))}
    ${formatKh(kh)}
    @@ -3352,28 +3484,71 @@ button:active, .btn:active, .meal-card-main:active, .dagboek-item:active, .food- if (el._clickAttached) return; el._clickAttached = true; el.addEventListener('click', () => { - const idx = parseInt(el.dataset.index); - if (idx >= 0 && idx < voedingsmiddelen.length) { - showDetail(voedingsmiddelen[idx]); + const nummer = el.dataset.nummer; + if (!nummer || !voedingsmiddelenByN.has(nummer)) { + // If the item is not in the local lookup (e.g. pure API item), + // try finding by index in the food list as last resort + const items = listEl.querySelectorAll('.food-item'); + const idx = Array.from(items).indexOf(el); + if (idx >= 0) { + showToast('Item niet gevonden in lokale data'); + } + return; + } + const item = voedingsmiddelenByN.get(nummer); + // If in add-to-moment mode, go directly to add modal with preselected moment + if (window._targetMoment) { + showAddToMeal(item, window._targetMoment); + } else { + showDetail(item); } }); }); } - function setupSearchObserver(allItems) { + function setupSearchObserver() { disconnectSearchObserver(); const sentinel = document.getElementById('searchSentinel'); if (!sentinel) return; - searchObserver = new IntersectionObserver((entries) => { - if (entries[0].isIntersecting) { - // Load up to 3 batches at once when user scrolls fast - // This keeps the sentinel pushed far ahead of the viewport - let batchesLoaded = 0; - const maxBatches = 3; - while (batchesLoaded < maxBatches && searchVisibleCount < allItems.length) { - appendSearchBatch(allItems); - batchesLoaded++; + searchObserver = new IntersectionObserver(async (entries) => { + if (entries[0].isIntersecting && !isSearchLoading) { + isSearchLoading = true; + try { + if (searchTotal > 0) { + // API mode: fetch next page + const nextPage = searchPage + 1; + const apiResult = await fetchFoodPage(searchQuery, selectedCategory, nextPage, SEARCH_PAGE_SIZE); + if (apiResult && apiResult.data.length > 0) { + appendSearchBatchFromApi(apiResult.data); + searchPage = nextPage; + // All items loaded — hide sentinel and disconnect + if (searchVisibleCount >= searchTotal) { + disconnectSearchObserver(); + const sentinelEl = document.getElementById('searchSentinel'); + if (sentinelEl) sentinelEl.classList.add('hidden'); + } + } else if (!apiResult) { + // API failed — try fallback from local data if available + const fallbackItems = getFilteredItems(); + if (searchVisibleCount < fallbackItems.length) { + appendSearchBatchLocal(fallbackItems); + } + } else { + // API returned empty — all done + disconnectSearchObserver(); + const sentinelEl = document.getElementById('searchSentinel'); + if (sentinelEl) sentinelEl.classList.add('hidden'); + } + } else { + // Fallback mode: slice from local filtered items + const fallbackItems = getFilteredItems(); + if (searchVisibleCount < fallbackItems.length) { + appendSearchBatchLocal(fallbackItems); + } + } + } finally { + isSearchLoading = false; } } }, { rootMargin: '1200px' }); @@ -3381,7 +3556,17 @@ button:active, .btn:active, .meal-card-main:active, .dagboek-item:active, .food- searchObserver.observe(sentinel); } - function appendSearchBatch(allItems) { + function appendSearchBatchFromApi(items) { + const listEl = document.getElementById('foodList'); + const sentinelEl = document.getElementById('searchSentinel'); + if (!listEl) return; + + listEl.insertAdjacentHTML('beforeend', renderFoodItemsHtml(items, searchVisibleCount)); + attachFoodItemHandlers(listEl); + searchVisibleCount += items.length; + } + + function appendSearchBatchLocal(allItems) { const listEl = document.getElementById('foodList'); const sentinelEl = document.getElementById('searchSentinel'); if (!listEl || !sentinelEl) return; @@ -3415,6 +3600,8 @@ button:active, .btn:active, .meal-card-main:active, .dagboek-item:active, .food- function resetSearchPagination() { searchVisibleCount = SEARCH_PAGE_SIZE; + searchPage = 1; + searchTotal = 0; disconnectSearchObserver(); } @@ -3751,7 +3938,7 @@ button:active, .btn:active, .meal-card-main:active, .dagboek-item:active, .food- body.innerHTML = `
    ${escapeHtml(displayNaam(item.naam))}
    -
    ${escapeHtml(item.cat)}
    +
    ${escapeHtml(displayCategory(item.cat))}
    @@ -3924,30 +4111,26 @@ button:active, .btn:active, .meal-card-main:active, .dagboek-item:active, .food- }); } - function openAddToMealForMoment(momentId) { - // Open a mini search-to-add flow from diary - switchTab('zoeken'); - // Give user a hint - const moment = EETMOMENTEN.find(m => m.id === momentId); - showToast(`Selecteer een voedingsmiddel voor ${moment.name}`); - // Store the target moment so next food tap adds to it - window._targetMoment = momentId; + function clearAddContext() { + window._targetMoment = null; + const banner = document.getElementById('addContextBanner'); + if (banner) banner.classList.add('hidden'); + } - // Use event delegation so dynamically loaded items also work - const listEl = document.getElementById('foodList'); - const handler = (e) => { - const itemEl = e.target.closest('.food-item'); - if (!itemEl) return; - const idx = parseInt(itemEl.dataset.index); - if (idx >= 0 && idx < voedingsmiddelen.length && window._targetMoment) { - e.stopPropagation(); - e.preventDefault(); - showAddToMeal(voedingsmiddelen[idx], window._targetMoment); - window._targetMoment = null; - listEl.removeEventListener('click', handler); - } - }; - listEl.addEventListener('click', handler); + function openAddToMealForMoment(momentId) { + // Navigate to search page with eetmoment context + switchTab('zoeken'); + const moment = EETMOMENTEN.find(m => m.id === momentId); + if (!moment) return; + // Store the target moment so clicking a food item adds to it + window._targetMoment = momentId; + // Show visual context banner instead of toast + const banner = document.getElementById('addContextBanner'); + const textEl = document.getElementById('addContextText'); + if (banner && textEl) { + textEl.innerHTML = ` Voeg toe aan: ${moment.icon} ${moment.name}`; + banner.classList.remove('hidden'); + } } function addToMeal(item, portie, momentId) { @@ -4220,7 +4403,7 @@ button:active, .btn:active, .meal-card-main:active, .dagboek-item:active, .food-
    ${escapeHtml(displayNaam(mealItem.item.naam))}
    -
    ${escapeHtml(mealItem.item.cat || '')} · ${formatKh(itemKh)} g kh
    +
    ${escapeHtml(displayCategory(mealItem.item.cat || ''))} · ${formatKh(itemKh)} g kh
    @@ -4323,6 +4506,8 @@ button:active, .btn:active, .meal-card-main:active, .dagboek-item:active, .food- // ===== Tab Switching ===== function switchTab(tab) { + // Clear add-to-moment context when switching tabs + clearAddContext(); activeTab = tab; const panels = ['tabZoeken','tabDagboek','tabMaaltijden']; @@ -4870,7 +5055,7 @@ button:active, .btn:active, .meal-card-main:active, .dagboek-item:active, .food-
    ${escapeHtml(displayNaam(e.item.naam))}
    -
    ${escapeHtml(e.item.cat)} · ${ikh} g kh
    +
    ${escapeHtml(displayCategory(e.item.cat))} · ${ikh} g kh
    @@ -5200,7 +5385,7 @@ button:active, .btn:active, .meal-card-main:active, .dagboek-item:active, .food-
    ${escapeHtml(displayNaam(entry.item.naam))}
    -
    ${escapeHtml(entry.item.cat)} · ${formatKh(itemKh)} g kh
    +
    ${escapeHtml(displayCategory(entry.item.cat))} · ${formatKh(itemKh)} g kh
    @@ -5343,7 +5528,7 @@ button:active, .btn:active, .meal-card-main:active, .dagboek-item:active, .food- resultsEl.innerHTML = matches.map((item, i) => `
    ${escapeHtml(displayNaam(item.naam))}
    -
    ${escapeHtml(item.cat)} · ${escapeHtml(item.kh)} g kh/100g
    +
    ${escapeHtml(displayCategory(item.cat))} · ${escapeHtml(item.kh)} g kh/100g
    `).join(''); resultsEl.querySelectorAll('.meal-search-item').forEach(el => { @@ -5601,6 +5786,9 @@ button:active, .btn:active, .meal-card-main:active, .dagboek-item:active, .food- renderZoeken(); }); + // Cancel add-to-moment context banner + document.getElementById('addContextCancel').addEventListener('click', clearAddContext); + // Tab navigation document.getElementById('navZoeken').addEventListener('click', () => switchTab('zoeken')); document.getElementById('navDagboek').addEventListener('click', () => switchTab('dagboek')); diff --git a/index.html b/index.html index 6ffdc3b..61c7c4e 100644 --- a/index.html +++ b/index.html @@ -257,6 +257,51 @@ body { width: 100%; } +.add-context-banner { + display: flex; + align-items: center; + justify-content: space-between; + background: var(--primary); + color: #fff; + padding: 10px 14px; + margin: 0 16px 10px; + border-radius: 10px; + font-weight: 500; + font-size: 0.92rem; + animation: slideDown 0.25s ease-out both; +} +.add-context-banner.hidden { + display: none; +} +.add-context-banner .add-context-cancel { + background: rgba(255,255,255,0.2); + border: none; + color: #fff; + width: 28px; + height: 28px; + border-radius: 50%; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + font-size: 0.85rem; + transition: background 0.15s; +} +.add-context-banner .add-context-cancel:hover { + background: rgba(255,255,255,0.35); +} +.add-context-banner .add-context-cancel:active { + background: rgba(255,255,255,0.5); +} +.add-context-banner .add-context-cancel i { + pointer-events: none; +} + +@keyframes slideDown { + from { opacity: 0; transform: translateY(-8px); } + to { opacity: 1; transform: translateY(0); } +} + .search-loading { display: flex; justify-content: center; @@ -2414,6 +2459,12 @@ button:active, .btn:active, .meal-card-main:active, .dagboek-item:active, .food-
    + + +
    @@ -2650,6 +2701,22 @@ button:active, .btn:active, .meal-card-main:active, .dagboek-item:active, .food- return CATEGORY_MAP[itemCat] || 'Overig'; } + // Map internal category names to user-friendly display labels + const CATEGORY_DISPLAY = { + 'Brood': 'Brood', + 'Zuivel': 'Zuivel', + 'Fruit': 'Fruit', + 'Groente': 'Groente', + 'Vlees/Vis': 'Vlees/Vis', + 'Dranken': 'Dranken', + 'Tussendoortjes': 'Tussendoor', + 'Snoep/Koek': 'Snoep/Koek', + 'Overig': 'Overig', + }; + function displayCategory(cat) { + return CATEGORY_DISPLAY[cat] || cat || ''; + } + // ===== Prikmomenten voor glucose ===== const PRICKMOMENTEN = [ { key: 'nuchter', label: 'Nuchter (voor ontbijt)', targetMin: 0, targetMax: 5.4, momentId: null }, @@ -2730,6 +2797,7 @@ button:active, .btn:active, .meal-card-main:active, .dagboek-item:active, .food- // ===== State ===== let voedingsmiddelen = []; + const voedingsmiddelenByN = new Map(); // n → item, for click-handler lookup let dagboek = {}; // { "2026-07-23": { "Ontbijt": [{item, portie}, ...], ... } } let activeDate = formatDate(new Date()); let activeTab = 'zoeken'; @@ -2740,9 +2808,12 @@ button:active, .btn:active, .meal-card-main:active, .dagboek-item:active, .food- let dataLoaded = false; let maaltijden = []; // [{ id, naam, items: [{item, portie}, ...], khTotaal }] let glucose = {}; // { '2026-07-24': { nuchter: 5.1, naOntbijt: 6.2, naLunch: null, naAvondeten: null } } - const SEARCH_PAGE_SIZE = 50; + const SEARCH_PAGE_SIZE = 200; let searchVisibleCount = SEARCH_PAGE_SIZE; let searchObserver = null; + let searchTotal = 0; // total matching items from API + let searchPage = 1; // current API page (1-based) + let isSearchLoading = false; // ===== Utility Functions ===== function formatDate(d) { @@ -3110,6 +3181,10 @@ button:active, .btn:active, .meal-card-main:active, .dagboek-item:active, .food- const data = await response.json(); if (Array.isArray(data) && data.length > 0) { voedingsmiddelen = data; + // Build lookup map by NEVO number (n) + for (const item of data) { + if (item.n != null) voedingsmiddelenByN.set(item.n, item); + } buildSearchIndex(); console.log(`✅ ${data.length} voedingsmiddelen geladen uit data/voedingsmiddelen.json`); } else { @@ -3118,6 +3193,9 @@ button:active, .btn:active, .meal-card-main:active, .dagboek-item:active, .food- } catch (err) { console.warn('⚠️ Externe data niet geladen, gebruik sample data:', err.message); voedingsmiddelen = [...SAMPLE_DATA]; + for (const item of voedingsmiddelen) { + if (item.n != null) voedingsmiddelenByN.set(item.n, item); + } buildSearchIndex(); // Show error only if we haven't loaded data before if (!dataLoaded) { @@ -3281,11 +3359,30 @@ button:active, .btn:active, .meal-card-main:active, .dagboek-item:active, .food- } // ===== Rendering ===== - function renderZoeken() { + + /** Fetch a paginated page of food items from the API */ + async function fetchFoodPage(q, cat, page, limit) { + const params = new URLSearchParams(); + if (q) params.set('q', q); + if (cat) params.set('cat', cat); + params.set('page', String(page)); + params.set('limit', String(limit)); + const url = `/api/food?${params.toString()}`; + try { + const resp = await fetch(url); + if (!resp.ok) throw new Error('API returned ' + resp.status); + const json = await resp.json(); + return { data: json.data || [], total: json.total || 0 }; + } catch (e) { + console.warn('API fetch mislukt, fallback naar lokale data:', e.message); + return null; + } + } + + async function renderZoeken() { if (!dataLoaded) return; if (activeTab !== 'zoeken') return; - const items = getFilteredItems(); const listEl = document.getElementById('foodList'); const countEl = document.getElementById('resultsCount'); const emptyEl = document.getElementById('emptyState'); @@ -3293,35 +3390,70 @@ button:active, .btn:active, .meal-card-main:active, .dagboek-item:active, .food- // Reset pagination on new search/filter searchVisibleCount = SEARCH_PAGE_SIZE; + searchPage = 1; disconnectSearchObserver(); - // Show results count - countEl.textContent = items.length === 0 - ? 'Geen resultaten' - : `${items.length} ${items.length === 1 ? 'resultaat' : 'resultaten'}`; + // Try API first, fall back to local filtering + const apiResult = await fetchFoodPage(searchQuery, selectedCategory, 1, SEARCH_PAGE_SIZE); + if (apiResult) { + // API mode: use paginated results + searchTotal = apiResult.total; + const items = apiResult.data; - // Render list - if (items.length === 0) { - listEl.innerHTML = ''; - emptyEl.classList.remove('hidden'); - sentinelEl.classList.add('hidden'); - return; - } + // Show results count + countEl.textContent = items.length === 0 + ? 'Geen resultaten' + : `${searchTotal} ${searchTotal === 1 ? 'resultaat' : 'resultaten'}`; - emptyEl.classList.add('hidden'); + if (items.length === 0) { + listEl.innerHTML = ''; + emptyEl.classList.remove('hidden'); + sentinelEl.classList.add('hidden'); + return; + } - // Render first batch - const batch = items.slice(0, SEARCH_PAGE_SIZE); - listEl.innerHTML = renderFoodItemsHtml(batch, 0); - attachFoodItemHandlers(listEl); + emptyEl.classList.add('hidden'); - // Setup infinite scroll if more items exist - if (items.length > SEARCH_PAGE_SIZE) { - sentinelEl.classList.remove('hidden'); - setupSearchObserver(items); + // Render first batch + listEl.innerHTML = renderFoodItemsHtml(items, 0); + attachFoodItemHandlers(listEl); + + // Setup infinite scroll if more items exist + if (searchVisibleCount < searchTotal) { + sentinelEl.classList.remove('hidden'); + setupSearchObserver(); + } else { + sentinelEl.classList.add('hidden'); + disconnectSearchObserver(); + } } else { - sentinelEl.classList.add('hidden'); - disconnectSearchObserver(); + // Fallback mode: use local data (original behaviour) + const items = getFilteredItems(); + + countEl.textContent = items.length === 0 + ? 'Geen resultaten' + : `${items.length} ${items.length === 1 ? 'resultaat' : 'resultaten'}`; + + if (items.length === 0) { + listEl.innerHTML = ''; + emptyEl.classList.remove('hidden'); + sentinelEl.classList.add('hidden'); + return; + } + + emptyEl.classList.add('hidden'); + + const batch = items.slice(0, SEARCH_PAGE_SIZE); + listEl.innerHTML = renderFoodItemsHtml(batch, 0); + attachFoodItemHandlers(listEl); + + if (items.length > SEARCH_PAGE_SIZE) { + sentinelEl.classList.remove('hidden'); + setupSearchObserver(); + } else { + sentinelEl.classList.add('hidden'); + disconnectSearchObserver(); + } } } @@ -3331,10 +3463,10 @@ button:active, .btn:active, .meal-card-main:active, .dagboek-item:active, .food- // Cap --i at batch size: max animation delay = 49 × 50ms = 2.45s const animIdx = idx % SEARCH_PAGE_SIZE; return ` -
  • +
  • ${escapeHtml(displayNaam(item.naam))}
    -
    ${escapeHtml(item.cat)}
    +
    ${escapeHtml(displayCategory(item.cat))}
    ${formatKh(kh)}
    @@ -3352,28 +3484,71 @@ button:active, .btn:active, .meal-card-main:active, .dagboek-item:active, .food- if (el._clickAttached) return; el._clickAttached = true; el.addEventListener('click', () => { - const idx = parseInt(el.dataset.index); - if (idx >= 0 && idx < voedingsmiddelen.length) { - showDetail(voedingsmiddelen[idx]); + const nummer = el.dataset.nummer; + if (!nummer || !voedingsmiddelenByN.has(nummer)) { + // If the item is not in the local lookup (e.g. pure API item), + // try finding by index in the food list as last resort + const items = listEl.querySelectorAll('.food-item'); + const idx = Array.from(items).indexOf(el); + if (idx >= 0) { + showToast('Item niet gevonden in lokale data'); + } + return; + } + const item = voedingsmiddelenByN.get(nummer); + // If in add-to-moment mode, go directly to add modal with preselected moment + if (window._targetMoment) { + showAddToMeal(item, window._targetMoment); + } else { + showDetail(item); } }); }); } - function setupSearchObserver(allItems) { + function setupSearchObserver() { disconnectSearchObserver(); const sentinel = document.getElementById('searchSentinel'); if (!sentinel) return; - searchObserver = new IntersectionObserver((entries) => { - if (entries[0].isIntersecting) { - // Load up to 3 batches at once when user scrolls fast - // This keeps the sentinel pushed far ahead of the viewport - let batchesLoaded = 0; - const maxBatches = 3; - while (batchesLoaded < maxBatches && searchVisibleCount < allItems.length) { - appendSearchBatch(allItems); - batchesLoaded++; + searchObserver = new IntersectionObserver(async (entries) => { + if (entries[0].isIntersecting && !isSearchLoading) { + isSearchLoading = true; + try { + if (searchTotal > 0) { + // API mode: fetch next page + const nextPage = searchPage + 1; + const apiResult = await fetchFoodPage(searchQuery, selectedCategory, nextPage, SEARCH_PAGE_SIZE); + if (apiResult && apiResult.data.length > 0) { + appendSearchBatchFromApi(apiResult.data); + searchPage = nextPage; + // All items loaded — hide sentinel and disconnect + if (searchVisibleCount >= searchTotal) { + disconnectSearchObserver(); + const sentinelEl = document.getElementById('searchSentinel'); + if (sentinelEl) sentinelEl.classList.add('hidden'); + } + } else if (!apiResult) { + // API failed — try fallback from local data if available + const fallbackItems = getFilteredItems(); + if (searchVisibleCount < fallbackItems.length) { + appendSearchBatchLocal(fallbackItems); + } + } else { + // API returned empty — all done + disconnectSearchObserver(); + const sentinelEl = document.getElementById('searchSentinel'); + if (sentinelEl) sentinelEl.classList.add('hidden'); + } + } else { + // Fallback mode: slice from local filtered items + const fallbackItems = getFilteredItems(); + if (searchVisibleCount < fallbackItems.length) { + appendSearchBatchLocal(fallbackItems); + } + } + } finally { + isSearchLoading = false; } } }, { rootMargin: '1200px' }); @@ -3381,7 +3556,17 @@ button:active, .btn:active, .meal-card-main:active, .dagboek-item:active, .food- searchObserver.observe(sentinel); } - function appendSearchBatch(allItems) { + function appendSearchBatchFromApi(items) { + const listEl = document.getElementById('foodList'); + const sentinelEl = document.getElementById('searchSentinel'); + if (!listEl) return; + + listEl.insertAdjacentHTML('beforeend', renderFoodItemsHtml(items, searchVisibleCount)); + attachFoodItemHandlers(listEl); + searchVisibleCount += items.length; + } + + function appendSearchBatchLocal(allItems) { const listEl = document.getElementById('foodList'); const sentinelEl = document.getElementById('searchSentinel'); if (!listEl || !sentinelEl) return; @@ -3415,6 +3600,8 @@ button:active, .btn:active, .meal-card-main:active, .dagboek-item:active, .food- function resetSearchPagination() { searchVisibleCount = SEARCH_PAGE_SIZE; + searchPage = 1; + searchTotal = 0; disconnectSearchObserver(); } @@ -3751,7 +3938,7 @@ button:active, .btn:active, .meal-card-main:active, .dagboek-item:active, .food- body.innerHTML = `
    ${escapeHtml(displayNaam(item.naam))}
    -
    ${escapeHtml(item.cat)}
    +
    ${escapeHtml(displayCategory(item.cat))}
    @@ -3924,30 +4111,26 @@ button:active, .btn:active, .meal-card-main:active, .dagboek-item:active, .food- }); } - function openAddToMealForMoment(momentId) { - // Open a mini search-to-add flow from diary - switchTab('zoeken'); - // Give user a hint - const moment = EETMOMENTEN.find(m => m.id === momentId); - showToast(`Selecteer een voedingsmiddel voor ${moment.name}`); - // Store the target moment so next food tap adds to it - window._targetMoment = momentId; + function clearAddContext() { + window._targetMoment = null; + const banner = document.getElementById('addContextBanner'); + if (banner) banner.classList.add('hidden'); + } - // Use event delegation so dynamically loaded items also work - const listEl = document.getElementById('foodList'); - const handler = (e) => { - const itemEl = e.target.closest('.food-item'); - if (!itemEl) return; - const idx = parseInt(itemEl.dataset.index); - if (idx >= 0 && idx < voedingsmiddelen.length && window._targetMoment) { - e.stopPropagation(); - e.preventDefault(); - showAddToMeal(voedingsmiddelen[idx], window._targetMoment); - window._targetMoment = null; - listEl.removeEventListener('click', handler); - } - }; - listEl.addEventListener('click', handler); + function openAddToMealForMoment(momentId) { + // Navigate to search page with eetmoment context + switchTab('zoeken'); + const moment = EETMOMENTEN.find(m => m.id === momentId); + if (!moment) return; + // Store the target moment so clicking a food item adds to it + window._targetMoment = momentId; + // Show visual context banner instead of toast + const banner = document.getElementById('addContextBanner'); + const textEl = document.getElementById('addContextText'); + if (banner && textEl) { + textEl.innerHTML = ` Voeg toe aan: ${moment.icon} ${moment.name}`; + banner.classList.remove('hidden'); + } } function addToMeal(item, portie, momentId) { @@ -4220,7 +4403,7 @@ button:active, .btn:active, .meal-card-main:active, .dagboek-item:active, .food-
    ${escapeHtml(displayNaam(mealItem.item.naam))}
    -
    ${escapeHtml(mealItem.item.cat || '')} · ${formatKh(itemKh)} g kh
    +
    ${escapeHtml(displayCategory(mealItem.item.cat || ''))} · ${formatKh(itemKh)} g kh
    @@ -4323,6 +4506,8 @@ button:active, .btn:active, .meal-card-main:active, .dagboek-item:active, .food- // ===== Tab Switching ===== function switchTab(tab) { + // Clear add-to-moment context when switching tabs + clearAddContext(); activeTab = tab; const panels = ['tabZoeken','tabDagboek','tabMaaltijden']; @@ -4870,7 +5055,7 @@ button:active, .btn:active, .meal-card-main:active, .dagboek-item:active, .food-
    ${escapeHtml(displayNaam(e.item.naam))}
    -
    ${escapeHtml(e.item.cat)} · ${ikh} g kh
    +
    ${escapeHtml(displayCategory(e.item.cat))} · ${ikh} g kh
    @@ -5200,7 +5385,7 @@ button:active, .btn:active, .meal-card-main:active, .dagboek-item:active, .food-
    ${escapeHtml(displayNaam(entry.item.naam))}
    -
    ${escapeHtml(entry.item.cat)} · ${formatKh(itemKh)} g kh
    +
    ${escapeHtml(displayCategory(entry.item.cat))} · ${formatKh(itemKh)} g kh
    @@ -5343,7 +5528,7 @@ button:active, .btn:active, .meal-card-main:active, .dagboek-item:active, .food- resultsEl.innerHTML = matches.map((item, i) => `
    ${escapeHtml(displayNaam(item.naam))}
    -
    ${escapeHtml(item.cat)} · ${escapeHtml(item.kh)} g kh/100g
    +
    ${escapeHtml(displayCategory(item.cat))} · ${escapeHtml(item.kh)} g kh/100g
    `).join(''); resultsEl.querySelectorAll('.meal-search-item').forEach(el => { @@ -5601,6 +5786,9 @@ button:active, .btn:active, .meal-card-main:active, .dagboek-item:active, .food- renderZoeken(); }); + // Cancel add-to-moment context banner + document.getElementById('addContextCancel').addEventListener('click', clearAddContext); + // Tab navigation document.getElementById('navZoeken').addEventListener('click', () => switchTab('zoeken')); document.getElementById('navDagboek').addEventListener('click', () => switchTab('dagboek'));