feat: infinite scroll koppelen aan API pagination

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.
This commit is contained in:
cas 2026-07-25 09:38:26 +02:00
parent 0ab09c6f1b
commit 11b3688779
2 changed files with 514 additions and 138 deletions

View file

@ -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-
<button class="cat-btn" data-cat="Overig">📦 Overig</button>
</div>
<!-- Add-to-moment context banner -->
<div class="add-context-banner hidden" id="addContextBanner">
<span id="addContextText"></span>
<button class="add-context-cancel" id="addContextCancel" aria-label="Annuleren"><i class="fas fa-xmark"></i></button>
</div>
<!-- Main Content -->
<main class="main-content" id="mainContent">
<!-- Zoeken Tab -->
@ -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,14 +3390,21 @@ 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();
// 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;
// Show results count
countEl.textContent = items.length === 0
? 'Geen resultaten'
: `${items.length} ${items.length === 1 ? 'resultaat' : 'resultaten'}`;
: `${searchTotal} ${searchTotal === 1 ? 'resultaat' : 'resultaten'}`;
// Render list
if (items.length === 0) {
listEl.innerHTML = '';
emptyEl.classList.remove('hidden');
@ -3311,19 +3415,47 @@ button:active, .btn:active, .meal-card-main:active, .dagboek-item:active, .food-
emptyEl.classList.add('hidden');
// 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 {
// 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);
// Setup infinite scroll if more items exist
if (items.length > SEARCH_PAGE_SIZE) {
sentinelEl.classList.remove('hidden');
setupSearchObserver(items);
setupSearchObserver();
} else {
sentinelEl.classList.add('hidden');
disconnectSearchObserver();
}
}
}
function renderFoodItemsHtml(items, startIndex) {
return items.map((item, idx) => {
@ -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 `
<li class="food-item" data-index="${voedingsmiddelen.indexOf(item)}" style="--i:${animIdx}" role="button" tabindex="0">
<li class="food-item" data-nummer="${item.n != null ? item.n : ''}" style="--i:${animIdx}" role="button" tabindex="0">
<div class="food-item-left">
<div class="food-item-name">${escapeHtml(displayNaam(item.naam))}</div>
<div class="food-item-cat">${escapeHtml(item.cat)}</div>
<div class="food-item-cat">${escapeHtml(displayCategory(item.cat))}</div>
</div>
<div class="food-item-right">
<div class="food-item-kh">${formatKh(kh)}</div>
@ -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 = `
<div class="detail-name">${escapeHtml(displayNaam(item.naam))}</div>
<div class="detail-category">${escapeHtml(item.cat)}</div>
<div class="detail-category">${escapeHtml(displayCategory(item.cat))}</div>
<div class="detail-links">
<a class="detail-info-link" href="https://www.voedingscentrum.nl/nl/zoek.aspx?query=${encodeURIComponent(displayNaam(item.naam))}" target="_blank" rel="noopener">Voedingscentrum <i class="fas fa-external-link-alt"></i></a>
</div>
@ -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;
// 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);
function clearAddContext() {
window._targetMoment = null;
listEl.removeEventListener('click', handler);
const banner = document.getElementById('addContextBanner');
if (banner) banner.classList.add('hidden');
}
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 = `<i class="fas fa-arrow-left-to-bracket"></i> Voeg toe aan: ${moment.icon} ${moment.name}`;
banner.classList.remove('hidden');
}
};
listEl.addEventListener('click', handler);
}
function addToMeal(item, portie, momentId) {
@ -4220,7 +4403,7 @@ button:active, .btn:active, .meal-card-main:active, .dagboek-item:active, .food-
<div class="meal-edit-item">
<div class="meal-edit-item-info">
<div class="meal-edit-item-name">${escapeHtml(displayNaam(mealItem.item.naam))}</div>
<div class="meal-edit-item-cat">${escapeHtml(mealItem.item.cat || '')} · ${formatKh(itemKh)} g kh</div>
<div class="meal-edit-item-cat">${escapeHtml(displayCategory(mealItem.item.cat || ''))} · ${formatKh(itemKh)} g kh</div>
</div>
<div class="meal-edit-item-actions">
<div class="portion-input-wrapper" style="margin:0;">
@ -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-
<div class="meal-edit-item">
<div class="meal-edit-item-info">
<div class="meal-edit-item-name">${escapeHtml(displayNaam(e.item.naam))}</div>
<div class="meal-edit-item-cat">${escapeHtml(e.item.cat)} · ${ikh} g kh</div>
<div class="meal-edit-item-cat">${escapeHtml(displayCategory(e.item.cat))} · ${ikh} g kh</div>
</div>
<div class="meal-edit-item-actions">
<div class="portion-input-wrapper" style="margin:0;">
@ -5200,7 +5385,7 @@ button:active, .btn:active, .meal-card-main:active, .dagboek-item:active, .food-
<div class="meal-edit-item">
<div class="meal-edit-item-info">
<div class="meal-edit-item-name">${escapeHtml(displayNaam(entry.item.naam))}</div>
<div class="meal-edit-item-cat">${escapeHtml(entry.item.cat)} · ${formatKh(itemKh)} g kh</div>
<div class="meal-edit-item-cat">${escapeHtml(displayCategory(entry.item.cat))} · ${formatKh(itemKh)} g kh</div>
</div>
<div class="meal-edit-item-actions">
<div class="portion-input-wrapper" style="margin:0;">
@ -5343,7 +5528,7 @@ button:active, .btn:active, .meal-card-main:active, .dagboek-item:active, .food-
resultsEl.innerHTML = matches.map((item, i) => `
<div class="meal-search-item" data-item-idx="${i}">
<div style="font-weight:500;">${escapeHtml(displayNaam(item.naam))}</div>
<div style="font-size:var(--font-xs);color:var(--text-muted);">${escapeHtml(item.cat)} · ${escapeHtml(item.kh)} g kh/100g</div>
<div style="font-size:var(--font-xs);color:var(--text-muted);">${escapeHtml(displayCategory(item.cat))} · ${escapeHtml(item.kh)} g kh/100g</div>
</div>
`).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'));

View file

@ -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-
<button class="cat-btn" data-cat="Overig">📦 Overig</button>
</div>
<!-- Add-to-moment context banner -->
<div class="add-context-banner hidden" id="addContextBanner">
<span id="addContextText"></span>
<button class="add-context-cancel" id="addContextCancel" aria-label="Annuleren"><i class="fas fa-xmark"></i></button>
</div>
<!-- Main Content -->
<main class="main-content" id="mainContent">
<!-- Zoeken Tab -->
@ -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,14 +3390,21 @@ 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();
// 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;
// Show results count
countEl.textContent = items.length === 0
? 'Geen resultaten'
: `${items.length} ${items.length === 1 ? 'resultaat' : 'resultaten'}`;
: `${searchTotal} ${searchTotal === 1 ? 'resultaat' : 'resultaten'}`;
// Render list
if (items.length === 0) {
listEl.innerHTML = '';
emptyEl.classList.remove('hidden');
@ -3311,19 +3415,47 @@ button:active, .btn:active, .meal-card-main:active, .dagboek-item:active, .food-
emptyEl.classList.add('hidden');
// 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 {
// 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);
// Setup infinite scroll if more items exist
if (items.length > SEARCH_PAGE_SIZE) {
sentinelEl.classList.remove('hidden');
setupSearchObserver(items);
setupSearchObserver();
} else {
sentinelEl.classList.add('hidden');
disconnectSearchObserver();
}
}
}
function renderFoodItemsHtml(items, startIndex) {
return items.map((item, idx) => {
@ -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 `
<li class="food-item" data-index="${voedingsmiddelen.indexOf(item)}" style="--i:${animIdx}" role="button" tabindex="0">
<li class="food-item" data-nummer="${item.n != null ? item.n : ''}" style="--i:${animIdx}" role="button" tabindex="0">
<div class="food-item-left">
<div class="food-item-name">${escapeHtml(displayNaam(item.naam))}</div>
<div class="food-item-cat">${escapeHtml(item.cat)}</div>
<div class="food-item-cat">${escapeHtml(displayCategory(item.cat))}</div>
</div>
<div class="food-item-right">
<div class="food-item-kh">${formatKh(kh)}</div>
@ -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 = `
<div class="detail-name">${escapeHtml(displayNaam(item.naam))}</div>
<div class="detail-category">${escapeHtml(item.cat)}</div>
<div class="detail-category">${escapeHtml(displayCategory(item.cat))}</div>
<div class="detail-links">
<a class="detail-info-link" href="https://www.voedingscentrum.nl/nl/zoek.aspx?query=${encodeURIComponent(displayNaam(item.naam))}" target="_blank" rel="noopener">Voedingscentrum <i class="fas fa-external-link-alt"></i></a>
</div>
@ -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;
// 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);
function clearAddContext() {
window._targetMoment = null;
listEl.removeEventListener('click', handler);
const banner = document.getElementById('addContextBanner');
if (banner) banner.classList.add('hidden');
}
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 = `<i class="fas fa-arrow-left-to-bracket"></i> Voeg toe aan: ${moment.icon} ${moment.name}`;
banner.classList.remove('hidden');
}
};
listEl.addEventListener('click', handler);
}
function addToMeal(item, portie, momentId) {
@ -4220,7 +4403,7 @@ button:active, .btn:active, .meal-card-main:active, .dagboek-item:active, .food-
<div class="meal-edit-item">
<div class="meal-edit-item-info">
<div class="meal-edit-item-name">${escapeHtml(displayNaam(mealItem.item.naam))}</div>
<div class="meal-edit-item-cat">${escapeHtml(mealItem.item.cat || '')} · ${formatKh(itemKh)} g kh</div>
<div class="meal-edit-item-cat">${escapeHtml(displayCategory(mealItem.item.cat || ''))} · ${formatKh(itemKh)} g kh</div>
</div>
<div class="meal-edit-item-actions">
<div class="portion-input-wrapper" style="margin:0;">
@ -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-
<div class="meal-edit-item">
<div class="meal-edit-item-info">
<div class="meal-edit-item-name">${escapeHtml(displayNaam(e.item.naam))}</div>
<div class="meal-edit-item-cat">${escapeHtml(e.item.cat)} · ${ikh} g kh</div>
<div class="meal-edit-item-cat">${escapeHtml(displayCategory(e.item.cat))} · ${ikh} g kh</div>
</div>
<div class="meal-edit-item-actions">
<div class="portion-input-wrapper" style="margin:0;">
@ -5200,7 +5385,7 @@ button:active, .btn:active, .meal-card-main:active, .dagboek-item:active, .food-
<div class="meal-edit-item">
<div class="meal-edit-item-info">
<div class="meal-edit-item-name">${escapeHtml(displayNaam(entry.item.naam))}</div>
<div class="meal-edit-item-cat">${escapeHtml(entry.item.cat)} · ${formatKh(itemKh)} g kh</div>
<div class="meal-edit-item-cat">${escapeHtml(displayCategory(entry.item.cat))} · ${formatKh(itemKh)} g kh</div>
</div>
<div class="meal-edit-item-actions">
<div class="portion-input-wrapper" style="margin:0;">
@ -5343,7 +5528,7 @@ button:active, .btn:active, .meal-card-main:active, .dagboek-item:active, .food-
resultsEl.innerHTML = matches.map((item, i) => `
<div class="meal-search-item" data-item-idx="${i}">
<div style="font-weight:500;">${escapeHtml(displayNaam(item.naam))}</div>
<div style="font-size:var(--font-xs);color:var(--text-muted);">${escapeHtml(item.cat)} · ${escapeHtml(item.kh)} g kh/100g</div>
<div style="font-size:var(--font-xs);color:var(--text-muted);">${escapeHtml(displayCategory(item.cat))} · ${escapeHtml(item.kh)} g kh/100g</div>
</div>
`).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'));