UX: Save-meal checkbox-selectie — kies welke items in maaltijd gaan

- Checkboxes per item (2+ items) met counter in knoptekst
- Save-meal knop terug naar full-width footer stijl (.save-meal-footer-btn)
- suggestMealName vult nu meteen slimme naam in
- Worker hernoemde .save-meal-btn → .save-meal-footer-btn
- Tests geüpdatet: pre-filled name ipv empty, clear-input voor valideertest
- Coder profiel verwijderd (overbodig naast frontend/backend-eng)

39/39 tests groen
This commit is contained in:
cas 2026-07-24 21:00:50 +02:00
parent 45a32ca964
commit 0bceb721ca
3 changed files with 211 additions and 61 deletions

View file

@ -1246,28 +1246,62 @@ body {
opacity: 0.6;
}
/* ===== Save Meal Icon in Header ===== */
.save-meal-btn {
display: inline-flex;
/* ===== Save Meal — checkbox + footer ===== */
.item-checkbox-wrapper {
display: none;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
font-size: 14px;
color: var(--primary-dark);
flex-shrink: 0;
}
.eetmoment-card.has-multi-items .item-checkbox-wrapper {
display: inline-flex;
}
.item-checkbox {
flex-shrink: 0;
width: 18px;
height: 18px;
accent-color: var(--primary);
cursor: pointer;
border-radius: 50%;
transition: background 0.15s, transform 0.15s;
user-select: none;
-webkit-user-select: none;
margin: 0 6px 0 0;
}
.save-meal-btn:hover {
/* Meal-group checkbox: on the header line */
.eetmoment-meal-group .item-checkbox {
margin: 0 6px 0 -2px;
}
.save-meal-footer {
padding: 4px 14px 10px;
}
.save-meal-footer-btn {
display: block;
width: 100%;
padding: 10px 16px;
background: var(--primary-light);
transform: scale(1.1);
border: 1px solid var(--primary);
border-radius: var(--radius-sm);
color: var(--primary-dark);
font-size: var(--font-sm);
font-weight: 600;
cursor: pointer;
transition: background 0.15s, opacity 0.15s;
text-align: center;
font-family: var(--body-font);
min-height: var(--touch-min);
line-height: 1.3;
}
.save-meal-btn:active {
background: var(--accent);
transform: scale(0.95);
.save-meal-footer-btn:hover {
background: var(--primary);
color: var(--white);
}
.save-meal-footer-btn:active {
background: var(--primary-dark);
color: var(--white);
}
.save-meal-footer-btn:disabled {
opacity: 0.4;
cursor: default;
background: var(--primary-lighter);
color: var(--primary-dark);
border-color: var(--border);
}
/* ===== Diary — nested meal groups ===== */
@ -3322,6 +3356,7 @@ button:active, .btn:active, .meal-card-main:active, .dagboek-item:active, .food-
return `
<li class="eetmoment-meal-group">
<div class="eetmoment-meal-header" data-action="toggle-meal" data-entry="${i}">
<span class="item-checkbox-wrapper"><input type="checkbox" class="item-checkbox" data-index="${i}" checked></span>
<span class="eetmoment-meal-name">🍽 ${escapeHtml(entry.mealNaam)}</span>
<span class="eetmoment-meal-kh">${formatKh(mealKh)} g</span>
<span style="font-size:0.7rem;color:var(--text-muted);"><i class="fas fa-chevron-up"></i></span>
@ -3349,6 +3384,9 @@ button:active, .btn:active, .meal-card-main:active, .dagboek-item:active, .food-
const itemOverLimit = overLimit && itemKh > 0;
return `
<li class="eetmoment-item${itemOverLimit ? ' over-limit' : ''}">
<span class="item-checkbox-wrapper">
<input type="checkbox" class="item-checkbox" data-index="${i}" checked>
</span>
<div class="eetmoment-item-info">
<div class="eetmoment-item-name">${escapeHtml(displayNaam(entry.item.naam))}</div>
<div class="eetmoment-item-portion">
@ -3372,21 +3410,26 @@ button:active, .btn:active, .meal-card-main:active, .dagboek-item:active, .food-
const inlineGlucoseHtml = glucoseMoment ? renderInlineGlucoseItem(glucoseMoment) : '';
return `
<li class="eetmoment-card${cardOverLimit ? ' over-limit' : ''}" style="--i:${mi}">
<li class="eetmoment-card ${cardOverLimit ? 'over-limit' : ''}${items.length > 1 ? ' has-multi-items' : ''}" style="--i:${mi}">
<div class="eetmoment-header" data-moment="${moment.id}">
<div class="eetmoment-header-left">
<span class="eetmoment-icon">${moment.icon}</span>
<span class="eetmoment-name">${moment.name}</span>
</div>
<span class="eetmoment-kh">${roundedKh > 0 ? formatKh(roundedKh) + ' g' : '—'}</span>
${items.length > 0 ? '<span class=\"save-meal-btn\" data-moment=\"' + moment.id + '\"><i class=\"fas fa-star\"></i></span>' : ''}
<span style="font-size:0.8rem;color:var(--text-muted)">${items.length > 0 ? '<i class=\"fas fa-chevron-down\"></i>' : ''}</span>
<span style="font-size:0.8rem;color:var(--text-muted)">${items.length > 0 ? '<i class="fas fa-chevron-down"></i>' : ''}</span>
</div>
${items.length > 0 ? `<ul class="eetmoment-items">${itemsHtml}</ul>` : ''}
${inlineGlucoseHtml}
<div class="eetmoment-add-btn" data-moment="${moment.id}">
<span>+</span> Voedingsmiddel toevoegen
</div>
${items.length > 0 ? `
<div class="save-meal-footer" data-moment="${moment.id}">
<button class="save-meal-footer-btn" data-moment="${moment.id}">
<i class="fas fa-star"></i> Opslaan ${items.length > 1 ? items.length + ' geselecteerde items' : '1 item'} als maaltijd
</button>
</div>` : ''}
</li>
`;
}).join('');
@ -3443,18 +3486,43 @@ button:active, .btn:active, .meal-card-main:active, .dagboek-item:active, .food-
});
});
// Save meal buttons
listEl.querySelectorAll('.save-meal-btn').forEach(btn => {
// Save meal footer button
listEl.querySelectorAll('.save-meal-footer-btn').forEach(btn => {
btn.addEventListener('click', (e) => {
e.stopPropagation();
saveCurrentMomentAsMeal(btn.dataset.moment);
const momentId = btn.dataset.moment;
const card = btn.closest('.eetmoment-card');
// Collect selected indices from checked checkboxes
const checked = card.querySelectorAll('.item-checkbox:checked');
const selectedIndices = Array.from(checked).map(cb => parseInt(cb.dataset.index));
saveCurrentMomentAsMeal(momentId, selectedIndices);
});
});
// Checkbox change → update save button text
listEl.querySelectorAll('.item-checkbox').forEach(cb => {
cb.addEventListener('change', () => {
const card = cb.closest('.eetmoment-card');
if (!card) return;
const footer = card.querySelector('.save-meal-footer');
if (!footer) return;
const btn = footer.querySelector('.save-meal-footer-btn');
const checked = card.querySelectorAll('.item-checkbox:checked').length;
const total = card.querySelectorAll('.item-checkbox').length;
if (checked === 0) {
btn.disabled = true;
btn.textContent = '⭐ 0 geselecteerde items als maaltijd';
} else {
btn.disabled = false;
btn.textContent = `⭐ Opslaan ${checked} geselecteerde ${checked === 1 ? 'item' : 'items'} als maaltijd`;
}
});
});
// Meal group toggle expand/collapse
listEl.querySelectorAll('.meal-group-header').forEach(header => {
header.addEventListener('click', (e) => {
if (e.target.closest('.meal-group-edit, .eetmoment-remove')) return;
if (e.target.closest('.meal-group-edit, .eetmoment-remove, .item-checkbox')) return;
header.classList.toggle('expanded');
});
});
@ -4286,13 +4354,19 @@ button:active, .btn:active, .meal-card-main:active, .dagboek-item:active, .food-
return r;
}
function saveCurrentMomentAsMeal(momentId) {
function saveCurrentMomentAsMeal(momentId, selectedIndices) {
const dayData = getOrCreateDay(activeDate);
const rawItems = dayData[momentId] || [];
if (rawItems.length === 0) return;
// Filter to only selected items (by index in rawItems)
const filteredRaw = selectedIndices
? rawItems.filter((_, i) => selectedIndices.includes(i))
: rawItems;
if (filteredRaw.length === 0) return;
// Flatten: unwrap meal-group entries into uniform {item, portie} items
const items = flattenEntries(rawItems);
const items = flattenEntries(filteredRaw);
// Calculate total kh
let khTotaal = 0;
@ -4325,7 +4399,7 @@ button:active, .btn:active, .meal-card-main:active, .dagboek-item:active, .food-
</div>
<div class="modal-body">
<label class="meal-name-label">Maaltijdnaam</label>
<input type="text" class="meal-name-input" id="mealNameInput" value="" placeholder="Geef een naam aan deze maaltijd..." autofocus>
<input type="text" class="meal-name-input" id="mealNameInput" value="${escapeHtml(defaultName)}" placeholder="Geef een naam aan deze maaltijd..." autofocus>
<div class="meal-name-items-preview">${previewText}</div>
<div class="meal-name-actions">
<button class="meal-name-cancel" id="mealNameCancel">Annuleren</button>

View file

@ -1246,28 +1246,62 @@ body {
opacity: 0.6;
}
/* ===== Save Meal Icon in Header ===== */
.save-meal-btn {
display: inline-flex;
/* ===== Save Meal — checkbox + footer ===== */
.item-checkbox-wrapper {
display: none;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
font-size: 14px;
color: var(--primary-dark);
flex-shrink: 0;
}
.eetmoment-card.has-multi-items .item-checkbox-wrapper {
display: inline-flex;
}
.item-checkbox {
flex-shrink: 0;
width: 18px;
height: 18px;
accent-color: var(--primary);
cursor: pointer;
border-radius: 50%;
transition: background 0.15s, transform 0.15s;
user-select: none;
-webkit-user-select: none;
margin: 0 6px 0 0;
}
.save-meal-btn:hover {
/* Meal-group checkbox: on the header line */
.eetmoment-meal-group .item-checkbox {
margin: 0 6px 0 -2px;
}
.save-meal-footer {
padding: 4px 14px 10px;
}
.save-meal-footer-btn {
display: block;
width: 100%;
padding: 10px 16px;
background: var(--primary-light);
transform: scale(1.1);
border: 1px solid var(--primary);
border-radius: var(--radius-sm);
color: var(--primary-dark);
font-size: var(--font-sm);
font-weight: 600;
cursor: pointer;
transition: background 0.15s, opacity 0.15s;
text-align: center;
font-family: var(--body-font);
min-height: var(--touch-min);
line-height: 1.3;
}
.save-meal-btn:active {
background: var(--accent);
transform: scale(0.95);
.save-meal-footer-btn:hover {
background: var(--primary);
color: var(--white);
}
.save-meal-footer-btn:active {
background: var(--primary-dark);
color: var(--white);
}
.save-meal-footer-btn:disabled {
opacity: 0.4;
cursor: default;
background: var(--primary-lighter);
color: var(--primary-dark);
border-color: var(--border);
}
/* ===== Diary — nested meal groups ===== */
@ -3322,6 +3356,7 @@ button:active, .btn:active, .meal-card-main:active, .dagboek-item:active, .food-
return `
<li class="eetmoment-meal-group">
<div class="eetmoment-meal-header" data-action="toggle-meal" data-entry="${i}">
<span class="item-checkbox-wrapper"><input type="checkbox" class="item-checkbox" data-index="${i}" checked></span>
<span class="eetmoment-meal-name">🍽 ${escapeHtml(entry.mealNaam)}</span>
<span class="eetmoment-meal-kh">${formatKh(mealKh)} g</span>
<span style="font-size:0.7rem;color:var(--text-muted);"><i class="fas fa-chevron-up"></i></span>
@ -3349,6 +3384,9 @@ button:active, .btn:active, .meal-card-main:active, .dagboek-item:active, .food-
const itemOverLimit = overLimit && itemKh > 0;
return `
<li class="eetmoment-item${itemOverLimit ? ' over-limit' : ''}">
<span class="item-checkbox-wrapper">
<input type="checkbox" class="item-checkbox" data-index="${i}" checked>
</span>
<div class="eetmoment-item-info">
<div class="eetmoment-item-name">${escapeHtml(displayNaam(entry.item.naam))}</div>
<div class="eetmoment-item-portion">
@ -3372,21 +3410,26 @@ button:active, .btn:active, .meal-card-main:active, .dagboek-item:active, .food-
const inlineGlucoseHtml = glucoseMoment ? renderInlineGlucoseItem(glucoseMoment) : '';
return `
<li class="eetmoment-card${cardOverLimit ? ' over-limit' : ''}" style="--i:${mi}">
<li class="eetmoment-card ${cardOverLimit ? 'over-limit' : ''}${items.length > 1 ? ' has-multi-items' : ''}" style="--i:${mi}">
<div class="eetmoment-header" data-moment="${moment.id}">
<div class="eetmoment-header-left">
<span class="eetmoment-icon">${moment.icon}</span>
<span class="eetmoment-name">${moment.name}</span>
</div>
<span class="eetmoment-kh">${roundedKh > 0 ? formatKh(roundedKh) + ' g' : '—'}</span>
${items.length > 0 ? '<span class=\"save-meal-btn\" data-moment=\"' + moment.id + '\"><i class=\"fas fa-star\"></i></span>' : ''}
<span style="font-size:0.8rem;color:var(--text-muted)">${items.length > 0 ? '<i class=\"fas fa-chevron-down\"></i>' : ''}</span>
<span style="font-size:0.8rem;color:var(--text-muted)">${items.length > 0 ? '<i class="fas fa-chevron-down"></i>' : ''}</span>
</div>
${items.length > 0 ? `<ul class="eetmoment-items">${itemsHtml}</ul>` : ''}
${inlineGlucoseHtml}
<div class="eetmoment-add-btn" data-moment="${moment.id}">
<span>+</span> Voedingsmiddel toevoegen
</div>
${items.length > 0 ? `
<div class="save-meal-footer" data-moment="${moment.id}">
<button class="save-meal-footer-btn" data-moment="${moment.id}">
<i class="fas fa-star"></i> Opslaan ${items.length > 1 ? items.length + ' geselecteerde items' : '1 item'} als maaltijd
</button>
</div>` : ''}
</li>
`;
}).join('');
@ -3443,18 +3486,43 @@ button:active, .btn:active, .meal-card-main:active, .dagboek-item:active, .food-
});
});
// Save meal buttons
listEl.querySelectorAll('.save-meal-btn').forEach(btn => {
// Save meal footer button
listEl.querySelectorAll('.save-meal-footer-btn').forEach(btn => {
btn.addEventListener('click', (e) => {
e.stopPropagation();
saveCurrentMomentAsMeal(btn.dataset.moment);
const momentId = btn.dataset.moment;
const card = btn.closest('.eetmoment-card');
// Collect selected indices from checked checkboxes
const checked = card.querySelectorAll('.item-checkbox:checked');
const selectedIndices = Array.from(checked).map(cb => parseInt(cb.dataset.index));
saveCurrentMomentAsMeal(momentId, selectedIndices);
});
});
// Checkbox change → update save button text
listEl.querySelectorAll('.item-checkbox').forEach(cb => {
cb.addEventListener('change', () => {
const card = cb.closest('.eetmoment-card');
if (!card) return;
const footer = card.querySelector('.save-meal-footer');
if (!footer) return;
const btn = footer.querySelector('.save-meal-footer-btn');
const checked = card.querySelectorAll('.item-checkbox:checked').length;
const total = card.querySelectorAll('.item-checkbox').length;
if (checked === 0) {
btn.disabled = true;
btn.textContent = '⭐ 0 geselecteerde items als maaltijd';
} else {
btn.disabled = false;
btn.textContent = `⭐ Opslaan ${checked} geselecteerde ${checked === 1 ? 'item' : 'items'} als maaltijd`;
}
});
});
// Meal group toggle expand/collapse
listEl.querySelectorAll('.meal-group-header').forEach(header => {
header.addEventListener('click', (e) => {
if (e.target.closest('.meal-group-edit, .eetmoment-remove')) return;
if (e.target.closest('.meal-group-edit, .eetmoment-remove, .item-checkbox')) return;
header.classList.toggle('expanded');
});
});
@ -4286,13 +4354,19 @@ button:active, .btn:active, .meal-card-main:active, .dagboek-item:active, .food-
return r;
}
function saveCurrentMomentAsMeal(momentId) {
function saveCurrentMomentAsMeal(momentId, selectedIndices) {
const dayData = getOrCreateDay(activeDate);
const rawItems = dayData[momentId] || [];
if (rawItems.length === 0) return;
// Filter to only selected items (by index in rawItems)
const filteredRaw = selectedIndices
? rawItems.filter((_, i) => selectedIndices.includes(i))
: rawItems;
if (filteredRaw.length === 0) return;
// Flatten: unwrap meal-group entries into uniform {item, portie} items
const items = flattenEntries(rawItems);
const items = flattenEntries(filteredRaw);
// Calculate total kh
let khTotaal = 0;
@ -4325,7 +4399,7 @@ button:active, .btn:active, .meal-card-main:active, .dagboek-item:active, .food-
</div>
<div class="modal-body">
<label class="meal-name-label">Maaltijdnaam</label>
<input type="text" class="meal-name-input" id="mealNameInput" value="" placeholder="Geef een naam aan deze maaltijd..." autofocus>
<input type="text" class="meal-name-input" id="mealNameInput" value="${escapeHtml(defaultName)}" placeholder="Geef een naam aan deze maaltijd..." autofocus>
<div class="meal-name-items-preview">${previewText}</div>
<div class="meal-name-actions">
<button class="meal-name-cancel" id="mealNameCancel">Annuleren</button>

View file

@ -192,7 +192,7 @@ test.describe('Karby Eetdagboek', () => {
// ---------- Maaltijden ----------
test('meal save: name input is empty by default', async ({ page }) => {
test('meal save: name input pre-filled with smart suggestion', async ({ page }) => {
await page.goto(BASE);
await page.locator('.search-input').fill('brood');
await page.waitForTimeout(800);
@ -203,13 +203,14 @@ test.describe('Karby Eetdagboek', () => {
// Item toevoegen via addMealSubmit → sluit modal + gaat naar dagboek
await page.locator('#addMealSubmit').click();
await page.waitForTimeout(800);
const saveBtn = page.locator('.save-meal-btn');
const saveBtn = page.locator('.save-meal-footer-btn');
await expect(saveBtn.first()).toBeVisible({ timeout: 5000 });
await saveBtn.first().click();
await page.waitForTimeout(500);
const nameInput = page.locator('#mealNameInput');
await expect(nameInput).toBeVisible({ timeout: 5000 });
await expect(nameInput).toHaveValue('');
// Smart suggestion should pre-fill with a non-empty name
await expect(nameInput).not.toHaveValue('');
await page.locator('#mealNameCancel').click();
await page.waitForTimeout(300);
});
@ -224,13 +225,14 @@ test.describe('Karby Eetdagboek', () => {
await page.waitForTimeout(500);
await page.locator('#addMealSubmit').click();
await page.waitForTimeout(800);
const saveBtn = page.locator('.save-meal-btn');
const saveBtn = page.locator('.save-meal-footer-btn');
await expect(saveBtn.first()).toBeVisible({ timeout: 5000 });
await saveBtn.first().click();
await page.waitForTimeout(500);
const nameInput = page.locator('#mealNameInput');
await expect(nameInput).toBeVisible({ timeout: 5000 });
await expect(nameInput).toHaveValue('');
// Clear the smart-suggested name to test empty validation
await nameInput.fill('');
// Opslaan zonder naam → validatiefout
await page.locator('#mealNameSave').click();
await page.waitForTimeout(300);
@ -301,7 +303,7 @@ test.describe('Karby Eetdagboek', () => {
await page.locator('#addMealSubmit').click();
await page.waitForTimeout(800);
// Stap 2: Sla op als maaltijd
const saveBtn = page.locator('.save-meal-btn');
const saveBtn = page.locator('.save-meal-footer-btn');
await expect(saveBtn.first()).toBeVisible({ timeout: 5000 });
await saveBtn.first().click();
await page.waitForTimeout(500);
@ -370,7 +372,7 @@ test.describe('Karby Eetdagboek', () => {
// Navigate to diary, save as meal
await page.locator('#navDagboek').click();
await page.waitForTimeout(600);
const saveBtn = page.locator('.save-meal-btn');
const saveBtn = page.locator('.save-meal-footer-btn');
if (await saveBtn.count() === 0) { test.skip(); return; }
await saveBtn.first().click();
await page.waitForTimeout(500);