UX — open tussendoortjessuggesties als bottom sheet

- Verplaatst de bestaande tabTussendoortjes-inhoud naar een nieuwe
  inspiratieSheet bottom-sheet overlay (reuseert .modal-overlay,
  .modal-sheet, .modal-handle, backdrop-close en setupSwipeDown).
- Klikken op 'Vind inspiratie' bij Tussendoor 1/2/3 opent nu de bottom
  sheet i.p.v. volledige tab-navigatie.
- Sheet heeft X-knop, backdrop-click en swipe-down om te sluiten.
- Haptics bij openen (10ms) en sluiten (6ms).
- renderTussendoortjes() werkt zowel in tab als sheet context.
- clearInspiratieContext() herstelt context ook als sheet open is.
- Playwright-tests geüpdatet: verifiëren sheet open/sluit, titel,
  context, X/backdrop close, Dagboek blijft actief, geen tab-navigatie.
- docker/index.html gesynchroniseerd.
This commit is contained in:
cas 2026-07-28 20:39:52 +02:00
parent 3422b09214
commit 85ef8ba2de
4 changed files with 467 additions and 172 deletions

View file

@ -2840,46 +2840,8 @@ button:active, .btn:active, .meal-card-main:active, .dagboek-item:active, .food-
</div>
</div>
<!-- Tussendoortjes Tab -->
<!-- Tussendoortjes Tab (leeg — content verplaatst naar inspiratieSheet bottom sheet) -->
<div id="tabTussendoortjes" class="hidden">
<div class="tussendoortjes-section">
<h2 class="tussendoortjes-header" id="tussendoortjesTitle">Tussendoortjes</h2>
<div class="inspiratie-context hidden" id="inspiratieContext">
<span class="inspiratie-context-label" id="inspiratieContextLabel"></span>
<button class="inspiratie-context-close" id="inspiratieContextClose" aria-label="Sluit context" data-haptic="normal">&times;</button>
</div>
<p class="tussendoortjes-sub">Lekkere, koolhydraatarme snacks binnen jouw gekozen bereik</p>
<div class="carb-range-control" id="carbRangeControl">
<div class="carb-range-label">
<span>Koolhydraten per portie</span>
</div>
<div class="range-inputs">
<div class="range-input-group">
<label for="rangeMin">Min (g)</label>
<input type="number" id="rangeMin" value="15" min="0" max="50" inputmode="numeric" data-haptic="normal">
</div>
<div class="range-input-group">
<label for="rangeMax">Max (g)</label>
<input type="number" id="rangeMax" value="20" min="1" max="100" inputmode="numeric" data-haptic="normal">
</div>
</div>
</div>
<div id="suggestieList"></div>
<div class="nieuwe-suggesties-btn-wrap">
<button class="nieuwe-suggesties-btn" id="nieuweSuggestiesBtn">
<i class="fas fa-rotate"></i> Nieuwe suggesties
</button>
</div>
<div class="suggestie-warning">
<strong>Belangrijk:</strong> De getoonde voedingswaarden zijn gebaseerd op de NEVO-database (RIVM) en kunnen afwijken van merkproducten. Controleer altijd het etiket van jouw eigen producten. Deze suggesties zijn geen medisch advies en vervangen geen begeleiding van een diëtist of arts.
</div>
</div>
</div>
</main>
@ -2946,6 +2908,55 @@ button:active, .btn:active, .meal-card-main:active, .dagboek-item:active, .food-
</div>
</div>
<!-- Inspiratie Bottom Sheet -->
<div class="modal-overlay hidden" id="inspiratieSheet">
<div class="modal-sheet">
<div class="modal-handle"></div>
<div class="modal-header">
<span class="modal-title" id="inspiratieSheetTitle">Tussendoortjes</span>
<button class="modal-close" id="inspiratieSheetClose"><i class="fas fa-xmark"></i></button>
</div>
<div class="modal-body" id="inspiratieSheetBody">
<div class="tussendoortjes-section" style="padding:0;">
<div class="inspiratie-context hidden" id="inspiratieContext">
<span class="inspiratie-context-label" id="inspiratieContextLabel"></span>
<button class="inspiratie-context-close" id="inspiratieContextClose" aria-label="Sluit context" data-haptic="normal">&times;</button>
</div>
<p class="tussendoortjes-sub">Lekkere, koolhydraatarme snacks binnen jouw gekozen bereik</p>
<div class="carb-range-control" id="carbRangeControl">
<div class="carb-range-label">
<span>Koolhydraten per portie</span>
</div>
<div class="range-inputs">
<div class="range-input-group">
<label for="rangeMin">Min (g)</label>
<input type="number" id="rangeMin" value="15" min="0" max="50" inputmode="numeric" data-haptic="normal">
</div>
<div class="range-input-group">
<label for="rangeMax">Max (g)</label>
<input type="number" id="rangeMax" value="20" min="1" max="100" inputmode="numeric" data-haptic="normal">
</div>
</div>
</div>
<div id="suggestieList"></div>
<div class="nieuwe-suggesties-btn-wrap">
<button class="nieuwe-suggesties-btn" id="nieuweSuggestiesBtn">
<i class="fas fa-rotate"></i> Nieuwe suggesties
</button>
</div>
<div class="suggestie-warning">
<strong>Belangrijk:</strong> De getoonde voedingswaarden zijn gebaseerd op de NEVO-database (RIVM) en kunnen afwijken van merkproducten. Controleer altijd het etiket van jouw eigen producten. Deze suggesties zijn geen medisch advies en vervangen geen begeleiding van een diëtist of arts.
</div>
</div>
</div>
</div>
</div>
<!-- Toast Container -->
<div id="toastContainer"></div>
@ -6022,21 +6033,27 @@ button:active, .btn:active, .meal-card-main:active, .dagboek-item:active, .food-
}
function renderTussendoortjes() {
if (activeTab !== 'tussendoortjes') return;
if (activeTab !== 'tussendoortjes') {
var sheet = document.getElementById('inspiratieSheet');
var isSheetOpen = sheet && !sheet.classList.contains('hidden') && !sheet.classList.contains('closing');
if (!isSheetOpen) return;
}
const listEl = document.getElementById('suggestieList');
if (!listEl) return;
const titleEl = document.getElementById('tussendoortjesTitle');
const sheetTitleEl = document.getElementById('inspiratieSheetTitle');
const contextEl = document.getElementById('inspiratieContext');
const contextLabel = document.getElementById('inspiratieContextLabel');
// Update title and context based on inspiration mode
if (_inspiratieMoment && titleEl) {
titleEl.textContent = 'Inspiratie voor ' + _inspiratieMoment;
} else {
titleEl.textContent = 'Tussendoortjes';
var titleText = 'Tussendoortjes';
if (_inspiratieMoment) {
titleText = 'Inspiratie voor ' + _inspiratieMoment;
}
if (titleEl) titleEl.textContent = titleText;
if (sheetTitleEl) sheetTitleEl.textContent = titleText;
// Show/hide context header
if (contextEl) {
@ -6129,7 +6146,7 @@ button:active, .btn:active, .meal-card-main:active, .dagboek-item:active, .food-
_inspiratieMoment = momentId;
_inspiratieDate = dateStr;
_cachedGeneratedSet = null;
switchTab('tussendoortjes');
openInspiratieSheet();
}
/**
@ -6139,9 +6156,15 @@ button:active, .btn:active, .meal-card-main:active, .dagboek-item:active, .food-
_inspiratieMoment = null;
_inspiratieDate = null;
_cachedGeneratedSet = null;
// Re-render if on tussendoortjes tab
// Re-render if on tussendoortjes tab or if sheet is open
if (activeTab === 'tussendoortjes') {
renderTussendoortjes();
} else {
var sheet = document.getElementById('inspiratieSheet');
var isSheetOpen = sheet && !sheet.classList.contains('hidden') && !sheet.classList.contains('closing');
if (isSheetOpen) {
renderTussendoortjes();
}
}
}
@ -6266,6 +6289,44 @@ button:active, .btn:active, .meal-card-main:active, .dagboek-item:active, .food-
}, { passive: true });
}
/**
* Open the inspiratie bottom sheet and render suggestions.
*/
function openInspiratieSheet() {
var overlay = document.getElementById('inspiratieSheet');
if (!overlay) return;
overlay.classList.remove('hidden', 'closing');
document.body.style.overflow = 'hidden';
// Render tussendoortjes content in the sheet
renderTussendoortjes();
// Haptic feedback
if (typeof navigator !== 'undefined' && navigator.vibrate) {
navigator.vibrate(10);
}
}
/**
* Close the inspiratie bottom sheet and return to dagboek context.
*/
function closeInspiratieSheet() {
var overlay = document.getElementById('inspiratieSheet');
if (!overlay) return;
overlay.classList.add('closing');
setTimeout(function() {
overlay.classList.add('hidden');
overlay.classList.remove('closing');
document.body.style.overflow = '';
clearInspiratieContext();
}, 280);
// Haptic feedback
if (typeof navigator !== 'undefined' && navigator.vibrate) {
navigator.vibrate(6);
}
}
function showToast(message) {
const container = document.getElementById('toastContainer');
const toast = document.createElement('div');
@ -6844,6 +6905,10 @@ button:active, .btn:active, .meal-card-main:active, .dagboek-item:active, .food-
document.getElementById('detailModalClose').addEventListener('click', () => closeModal('detailModal'));
document.getElementById('addMealModalClose').addEventListener('click', () => closeModal('addMealModal'));
// Inspiratie sheet close
var inspiratieSheetClose = document.getElementById('inspiratieSheetClose');
if (inspiratieSheetClose) inspiratieSheetClose.addEventListener('click', closeInspiratieSheet);
// Close modals on overlay click
document.getElementById('detailModal').addEventListener('click', (e) => {
if (e.target === document.getElementById('detailModal')) closeModal('detailModal');
@ -6852,6 +6917,15 @@ button:active, .btn:active, .meal-card-main:active, .dagboek-item:active, .food-
if (e.target === document.getElementById('addMealModal')) closeModal('addMealModal');
});
// Inspiratie sheet backdrop close + swipe-down
var inspiratieSheet = document.getElementById('inspiratieSheet');
if (inspiratieSheet) {
inspiratieSheet.addEventListener('click', function(e) {
if (e.target === inspiratieSheet) closeInspiratieSheet();
});
setupSwipeDown(inspiratieSheet, closeInspiratieSheet);
}
// Swipe-down to close for static modals
setupSwipeDown(document.getElementById('detailModal'), () => closeModal('detailModal'));
setupSwipeDown(document.getElementById('addMealModal'), () => closeModal('addMealModal'));

View file

@ -2840,46 +2840,8 @@ button:active, .btn:active, .meal-card-main:active, .dagboek-item:active, .food-
</div>
</div>
<!-- Tussendoortjes Tab -->
<!-- Tussendoortjes Tab (leeg — content verplaatst naar inspiratieSheet bottom sheet) -->
<div id="tabTussendoortjes" class="hidden">
<div class="tussendoortjes-section">
<h2 class="tussendoortjes-header" id="tussendoortjesTitle">Tussendoortjes</h2>
<div class="inspiratie-context hidden" id="inspiratieContext">
<span class="inspiratie-context-label" id="inspiratieContextLabel"></span>
<button class="inspiratie-context-close" id="inspiratieContextClose" aria-label="Sluit context" data-haptic="normal">&times;</button>
</div>
<p class="tussendoortjes-sub">Lekkere, koolhydraatarme snacks binnen jouw gekozen bereik</p>
<div class="carb-range-control" id="carbRangeControl">
<div class="carb-range-label">
<span>Koolhydraten per portie</span>
</div>
<div class="range-inputs">
<div class="range-input-group">
<label for="rangeMin">Min (g)</label>
<input type="number" id="rangeMin" value="15" min="0" max="50" inputmode="numeric" data-haptic="normal">
</div>
<div class="range-input-group">
<label for="rangeMax">Max (g)</label>
<input type="number" id="rangeMax" value="20" min="1" max="100" inputmode="numeric" data-haptic="normal">
</div>
</div>
</div>
<div id="suggestieList"></div>
<div class="nieuwe-suggesties-btn-wrap">
<button class="nieuwe-suggesties-btn" id="nieuweSuggestiesBtn">
<i class="fas fa-rotate"></i> Nieuwe suggesties
</button>
</div>
<div class="suggestie-warning">
<strong>Belangrijk:</strong> De getoonde voedingswaarden zijn gebaseerd op de NEVO-database (RIVM) en kunnen afwijken van merkproducten. Controleer altijd het etiket van jouw eigen producten. Deze suggesties zijn geen medisch advies en vervangen geen begeleiding van een diëtist of arts.
</div>
</div>
</div>
</main>
@ -2946,6 +2908,55 @@ button:active, .btn:active, .meal-card-main:active, .dagboek-item:active, .food-
</div>
</div>
<!-- Inspiratie Bottom Sheet -->
<div class="modal-overlay hidden" id="inspiratieSheet">
<div class="modal-sheet">
<div class="modal-handle"></div>
<div class="modal-header">
<span class="modal-title" id="inspiratieSheetTitle">Tussendoortjes</span>
<button class="modal-close" id="inspiratieSheetClose"><i class="fas fa-xmark"></i></button>
</div>
<div class="modal-body" id="inspiratieSheetBody">
<div class="tussendoortjes-section" style="padding:0;">
<div class="inspiratie-context hidden" id="inspiratieContext">
<span class="inspiratie-context-label" id="inspiratieContextLabel"></span>
<button class="inspiratie-context-close" id="inspiratieContextClose" aria-label="Sluit context" data-haptic="normal">&times;</button>
</div>
<p class="tussendoortjes-sub">Lekkere, koolhydraatarme snacks binnen jouw gekozen bereik</p>
<div class="carb-range-control" id="carbRangeControl">
<div class="carb-range-label">
<span>Koolhydraten per portie</span>
</div>
<div class="range-inputs">
<div class="range-input-group">
<label for="rangeMin">Min (g)</label>
<input type="number" id="rangeMin" value="15" min="0" max="50" inputmode="numeric" data-haptic="normal">
</div>
<div class="range-input-group">
<label for="rangeMax">Max (g)</label>
<input type="number" id="rangeMax" value="20" min="1" max="100" inputmode="numeric" data-haptic="normal">
</div>
</div>
</div>
<div id="suggestieList"></div>
<div class="nieuwe-suggesties-btn-wrap">
<button class="nieuwe-suggesties-btn" id="nieuweSuggestiesBtn">
<i class="fas fa-rotate"></i> Nieuwe suggesties
</button>
</div>
<div class="suggestie-warning">
<strong>Belangrijk:</strong> De getoonde voedingswaarden zijn gebaseerd op de NEVO-database (RIVM) en kunnen afwijken van merkproducten. Controleer altijd het etiket van jouw eigen producten. Deze suggesties zijn geen medisch advies en vervangen geen begeleiding van een diëtist of arts.
</div>
</div>
</div>
</div>
</div>
<!-- Toast Container -->
<div id="toastContainer"></div>
@ -6022,21 +6033,27 @@ button:active, .btn:active, .meal-card-main:active, .dagboek-item:active, .food-
}
function renderTussendoortjes() {
if (activeTab !== 'tussendoortjes') return;
if (activeTab !== 'tussendoortjes') {
var sheet = document.getElementById('inspiratieSheet');
var isSheetOpen = sheet && !sheet.classList.contains('hidden') && !sheet.classList.contains('closing');
if (!isSheetOpen) return;
}
const listEl = document.getElementById('suggestieList');
if (!listEl) return;
const titleEl = document.getElementById('tussendoortjesTitle');
const sheetTitleEl = document.getElementById('inspiratieSheetTitle');
const contextEl = document.getElementById('inspiratieContext');
const contextLabel = document.getElementById('inspiratieContextLabel');
// Update title and context based on inspiration mode
if (_inspiratieMoment && titleEl) {
titleEl.textContent = 'Inspiratie voor ' + _inspiratieMoment;
} else {
titleEl.textContent = 'Tussendoortjes';
var titleText = 'Tussendoortjes';
if (_inspiratieMoment) {
titleText = 'Inspiratie voor ' + _inspiratieMoment;
}
if (titleEl) titleEl.textContent = titleText;
if (sheetTitleEl) sheetTitleEl.textContent = titleText;
// Show/hide context header
if (contextEl) {
@ -6129,7 +6146,7 @@ button:active, .btn:active, .meal-card-main:active, .dagboek-item:active, .food-
_inspiratieMoment = momentId;
_inspiratieDate = dateStr;
_cachedGeneratedSet = null;
switchTab('tussendoortjes');
openInspiratieSheet();
}
/**
@ -6139,9 +6156,15 @@ button:active, .btn:active, .meal-card-main:active, .dagboek-item:active, .food-
_inspiratieMoment = null;
_inspiratieDate = null;
_cachedGeneratedSet = null;
// Re-render if on tussendoortjes tab
// Re-render if on tussendoortjes tab or if sheet is open
if (activeTab === 'tussendoortjes') {
renderTussendoortjes();
} else {
var sheet = document.getElementById('inspiratieSheet');
var isSheetOpen = sheet && !sheet.classList.contains('hidden') && !sheet.classList.contains('closing');
if (isSheetOpen) {
renderTussendoortjes();
}
}
}
@ -6266,6 +6289,44 @@ button:active, .btn:active, .meal-card-main:active, .dagboek-item:active, .food-
}, { passive: true });
}
/**
* Open the inspiratie bottom sheet and render suggestions.
*/
function openInspiratieSheet() {
var overlay = document.getElementById('inspiratieSheet');
if (!overlay) return;
overlay.classList.remove('hidden', 'closing');
document.body.style.overflow = 'hidden';
// Render tussendoortjes content in the sheet
renderTussendoortjes();
// Haptic feedback
if (typeof navigator !== 'undefined' && navigator.vibrate) {
navigator.vibrate(10);
}
}
/**
* Close the inspiratie bottom sheet and return to dagboek context.
*/
function closeInspiratieSheet() {
var overlay = document.getElementById('inspiratieSheet');
if (!overlay) return;
overlay.classList.add('closing');
setTimeout(function() {
overlay.classList.add('hidden');
overlay.classList.remove('closing');
document.body.style.overflow = '';
clearInspiratieContext();
}, 280);
// Haptic feedback
if (typeof navigator !== 'undefined' && navigator.vibrate) {
navigator.vibrate(6);
}
}
function showToast(message) {
const container = document.getElementById('toastContainer');
const toast = document.createElement('div');
@ -6844,6 +6905,10 @@ button:active, .btn:active, .meal-card-main:active, .dagboek-item:active, .food-
document.getElementById('detailModalClose').addEventListener('click', () => closeModal('detailModal'));
document.getElementById('addMealModalClose').addEventListener('click', () => closeModal('addMealModal'));
// Inspiratie sheet close
var inspiratieSheetClose = document.getElementById('inspiratieSheetClose');
if (inspiratieSheetClose) inspiratieSheetClose.addEventListener('click', closeInspiratieSheet);
// Close modals on overlay click
document.getElementById('detailModal').addEventListener('click', (e) => {
if (e.target === document.getElementById('detailModal')) closeModal('detailModal');
@ -6852,6 +6917,15 @@ button:active, .btn:active, .meal-card-main:active, .dagboek-item:active, .food-
if (e.target === document.getElementById('addMealModal')) closeModal('addMealModal');
});
// Inspiratie sheet backdrop close + swipe-down
var inspiratieSheet = document.getElementById('inspiratieSheet');
if (inspiratieSheet) {
inspiratieSheet.addEventListener('click', function(e) {
if (e.target === inspiratieSheet) closeInspiratieSheet();
});
setupSwipeDown(inspiratieSheet, closeInspiratieSheet);
}
// Swipe-down to close for static modals
setupSwipeDown(document.getElementById('detailModal'), () => closeModal('detailModal'));
setupSwipeDown(document.getElementById('addMealModal'), () => closeModal('addMealModal'));

View file

@ -578,29 +578,41 @@ test.describe('Karby Eetdagboek', () => {
// ========== TUSSENDOORTJES (via inspiratie-flow, nav-knop verwijderd) ==========
test('tussendoortjes tab: bottom-nav button is removed, tab accessible via switchTab', async ({ page }) => {
test('tussendoortjes inspiratie: bottom sheet opent via Vind inspiratie vanuit elk Tussendoor-moment', async ({ page }) => {
await page.goto('/');
await page.waitForTimeout(1000);
// Verify the bottom-nav button is NOT present
await expect(page.locator('#navTussendoortjes')).not.toBeAttached();
// Verify the tab content still exists
await expect(page.locator('#tabTussendoortjes')).toBeAttached();
// Navigate via page.evaluate (simulating Vind inspiratie flow)
await page.evaluate(() => switchTab('tussendoortjes'));
await page.waitForTimeout(500);
await expect(page.locator('#tabTussendoortjes')).toBeVisible();
await expect(page.locator('.tussendoortjes-header')).toHaveText('Tussendoortjes');
await expect(page.locator('#rangeMin')).toBeVisible();
await expect(page.locator('#rangeMax')).toBeVisible();
// Open dagboek
await page.click('#navDagboek');
await page.waitForTimeout(600);
// Click first Vind inspiratie button
const btn = page.locator('.vind-inspiratie-btn').first();
await expect(btn).toBeVisible();
await btn.click();
await page.waitForTimeout(600);
// Sheet should open
await expect(page.locator('#inspiratieSheet')).toBeVisible();
await expect(page.locator('#inspiratieSheet')).not.toHaveClass(/hidden/);
// Sheet title shows inspiration context
await expect(page.locator('#inspiratieSheetTitle')).toContainText('Inspiratie voor');
// Range controls are in the sheet
await expect(page.locator('#inspiratieSheet #rangeMin')).toBeVisible();
await expect(page.locator('#inspiratieSheet #rangeMax')).toBeVisible();
// Dagboek stays active (no tab switch)
await expect(page.locator('#navDagboek')).toHaveClass(/active/);
});
test('tussendoortjes tab: default range shows suggestie cards', async ({ page }) => {
test('tussendoortjes inspiratie: default range shows suggestie cards in sheet', async ({ page }) => {
await page.goto('/');
await page.waitForTimeout(1500);
await page.evaluate(() => switchTab('tussendoortjes'));
await page.click('#navDagboek');
await page.waitForTimeout(600);
// Click first Vind inspiratie to open sheet
await page.locator('.vind-inspiratie-btn').first().click();
await page.waitForTimeout(800);
// Default range is 15-20g — should show at least one suggestie card
const cards = page.locator('.suggestie-card');
const cards = page.locator('#inspiratieSheet .suggestie-card');
const count = await cards.count();
expect(count).toBeGreaterThanOrEqual(1);
// Each card should have a name, total, ingredients, and preparation
@ -611,21 +623,23 @@ test.describe('Karby Eetdagboek', () => {
await expect(firstCard.locator('.suggestie-bereiding')).toBeVisible();
});
test('tussendoortjes tab: filter by range shows only matching totals', async ({ page }) => {
test('tussendoortjes inspiratie: filter by range in sheet shows only matching totals', async ({ page }) => {
await page.goto('/');
await page.waitForTimeout(1500);
await page.evaluate(() => switchTab('tussendoortjes'));
await page.click('#navDagboek');
await page.waitForTimeout(600);
await page.locator('.vind-inspiratie-btn').first().click();
await page.waitForTimeout(800);
// Set range to 10-15g — should show suggestions within this range
await page.locator('#rangeMax').click({ clickCount: 3 });
await page.locator('#rangeMax').fill('15');
await page.locator('#rangeMin').click({ clickCount: 3 });
await page.locator('#rangeMin').fill('10');
await page.locator('#inspiratieSheet #rangeMax').click({ clickCount: 3 });
await page.locator('#inspiratieSheet #rangeMax').fill('15');
await page.locator('#inspiratieSheet #rangeMin').click({ clickCount: 3 });
await page.locator('#inspiratieSheet #rangeMin').fill('10');
await page.waitForTimeout(500);
// Check that all visible totals are within 10-15g
const totals = page.locator('.suggestie-card-totaal');
const totals = page.locator('#inspiratieSheet .suggestie-card-totaal');
const totalCount = await totals.count();
for (let i = 0; i < totalCount; i++) {
const text = await totals.nth(i).textContent();
@ -635,46 +649,52 @@ test.describe('Karby Eetdagboek', () => {
}
});
test('tussendoortjes tab: narrow range shows empty state', async ({ page }) => {
test('tussendoortjes inspiratie: narrow range shows empty state in sheet', async ({ page }) => {
await page.goto('/');
await page.waitForTimeout(1500);
await page.evaluate(() => switchTab('tussendoortjes'));
await page.click('#navDagboek');
await page.waitForTimeout(600);
await page.locator('.vind-inspiratie-btn').first().click();
await page.waitForTimeout(800);
// Set range to 0-1g — should show no suggestions
await page.locator('#rangeMax').click({ clickCount: 3 });
await page.locator('#rangeMax').fill('1');
await page.locator('#rangeMin').click({ clickCount: 3 });
await page.locator('#rangeMin').fill('0');
await page.locator('#inspiratieSheet #rangeMax').click({ clickCount: 3 });
await page.locator('#inspiratieSheet #rangeMax').fill('1');
await page.locator('#inspiratieSheet #rangeMin').click({ clickCount: 3 });
await page.locator('#inspiratieSheet #rangeMin').fill('0');
await page.waitForTimeout(500);
// Should show empty state
const cards = page.locator('.suggestie-card');
const cards = page.locator('#inspiratieSheet .suggestie-card');
const cardCount = await cards.count();
expect(cardCount).toBe(0);
// Empty state text should be visible
await expect(page.locator('#tabTussendoortjes .empty-state-text')).toBeVisible();
// Empty state text should be visible in the sheet
await expect(page.locator('#inspiratieSheet .inspiratie-empty')).toBeVisible();
});
test('tussendoortjes tab: warning label is present', async ({ page }) => {
test('tussendoortjes inspiratie: warning label is present in sheet', async ({ page }) => {
await page.goto('/');
await page.waitForTimeout(1000);
await page.evaluate(() => switchTab('tussendoortjes'));
await page.waitForTimeout(500);
const warning = page.locator('.suggestie-warning');
await page.click('#navDagboek');
await page.waitForTimeout(600);
await page.locator('.vind-inspiratie-btn').first().click();
await page.waitForTimeout(600);
const warning = page.locator('#inspiratieSheet .suggestie-warning');
await expect(warning).toBeVisible();
const text = await warning.textContent();
expect(text).toContain('NEVO');
expect(text).toContain('medisch');
});
test('tussendoortjes tab: each ingredient shows name, grams, and kh', async ({ page }) => {
test('tussendoortjes inspiratie: each ingredient shows name, grams, and kh in sheet', async ({ page }) => {
await page.goto('/');
await page.waitForTimeout(2000);
await page.evaluate(() => switchTab('tussendoortjes'));
await page.click('#navDagboek');
await page.waitForTimeout(600);
await page.locator('.vind-inspiratie-btn').first().click();
await page.waitForTimeout(1000);
const ingredients = page.locator('.suggestie-ingredient');
const ingredients = page.locator('#inspiratieSheet .suggestie-ingredient');
const count = await ingredients.count();
expect(count).toBeGreaterThanOrEqual(3); // at least 3 total ingredient rows across all cards
@ -689,19 +709,21 @@ test.describe('Karby Eetdagboek', () => {
expect(khText).toMatch(/\d+[.,]?\d* g kh/); // e.g. "6.7 g kh"
});
test('tussendoortjes tab: range value 0 is not defaulted to 15', async ({ page }) => {
test('tussendoortjes inspiratie: range value 0 is not defaulted to 15 in sheet', async ({ page }) => {
await page.goto('/');
await page.waitForTimeout(1500);
await page.evaluate(() => switchTab('tussendoortjes'));
await page.click('#navDagboek');
await page.waitForTimeout(600);
await page.locator('.vind-inspiratie-btn').first().click();
await page.waitForTimeout(800);
// Set min to 0 — previously this would silently become 15 due to parseInt() || 15
await page.locator('#rangeMin').click({ clickCount: 3 });
await page.locator('#rangeMin').fill('0');
await page.locator('#inspiratieSheet #rangeMin').click({ clickCount: 3 });
await page.locator('#inspiratieSheet #rangeMin').fill('0');
await page.waitForTimeout(300);
// Verify the input shows 0
await expect(page.locator('#rangeMin')).toHaveValue('0');
await expect(page.locator('#inspiratieSheet #rangeMin')).toHaveValue('0');
// Verify that the app correctly interprets 0 (not falling back to 15)
const parseResult = await page.evaluate(() => {
@ -717,17 +739,19 @@ test.describe('Karby Eetdagboek', () => {
expect(parseResult.newFallback).toBe(0); // isNaN(0) ? 15 : 0 → 0 (fixed)
});
test('tussendoortjes tab: range 01 is interpreted correctly and shows empty state', async ({ page }) => {
test('tussendoortjes inspiratie: range 01 is interpreted correctly and shows empty state in sheet', async ({ page }) => {
await page.goto('/');
await page.waitForTimeout(1500);
await page.evaluate(() => switchTab('tussendoortjes'));
await page.click('#navDagboek');
await page.waitForTimeout(600);
await page.locator('.vind-inspiratie-btn').first().click();
await page.waitForTimeout(800);
// Set range to 01
await page.locator('#rangeMax').click({ clickCount: 3 });
await page.locator('#rangeMax').fill('1');
await page.locator('#rangeMin').click({ clickCount: 3 });
await page.locator('#rangeMin').fill('0');
await page.locator('#inspiratieSheet #rangeMax').click({ clickCount: 3 });
await page.locator('#inspiratieSheet #rangeMax').fill('1');
await page.locator('#inspiratieSheet #rangeMin').click({ clickCount: 3 });
await page.locator('#inspiratieSheet #rangeMin').fill('0');
await page.waitForTimeout(500);
// Verify the actual parsed values via berekenReceptTotalen — min should be 0, not 15
@ -743,21 +767,23 @@ test.describe('Karby Eetdagboek', () => {
expect(rangeValues.rangeMax).toBe(1);
// Should show empty state
const cards = page.locator('.suggestie-card');
const cards = page.locator('#inspiratieSheet .suggestie-card');
const cardCount = await cards.count();
expect(cardCount).toBe(0);
await expect(page.locator('#tabTussendoortjes .empty-state-text')).toBeVisible();
await expect(page.locator('#inspiratieSheet .inspiratie-empty')).toBeVisible();
});
test('tussendoortjes tab: range inputs have haptic metadata', async ({ page }) => {
test('tussendoortjes inspiratie: range inputs have haptic metadata in sheet', async ({ page }) => {
await page.goto('/');
await page.waitForTimeout(1000);
await page.evaluate(() => switchTab('tussendoortjes'));
await page.click('#navDagboek');
await page.waitForTimeout(600);
await page.locator('.vind-inspiratie-btn').first().click();
await page.waitForTimeout(500);
// Both range inputs should have data-haptic attribute
await expect(page.locator('#rangeMin')).toHaveAttribute('data-haptic', 'normal');
await expect(page.locator('#rangeMax')).toHaveAttribute('data-haptic', 'normal');
await expect(page.locator('#inspiratieSheet #rangeMin')).toHaveAttribute('data-haptic', 'normal');
await expect(page.locator('#inspiratieSheet #rangeMax')).toHaveAttribute('data-haptic', 'normal');
});
});

View file

@ -3,6 +3,8 @@ const { test, expect } = require('@playwright/test');
/**
* E2E tests for the "Vind inspiratie" feature on Tussendoor 1/2/3
*
* The feature opens a bottom sheet (inspiratieSheet) instead of a full tab/page.
*
* Prerequisites:
* - The app must be served at the configured baseURL
* - Run `npx playwright test` from the project root
@ -25,13 +27,18 @@ async function goToDagboek(page) {
await page.waitForTimeout(400);
}
// Helper: switch to tussendoortjes tab (via switchTab, nav-knop is verwijderd)
async function goToTussendoortjes(page) {
await page.evaluate(() => switchTab('tussendoortjes'));
await page.waitForTimeout(400);
// Helper: open inspiratieSheet via Vind inspiratie button on the first Tussendoor card
async function openInspiratieSheet(page) {
await goToDagboek(page);
const inspiratieBtn = page.locator('.vind-inspiratie-btn').first();
await expect(inspiratieBtn).toBeVisible();
await inspiratieBtn.click();
await page.waitForTimeout(500);
// Verify sheet is open
await expect(page.locator('#inspiratieSheet')).toBeVisible();
}
test.describe('Vind inspiratie feature', () => {
test.describe('Vind inspiratie feature (bottom sheet)', () => {
test('Vind inspiratie button only appears on Tussendoor 1, 2, 3 cards in dagboek', async ({ page }) => {
await page.goto('/');
@ -55,7 +62,7 @@ test.describe('Vind inspiratie feature', () => {
}
});
test('Clicking Vind inspiratie opens tussendoortjes with correct context title', async ({ page }) => {
test('Clicking Vind inspiratie opens bottom sheet with correct context title', async ({ page }) => {
await page.goto('/');
await waitForApp(page);
await goToDagboek(page);
@ -67,16 +74,68 @@ test.describe('Vind inspiratie feature', () => {
await page.waitForTimeout(500);
// Should now be on tussendoortjes tab
const title = page.locator('#tussendoortjesTitle');
await expect(title).toBeVisible();
// Should now have the inspiratieSheet open (bottom sheet)
const sheet = page.locator('#inspiratieSheet');
await expect(sheet).toBeVisible();
await expect(sheet).not.toHaveClass(/hidden/);
// Title should start with "Inspiratie voor Tussendoor"
// Sheet title should start with "Inspiratie voor Tussendoor"
const title = page.locator('#inspiratieSheetTitle');
const titleText = await title.textContent();
expect(titleText).toContain('Inspiratie voor Tussendoor');
});
test('Vind inspiratie shows context header with date and moment name', async ({ page }) => {
test('Afsluiten X-knop sluit bottom sheet en keert terug naar Dagboek', async ({ page }) => {
await page.goto('/');
await waitForApp(page);
await goToDagboek(page);
// Open inspiratie sheet
const inspiratieBtn = page.locator('.vind-inspiratie-btn').first();
await inspiratieBtn.click();
await page.waitForTimeout(500);
// Sheet should be open
await expect(page.locator('#inspiratieSheet')).toBeVisible();
// Click X close button
await page.click('#inspiratieSheetClose');
await page.waitForTimeout(500);
// Sheet should be hidden
await expect(page.locator('#inspiratieSheet')).toHaveClass(/hidden/);
// Dagboek should still be active
await expect(page.locator('#navDagboek')).toHaveClass(/active/);
});
test('Afsluiten via backdrop-click sluit bottom sheet en keert terug naar Dagboek', async ({ page }) => {
await page.goto('/');
await waitForApp(page);
await goToDagboek(page);
// Open inspiratie sheet
const inspiratieBtn = page.locator('.vind-inspiratie-btn').first();
await inspiratieBtn.click();
await page.waitForTimeout(500);
// Sheet should be open
await expect(page.locator('#inspiratieSheet')).toBeVisible();
// Click the overlay backdrop (not the sheet content)
const overlay = page.locator('#inspiratieSheet');
// Click outside the modal-sheet area (the overlay itself)
await overlay.click({ position: { x: 10, y: 10 } });
await page.waitForTimeout(500);
// Sheet should be hidden
await expect(page.locator('#inspiratieSheet')).toHaveClass(/hidden/);
// Dagboek should still be active
await expect(page.locator('#navDagboek')).toHaveClass(/active/);
});
test('Vind inspiratie shows context header with date and moment name in sheet', async ({ page }) => {
await page.goto('/');
await waitForApp(page);
await goToDagboek(page);
@ -86,7 +145,7 @@ test.describe('Vind inspiratie feature', () => {
await inspiratieBtn.click();
await page.waitForTimeout(500);
// Context header should be visible
// Context header should be visible in the sheet
const contextEl = page.locator('#inspiratieContext');
await expect(contextEl).toBeVisible();
@ -97,7 +156,7 @@ test.describe('Vind inspiratie feature', () => {
expect(labelText).toContain('202'); // any year prefix in the date
});
test('Context header close button clears inspiration mode and shows normal title', async ({ page }) => {
test('Context header close button clears inspiration mode but keeps sheet open', async ({ page }) => {
await page.goto('/');
await waitForApp(page);
await goToDagboek(page);
@ -110,24 +169,29 @@ test.describe('Vind inspiratie feature', () => {
// Context is visible
await expect(page.locator('#inspiratieContext')).toBeVisible();
// Click close
// Click context close (× in the context banner, not the sheet X)
await page.click('#inspiratieContextClose');
await page.waitForTimeout(400);
// Title should now be "Tussendoortjes"
const titleText = await page.locator('#tussendoortjesTitle').textContent();
// Title should now show "Tussendoortjes" (inspiratie context cleared)
const titleText = await page.locator('#inspiratieSheetTitle').textContent();
expect(titleText).toBe('Tussendoortjes');
// Context should be hidden
await expect(page.locator('#inspiratieContext')).toBeHidden();
// Sheet should still be open (not closed)
await expect(page.locator('#inspiratieSheet')).toBeVisible();
await expect(page.locator('#inspiratieSheet')).not.toHaveClass(/hidden/);
});
test('Nieuwe suggesties button exists and generates suggestions', async ({ page }) => {
test('Nieuwe suggesties button exists in sheet and generates suggestions', async ({ page }) => {
await page.goto('/');
await waitForApp(page);
await goToTussendoortjes(page);
// Open sheet via first Vind inspiratie button to get suggestions context
await openInspiratieSheet(page);
// Button should be visible
// Button should be visible in the sheet
const nieuweBtn = page.locator('#nieuweSuggestiesBtn');
await expect(nieuweBtn).toBeVisible();
expect(await nieuweBtn.textContent()).toContain('Nieuwe suggesties');
@ -149,7 +213,7 @@ test.describe('Vind inspiratie feature', () => {
test('Nieuwe suggesties changes the set on consecutive presses', async ({ page }) => {
await page.goto('/');
await waitForApp(page);
await goToTussendoortjes(page);
await openInspiratieSheet(page);
const nieuweBtn = page.locator('#nieuweSuggestiesBtn');
@ -181,7 +245,7 @@ test.describe('Vind inspiratie feature', () => {
test('All shown suggestion totals are within the carb range (default 15-20g)', async ({ page }) => {
await page.goto('/');
await waitForApp(page);
await goToTussendoortjes(page);
await openInspiratieSheet(page);
// Click Nieuwe suggesties a few times to increase coverage
const nieuweBtn = page.locator('#nieuweSuggestiesBtn');
@ -216,11 +280,9 @@ test.describe('Vind inspiratie feature', () => {
expect(haptic).toBeTruthy();
}
await goToTussendoortjes(page);
// Open sheet and check Nieuwe suggesties button exists inside
await openInspiratieSheet(page);
// Check Nieuwe suggesties button has haptic
// (data-haptic is set globally via the event delegation on buttons,
// but the button itself should trigger haptic via the global setup)
const nieuweBtn = page.locator('#nieuweSuggestiesBtn');
await expect(nieuweBtn).toBeVisible();
});
@ -228,7 +290,7 @@ test.describe('Vind inspiratie feature', () => {
test('All shown ingredients have valid NEVO-n in database; kaneel uses actual DB value (56g/100g, not 0)', async ({ page }) => {
await page.goto('/');
await waitForApp(page);
await goToTussendoortjes(page);
await openInspiratieSheet(page);
const nieuweBtn = page.locator('#nieuweSuggestiesBtn');
@ -303,7 +365,7 @@ test.describe('Vind inspiratie feature', () => {
test('Generated recipe titles use friendly names, not raw NEVO display names', async ({ page }) => {
await page.goto('/');
await waitForApp(page);
await goToTussendoortjes(page);
await openInspiratieSheet(page);
// Known NEVO display-name fragments that should NEVER appear in a recipe title.
// These are raw NEVO aliases that get stripped by displayNaam() — NOT the friendly
@ -345,4 +407,63 @@ test.describe('Vind inspiratie feature', () => {
console.log(`OK: Checked ${titlesChecked} generated titles — no NEVO alias fragments found`);
}
});
test('Dagboek blijft actief na openen en sluiten van inspiratie-sheet', async ({ page }) => {
await page.goto('/');
await waitForApp(page);
await goToDagboek(page);
// Verify Dagboek is active initially
await expect(page.locator('#navDagboek')).toHaveClass(/active/);
// Open sheet
const inspiratieBtn = page.locator('.vind-inspiratie-btn').first();
await inspiratieBtn.click();
await page.waitForTimeout(500);
// Sheet is open
await expect(page.locator('#inspiratieSheet')).toBeVisible();
// Dagboek should still be active (no tab switch)
await expect(page.locator('#navDagboek')).toHaveClass(/active/);
// Close sheet via X
await page.click('#inspiratieSheetClose');
await page.waitForTimeout(500);
// Sheet is hidden
await expect(page.locator('#inspiratieSheet')).toHaveClass(/hidden/);
// Dagboek should still be active
await expect(page.locator('#navDagboek')).toHaveClass(/active/);
});
test('Geen navigatie naar tussendoortjes tab (tabTussendoortjes blijft leeg/verborgen)', async ({ page }) => {
await page.goto('/');
await waitForApp(page);
await goToDagboek(page);
// Open inspiratie sheet
const inspiratieBtn = page.locator('.vind-inspiratie-btn').first();
await inspiratieBtn.click();
await page.waitForTimeout(500);
// Sheet is visible
await expect(page.locator('#inspiratieSheet')).toBeVisible();
// tabTussendoortjes should either be hidden or empty
const tussendoortjesTab = page.locator('#tabTussendoortjes');
const classAttr = await tussendoortjesTab.getAttribute('class');
const isHidden = classAttr && classAttr.includes('hidden');
if (!isHidden) {
// If visible, it should be empty (no suggestieList in it, no content)
const suggestieCount = await tussendoortjesTab.locator('.suggestie-card').count();
expect(suggestieCount).toBe(0);
}
// Dagboek stays active
await expect(page.locator('#navDagboek')).toHaveClass(/active/);
});
});