diff --git a/docker/index.html b/docker/index.html
index ef80903..df9f937 100644
--- a/docker/index.html
+++ b/docker/index.html
@@ -252,6 +252,19 @@ body {
list-style: none;
}
+.search-sentinel {
+ height: 1px;
+ width: 100%;
+}
+
+.search-loading {
+ display: flex;
+ justify-content: center;
+ padding: 16px;
+ color: var(--text-muted);
+ font-size: var(--font-sm);
+}
+
.food-item {
background: var(--white);
border-radius: var(--radius-sm);
@@ -2048,6 +2061,7 @@ button:active, .btn:active, .meal-card-main:active, .dagboek-item:active, .food-
+
Voedingsmiddelen laden...
@@ -2355,6 +2369,9 @@ button:active, .btn:active, .meal-card-main:active, .dagboek-item:active, .food-
let searchIndex = null; // Map
-
let dataLoaded = false;
let maaltijden = []; // [{ id, naam, items: [{item, portie}, ...], khTotaal }]
+ const SEARCH_PAGE_SIZE = 50;
+ let searchVisibleCount = SEARCH_PAGE_SIZE;
+ let searchObserver = null;
// ===== Utility Functions =====
function formatDate(d) {
@@ -2671,16 +2688,17 @@ button:active, .btn:active, .meal-card-main:active, .dagboek-item:active, .food-
// ===== Rendering =====
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');
- const container = document.getElementById('tabZoeken');
- const errorEl = document.getElementById('errorState');
+ const sentinelEl = document.getElementById('searchSentinel');
- // Make sure zoeken tab is visible if active
- if (activeTab !== 'zoeken') return;
+ // Reset pagination on new search/filter
+ searchVisibleCount = SEARCH_PAGE_SIZE;
+ disconnectSearchObserver();
// Show results count
countEl.textContent = items.length === 0
@@ -2691,35 +2709,110 @@ button:active, .btn:active, .meal-card-main:active, .dagboek-item:active, .food-
if (items.length === 0) {
listEl.innerHTML = '';
emptyEl.classList.remove('hidden');
- } else {
- emptyEl.classList.add('hidden');
- listEl.innerHTML = items.map((item, idx) => {
- const kh = parseKh(item.kh);
- return `
-
-
-
${escapeHtml(displayNaam(item.naam))}
-
${escapeHtml(item.cat)}
-
-
-
${formatKh(kh)}
-
g kh / 100g
-
- ›
-
- `;
- }).join('');
-
- // Add click handlers
- listEl.querySelectorAll('.food-item').forEach(el => {
- el.addEventListener('click', () => {
- const idx = parseInt(el.dataset.index);
- if (idx >= 0 && idx < voedingsmiddelen.length) {
- showDetail(voedingsmiddelen[idx]);
- }
- });
- });
+ sentinelEl.classList.add('hidden');
+ return;
}
+
+ emptyEl.classList.add('hidden');
+
+ // Render first batch
+ 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);
+ } else {
+ sentinelEl.classList.add('hidden');
+ disconnectSearchObserver();
+ }
+ }
+
+ function renderFoodItemsHtml(items, startIndex) {
+ return items.map((item, idx) => {
+ const globalIdx = startIndex + idx;
+ const kh = parseKh(item.kh);
+ return `
+
+
+
${escapeHtml(displayNaam(item.naam))}
+
${escapeHtml(item.cat)}
+
+
+
${formatKh(kh)}
+
g kh / 100g
+
+ ›
+
+ `;
+ }).join('');
+ }
+
+ function attachFoodItemHandlers(listEl) {
+ listEl.querySelectorAll('.food-item').forEach(el => {
+ // Avoid double handlers on re-render edge cases
+ 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]);
+ }
+ });
+ });
+ }
+
+ function setupSearchObserver(allItems) {
+ disconnectSearchObserver();
+ const sentinel = document.getElementById('searchSentinel');
+ if (!sentinel) return;
+
+ searchObserver = new IntersectionObserver((entries) => {
+ if (entries[0].isIntersecting) {
+ appendSearchBatch(allItems);
+ }
+ }, { rootMargin: '200px' });
+
+ searchObserver.observe(sentinel);
+ }
+
+ function appendSearchBatch(allItems) {
+ const listEl = document.getElementById('foodList');
+ const sentinelEl = document.getElementById('searchSentinel');
+ if (!listEl || !sentinelEl) return;
+
+ const remaining = allItems.length - searchVisibleCount;
+ if (remaining <= 0) {
+ disconnectSearchObserver();
+ sentinelEl.classList.add('hidden');
+ return;
+ }
+
+ const batchSize = Math.min(SEARCH_PAGE_SIZE, remaining);
+ const batch = allItems.slice(searchVisibleCount, searchVisibleCount + batchSize);
+ listEl.insertAdjacentHTML('beforeend', renderFoodItemsHtml(batch, searchVisibleCount));
+ attachFoodItemHandlers(listEl);
+ searchVisibleCount += batchSize;
+
+ // All items loaded — hide sentinel and disconnect
+ if (searchVisibleCount >= allItems.length) {
+ disconnectSearchObserver();
+ sentinelEl.classList.add('hidden');
+ }
+ }
+
+ function disconnectSearchObserver() {
+ if (searchObserver) {
+ searchObserver.disconnect();
+ searchObserver = null;
+ }
+ }
+
+ function resetSearchPagination() {
+ searchVisibleCount = SEARCH_PAGE_SIZE;
+ disconnectSearchObserver();
}
function renderProgressBar(totaal) {
@@ -3151,21 +3244,21 @@ button:active, .btn:active, .meal-card-main:active, .dagboek-item:active, .food-
// Store the target moment so next food tap adds to it
window._targetMoment = momentId;
- // Override food item click behavior temporarily
+ // Use event delegation so dynamically loaded items also work
const listEl = document.getElementById('foodList');
- listEl.querySelectorAll('.food-item').forEach(el => {
- const origClick = el.onclick;
- el.addEventListener('click', function handler(e) {
- const idx = parseInt(el.dataset.index);
- if (idx >= 0 && idx < voedingsmiddelen.length && window._targetMoment) {
- e.stopPropagation();
- e.preventDefault();
- showAddToMeal(voedingsmiddelen[idx], window._targetMoment);
- window._targetMoment = null;
- el.removeEventListener('click', handler);
- }
- }, { once: true });
- });
+ 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 addToMeal(item, portie, momentId) {
@@ -3546,10 +3639,13 @@ button:active, .btn:active, .meal-card-main:active, .dagboek-item:active, .food-
setTimeout(() => mainContent.classList.remove('page-transition'), 350);
if (tab === 'zoeken') {
+ resetSearchPagination();
renderZoeken();
} else if (tab === 'dagboek') {
+ disconnectSearchObserver();
renderDagboek();
} else if (tab === 'maaltijden') {
+ disconnectSearchObserver();
renderMaaltijden();
}
}
@@ -4599,6 +4695,7 @@ button:active, .btn:active, .meal-card-main:active, .dagboek-item:active, .food-
searchInput.addEventListener('input', () => {
searchQuery = searchInput.value;
searchClear.classList.toggle('visible', searchQuery.length > 0);
+ resetSearchPagination();
renderZoeken();
});
@@ -4606,6 +4703,7 @@ button:active, .btn:active, .meal-card-main:active, .dagboek-item:active, .food-
searchInput.value = '';
searchQuery = '';
searchClear.classList.remove('visible');
+ resetSearchPagination();
renderZoeken();
searchInput.focus();
});
@@ -4619,6 +4717,7 @@ button:active, .btn:active, .meal-card-main:active, .dagboek-item:active, .food-
btn.classList.add('active');
selectedCategory = btn.dataset.cat || null;
+ resetSearchPagination();
renderZoeken();
});
diff --git a/tests/e2e.spec.js b/tests/e2e.spec.js
index 7a1dd39..84919ca 100644
--- a/tests/e2e.spec.js
+++ b/tests/e2e.spec.js
@@ -437,4 +437,58 @@ test.describe('Karby Eetdagboek', () => {
await expect(page.locator('#portieEditInput')).not.toBeAttached({ timeout: 3000 });
});
+ test('infinite scroll: only first batch rendered, sentinel triggers more', async ({ page }) => {
+ await page.goto(BASE);
+ await page.waitForTimeout(1500); // wait for data load
+
+ // Clear any search query to see all items
+ const clearBtn = page.locator('#searchClear');
+ if (await clearBtn.isVisible().catch(() => false)) {
+ await clearBtn.click();
+ await page.waitForTimeout(500);
+ }
+
+ // Count initially rendered food items — should be ≤ SEARCH_PAGE_SIZE (50)
+ const initialCount = await page.locator('.food-item').count();
+ expect(initialCount).toBeLessThanOrEqual(50);
+ expect(initialCount).toBeGreaterThan(0);
+
+ // Sentinel should exist (there are more than 50 items)
+ await expect(page.locator('#searchSentinel')).toBeAttached({ timeout: 3000 });
+
+ // Scroll sentinel into view to trigger next batch
+ await page.locator('#searchSentinel').scrollIntoViewIfNeeded();
+ await page.waitForTimeout(800);
+
+ // Now more items should be rendered
+ const afterScrollCount = await page.locator('.food-item').count();
+ expect(afterScrollCount).toBeGreaterThan(initialCount);
+ expect(afterScrollCount).toBeLessThanOrEqual(100); // 2nd page = 100 max
+ });
+
+ test('infinite scroll: search query resets pagination', async ({ page }) => {
+ await page.goto(BASE);
+ await page.waitForTimeout(1500);
+
+ // Scroll down a bit to load more
+ const sentinel = page.locator('#searchSentinel');
+ if (await sentinel.isVisible().catch(() => false)) {
+ await sentinel.scrollIntoViewIfNeeded();
+ await page.waitForTimeout(800);
+ }
+
+ const beforeSearch = await page.locator('.food-item').count();
+
+ // Now type a search query — should reset to ≤ 50 items
+ await page.locator('.search-input').fill('aardappel');
+ await page.waitForTimeout(800);
+
+ const afterSearch = await page.locator('.food-item').count();
+ expect(afterSearch).toBeLessThanOrEqual(50);
+ // Should be fewer than before (filtered), unless we hadn't scrolled far
+ if (beforeSearch > 50) {
+ expect(afterSearch).toBeLessThan(beforeSearch);
+ }
+ });
+
});