test: e2e tests voor infinite scroll — batch loading + search reset

2 nieuwe Playwright tests:
- Sentinels triggeren meer items na scrollIntoView
- Zoekopdracht reset paginering terug naar ≤ SEARCH_PAGE_SIZE

docker/index.html synced met worker-implementatie
This commit is contained in:
cas 2026-07-24 11:13:36 +02:00
parent 544d71e1ac
commit 35278053a7
2 changed files with 199 additions and 46 deletions

View file

@ -252,6 +252,19 @@ body {
list-style: none; 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 { .food-item {
background: var(--white); background: var(--white);
border-radius: var(--radius-sm); border-radius: var(--radius-sm);
@ -2048,6 +2061,7 @@ button:active, .btn:active, .meal-card-main:active, .dagboek-item:active, .food-
<div class="results-section"> <div class="results-section">
<div class="results-count" id="resultsCount"></div> <div class="results-count" id="resultsCount"></div>
<ul class="food-list" id="foodList"></ul> <ul class="food-list" id="foodList"></ul>
<div id="searchSentinel" class="search-sentinel"></div>
<div class="loading hidden" id="loadingState"> <div class="loading hidden" id="loadingState">
<div class="loading-spinner"></div> <div class="loading-spinner"></div>
<div>Voedingsmiddelen laden...</div> <div>Voedingsmiddelen laden...</div>
@ -2355,6 +2369,9 @@ button:active, .btn:active, .meal-card-main:active, .dagboek-item:active, .food-
let searchIndex = null; // Map<item, expanded search tokens string> let searchIndex = null; // Map<item, expanded search tokens string>
let dataLoaded = false; let dataLoaded = false;
let maaltijden = []; // [{ id, naam, items: [{item, portie}, ...], khTotaal }] let maaltijden = []; // [{ id, naam, items: [{item, portie}, ...], khTotaal }]
const SEARCH_PAGE_SIZE = 50;
let searchVisibleCount = SEARCH_PAGE_SIZE;
let searchObserver = null;
// ===== Utility Functions ===== // ===== Utility Functions =====
function formatDate(d) { function formatDate(d) {
@ -2671,16 +2688,17 @@ button:active, .btn:active, .meal-card-main:active, .dagboek-item:active, .food-
// ===== Rendering ===== // ===== Rendering =====
function renderZoeken() { function renderZoeken() {
if (!dataLoaded) return; if (!dataLoaded) return;
if (activeTab !== 'zoeken') return;
const items = getFilteredItems(); 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');
const container = document.getElementById('tabZoeken'); const sentinelEl = document.getElementById('searchSentinel');
const errorEl = document.getElementById('errorState');
// Make sure zoeken tab is visible if active // Reset pagination on new search/filter
if (activeTab !== 'zoeken') return; searchVisibleCount = SEARCH_PAGE_SIZE;
disconnectSearchObserver();
// Show results count // Show results count
countEl.textContent = items.length === 0 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) { if (items.length === 0) {
listEl.innerHTML = ''; listEl.innerHTML = '';
emptyEl.classList.remove('hidden'); emptyEl.classList.remove('hidden');
} else { sentinelEl.classList.add('hidden');
emptyEl.classList.add('hidden'); return;
listEl.innerHTML = items.map((item, idx) => {
const kh = parseKh(item.kh);
return `
<li class="food-item" data-index="${voedingsmiddelen.indexOf(item)}" style="--i:${idx}" 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>
<div class="food-item-right">
<div class="food-item-kh">${formatKh(kh)}</div>
<div class="food-item-unit">g kh / 100g</div>
</div>
<span class="food-item-arrow"></span>
</li>
`;
}).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]);
}
});
});
} }
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 `
<li class="food-item" data-index="${voedingsmiddelen.indexOf(item)}" style="--i:${globalIdx}" 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>
<div class="food-item-right">
<div class="food-item-kh">${formatKh(kh)}</div>
<div class="food-item-unit">g kh / 100g</div>
</div>
<span class="food-item-arrow"></span>
</li>
`;
}).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) { 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 // Store the target moment so next food tap adds to it
window._targetMoment = momentId; window._targetMoment = momentId;
// Override food item click behavior temporarily // Use event delegation so dynamically loaded items also work
const listEl = document.getElementById('foodList'); const listEl = document.getElementById('foodList');
listEl.querySelectorAll('.food-item').forEach(el => { const handler = (e) => {
const origClick = el.onclick; const itemEl = e.target.closest('.food-item');
el.addEventListener('click', function handler(e) { if (!itemEl) return;
const idx = parseInt(el.dataset.index); const idx = parseInt(itemEl.dataset.index);
if (idx >= 0 && idx < voedingsmiddelen.length && window._targetMoment) { if (idx >= 0 && idx < voedingsmiddelen.length && window._targetMoment) {
e.stopPropagation(); e.stopPropagation();
e.preventDefault(); e.preventDefault();
showAddToMeal(voedingsmiddelen[idx], window._targetMoment); showAddToMeal(voedingsmiddelen[idx], window._targetMoment);
window._targetMoment = null; window._targetMoment = null;
el.removeEventListener('click', handler); listEl.removeEventListener('click', handler);
} }
}, { once: true }); };
}); listEl.addEventListener('click', handler);
} }
function addToMeal(item, portie, momentId) { 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); setTimeout(() => mainContent.classList.remove('page-transition'), 350);
if (tab === 'zoeken') { if (tab === 'zoeken') {
resetSearchPagination();
renderZoeken(); renderZoeken();
} else if (tab === 'dagboek') { } else if (tab === 'dagboek') {
disconnectSearchObserver();
renderDagboek(); renderDagboek();
} else if (tab === 'maaltijden') { } else if (tab === 'maaltijden') {
disconnectSearchObserver();
renderMaaltijden(); renderMaaltijden();
} }
} }
@ -4599,6 +4695,7 @@ button:active, .btn:active, .meal-card-main:active, .dagboek-item:active, .food-
searchInput.addEventListener('input', () => { searchInput.addEventListener('input', () => {
searchQuery = searchInput.value; searchQuery = searchInput.value;
searchClear.classList.toggle('visible', searchQuery.length > 0); searchClear.classList.toggle('visible', searchQuery.length > 0);
resetSearchPagination();
renderZoeken(); renderZoeken();
}); });
@ -4606,6 +4703,7 @@ button:active, .btn:active, .meal-card-main:active, .dagboek-item:active, .food-
searchInput.value = ''; searchInput.value = '';
searchQuery = ''; searchQuery = '';
searchClear.classList.remove('visible'); searchClear.classList.remove('visible');
resetSearchPagination();
renderZoeken(); renderZoeken();
searchInput.focus(); searchInput.focus();
}); });
@ -4619,6 +4717,7 @@ button:active, .btn:active, .meal-card-main:active, .dagboek-item:active, .food-
btn.classList.add('active'); btn.classList.add('active');
selectedCategory = btn.dataset.cat || null; selectedCategory = btn.dataset.cat || null;
resetSearchPagination();
renderZoeken(); renderZoeken();
}); });

View file

@ -437,4 +437,58 @@ test.describe('Karby Eetdagboek', () => {
await expect(page.locator('#portieEditInput')).not.toBeAttached({ timeout: 3000 }); 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);
}
});
}); });