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:
parent
0ab09c6f1b
commit
11b3688779
2 changed files with 514 additions and 138 deletions
|
|
@ -257,6 +257,51 @@ body {
|
||||||
width: 100%;
|
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 {
|
.search-loading {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
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>
|
<button class="cat-btn" data-cat="Overig">📦 Overig</button>
|
||||||
</div>
|
</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 Content -->
|
||||||
<main class="main-content" id="mainContent">
|
<main class="main-content" id="mainContent">
|
||||||
<!-- Zoeken Tab -->
|
<!-- Zoeken Tab -->
|
||||||
|
|
@ -2650,6 +2701,22 @@ button:active, .btn:active, .meal-card-main:active, .dagboek-item:active, .food-
|
||||||
return CATEGORY_MAP[itemCat] || 'Overig';
|
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 =====
|
// ===== Prikmomenten voor glucose =====
|
||||||
const PRICKMOMENTEN = [
|
const PRICKMOMENTEN = [
|
||||||
{ key: 'nuchter', label: 'Nuchter (voor ontbijt)', targetMin: 0, targetMax: 5.4, momentId: null },
|
{ 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 =====
|
// ===== State =====
|
||||||
let voedingsmiddelen = [];
|
let voedingsmiddelen = [];
|
||||||
|
const voedingsmiddelenByN = new Map(); // n → item, for click-handler lookup
|
||||||
let dagboek = {}; // { "2026-07-23": { "Ontbijt": [{item, portie}, ...], ... } }
|
let dagboek = {}; // { "2026-07-23": { "Ontbijt": [{item, portie}, ...], ... } }
|
||||||
let activeDate = formatDate(new Date());
|
let activeDate = formatDate(new Date());
|
||||||
let activeTab = 'zoeken';
|
let activeTab = 'zoeken';
|
||||||
|
|
@ -2740,9 +2808,12 @@ button:active, .btn:active, .meal-card-main:active, .dagboek-item:active, .food-
|
||||||
let dataLoaded = false;
|
let dataLoaded = false;
|
||||||
let maaltijden = []; // [{ id, naam, items: [{item, portie}, ...], khTotaal }]
|
let maaltijden = []; // [{ id, naam, items: [{item, portie}, ...], khTotaal }]
|
||||||
let glucose = {}; // { '2026-07-24': { nuchter: 5.1, naOntbijt: 6.2, naLunch: null, naAvondeten: null } }
|
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 searchVisibleCount = SEARCH_PAGE_SIZE;
|
||||||
let searchObserver = null;
|
let searchObserver = null;
|
||||||
|
let searchTotal = 0; // total matching items from API
|
||||||
|
let searchPage = 1; // current API page (1-based)
|
||||||
|
let isSearchLoading = false;
|
||||||
|
|
||||||
// ===== Utility Functions =====
|
// ===== Utility Functions =====
|
||||||
function formatDate(d) {
|
function formatDate(d) {
|
||||||
|
|
@ -3110,6 +3181,10 @@ button:active, .btn:active, .meal-card-main:active, .dagboek-item:active, .food-
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
if (Array.isArray(data) && data.length > 0) {
|
if (Array.isArray(data) && data.length > 0) {
|
||||||
voedingsmiddelen = data;
|
voedingsmiddelen = data;
|
||||||
|
// Build lookup map by NEVO number (n)
|
||||||
|
for (const item of data) {
|
||||||
|
if (item.n != null) voedingsmiddelenByN.set(item.n, item);
|
||||||
|
}
|
||||||
buildSearchIndex();
|
buildSearchIndex();
|
||||||
console.log(`✅ ${data.length} voedingsmiddelen geladen uit data/voedingsmiddelen.json`);
|
console.log(`✅ ${data.length} voedingsmiddelen geladen uit data/voedingsmiddelen.json`);
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -3118,6 +3193,9 @@ button:active, .btn:active, .meal-card-main:active, .dagboek-item:active, .food-
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.warn('⚠️ Externe data niet geladen, gebruik sample data:', err.message);
|
console.warn('⚠️ Externe data niet geladen, gebruik sample data:', err.message);
|
||||||
voedingsmiddelen = [...SAMPLE_DATA];
|
voedingsmiddelen = [...SAMPLE_DATA];
|
||||||
|
for (const item of voedingsmiddelen) {
|
||||||
|
if (item.n != null) voedingsmiddelenByN.set(item.n, item);
|
||||||
|
}
|
||||||
buildSearchIndex();
|
buildSearchIndex();
|
||||||
// Show error only if we haven't loaded data before
|
// Show error only if we haven't loaded data before
|
||||||
if (!dataLoaded) {
|
if (!dataLoaded) {
|
||||||
|
|
@ -3281,11 +3359,30 @@ button:active, .btn:active, .meal-card-main:active, .dagboek-item:active, .food-
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===== Rendering =====
|
// ===== 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 (!dataLoaded) return;
|
||||||
if (activeTab !== 'zoeken') return;
|
if (activeTab !== 'zoeken') return;
|
||||||
|
|
||||||
const items = getFilteredItems();
|
|
||||||
const listEl = document.getElementById('foodList');
|
const listEl = document.getElementById('foodList');
|
||||||
const countEl = document.getElementById('resultsCount');
|
const countEl = document.getElementById('resultsCount');
|
||||||
const emptyEl = document.getElementById('emptyState');
|
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
|
// Reset pagination on new search/filter
|
||||||
searchVisibleCount = SEARCH_PAGE_SIZE;
|
searchVisibleCount = SEARCH_PAGE_SIZE;
|
||||||
|
searchPage = 1;
|
||||||
disconnectSearchObserver();
|
disconnectSearchObserver();
|
||||||
|
|
||||||
// Show results count
|
// Try API first, fall back to local filtering
|
||||||
countEl.textContent = items.length === 0
|
const apiResult = await fetchFoodPage(searchQuery, selectedCategory, 1, SEARCH_PAGE_SIZE);
|
||||||
? 'Geen resultaten'
|
if (apiResult) {
|
||||||
: `${items.length} ${items.length === 1 ? 'resultaat' : 'resultaten'}`;
|
// API mode: use paginated results
|
||||||
|
searchTotal = apiResult.total;
|
||||||
|
const items = apiResult.data;
|
||||||
|
|
||||||
// Render list
|
// Show results count
|
||||||
if (items.length === 0) {
|
countEl.textContent = items.length === 0
|
||||||
listEl.innerHTML = '';
|
? 'Geen resultaten'
|
||||||
emptyEl.classList.remove('hidden');
|
: `${searchTotal} ${searchTotal === 1 ? 'resultaat' : 'resultaten'}`;
|
||||||
sentinelEl.classList.add('hidden');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
emptyEl.classList.add('hidden');
|
if (items.length === 0) {
|
||||||
|
listEl.innerHTML = '';
|
||||||
|
emptyEl.classList.remove('hidden');
|
||||||
|
sentinelEl.classList.add('hidden');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Render first batch
|
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
|
// Render first batch
|
||||||
if (items.length > SEARCH_PAGE_SIZE) {
|
listEl.innerHTML = renderFoodItemsHtml(items, 0);
|
||||||
sentinelEl.classList.remove('hidden');
|
attachFoodItemHandlers(listEl);
|
||||||
setupSearchObserver(items);
|
|
||||||
|
// Setup infinite scroll if more items exist
|
||||||
|
if (searchVisibleCount < searchTotal) {
|
||||||
|
sentinelEl.classList.remove('hidden');
|
||||||
|
setupSearchObserver();
|
||||||
|
} else {
|
||||||
|
sentinelEl.classList.add('hidden');
|
||||||
|
disconnectSearchObserver();
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
sentinelEl.classList.add('hidden');
|
// Fallback mode: use local data (original behaviour)
|
||||||
disconnectSearchObserver();
|
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
|
// Cap --i at batch size: max animation delay = 49 × 50ms = 2.45s
|
||||||
const animIdx = idx % SEARCH_PAGE_SIZE;
|
const animIdx = idx % SEARCH_PAGE_SIZE;
|
||||||
return `
|
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-left">
|
||||||
<div class="food-item-name">${escapeHtml(displayNaam(item.naam))}</div>
|
<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>
|
||||||
<div class="food-item-right">
|
<div class="food-item-right">
|
||||||
<div class="food-item-kh">${formatKh(kh)}</div>
|
<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;
|
if (el._clickAttached) return;
|
||||||
el._clickAttached = true;
|
el._clickAttached = true;
|
||||||
el.addEventListener('click', () => {
|
el.addEventListener('click', () => {
|
||||||
const idx = parseInt(el.dataset.index);
|
const nummer = el.dataset.nummer;
|
||||||
if (idx >= 0 && idx < voedingsmiddelen.length) {
|
if (!nummer || !voedingsmiddelenByN.has(nummer)) {
|
||||||
showDetail(voedingsmiddelen[idx]);
|
// 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();
|
disconnectSearchObserver();
|
||||||
const sentinel = document.getElementById('searchSentinel');
|
const sentinel = document.getElementById('searchSentinel');
|
||||||
if (!sentinel) return;
|
if (!sentinel) return;
|
||||||
|
|
||||||
searchObserver = new IntersectionObserver((entries) => {
|
searchObserver = new IntersectionObserver(async (entries) => {
|
||||||
if (entries[0].isIntersecting) {
|
if (entries[0].isIntersecting && !isSearchLoading) {
|
||||||
// Load up to 3 batches at once when user scrolls fast
|
isSearchLoading = true;
|
||||||
// This keeps the sentinel pushed far ahead of the viewport
|
try {
|
||||||
let batchesLoaded = 0;
|
if (searchTotal > 0) {
|
||||||
const maxBatches = 3;
|
// API mode: fetch next page
|
||||||
while (batchesLoaded < maxBatches && searchVisibleCount < allItems.length) {
|
const nextPage = searchPage + 1;
|
||||||
appendSearchBatch(allItems);
|
const apiResult = await fetchFoodPage(searchQuery, selectedCategory, nextPage, SEARCH_PAGE_SIZE);
|
||||||
batchesLoaded++;
|
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' });
|
}, { rootMargin: '1200px' });
|
||||||
|
|
@ -3381,7 +3556,17 @@ button:active, .btn:active, .meal-card-main:active, .dagboek-item:active, .food-
|
||||||
searchObserver.observe(sentinel);
|
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 listEl = document.getElementById('foodList');
|
||||||
const sentinelEl = document.getElementById('searchSentinel');
|
const sentinelEl = document.getElementById('searchSentinel');
|
||||||
if (!listEl || !sentinelEl) return;
|
if (!listEl || !sentinelEl) return;
|
||||||
|
|
@ -3415,6 +3600,8 @@ button:active, .btn:active, .meal-card-main:active, .dagboek-item:active, .food-
|
||||||
|
|
||||||
function resetSearchPagination() {
|
function resetSearchPagination() {
|
||||||
searchVisibleCount = SEARCH_PAGE_SIZE;
|
searchVisibleCount = SEARCH_PAGE_SIZE;
|
||||||
|
searchPage = 1;
|
||||||
|
searchTotal = 0;
|
||||||
disconnectSearchObserver();
|
disconnectSearchObserver();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -3751,7 +3938,7 @@ button:active, .btn:active, .meal-card-main:active, .dagboek-item:active, .food-
|
||||||
|
|
||||||
body.innerHTML = `
|
body.innerHTML = `
|
||||||
<div class="detail-name">${escapeHtml(displayNaam(item.naam))}</div>
|
<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">
|
<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>
|
<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>
|
</div>
|
||||||
|
|
@ -3924,30 +4111,26 @@ button:active, .btn:active, .meal-card-main:active, .dagboek-item:active, .food-
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function openAddToMealForMoment(momentId) {
|
function clearAddContext() {
|
||||||
// Open a mini search-to-add flow from diary
|
window._targetMoment = null;
|
||||||
switchTab('zoeken');
|
const banner = document.getElementById('addContextBanner');
|
||||||
// Give user a hint
|
if (banner) banner.classList.add('hidden');
|
||||||
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
|
function openAddToMealForMoment(momentId) {
|
||||||
const listEl = document.getElementById('foodList');
|
// Navigate to search page with eetmoment context
|
||||||
const handler = (e) => {
|
switchTab('zoeken');
|
||||||
const itemEl = e.target.closest('.food-item');
|
const moment = EETMOMENTEN.find(m => m.id === momentId);
|
||||||
if (!itemEl) return;
|
if (!moment) return;
|
||||||
const idx = parseInt(itemEl.dataset.index);
|
// Store the target moment so clicking a food item adds to it
|
||||||
if (idx >= 0 && idx < voedingsmiddelen.length && window._targetMoment) {
|
window._targetMoment = momentId;
|
||||||
e.stopPropagation();
|
// Show visual context banner instead of toast
|
||||||
e.preventDefault();
|
const banner = document.getElementById('addContextBanner');
|
||||||
showAddToMeal(voedingsmiddelen[idx], window._targetMoment);
|
const textEl = document.getElementById('addContextText');
|
||||||
window._targetMoment = null;
|
if (banner && textEl) {
|
||||||
listEl.removeEventListener('click', handler);
|
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) {
|
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">
|
||||||
<div class="meal-edit-item-info">
|
<div class="meal-edit-item-info">
|
||||||
<div class="meal-edit-item-name">${escapeHtml(displayNaam(mealItem.item.naam))}</div>
|
<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>
|
||||||
<div class="meal-edit-item-actions">
|
<div class="meal-edit-item-actions">
|
||||||
<div class="portion-input-wrapper" style="margin:0;">
|
<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 =====
|
// ===== Tab Switching =====
|
||||||
function switchTab(tab) {
|
function switchTab(tab) {
|
||||||
|
// Clear add-to-moment context when switching tabs
|
||||||
|
clearAddContext();
|
||||||
activeTab = tab;
|
activeTab = tab;
|
||||||
|
|
||||||
const panels = ['tabZoeken','tabDagboek','tabMaaltijden'];
|
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">
|
||||||
<div class="meal-edit-item-info">
|
<div class="meal-edit-item-info">
|
||||||
<div class="meal-edit-item-name">${escapeHtml(displayNaam(e.item.naam))}</div>
|
<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>
|
||||||
<div class="meal-edit-item-actions">
|
<div class="meal-edit-item-actions">
|
||||||
<div class="portion-input-wrapper" style="margin:0;">
|
<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">
|
||||||
<div class="meal-edit-item-info">
|
<div class="meal-edit-item-info">
|
||||||
<div class="meal-edit-item-name">${escapeHtml(displayNaam(entry.item.naam))}</div>
|
<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>
|
||||||
<div class="meal-edit-item-actions">
|
<div class="meal-edit-item-actions">
|
||||||
<div class="portion-input-wrapper" style="margin:0;">
|
<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) => `
|
resultsEl.innerHTML = matches.map((item, i) => `
|
||||||
<div class="meal-search-item" data-item-idx="${i}">
|
<div class="meal-search-item" data-item-idx="${i}">
|
||||||
<div style="font-weight:500;">${escapeHtml(displayNaam(item.naam))}</div>
|
<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>
|
</div>
|
||||||
`).join('');
|
`).join('');
|
||||||
resultsEl.querySelectorAll('.meal-search-item').forEach(el => {
|
resultsEl.querySelectorAll('.meal-search-item').forEach(el => {
|
||||||
|
|
@ -5601,6 +5786,9 @@ button:active, .btn:active, .meal-card-main:active, .dagboek-item:active, .food-
|
||||||
renderZoeken();
|
renderZoeken();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Cancel add-to-moment context banner
|
||||||
|
document.getElementById('addContextCancel').addEventListener('click', clearAddContext);
|
||||||
|
|
||||||
// Tab navigation
|
// Tab navigation
|
||||||
document.getElementById('navZoeken').addEventListener('click', () => switchTab('zoeken'));
|
document.getElementById('navZoeken').addEventListener('click', () => switchTab('zoeken'));
|
||||||
document.getElementById('navDagboek').addEventListener('click', () => switchTab('dagboek'));
|
document.getElementById('navDagboek').addEventListener('click', () => switchTab('dagboek'));
|
||||||
|
|
|
||||||
326
index.html
326
index.html
|
|
@ -257,6 +257,51 @@ body {
|
||||||
width: 100%;
|
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 {
|
.search-loading {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
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>
|
<button class="cat-btn" data-cat="Overig">📦 Overig</button>
|
||||||
</div>
|
</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 Content -->
|
||||||
<main class="main-content" id="mainContent">
|
<main class="main-content" id="mainContent">
|
||||||
<!-- Zoeken Tab -->
|
<!-- Zoeken Tab -->
|
||||||
|
|
@ -2650,6 +2701,22 @@ button:active, .btn:active, .meal-card-main:active, .dagboek-item:active, .food-
|
||||||
return CATEGORY_MAP[itemCat] || 'Overig';
|
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 =====
|
// ===== Prikmomenten voor glucose =====
|
||||||
const PRICKMOMENTEN = [
|
const PRICKMOMENTEN = [
|
||||||
{ key: 'nuchter', label: 'Nuchter (voor ontbijt)', targetMin: 0, targetMax: 5.4, momentId: null },
|
{ 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 =====
|
// ===== State =====
|
||||||
let voedingsmiddelen = [];
|
let voedingsmiddelen = [];
|
||||||
|
const voedingsmiddelenByN = new Map(); // n → item, for click-handler lookup
|
||||||
let dagboek = {}; // { "2026-07-23": { "Ontbijt": [{item, portie}, ...], ... } }
|
let dagboek = {}; // { "2026-07-23": { "Ontbijt": [{item, portie}, ...], ... } }
|
||||||
let activeDate = formatDate(new Date());
|
let activeDate = formatDate(new Date());
|
||||||
let activeTab = 'zoeken';
|
let activeTab = 'zoeken';
|
||||||
|
|
@ -2740,9 +2808,12 @@ button:active, .btn:active, .meal-card-main:active, .dagboek-item:active, .food-
|
||||||
let dataLoaded = false;
|
let dataLoaded = false;
|
||||||
let maaltijden = []; // [{ id, naam, items: [{item, portie}, ...], khTotaal }]
|
let maaltijden = []; // [{ id, naam, items: [{item, portie}, ...], khTotaal }]
|
||||||
let glucose = {}; // { '2026-07-24': { nuchter: 5.1, naOntbijt: 6.2, naLunch: null, naAvondeten: null } }
|
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 searchVisibleCount = SEARCH_PAGE_SIZE;
|
||||||
let searchObserver = null;
|
let searchObserver = null;
|
||||||
|
let searchTotal = 0; // total matching items from API
|
||||||
|
let searchPage = 1; // current API page (1-based)
|
||||||
|
let isSearchLoading = false;
|
||||||
|
|
||||||
// ===== Utility Functions =====
|
// ===== Utility Functions =====
|
||||||
function formatDate(d) {
|
function formatDate(d) {
|
||||||
|
|
@ -3110,6 +3181,10 @@ button:active, .btn:active, .meal-card-main:active, .dagboek-item:active, .food-
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
if (Array.isArray(data) && data.length > 0) {
|
if (Array.isArray(data) && data.length > 0) {
|
||||||
voedingsmiddelen = data;
|
voedingsmiddelen = data;
|
||||||
|
// Build lookup map by NEVO number (n)
|
||||||
|
for (const item of data) {
|
||||||
|
if (item.n != null) voedingsmiddelenByN.set(item.n, item);
|
||||||
|
}
|
||||||
buildSearchIndex();
|
buildSearchIndex();
|
||||||
console.log(`✅ ${data.length} voedingsmiddelen geladen uit data/voedingsmiddelen.json`);
|
console.log(`✅ ${data.length} voedingsmiddelen geladen uit data/voedingsmiddelen.json`);
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -3118,6 +3193,9 @@ button:active, .btn:active, .meal-card-main:active, .dagboek-item:active, .food-
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.warn('⚠️ Externe data niet geladen, gebruik sample data:', err.message);
|
console.warn('⚠️ Externe data niet geladen, gebruik sample data:', err.message);
|
||||||
voedingsmiddelen = [...SAMPLE_DATA];
|
voedingsmiddelen = [...SAMPLE_DATA];
|
||||||
|
for (const item of voedingsmiddelen) {
|
||||||
|
if (item.n != null) voedingsmiddelenByN.set(item.n, item);
|
||||||
|
}
|
||||||
buildSearchIndex();
|
buildSearchIndex();
|
||||||
// Show error only if we haven't loaded data before
|
// Show error only if we haven't loaded data before
|
||||||
if (!dataLoaded) {
|
if (!dataLoaded) {
|
||||||
|
|
@ -3281,11 +3359,30 @@ button:active, .btn:active, .meal-card-main:active, .dagboek-item:active, .food-
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===== Rendering =====
|
// ===== 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 (!dataLoaded) return;
|
||||||
if (activeTab !== 'zoeken') return;
|
if (activeTab !== 'zoeken') return;
|
||||||
|
|
||||||
const items = getFilteredItems();
|
|
||||||
const listEl = document.getElementById('foodList');
|
const listEl = document.getElementById('foodList');
|
||||||
const countEl = document.getElementById('resultsCount');
|
const countEl = document.getElementById('resultsCount');
|
||||||
const emptyEl = document.getElementById('emptyState');
|
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
|
// Reset pagination on new search/filter
|
||||||
searchVisibleCount = SEARCH_PAGE_SIZE;
|
searchVisibleCount = SEARCH_PAGE_SIZE;
|
||||||
|
searchPage = 1;
|
||||||
disconnectSearchObserver();
|
disconnectSearchObserver();
|
||||||
|
|
||||||
// Show results count
|
// Try API first, fall back to local filtering
|
||||||
countEl.textContent = items.length === 0
|
const apiResult = await fetchFoodPage(searchQuery, selectedCategory, 1, SEARCH_PAGE_SIZE);
|
||||||
? 'Geen resultaten'
|
if (apiResult) {
|
||||||
: `${items.length} ${items.length === 1 ? 'resultaat' : 'resultaten'}`;
|
// API mode: use paginated results
|
||||||
|
searchTotal = apiResult.total;
|
||||||
|
const items = apiResult.data;
|
||||||
|
|
||||||
// Render list
|
// Show results count
|
||||||
if (items.length === 0) {
|
countEl.textContent = items.length === 0
|
||||||
listEl.innerHTML = '';
|
? 'Geen resultaten'
|
||||||
emptyEl.classList.remove('hidden');
|
: `${searchTotal} ${searchTotal === 1 ? 'resultaat' : 'resultaten'}`;
|
||||||
sentinelEl.classList.add('hidden');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
emptyEl.classList.add('hidden');
|
if (items.length === 0) {
|
||||||
|
listEl.innerHTML = '';
|
||||||
|
emptyEl.classList.remove('hidden');
|
||||||
|
sentinelEl.classList.add('hidden');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Render first batch
|
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
|
// Render first batch
|
||||||
if (items.length > SEARCH_PAGE_SIZE) {
|
listEl.innerHTML = renderFoodItemsHtml(items, 0);
|
||||||
sentinelEl.classList.remove('hidden');
|
attachFoodItemHandlers(listEl);
|
||||||
setupSearchObserver(items);
|
|
||||||
|
// Setup infinite scroll if more items exist
|
||||||
|
if (searchVisibleCount < searchTotal) {
|
||||||
|
sentinelEl.classList.remove('hidden');
|
||||||
|
setupSearchObserver();
|
||||||
|
} else {
|
||||||
|
sentinelEl.classList.add('hidden');
|
||||||
|
disconnectSearchObserver();
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
sentinelEl.classList.add('hidden');
|
// Fallback mode: use local data (original behaviour)
|
||||||
disconnectSearchObserver();
|
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
|
// Cap --i at batch size: max animation delay = 49 × 50ms = 2.45s
|
||||||
const animIdx = idx % SEARCH_PAGE_SIZE;
|
const animIdx = idx % SEARCH_PAGE_SIZE;
|
||||||
return `
|
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-left">
|
||||||
<div class="food-item-name">${escapeHtml(displayNaam(item.naam))}</div>
|
<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>
|
||||||
<div class="food-item-right">
|
<div class="food-item-right">
|
||||||
<div class="food-item-kh">${formatKh(kh)}</div>
|
<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;
|
if (el._clickAttached) return;
|
||||||
el._clickAttached = true;
|
el._clickAttached = true;
|
||||||
el.addEventListener('click', () => {
|
el.addEventListener('click', () => {
|
||||||
const idx = parseInt(el.dataset.index);
|
const nummer = el.dataset.nummer;
|
||||||
if (idx >= 0 && idx < voedingsmiddelen.length) {
|
if (!nummer || !voedingsmiddelenByN.has(nummer)) {
|
||||||
showDetail(voedingsmiddelen[idx]);
|
// 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();
|
disconnectSearchObserver();
|
||||||
const sentinel = document.getElementById('searchSentinel');
|
const sentinel = document.getElementById('searchSentinel');
|
||||||
if (!sentinel) return;
|
if (!sentinel) return;
|
||||||
|
|
||||||
searchObserver = new IntersectionObserver((entries) => {
|
searchObserver = new IntersectionObserver(async (entries) => {
|
||||||
if (entries[0].isIntersecting) {
|
if (entries[0].isIntersecting && !isSearchLoading) {
|
||||||
// Load up to 3 batches at once when user scrolls fast
|
isSearchLoading = true;
|
||||||
// This keeps the sentinel pushed far ahead of the viewport
|
try {
|
||||||
let batchesLoaded = 0;
|
if (searchTotal > 0) {
|
||||||
const maxBatches = 3;
|
// API mode: fetch next page
|
||||||
while (batchesLoaded < maxBatches && searchVisibleCount < allItems.length) {
|
const nextPage = searchPage + 1;
|
||||||
appendSearchBatch(allItems);
|
const apiResult = await fetchFoodPage(searchQuery, selectedCategory, nextPage, SEARCH_PAGE_SIZE);
|
||||||
batchesLoaded++;
|
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' });
|
}, { rootMargin: '1200px' });
|
||||||
|
|
@ -3381,7 +3556,17 @@ button:active, .btn:active, .meal-card-main:active, .dagboek-item:active, .food-
|
||||||
searchObserver.observe(sentinel);
|
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 listEl = document.getElementById('foodList');
|
||||||
const sentinelEl = document.getElementById('searchSentinel');
|
const sentinelEl = document.getElementById('searchSentinel');
|
||||||
if (!listEl || !sentinelEl) return;
|
if (!listEl || !sentinelEl) return;
|
||||||
|
|
@ -3415,6 +3600,8 @@ button:active, .btn:active, .meal-card-main:active, .dagboek-item:active, .food-
|
||||||
|
|
||||||
function resetSearchPagination() {
|
function resetSearchPagination() {
|
||||||
searchVisibleCount = SEARCH_PAGE_SIZE;
|
searchVisibleCount = SEARCH_PAGE_SIZE;
|
||||||
|
searchPage = 1;
|
||||||
|
searchTotal = 0;
|
||||||
disconnectSearchObserver();
|
disconnectSearchObserver();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -3751,7 +3938,7 @@ button:active, .btn:active, .meal-card-main:active, .dagboek-item:active, .food-
|
||||||
|
|
||||||
body.innerHTML = `
|
body.innerHTML = `
|
||||||
<div class="detail-name">${escapeHtml(displayNaam(item.naam))}</div>
|
<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">
|
<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>
|
<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>
|
</div>
|
||||||
|
|
@ -3924,30 +4111,26 @@ button:active, .btn:active, .meal-card-main:active, .dagboek-item:active, .food-
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function openAddToMealForMoment(momentId) {
|
function clearAddContext() {
|
||||||
// Open a mini search-to-add flow from diary
|
window._targetMoment = null;
|
||||||
switchTab('zoeken');
|
const banner = document.getElementById('addContextBanner');
|
||||||
// Give user a hint
|
if (banner) banner.classList.add('hidden');
|
||||||
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
|
function openAddToMealForMoment(momentId) {
|
||||||
const listEl = document.getElementById('foodList');
|
// Navigate to search page with eetmoment context
|
||||||
const handler = (e) => {
|
switchTab('zoeken');
|
||||||
const itemEl = e.target.closest('.food-item');
|
const moment = EETMOMENTEN.find(m => m.id === momentId);
|
||||||
if (!itemEl) return;
|
if (!moment) return;
|
||||||
const idx = parseInt(itemEl.dataset.index);
|
// Store the target moment so clicking a food item adds to it
|
||||||
if (idx >= 0 && idx < voedingsmiddelen.length && window._targetMoment) {
|
window._targetMoment = momentId;
|
||||||
e.stopPropagation();
|
// Show visual context banner instead of toast
|
||||||
e.preventDefault();
|
const banner = document.getElementById('addContextBanner');
|
||||||
showAddToMeal(voedingsmiddelen[idx], window._targetMoment);
|
const textEl = document.getElementById('addContextText');
|
||||||
window._targetMoment = null;
|
if (banner && textEl) {
|
||||||
listEl.removeEventListener('click', handler);
|
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) {
|
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">
|
||||||
<div class="meal-edit-item-info">
|
<div class="meal-edit-item-info">
|
||||||
<div class="meal-edit-item-name">${escapeHtml(displayNaam(mealItem.item.naam))}</div>
|
<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>
|
||||||
<div class="meal-edit-item-actions">
|
<div class="meal-edit-item-actions">
|
||||||
<div class="portion-input-wrapper" style="margin:0;">
|
<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 =====
|
// ===== Tab Switching =====
|
||||||
function switchTab(tab) {
|
function switchTab(tab) {
|
||||||
|
// Clear add-to-moment context when switching tabs
|
||||||
|
clearAddContext();
|
||||||
activeTab = tab;
|
activeTab = tab;
|
||||||
|
|
||||||
const panels = ['tabZoeken','tabDagboek','tabMaaltijden'];
|
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">
|
||||||
<div class="meal-edit-item-info">
|
<div class="meal-edit-item-info">
|
||||||
<div class="meal-edit-item-name">${escapeHtml(displayNaam(e.item.naam))}</div>
|
<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>
|
||||||
<div class="meal-edit-item-actions">
|
<div class="meal-edit-item-actions">
|
||||||
<div class="portion-input-wrapper" style="margin:0;">
|
<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">
|
||||||
<div class="meal-edit-item-info">
|
<div class="meal-edit-item-info">
|
||||||
<div class="meal-edit-item-name">${escapeHtml(displayNaam(entry.item.naam))}</div>
|
<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>
|
||||||
<div class="meal-edit-item-actions">
|
<div class="meal-edit-item-actions">
|
||||||
<div class="portion-input-wrapper" style="margin:0;">
|
<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) => `
|
resultsEl.innerHTML = matches.map((item, i) => `
|
||||||
<div class="meal-search-item" data-item-idx="${i}">
|
<div class="meal-search-item" data-item-idx="${i}">
|
||||||
<div style="font-weight:500;">${escapeHtml(displayNaam(item.naam))}</div>
|
<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>
|
</div>
|
||||||
`).join('');
|
`).join('');
|
||||||
resultsEl.querySelectorAll('.meal-search-item').forEach(el => {
|
resultsEl.querySelectorAll('.meal-search-item').forEach(el => {
|
||||||
|
|
@ -5601,6 +5786,9 @@ button:active, .btn:active, .meal-card-main:active, .dagboek-item:active, .food-
|
||||||
renderZoeken();
|
renderZoeken();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Cancel add-to-moment context banner
|
||||||
|
document.getElementById('addContextCancel').addEventListener('click', clearAddContext);
|
||||||
|
|
||||||
// Tab navigation
|
// Tab navigation
|
||||||
document.getElementById('navZoeken').addEventListener('click', () => switchTab('zoeken'));
|
document.getElementById('navZoeken').addEventListener('click', () => switchTab('zoeken'));
|
||||||
document.getElementById('navDagboek').addEventListener('click', () => switchTab('dagboek'));
|
document.getElementById('navDagboek').addEventListener('click', () => switchTab('dagboek'));
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue