diff --git a/index.html b/index.html
index 79bba48..33b64d6 100644
--- a/index.html
+++ b/index.html
@@ -465,6 +465,161 @@ body {
background: var(--progress-red);
}
+/* ===== Glucose Section ===== */
+.glucose-section {
+ background: var(--white);
+ border-radius: var(--radius);
+ box-shadow: var(--shadow-sm);
+ padding: 14px 16px;
+ margin-bottom: 10px;
+}
+
+.glucose-header {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ margin-bottom: 12px;
+}
+
+.glucose-icon {
+ font-size: 1.1rem;
+ color: var(--progress-red);
+}
+
+.glucose-title {
+ font-weight: 700;
+ font-size: var(--font-md);
+ color: var(--text);
+}
+
+.glucose-unit-label {
+ font-size: var(--font-xs);
+ color: var(--text-muted);
+ margin-left: 2px;
+}
+
+/* Grid: 4 columns on wide, 2×2 on narrow */
+.glucose-grid {
+ display: grid;
+ grid-template-columns: repeat(4, 1fr);
+ gap: 8px;
+}
+
+@media (max-width: 480px) {
+ .glucose-grid {
+ grid-template-columns: repeat(2, 1fr);
+ }
+}
+
+.glucose-item {
+ background: var(--bg);
+ border-radius: var(--radius-sm);
+ padding: 10px 8px;
+ text-align: center;
+ border: 1px solid var(--border);
+ transition: border-color 0.2s, background 0.2s;
+}
+
+.glucose-item.glucose-good {
+ border-color: #28a745;
+ background: #f0fdf4;
+}
+
+.glucose-item.glucose-bad {
+ border-color: var(--progress-red);
+ background: #fef2f2;
+}
+
+.glucose-item.glucose-empty {
+ border-color: var(--border);
+ background: var(--bg);
+}
+
+.glucose-item-label {
+ font-size: var(--font-xs);
+ font-weight: 600;
+ color: var(--text-secondary);
+ margin-bottom: 2px;
+ line-height: 1.3;
+}
+
+.glucose-item-range {
+ font-size: 0.65rem;
+ color: var(--text-muted);
+ margin-bottom: 8px;
+}
+
+.glucose-input-wrapper {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: 2px;
+ margin-bottom: 6px;
+}
+
+.glucose-input {
+ width: 58px;
+ padding: 4px 4px;
+ border: 2px solid var(--border);
+ border-radius: var(--radius-xs);
+ font-size: var(--font-md);
+ font-weight: 600;
+ text-align: center;
+ background: var(--white);
+ color: var(--text);
+ transition: border-color 0.2s, box-shadow 0.2s;
+ outline: none;
+ /* Hide spinners */
+ -moz-appearance: textfield;
+}
+
+.glucose-input::-webkit-outer-spin-button,
+.glucose-input::-webkit-inner-spin-button {
+ -webkit-appearance: none;
+ margin: 0;
+}
+
+.glucose-input:focus {
+ border-color: var(--accent);
+ box-shadow: 0 0 0 3px rgba(250,232,146,0.25);
+}
+
+.glucose-unit {
+ font-size: var(--font-xs);
+ color: var(--text-muted);
+}
+
+.glucose-status {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: 3px;
+ font-size: var(--font-xs);
+ line-height: 1.2;
+ min-height: 20px;
+}
+
+.glucose-status-icon {
+ font-size: 0.85rem;
+}
+
+.glucose-status-text {
+ font-size: 0.65rem;
+ font-weight: 500;
+}
+
+.glucose-good .glucose-status-text {
+ color: #15803d;
+}
+
+.glucose-bad .glucose-status-text {
+ color: var(--progress-red);
+}
+
+.glucose-empty .glucose-status-text {
+ color: var(--text-muted);
+}
+
/* Warning text */
.carb-warning {
display: none;
@@ -2103,6 +2258,16 @@ button:active, .btn:active, .meal-card-main:active, .dagboek-item:active, .food-
+
+
+
@@ -2288,6 +2453,14 @@ button:active, .btn:active, .meal-card-main:active, .dagboek-item:active, .food-
return CATEGORY_MAP[itemCat] || 'Overig';
}
+ // ===== Prikmomenten voor glucose =====
+ const PRICKMOMENTEN = [
+ { key: 'nuchter', label: 'Nuchter (voor ontbijt)', targetMin: 0, targetMax: 5.4 },
+ { key: 'naOntbijt', label: '2 uur na ontbijt', targetMin: 5.4, targetMax: 6.7 },
+ { key: 'naLunch', label: '2 uur na lunch', targetMin: 5.4, targetMax: 6.7 },
+ { key: 'naAvondeten', label: '2 uur na avondeten', targetMin: 5.4, targetMax: 6.7 }
+ ];
+
// ===== Standaard portiegroottes per categorie =====
// Gebaseerd op Voedingscentrum / NEVO richtlijnen
/** Drie praktische porties per categorie: een intuïtieve eenheid + 100g + 50g */
@@ -2369,6 +2542,7 @@ 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 }]
+ let glucose = {}; // { '2026-07-24': { nuchter: 5.1, naOntbijt: 6.2, naLunch: null, naAvondeten: null } }
const SEARCH_PAGE_SIZE = 50;
let searchVisibleCount = SEARCH_PAGE_SIZE;
let searchObserver = null;
@@ -2461,6 +2635,8 @@ button:active, .btn:active, .meal-card-main:active, .dagboek-item:active, .food-
getOrCreateDay(activeDate);
// Load maaltijden
loadMaaltijdenFromStorage();
+ // Load glucose data
+ loadGlucoseFromStorage();
}
function saveMaaltijden() {
@@ -2482,6 +2658,139 @@ button:active, .btn:active, .meal-card-main:active, .dagboek-item:active, .food-
}
}
+ // ===== Glucose =====
+ function isGlucoseInRange(value, moment) {
+ if (value === null || value === undefined || value === '') return null;
+ const m = PRICKMOMENTEN.find(p => p.key === moment);
+ if (!m) return null;
+ // Nuchter (fasting): only upper bound — < targetMax is good
+ if (moment === 'nuchter') {
+ return value < m.targetMax ? 'good' : 'high';
+ }
+ // Post-meal: range 5.4 – 6.7 (inclusive)
+ if (value < m.targetMin) return 'low';
+ if (value > m.targetMax) return 'high';
+ return 'good';
+ }
+
+ function saveGlucoseToStorage() {
+ try {
+ localStorage.setItem('koolhydraat-glucose', JSON.stringify(glucose));
+ } catch (e) {
+ // Storage full — silently ignore
+ }
+ }
+
+ function loadGlucoseFromStorage() {
+ try {
+ const raw = localStorage.getItem('koolhydraat-glucose');
+ if (raw) {
+ glucose = JSON.parse(raw);
+ }
+ } catch (e) {
+ glucose = {};
+ }
+ // Ensure today's entry exists
+ if (!glucose[activeDate]) {
+ glucose[activeDate] = {};
+ PRICKMOMENTEN.forEach(m => { glucose[activeDate][m.key] = null; });
+ }
+ }
+
+ function ensureGlucoseDay(dateStr) {
+ if (!glucose[dateStr]) {
+ glucose[dateStr] = {};
+ PRICKMOMENTEN.forEach(m => { glucose[dateStr][m.key] = null; });
+ }
+ }
+
+ function renderGlucoseSection() {
+ const grid = document.getElementById('glucoseGrid');
+ if (!grid) return;
+
+ ensureGlucoseDay(activeDate);
+
+ grid.innerHTML = PRICKMOMENTEN.map(m => {
+ const value = glucose[activeDate][m.key];
+ const status = isGlucoseInRange(value, m.key);
+
+ let statusIcon, statusClass, statusText;
+ if (value === null || value === undefined || value === '') {
+ statusIcon = '⏹️';
+ statusClass = 'glucose-empty';
+ statusText = 'Nog meten';
+ } else if (status === 'good') {
+ statusIcon = '✅';
+ statusClass = 'glucose-good';
+ statusText = 'Binnen range';
+ } else if (status === 'high') {
+ statusIcon = '🔴';
+ statusClass = 'glucose-bad';
+ statusText = 'Te hoog';
+ } else {
+ statusIcon = '🔴';
+ statusClass = 'glucose-bad';
+ statusText = 'Te laag';
+ }
+
+ const displayValue = (value !== null && value !== undefined) ? value : '';
+
+ // Short label: remove parenthetical and "2 uur na" prefix
+ let shortLabel = m.label;
+ shortLabel = shortLabel.replace(/\([^)]*\)/g, '').trim();
+ shortLabel = shortLabel.replace(/^2 uur na /i, '').trim();
+ // Capitalize first letter
+ shortLabel = shortLabel.charAt(0).toUpperCase() + shortLabel.slice(1);
+
+ return `
+
+
${shortLabel}
+
${m.key === 'nuchter' ? '< ' + m.targetMax : m.targetMin + '–' + m.targetMax}
+
+
+ mmol/L
+
+
+ ${statusIcon}
+ ${statusText}
+
+
+ `;
+ }).join('');
+
+ // Attach save-on-blur handlers
+ grid.querySelectorAll('.glucose-input').forEach(input => {
+ input.addEventListener('blur', () => {
+ const moment = input.dataset.moment;
+ const val = input.value.trim() === '' ? null : parseFloat(input.value);
+
+ // Validate
+ if (val !== null && (isNaN(val) || val < 0 || val > 30)) {
+ input.value = glucose[activeDate][moment] !== null && glucose[activeDate][moment] !== undefined ? glucose[activeDate][moment] : '';
+ return;
+ }
+
+ if (glucose[activeDate][moment] !== val) {
+ glucose[activeDate][moment] = val;
+ saveGlucoseToStorage();
+ renderGlucoseSection();
+ }
+ });
+
+ // Enter key also saves
+ input.addEventListener('keydown', (e) => {
+ if (e.key === 'Enter') {
+ input.blur();
+ }
+ });
+ });
+ }
+
// ===== Daily Carb Limit =====
const DEFAULT_CARB_LIMIT = 175; // grams per day
@@ -2875,6 +3184,9 @@ button:active, .btn:active, .meal-card-main:active, .dagboek-item:active, .food-
// Progress bar
renderProgressBar(totaal);
+ // Glucose section
+ renderGlucoseSection();
+
// Eating moments
const listEl = document.getElementById('eetmomentList');
listEl.innerHTML = EETMOMENTEN.map((moment, mi) => {
@@ -3664,12 +3976,14 @@ button:active, .btn:active, .meal-card-main:active, .dagboek-item:active, .food-
current.setDate(current.getDate() + direction);
activeDate = formatDate(current);
getOrCreateDay(activeDate);
+ ensureGlucoseDay(activeDate);
renderDagboek();
}
function goToToday() {
activeDate = formatDate(new Date());
getOrCreateDay(activeDate);
+ ensureGlucoseDay(activeDate);
renderDagboek();
}
diff --git a/test-results/.last-run.json b/test-results/.last-run.json
index cbcc1fb..6f61ff9 100644
--- a/test-results/.last-run.json
+++ b/test-results/.last-run.json
@@ -1,4 +1,6 @@
{
- "status": "passed",
- "failedTests": []
+ "status": "failed",
+ "failedTests": [
+ "ed088d4b1ffb8f06e682-69d589b2ce0725f7c6b3"
+ ]
}
\ No newline at end of file
diff --git a/tests/glucose-e2e.spec.js b/tests/glucose-e2e.spec.js
new file mode 100644
index 0000000..b8b02d9
--- /dev/null
+++ b/tests/glucose-e2e.spec.js
@@ -0,0 +1,280 @@
+const { test, expect } = require('@playwright/test');
+
+const BASE = 'https://eetdagboek.vantwout.dev';
+
+/**
+ * Helpers for glucose tests
+ */
+
+/** Navigate to diary tab and wait for render */
+async function openDiary(page) {
+ await page.locator('#navDagboek').click();
+ await page.waitForTimeout(600);
+ // Wait for glucose section to render
+ await expect(page.locator('.glucose-section')).toBeVisible({ timeout: 5000 });
+}
+
+/** Fill a glucose input and trigger save-on-blur */
+async function setGlucoseValue(page, momentKey, value) {
+ const input = page.locator(`input.glucose-input[data-moment="${momentKey}"]`);
+ await input.fill(String(value));
+ // Trigger blur — saves to localStorage and re-renders the grid
+ await input.evaluate(el => el.blur());
+ // Wait for re-render to complete
+ await page.waitForTimeout(400);
+}
+
+/** Get the parent .glucose-item for a given moment */
+function glucoseItem(page, momentKey) {
+ return page.locator(`input.glucose-input[data-moment="${momentKey}"]`).locator('..').locator('..');
+}
+
+/** Get status text element for a given moment */
+function statusText(page, momentKey) {
+ return page.locator(`input.glucose-input[data-moment="${momentKey}"]`)
+ .locator('..') // .glucose-input-wrapper
+ .locator('..') // .glucose-item
+ .locator('.glucose-status-text');
+}
+
+// =============================================================================
+
+test.describe('Glucose-metingen — invoer, validatie, kleurcodering, persistentie', () => {
+
+ // ---- 1. Glucose-sectie zichtbaar ----
+
+ test('1. glucose section has 4 input fields with correct labels and target ranges', async ({ page }) => {
+ await page.goto(BASE);
+ await openDiary(page);
+
+ // Section structure
+ await expect(page.locator('.glucose-section')).toBeVisible();
+ await expect(page.locator('.glucose-title')).toHaveText('Glucose');
+ await expect(page.locator('.glucose-unit-label')).toHaveText('mmol/L');
+ await expect(page.locator('.glucose-header .glucose-icon i')).toBeVisible();
+
+ // 4 grid items
+ const items = page.locator('.glucose-item');
+ await expect(items).toHaveCount(4);
+
+ // Labels
+ await expect(items.nth(0).locator('.glucose-item-label')).toHaveText('Nuchter');
+ await expect(items.nth(1).locator('.glucose-item-label')).toHaveText('Ontbijt');
+ await expect(items.nth(2).locator('.glucose-item-label')).toHaveText('Lunch');
+ await expect(items.nth(3).locator('.glucose-item-label')).toHaveText('Avondeten');
+
+ // Target ranges
+ await expect(items.nth(0).locator('.glucose-item-range')).toHaveText('< 5.4');
+ await expect(items.nth(1).locator('.glucose-item-range')).toHaveText('5.4\u20136.7');
+ await expect(items.nth(2).locator('.glucose-item-range')).toHaveText('5.4\u20136.7');
+ await expect(items.nth(3).locator('.glucose-item-range')).toHaveText('5.4\u20136.7');
+
+ // Input attributes
+ const moments = ['nuchter', 'naOntbijt', 'naLunch', 'naAvondeten'];
+ for (let i = 0; i < 4; i++) {
+ const input = items.nth(i).locator('.glucose-input');
+ await expect(input).toHaveAttribute('data-moment', moments[i]);
+ await expect(input).toHaveAttribute('step', '0.1');
+ await expect(input).toHaveAttribute('min', '0');
+ await expect(input).toHaveAttribute('max', '30');
+ await expect(input).toHaveAttribute('inputmode', 'decimal');
+ await expect(input).toHaveAttribute('placeholder', '\u2014');
+ }
+ });
+
+ // ---- 2. Nuchter binnen range (groen) ----
+
+ test('2. nuchter waarde 5.1 (binnen < 5.4) toont groene indicator', async ({ page }) => {
+ await page.goto(BASE);
+ await openDiary(page);
+
+ await setGlucoseValue(page, 'nuchter', 5.1);
+
+ // Check the item class and status
+ const item = glucoseItem(page, 'nuchter');
+ await expect(item).toHaveClass(/glucose-good/);
+ await expect(item.locator('.glucose-status-icon')).toContainText('\u2705'); // ✅
+ await expect(item.locator('.glucose-status-text')).toHaveText('Binnen range');
+ });
+
+ // ---- 3. Nuchter ≥ 5.4 (rood) ----
+
+ test('3. nuchter waarde 5.4 (te hoog, ≥ 5.4) toont rode indicator', async ({ page }) => {
+ await page.goto(BASE);
+ await openDiary(page);
+
+ await setGlucoseValue(page, 'nuchter', 5.4);
+
+ const item = glucoseItem(page, 'nuchter');
+ await expect(item).toHaveClass(/glucose-bad/);
+ await expect(item.locator('.glucose-status-text')).toHaveText('Te hoog');
+ });
+
+ // ---- 4. Postprandiaal < 5.4 (rood/te laag) ----
+
+ test('4. naOntbijt waarde 5.3 (te laag, < 5.4) toont rode indicator', async ({ page }) => {
+ await page.goto(BASE);
+ await openDiary(page);
+
+ await setGlucoseValue(page, 'naOntbijt', 5.3);
+
+ const item = glucoseItem(page, 'naOntbijt');
+ await expect(item).toHaveClass(/glucose-bad/);
+ await expect(item.locator('.glucose-status-text')).toHaveText('Te laag');
+ });
+
+ // ---- 5. Postprandiaal binnen range (groen) ----
+
+ test('5. naOntbijt waarde 6.0 (binnen 5.4\u20136.7) toont groene indicator', async ({ page }) => {
+ await page.goto(BASE);
+ await openDiary(page);
+
+ await setGlucoseValue(page, 'naOntbijt', 6.0);
+
+ const item = glucoseItem(page, 'naOntbijt');
+ await expect(item).toHaveClass(/glucose-good/);
+ await expect(item.locator('.glucose-status-text')).toHaveText('Binnen range');
+ });
+
+ // ---- 6. Postprandiaal > 6.7 (rood/te hoog) ----
+
+ test('6. naLunch waarde 6.8 (te hoog, > 6.7) toont rode indicator', async ({ page }) => {
+ await page.goto(BASE);
+ await openDiary(page);
+
+ await setGlucoseValue(page, 'naLunch', 6.8);
+
+ const item = glucoseItem(page, 'naLunch');
+ await expect(item).toHaveClass(/glucose-bad/);
+ await expect(item.locator('.glucose-status-text')).toHaveText('Te hoog');
+ });
+
+ // ---- 7. Leeg veld — grijze 'nog meten' indicator ----
+
+ test('7. lege velden tonen grijze nog-meten indicator', async ({ page }) => {
+ await page.goto(BASE);
+ await openDiary(page);
+
+ // All 4 items should start empty
+ const items = page.locator('.glucose-item');
+ const count = await items.count();
+ expect(count).toBe(4);
+
+ for (let i = 0; i < count; i++) {
+ await expect(items.nth(i)).toHaveClass(/glucose-empty/);
+ }
+
+ const statusTexts = page.locator('.glucose-status-text');
+ const texts = await statusTexts.allTextContents();
+ for (const t of texts) {
+ expect(t).toBe('Nog meten');
+ }
+
+ // Status icons should be ⏹️
+ const icons = page.locator('.glucose-status-icon');
+ const iconTexts = await icons.allTextContents();
+ for (const icon of iconTexts) {
+ expect(icon.includes('\u23F9\uFE0F') || icon.includes('\u23F9')).toBeTruthy();
+ }
+
+ // All inputs should have empty value
+ const inputs = page.locator('.glucose-input');
+ const inputCount = await inputs.count();
+ for (let i = 0; i < inputCount; i++) {
+ await expect(inputs.nth(i)).toHaveValue('');
+ }
+ });
+
+ // ---- 8. Persistentie — waarde blijft na page reload ----
+
+ test('8. persistentie: waarde blijft na page reload bewaard', async ({ page }) => {
+ await page.goto(BASE);
+ await openDiary(page);
+
+ // Set a nuchter value
+ await setGlucoseValue(page, 'nuchter', 5.1);
+
+ // Verify it's shown correctly
+ let item = glucoseItem(page, 'nuchter');
+ await expect(item).toHaveClass(/glucose-good/);
+ await expect(item.locator('.glucose-status-text')).toHaveText('Binnen range');
+ await expect(item.locator('.glucose-input')).toHaveValue('5.1');
+
+ // Reload the page
+ await page.goto(BASE);
+ await openDiary(page);
+
+ // Value should still be there
+ item = glucoseItem(page, 'nuchter');
+ await expect(item).toHaveClass(/glucose-good/);
+ await expect(item.locator('.glucose-status-text')).toHaveText('Binnen range');
+ await expect(item.locator('.glucose-input')).toHaveValue('5.1');
+ });
+
+ // ---- 9. Datum-navigatie — andere datum andere waarden ----
+
+ test('9. datum-navigatie: andere datum toont lege glucose waarden', async ({ page }) => {
+ await page.goto(BASE);
+ await openDiary(page);
+
+ // Set a nuchter value for today
+ await setGlucoseValue(page, 'nuchter', 5.1);
+
+ // Verify today shows the value
+ let item = glucoseItem(page, 'nuchter');
+ await expect(item).toHaveClass(/glucose-good/);
+
+ // Navigate to tomorrow
+ await page.locator('#dayNext').click();
+ await page.waitForTimeout(600);
+
+ // Tomorrow should have all empty glucose fields
+ item = glucoseItem(page, 'nuchter');
+ await expect(item).toHaveClass(/glucose-empty/);
+ await expect(item.locator('.glucose-status-text')).toHaveText('Nog meten');
+ await expect(item.locator('.glucose-input')).toHaveValue('');
+
+ // Also check the other moments are empty too
+ for (const moment of ['naOntbijt', 'naLunch', 'naAvondeten']) {
+ const otherItem = glucoseItem(page, moment);
+ await expect(otherItem).toHaveClass(/glucose-empty/);
+ }
+
+ // Navigate back to today
+ await page.locator('#dayToday').click();
+ await page.waitForTimeout(600);
+
+ // Today's value should still be there
+ item = glucoseItem(page, 'nuchter');
+ await expect(item).toHaveClass(/glucose-good/);
+ await expect(item.locator('.glucose-status-text')).toHaveText('Binnen range');
+ await expect(item.locator('.glucose-input')).toHaveValue('5.1');
+ });
+
+ // ---- 10. Decimale invoer — step 0.1 ----
+
+ test('10. decimale invoer: step 0.1 accepteert 5.5 en 6.2 correct', async ({ page }) => {
+ await page.goto(BASE);
+ await openDiary(page);
+
+ // Check step attribute
+ const input = page.locator('input.glucose-input[data-moment="nuchter"]');
+ await expect(input).toHaveAttribute('step', '0.1');
+
+ // Enter 5.5 (decimal)
+ await setGlucoseValue(page, 'nuchter', 5.5);
+ let item = glucoseItem(page, 'nuchter');
+ await expect(item.locator('.glucose-input')).toHaveValue('5.5');
+ // 5.5 ≥ 5.4 for nuchter → should be bad/Te hoog
+ await expect(item).toHaveClass(/glucose-bad/);
+ await expect(item.locator('.glucose-status-text')).toHaveText('Te hoog');
+
+ // Now test a postprandial decimal value
+ await setGlucoseValue(page, 'naOntbijt', 6.2);
+ item = glucoseItem(page, 'naOntbijt');
+ await expect(item.locator('.glucose-input')).toHaveValue('6.2');
+ await expect(item).toHaveClass(/glucose-good/);
+ await expect(item.locator('.glucose-status-text')).toHaveText('Binnen range');
+ });
+
+});