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;
}
.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-
<div class="results-section">
<div class="results-count" id="resultsCount"></div>
<ul class="food-list" id="foodList"></ul>
<div id="searchSentinel" class="search-sentinel"></div>
<div class="loading hidden" id="loadingState">
<div class="loading-spinner"></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 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,12 +2709,33 @@ button:active, .btn:active, .meal-card-main:active, .dagboek-item:active, .food-
if (items.length === 0) {
listEl.innerHTML = '';
emptyEl.classList.remove('hidden');
} else {
sentinelEl.classList.add('hidden');
return;
}
emptyEl.classList.add('hidden');
listEl.innerHTML = items.map((item, idx) => {
// 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:${idx}" role="button" tabindex="0">
<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>
@ -2709,9 +2748,13 @@ button:active, .btn:active, .meal-card-main:active, .dagboek-item:active, .food-
</li>
`;
}).join('');
}
// Add click handlers
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) {
@ -2720,6 +2763,56 @@ button:active, .btn:active, .meal-card-main:active, .dagboek-item:active, .food-
});
});
}
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);
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;
el.removeEventListener('click', handler);
listEl.removeEventListener('click', handler);
}
}, { once: true });
});
};
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();
});

View file

@ -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);
}
});
});