karby/index.html

5821 lines
187 KiB
HTML
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!DOCTYPE html>
<html lang="nl">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">
<meta name="theme-color" content="#E8B730">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="default">
<meta name="apple-mobile-web-app-title" content="Karby">
<meta name="description" content="Karby — tel je koolhydraten per eetmoment">
<title>Karby</title>
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><rect width='100' height='100' rx='20' fill='%23E8B730'/><text y='.75em' x='50' text-anchor='middle' font-size='60' font-weight='bold' font-family='system-ui' fill='%23191919'>K</text></svg>">
<link rel="apple-touch-icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><rect width='100' height='100' rx='20' fill='%23E8B730'/><text y='.75em' x='50' text-anchor='middle' font-size='60' font-weight='bold' font-family='system-ui' fill='%23191919'>K</text></svg>">
<style>
/* ===== CSS Custom Properties ===== */
:root {
--primary: #E8B730;
--primary-light: #FFF3CD;
--primary-lighter: #FFF8E1;
--primary-dark: #8B6914;
--bg: #FDFDFD;
--white: #FFFFFF;
--surface: #FFFFFF;
--text: #212529;
--text-secondary: #343A40;
--text-muted: #6C757D;
--border: #E5E7EB;
--accent: #FAE892;
--accent-dark: #C9A80D;
--accent-light: #FFF8E1;
--danger: #E53935;
--danger-light: #FFEBEE;
--progress-green: #28a745;
--progress-red: #dc3545;
--progress-amber: #F59E0B;
--shadow-sm: 0 1px 3px rgba(0,0,0,0.06);
--shadow: 0 2px 8px rgba(0,0,0,0.08);
--shadow-lg: 0 4px 16px rgba(0,0,0,0.10);
--radius: 12px;
--radius-sm: 8px;
--radius-xs: 6px;
--touch-min: 44px;
--font-xs: 0.75rem;
--font-sm: 0.875rem;
--font-md: 1rem;
--font-lg: 1.125rem;
--font-xl: 1.25rem;
--font-2xl: 1.5rem;
--max-width: 480px;
--heading-font: system-ui, -apple-system, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
--body-font: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
--heading-color: #191919;
}
/* ===== Reset & Base ===== */
*, *::before, *::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
html {
font-size: 16px;
-webkit-text-size-adjust: 100%;
-webkit-tap-highlight-color: transparent;
}
body {
font-family: var(--body-font);
background: #F5F5F5;
color: var(--text);
line-height: 1.5;
min-height: 100vh;
min-height: 100dvh;
display: flex;
justify-content: center;
-webkit-font-smoothing: antialiased;
}
/* ===== App Container ===== */
#app {
width: 100%;
max-width: var(--max-width);
background: var(--bg);
min-height: 100vh;
min-height: 100dvh;
display: flex;
flex-direction: column;
position: relative;
box-shadow: var(--shadow-lg);
}
/* ===== Header ===== */
.app-header {
background: var(--white);
color: var(--text);
padding: 12px 12px 12px 16px;
text-align: center;
position: sticky;
top: 0;
z-index: 100;
box-shadow: var(--shadow-sm);
border-bottom: 1px solid var(--border);
display: flex;
align-items: center;
justify-content: space-between;
}
/* ===== Logo ===== */
.app-logo {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
flex: 1;
}
.app-logo svg {
width: 28px;
height: 28px;
flex-shrink: 0;
}
.app-logo-text {
font-size: var(--font-lg);
font-weight: 700;
letter-spacing: -0.02em;
font-family: var(--heading-font);
color: var(--heading-color);
}
/* ===== Search Bar ===== */
.search-container {
padding: 12px 16px 8px;
position: sticky;
top: 0;
background: var(--bg);
z-index: 90;
}
.search-wrapper {
display: flex;
align-items: center;
background: var(--white);
border: 2px solid var(--border);
border-radius: var(--radius);
padding: 0 12px;
transition: border-color 0.2s;
}
.search-wrapper:focus-within {
border-color: var(--accent);
box-shadow: 0 0 0 3px rgba(250,232,146,0.25);
}
.search-icon {
font-size: 1.2rem;
margin-right: 8px;
flex-shrink: 0;
}
.search-input {
flex: 1;
border: none;
outline: none;
font-size: var(--font-md);
padding: 10px 0;
background: transparent;
color: var(--text);
min-height: var(--touch-min);
}
.search-input::placeholder {
color: var(--text-muted);
}
.search-clear {
background: none;
border: none;
font-size: 1.2rem;
cursor: pointer;
color: var(--text-muted);
padding: 4px 8px;
min-height: var(--touch-min);
min-width: var(--touch-min);
display: none;
}
.search-clear.visible {
display: inline-flex;
align-items: center;
justify-content: center;
}
/* ===== Category Filter ===== */
.category-filter {
padding: 4px 16px 12px;
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.cat-btn {
padding: 6px 12px;
border-radius: 20px;
border: 1.5px solid var(--border);
background: var(--white);
color: var(--text-secondary);
font-size: var(--font-sm);
font-weight: 500;
cursor: pointer;
white-space: nowrap;
transition: all 0.15s;
min-height: 36px;
user-select: none;
-webkit-user-select: none;
}
.cat-btn:active {
transform: scale(0.96);
}
.cat-btn.active {
background: var(--accent);
color: var(--text);
border-color: var(--accent);
font-weight: 600;
}
/* ===== Main Content Area ===== */
.main-content {
flex: 1;
overflow-y: auto;
-webkit-overflow-scrolling: touch;
padding-bottom: 80px; /* space for bottom nav + safe area */
}
/* ===== Results List ===== */
.results-section {
padding: 0 16px;
}
.results-count {
font-size: var(--font-sm);
color: var(--text-muted);
margin-bottom: 8px;
padding: 0 2px;
}
.food-list {
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);
padding: 12px 14px;
margin-bottom: 8px;
box-shadow: var(--shadow-sm);
cursor: pointer;
display: flex;
justify-content: space-between;
align-items: center;
transition: transform 0.1s, box-shadow 0.1s;
min-height: var(--touch-min);
}
.food-item:active {
transform: scale(0.98);
box-shadow: var(--shadow);
}
.food-item-left {
flex: 1;
min-width: 0;
}
.food-item-name {
font-weight: 500;
font-size: var(--font-md);
color: var(--text);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.food-item-cat {
font-size: var(--font-xs);
color: var(--text-muted);
margin-top: 2px;
}
.food-item-right {
text-align: right;
margin-left: 12px;
flex-shrink: 0;
}
.food-item-kh {
font-weight: 700;
font-size: var(--font-lg);
color: var(--primary-dark);
}
.food-item-unit {
font-size: var(--font-xs);
color: var(--text-muted);
}
.food-item-arrow {
color: var(--text-muted);
margin-left: 8px;
font-size: 1.1rem;
}
/* ===== Empty State ===== */
.empty-state {
text-align: center;
padding: 40px 20px;
color: var(--text-muted);
}
.empty-state-icon {
font-size: 3rem;
margin-bottom: 12px;
}
.empty-state-text {
font-size: var(--font-md);
}
/* ===== Diary View ===== */
.diary-section {
padding: 0 16px;
}
.day-navigator {
display: flex;
align-items: center;
justify-content: center;
gap: 12px;
padding: 8px 0 16px;
}
.day-nav-btn {
background: var(--white);
border: 1.5px solid var(--border);
border-radius: 50%;
width: 40px;
height: 40px;
font-size: 1.2rem;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
color: var(--text-secondary);
transition: all 0.15s;
}
.day-nav-btn:active {
background: var(--primary-lighter);
transform: scale(0.95);
}
.day-label {
font-weight: 600;
font-size: var(--font-md);
color: var(--heading-color);
min-width: 120px;
text-align: center;
}
.day-label small {
display: block;
font-weight: 400;
font-size: var(--font-xs);
color: var(--text-muted);
}
.day-nav-today {
background: var(--accent);
color: var(--text);
border: none;
border-radius: var(--radius-xs);
padding: 6px 14px;
font-size: var(--font-xs);
font-weight: 700;
cursor: pointer;
min-height: 32px;
transition: background 0.15s;
}
/* ===== Total Carb Bar ===== */
.total-carb-bar {
background: var(--white);
border-radius: var(--radius);
padding: 14px 16px;
box-shadow: var(--shadow-sm);
border-left: 4px solid var(--accent);
margin-bottom: 6px;
}
.total-carb-bar-top {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 10px;
}
.total-carb-bar-label {
font-size: var(--font-sm);
font-weight: 600;
color: var(--text-secondary);
}
.total-carb-bar-numbers {
font-size: var(--font-sm);
font-weight: 700;
color: var(--heading-color);
}
.total-carb-bar-unit {
font-size: var(--font-xs);
font-weight: 400;
color: var(--text-muted);
}
/* Progress bar track */
.progress-track {
width: 100%;
height: 6px;
background: #E9ECEF;
border-radius: 3px;
overflow: hidden;
}
.progress-fill {
height: 100%;
border-radius: 3px;
transition: width 0.4s ease, background-color 0.4s ease;
width: 0%;
}
.progress-fill.color-green {
background: var(--progress-green);
}
.progress-fill.color-amber {
background: var(--progress-amber);
}
.progress-fill.color-red {
background: var(--progress-red);
}
/* ===== Nuchter card — compact glucose card vóór Ontbijt ===== */
.nuchter-card {
background: var(--white);
border-radius: var(--radius);
box-shadow: var(--shadow-sm);
overflow: hidden;
margin-bottom: 10px;
border: 1px solid var(--border);
transition: border-color 0.2s, background 0.2s;
list-style: none;
}
.nuchter-card.glucose-good {
border-color: #28a745;
background: #f0fdf4;
}
.nuchter-card.glucose-bad {
border-color: var(--progress-red);
background: #fef2f2;
}
.nuchter-card.glucose-empty {
border-color: var(--border);
background: var(--white);
}
.nuchter-card-header {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 14px;
background: var(--primary-lighter);
}
.nuchter-card-icon {
font-size: 1.1rem;
}
.nuchter-card-label {
font-weight: 600;
font-size: var(--font-sm);
color: var(--text);
}
.nuchter-card-range {
font-size: var(--font-xs);
color: var(--text-muted);
margin-left: auto;
}
.nuchter-card-body {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 14px;
gap: 12px;
}
.nuchter-card-body .glucose-input-wrapper {
margin-bottom: 0;
}
/* ===== Inline glucose in eetmoment card ===== */
.glucose-inline {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 14px;
border-top: 1px solid #F0F0F0;
gap: 8px;
transition: background 0.15s;
}
.glucose-inline.glucose-good {
background: #f0fdf4;
}
.glucose-inline.glucose-bad {
background: #fef2f2;
}
.glucose-inline.glucose-empty {
background: transparent;
}
.glucose-inline-left {
display: flex;
align-items: center;
gap: 6px;
min-width: 0;
}
.glucose-inline-icon {
font-size: 0.9rem;
color: var(--progress-red);
}
.glucose-inline-label {
font-size: var(--font-xs);
font-weight: 600;
color: var(--text-secondary);
}
.glucose-inline-range {
font-size: 0.65rem;
color: var(--text-muted);
}
.glucose-inline-right {
display: flex;
align-items: center;
gap: 8px;
}
/* ===== Shared glucose input styles ===== */
.glucose-input-wrapper {
display: flex;
align-items: center;
justify-content: center;
gap: 2px;
}
.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;
margin-top: 8px;
font-size: var(--font-xs);
font-weight: 600;
color: var(--progress-red);
background: var(--danger-light);
border-radius: var(--radius-xs);
padding: 6px 10px;
line-height: 1.4;
}
.carb-warning.visible {
display: block;
}
/* Settings gear button */
.settings-btn {
background: none;
border: none;
font-size: 1.3rem;
cursor: pointer;
padding: 4px;
color: var(--text-muted);
transition: transform 0.2s;
min-height: 40px;
min-width: 40px;
display: flex;
align-items: center;
justify-content: center;
}
.settings-btn:active {
transform: rotate(30deg);
}
/* Over-limit eetmoment indicator */
.eetmoment-card.over-limit {
border-left: 3px solid var(--progress-red);
}
.eetmoment-item.over-limit .eetmoment-item-kh {
color: var(--progress-red);
font-weight: 700;
}
/* Meal group in diary */
.eetmoment-meal-group {
display: flex;
flex-direction: column;
padding: 0;
}
.meal-group-header {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 14px;
cursor: pointer;
min-height: var(--touch-min);
transition: background 0.15s;
}
.meal-group-header:active {
background: var(--primary-lighter);
}
.meal-group-info {
display: flex;
align-items: center;
gap: 6px;
flex: 1;
min-width: 0;
}
.meal-group-icon {
font-size: 1.1rem;
}
.meal-group-name {
font-weight: 600;
font-size: var(--font-sm);
color: var(--text);
}
.meal-group-meta {
font-size: var(--font-xs);
color: var(--text-muted);
}
.meal-group-edit {
background: none;
border: none;
color: var(--text-muted);
cursor: pointer;
padding: 6px;
border-radius: 50%;
min-width: 32px;
min-height: 32px;
display: flex;
align-items: center;
justify-content: center;
transition: color 0.15s, background 0.15s;
font-size: 0.9rem;
}
.meal-group-edit:active {
color: var(--primary-dark);
background: var(--primary-light);
}
.meal-group-items {
max-height: 0;
opacity: 0;
overflow: hidden;
border-top: 1px solid var(--border);
transition: max-height 0.35s cubic-bezier(0.4, 0, 0.2, 1), opacity 0.3s ease;
}
.meal-group-header.expanded + .meal-group-items {
max-height: 2000px;
opacity: 1;
}
.meal-sub-item {
padding-left: 36px;
border-bottom: 1px solid #F8F8F8;
}
/* Settings modal */
.settings-option {
margin-bottom: 20px;
}
.settings-option label {
display: block;
font-size: var(--font-sm);
font-weight: 600;
color: var(--text-secondary);
margin-bottom: 6px;
}
.settings-option .help-text {
font-size: var(--font-xs);
color: var(--text-muted);
margin-top: 6px;
}
.total-carb-label {
font-weight: 600;
font-size: var(--font-md);
color: var(--heading-color);
}
.total-carb-value {
font-weight: 800;
font-size: var(--font-2xl);
color: var(--primary-dark);
}
.total-carb-unit {
font-size: var(--font-sm);
font-weight: 400;
color: var(--text-secondary);
}
/* ===== Eating Moments ===== */
.eetmoment-list {
list-style: none;
display: flex;
flex-direction: column;
gap: 10px;
}
.eetmoment-card {
background: var(--white);
border-radius: var(--radius);
box-shadow: var(--shadow-sm);
overflow: hidden;
transition: box-shadow 0.15s;
}
.eetmoment-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 14px;
background: var(--primary-lighter);
cursor: pointer;
user-select: none;
-webkit-user-select: none;
}
.eetmoment-header-left {
display: flex;
align-items: center;
gap: 8px;
}
.eetmoment-icon {
font-size: 1.3rem;
}
.eetmoment-name {
font-weight: 600;
font-size: var(--font-md);
color: var(--text);
}
.eetmoment-kh {
font-weight: 700;
font-size: var(--font-md);
color: var(--primary-dark);
margin-left: auto;
margin-right: 8px;
}
.eetmoment-items {
--collapsible-pt: 4px;
--collapsible-pb: 4px;
}
.eetmoment-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 14px;
border-bottom: 1px solid #F5F5F5;
gap: 8px;
}
.eetmoment-item:last-child {
border-bottom: none;
}
.eetmoment-item-info {
flex: 1;
min-width: 0;
}
.eetmoment-item-name {
font-weight: 500;
font-size: var(--font-sm);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.eetmoment-item-portion {
font-size: var(--font-xs);
color: var(--text-muted);
}
.eetmoment-item-kh {
font-weight: 700;
font-size: var(--font-md);
color: var(--primary-dark);
white-space: nowrap;
}
.eetmoment-remove {
background: none;
border: none;
color: var(--text-muted);
cursor: pointer;
font-size: 0.8rem;
padding: 2px 4px;
min-width: var(--touch-min);
min-height: var(--touch-min);
display: flex;
align-items: center;
justify-content: center;
border-radius: var(--radius-xs);
transition: color 0.15s, opacity 0.15s;
}
.eetmoment-remove:active {
color: var(--danger);
opacity: 0.7;
}
.eetmoment-add-btn {
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
padding: 10px 14px;
color: var(--primary-dark);
font-weight: 500;
font-size: var(--font-sm);
cursor: pointer;
border-top: 1px dashed var(--border);
transition: background 0.15s;
min-height: var(--touch-min);
}
.eetmoment-add-btn:active {
background: var(--primary-lighter);
}
/* ===== Bottom Navigation ===== */
.bottom-nav {
position: fixed;
bottom: 0;
left: 50%;
transform: translateX(-50%);
width: 100%;
max-width: var(--max-width);
background: var(--white);
border-top: 1px solid var(--border);
display: flex;
z-index: 100;
box-shadow: 0 -2px 8px rgba(0,0,0,0.06);
padding-bottom: env(safe-area-inset-bottom, 0);
}
.nav-btn {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 2px;
padding: 8px 4px 6px;
background: none;
border: none;
cursor: pointer;
color: var(--text-muted);
font-size: var(--font-xs);
font-weight: 500;
transition: color 0.15s;
min-height: 56px;
}
.nav-btn.active {
color: var(--text);
position: relative;
}
.nav-btn.active::after {
content: '';
position: absolute;
top: 0;
left: 50%;
transform: translateX(-50%);
width: 24px;
height: 3px;
background: var(--accent);
border-radius: 0 0 3px 3px;
}
.nav-btn-icon {
font-size: 1.4rem;
}
/* ===== Modal Overlay ===== */
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(0,0,0,0.4);
z-index: 200;
display: flex;
align-items: flex-end;
justify-content: center;
}
.modal-sheet {
background: var(--white);
width: 100%;
max-width: var(--max-width);
border-radius: var(--radius) var(--radius) 0 0;
max-height: 85vh;
max-height: 85dvh;
overflow-y: auto;
padding-bottom: env(safe-area-inset-bottom, 0);
}
.modal-handle {
width: 36px;
height: 4px;
background: #D0D0D0;
border-radius: 2px;
margin: 10px auto 6px;
}
.modal-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 16px 12px;
border-bottom: 1px solid var(--border);
position: sticky;
top: 0;
background: var(--white);
z-index: 1;
}
.modal-title {
font-weight: 600;
font-size: var(--font-lg);
}
.modal-close {
background: none;
border: none;
font-size: 1.5rem;
cursor: pointer;
color: var(--text-secondary);
padding: 4px 8px;
min-width: var(--touch-min);
min-height: var(--touch-min);
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
}
.modal-close:active {
background: #F5F5F5;
}
.modal-body {
padding: 16px;
}
/* ===== Meal Name Dialog ===== */
.meal-name-input {
width: 100%;
padding: 14px 16px;
border: 2px solid var(--border);
border-radius: var(--radius-sm);
font-size: var(--font-lg);
font-family: var(--body-font);
background: var(--bg);
color: var(--text);
transition: border-color 0.2s, box-shadow 0.2s;
outline: none;
min-height: var(--touch-min);
}
.meal-name-input:focus {
border-color: var(--accent);
box-shadow: 0 0 0 3px var(--primary-light);
}
.meal-name-input::placeholder {
color: var(--text-muted);
}
.meal-name-label {
display: block;
font-size: var(--font-sm);
font-weight: 600;
color: var(--text-secondary);
margin-bottom: 8px;
}
.meal-name-actions {
display: flex;
gap: 10px;
margin-top: 24px;
}
.meal-name-cancel {
flex: 1;
padding: 14px 20px;
background: #F0F0F0;
color: var(--text-secondary);
border: none;
border-radius: var(--radius-sm);
font-size: var(--font-md);
font-weight: 600;
cursor: pointer;
min-height: var(--touch-min);
transition: background 0.15s;
font-family: inherit;
}
.meal-name-cancel:hover {
background: #E0E0E0;
}
.meal-name-cancel:active {
background: #D0D0D0;
}
.meal-name-save {
flex: 1;
padding: 14px 20px;
background: var(--accent);
color: var(--text);
border: none;
border-radius: var(--radius-sm);
font-size: var(--font-md);
font-weight: 600;
cursor: pointer;
min-height: var(--touch-min);
transition: background 0.15s;
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
font-family: inherit;
}
.meal-name-save:hover {
background: var(--accent-dark);
}
.meal-name-save:active {
background: #B8960A;
}
.meal-name-items-preview {
font-size: var(--font-sm);
color: var(--text-muted);
margin-bottom: 16px;
padding: 10px 12px;
background: var(--primary-lighter);
border-radius: var(--radius-xs);
line-height: 1.4;
}
/* ===== Detail View ===== */
.detail-name {
font-size: var(--font-xl);
font-weight: 600;
margin-bottom: 4px;
color: var(--heading-color);
}
.detail-category {
display: inline-block;
background: var(--accent-light);
color: var(--text-secondary);
padding: 4px 10px;
border-radius: 20px;
font-size: var(--font-xs);
font-weight: 500;
}
.detail-kh-big {
background: var(--accent-light);
border-radius: var(--radius);
padding: 16px;
text-align: center;
margin-bottom: 16px;
border: 2px solid var(--accent);
}
.detail-kh-number {
font-size: 2.5rem;
font-weight: 800;
color: var(--primary-dark);
line-height: 1;
}
.detail-kh-label {
font-size: var(--font-sm);
color: var(--text-secondary);
margin-top: 4px;
}
.detail-nutrition {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 8px;
margin-bottom: 16px;
}
.detail-nutrition-item {
background: #F5F5F5;
border-radius: var(--radius-xs);
padding: 10px 8px;
text-align: center;
}
.detail-nutrition-value {
font-weight: 700;
font-size: var(--font-md);
color: var(--text);
}
.detail-nutrition-label {
font-size: var(--font-xs);
color: var(--text-muted);
margin-top: 2px;
}
.detail-add-btn {
width: 100%;
padding: 14px 24px;
background: var(--accent);
color: var(--text);
border: none;
border-radius: 4px;
font-size: var(--font-md);
font-weight: 600;
cursor: pointer;
min-height: var(--touch-min);
transition: background 0.15s;
}
.detail-add-btn:active {
background: var(--primary-dark);
}
/* Detail info link — styled as regular text link, not a button */
.detail-links {
margin: 16px 0 12px;
}
.detail-info-link {
font-size: var(--font-sm);
color: var(--primary-dark);
text-decoration: underline;
text-underline-offset: 2px;
text-decoration-color: color-mix(in srgb, var(--primary-dark) 40%, transparent);
transition: color 0.15s, text-decoration-color 0.15s;
cursor: pointer;
}
.detail-info-link:hover {
color: var(--primary-dark);
text-decoration-color: var(--primary-dark);
}
.detail-info-link .fa-external-link-alt {
font-size: 0.7em;
margin-left: 4px;
opacity: 0.6;
}
/* ===== Save Meal — checkbox + footer ===== */
.item-checkbox-wrapper {
display: none;
align-items: center;
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;
margin: 0 6px 0 0;
}
/* 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);
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.3s ease, max-height 0.3s ease, padding 0.3s ease, margin 0.3s ease, border-width 0.3s ease;
text-align: center;
font-family: var(--body-font);
min-height: var(--touch-min);
line-height: 1.3;
max-height: 80px;
overflow: hidden;
}
.save-meal-footer-btn.footer-hidden {
opacity: 0;
max-height: 0;
padding-top: 0;
padding-bottom: 0;
margin-top: 0;
margin-bottom: 0;
border-width: 0;
pointer-events: none;
}
.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);
}
/* Bulk delete button in footer */
.bulk-delete-btn {
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
width: 100%;
padding: 10px 14px;
font-size: var(--font-sm);
color: var(--danger);
cursor: pointer;
border: 1px solid var(--danger);
border-radius: var(--radius-xs);
background: transparent;
margin-top: 6px;
transition: background 0.15s;
}
.bulk-delete-btn:hover {
background: var(--danger);
color: var(--white);
}
/* ===== Diary — nested meal groups ===== */
.delete-selected-footer-btn {
display: block;
width: 100%;
padding: 10px 16px;
margin-top: 6px;
background: var(--danger-light);
border: 1px solid var(--danger);
border-radius: var(--radius-sm);
color: var(--danger);
font-size: var(--font-sm);
font-weight: 600;
cursor: pointer;
transition: background 0.15s, opacity 0.3s ease, max-height 0.3s ease, padding 0.3s ease, margin 0.3s ease, border-width 0.3s ease;
text-align: center;
font-family: var(--body-font);
min-height: var(--touch-min);
line-height: 1.3;
max-height: 80px;
overflow: hidden;
}
.delete-selected-footer-btn:hover {
background: var(--danger);
color: var(--white);
}
.delete-selected-footer-btn:active {
background: #B71C1C;
color: var(--white);
}
.delete-selected-footer-btn.hidden,
.delete-selected-footer-btn.footer-hidden {
opacity: 0;
max-height: 0;
padding-top: 0;
padding-bottom: 0;
margin-top: 0;
margin-bottom: 0;
border-width: 0;
pointer-events: none;
overflow: hidden;
}
/* ===== Diary — nested meal groups ===== */
.eetmoment-meal-group {
background: var(--primary-lighter);
border-left: 3px solid var(--primary);
border-radius: var(--radius-xs);
margin: 4px 8px;
overflow: hidden;
}
.eetmoment-meal-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 6px 10px;
gap: 6px;
cursor: pointer;
user-select: none;
}
.eetmoment-meal-name {
flex: 1;
font-weight: 600;
font-size: var(--font-sm);
color: var(--primary-dark);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.eetmoment-meal-kh {
font-weight: 700;
font-size: var(--font-xs);
color: var(--primary-dark);
white-space: nowrap;
}
.eetmoment-meal-items-wrap {
border-top: 1px solid rgba(232, 183, 48, 0.2);
--collapsible-pt: 2px;
--collapsible-pb: 2px;
}
.eetmoment-meal-item {
display: flex;
align-items: center;
padding: 6px 10px 6px 16px;
gap: 6px;
border-bottom: 1px solid rgba(232, 183, 48, 0.1);
}
.eetmoment-meal-item:last-child {
border-bottom: none;
}
.eetmoment-meal-item .eetmoment-item-info {
flex: 1;
min-width: 0;
}
.eetmoment-meal-item .eetmoment-item-name {
font-size: var(--font-xs);
font-weight: 500;
}
.eetmoment-meal-item .eetmoment-item-portion {
font-size: var(--font-xs);
color: var(--text-muted);
display: inline-flex;
align-items: center;
gap: 4px;
}
.eetmoment-meal-item .portion-badge {
background: var(--white);
border-color: var(--border);
color: var(--text);
}
.eetmoment-meal-item .portion-badge:hover {
background: var(--white);
border-color: var(--primary);
}
.eetmoment-meal-item .eetmoment-item-kh {
font-size: var(--font-sm);
font-weight: 700;
color: var(--primary-dark);
white-space: nowrap;
}
.eetmoment-meal-actions {
display: flex;
gap: 6px;
padding: 4px 10px 6px 16px;
border-top: 1px solid rgba(232, 183, 48, 0.15);
}
.eetmoment-meal-action-btn {
background: none;
border: 1px solid var(--border);
border-radius: var(--radius-xs);
padding: 6px 10px;
font-size: var(--font-xs);
font-weight: 500;
color: var(--text-secondary);
cursor: pointer;
display: flex;
align-items: center;
gap: 4px;
transition: background 0.15s, border-color 0.15s;
font-family: inherit;
}
.eetmoment-meal-action-btn:active {
background: var(--primary-light);
border-color: var(--primary);
}
.eetmoment-meal-action-btn.eetmoment-delete {
color: var(--danger, #e74c3c);
border-color: var(--danger, #e74c3c);
}
.eetmoment-meal-action-btn.eetmoment-delete:active {
background: #fdeaea;
}
/* ===== Inline portion edit — badge/chip ===== */
.portion-badge {
cursor: pointer;
display: inline-flex;
align-items: center;
gap: 4px;
background: var(--primary-lighter);
border: 1px solid var(--primary-light);
border-radius: 4px;
padding: 1px 6px;
transition: background 0.15s, border-color 0.15s;
font-size: var(--font-xs);
color: var(--text-secondary);
white-space: nowrap;
}
.portion-badge:hover {
background: var(--primary-light);
border-color: var(--primary);
}
.portion-badge .fa-pen {
font-size: 0.7rem;
opacity: 0.7;
color: var(--primary-dark);
}
.portion-badge:hover .fa-pen {
opacity: 1;
}
/* ===== Eenmalig / Permanent dialog ===== */
.keuze-dialog {
padding: 8px 16px 16px;
}
.keuze-dialog-title {
font-weight: 600;
font-size: var(--font-md);
margin-bottom: 4px;
}
.keuze-dialog-desc {
font-size: var(--font-sm);
color: var(--text-muted);
margin-bottom: 14px;
}
.keuze-dialog-buttons {
display: flex;
flex-direction: column;
gap: 8px;
}
.keuze-btn {
width: 100%;
padding: 14px 20px;
border: none;
border-radius: var(--radius-sm);
font-size: var(--font-md);
font-weight: 600;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
font-family: inherit;
transition: background 0.15s, transform 0.1s;
min-height: var(--touch-min);
}
.keuze-btn:active {
transform: scale(0.98);
}
.keuze-btn-primary {
background: var(--accent);
color: var(--text);
}
.keuze-btn-primary:active {
background: var(--primary-dark);
color: var(--white);
}
.keuze-btn-secondary {
background: #F0F0F0;
color: var(--text-secondary);
}
.keuze-btn-secondary:active {
background: #E0E0E0;
}
.keuze-btn-cancel {
background: transparent;
color: var(--text-muted);
font-weight: 400;
font-size: var(--font-sm);
}
/* ===== Meal Cards ===== */
.meal-card {
background: var(--white);
border: 1px solid var(--border);
border-radius: var(--radius);
margin-bottom: 10px;
overflow: hidden;
box-shadow: var(--shadow-sm);
}
.meal-card-main {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 16px;
cursor: pointer;
min-height: var(--touch-min);
}
.meal-card-info {
flex: 1;
}
.meal-card-name {
font-weight: 600;
font-size: var(--font-md);
color: var(--text);
}
.meal-card-meta {
font-size: var(--font-xs);
color: var(--text-muted);
margin-top: 2px;
}
.meal-card-delete {
background: none;
border: none;
color: var(--text-muted);
font-size: var(--font-md);
cursor: pointer;
padding: 8px;
min-width: var(--touch-min);
min-height: var(--touch-min);
border-radius: var(--radius-xs);
transition: color 0.15s, background 0.15s;
}
.meal-card-delete:hover {
color: var(--danger);
background: var(--danger-light);
}
.meal-card-add {
border-top: 1px solid var(--border);
padding: 12px 16px;
font-size: var(--font-sm);
font-weight: 500;
color: var(--primary-dark);
cursor: pointer;
display: flex;
align-items: center;
gap: 6px;
min-height: var(--touch-min);
transition: background 0.15s;
}
.meal-card-add:hover {
background: var(--primary-light);
}
/* ===== Meal Add Moment Modal ===== */
.meal-option-list {
display: flex;
flex-direction: column;
gap: 4px;
padding: 0 20px 16px;
}
.meal-add-option {
display: flex;
align-items: center;
gap: 10px;
padding: 14px 16px;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
background: var(--white);
font-size: var(--font-md);
font-weight: 500;
color: var(--text);
cursor: pointer;
width: 100%;
text-align: left;
min-height: var(--touch-min);
transition: background 0.15s, border-color 0.15s;
font-family: inherit;
}
.meal-add-option:active {
background: var(--primary-light);
border-color: var(--primary);
}
.meal-add-moment-icon {
width: 24px;
text-align: center;
color: var(--primary-dark);
}
/* ===== Meal Editor Modal ===== */
.meal-edit-title {
cursor: pointer;
transition: background 0.15s;
border-radius: var(--radius-xs);
padding: 4px 8px;
margin: -4px -8px;
}
.meal-edit-title:hover {
background: var(--primary-light);
}
.meal-edit-title:active {
background: var(--accent-light);
}
.meal-edit-name-hint {
text-align: center;
font-size: var(--font-xs);
color: var(--text-muted);
margin-bottom: 12px;
}
.meal-edit-items {
margin-bottom: 12px;
}
.meal-edit-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 12px;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
margin-bottom: 8px;
background: var(--bg);
gap: 8px;
transition: opacity 0.2s, text-decoration 0.2s;
}
.meal-edit-item-info {
flex: 1;
min-width: 0;
}
.meal-edit-item-name {
font-weight: 500;
font-size: var(--font-sm);
color: var(--text);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.meal-edit-item-cat {
font-size: var(--font-xs);
color: var(--text-muted);
margin-top: 2px;
}
.meal-edit-item-actions {
display: flex;
align-items: center;
gap: 4px;
flex-shrink: 0;
}
.meal-edit-item-remove {
background: none;
border: none;
color: var(--text-muted);
cursor: pointer;
font-size: 1.2rem;
padding: 6px;
min-width: 38px;
min-height: 38px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
transition: color 0.15s, background 0.15s;
}
.meal-edit-item-remove:active {
color: var(--danger);
background: var(--danger-light);
}
.meal-edit-add-item {
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
width: 100%;
padding: 12px;
border: 2px dashed var(--border);
border-radius: var(--radius-sm);
background: transparent;
color: var(--primary-dark);
font-size: var(--font-md);
font-weight: 500;
cursor: pointer;
min-height: var(--touch-min);
transition: background 0.15s, border-color 0.15s;
font-family: inherit;
margin-bottom: 16px;
}
.meal-edit-add-item:active {
background: var(--primary-light);
border-color: var(--primary);
}
.meal-edit-actions {
display: flex;
flex-direction: column;
gap: 8px;
}
.btn-save {
width: 100%;
padding: 14px 24px;
background: var(--primary);
color: var(--text);
border: none;
border-radius: var(--radius-sm);
font-size: var(--font-md);
font-weight: 600;
cursor: pointer;
min-height: var(--touch-min);
transition: background 0.15s;
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
font-family: inherit;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.btn-save:hover {
background: var(--primary-dark);
color: var(--white);
}
.btn-save:active {
background: #6B4F0D;
color: var(--white);
}
.btn-cancel {
flex: 1;
padding: 14px 20px;
background: #F0F0F0;
color: var(--text-secondary);
border: none;
border-radius: var(--radius-sm);
font-size: var(--font-md);
font-weight: 600;
cursor: pointer;
min-height: var(--touch-min);
transition: background 0.15s;
font-family: inherit;
}
.btn-cancel:hover {
background: #E0E0E0;
}
.btn-cancel:active {
background: #D0D0D0;
}
.btn-danger-outline {
width: 100%;
padding: 14px 24px;
background: transparent;
color: var(--danger);
border: 2px solid var(--danger);
border-radius: var(--radius-sm);
font-size: var(--font-md);
font-weight: 600;
cursor: pointer;
min-height: var(--touch-min);
transition: background 0.15s;
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
font-family: inherit;
}
.btn-danger-outline:hover {
background: var(--danger-light);
}
.btn-danger-outline:active {
background: var(--danger);
color: var(--white);
}
/* Name input dialog */
.name-input-field {
width: 100%;
padding: 14px 16px;
border: 2px solid var(--border);
border-radius: var(--radius-sm);
font-size: var(--font-lg);
font-family: var(--body-font);
background: var(--bg);
color: var(--text);
transition: border-color 0.2s, box-shadow 0.2s;
outline: none;
min-height: var(--touch-min);
}
.name-input-field:focus {
border-color: var(--accent);
box-shadow: 0 0 0 3px var(--primary-light);
}
.name-input-field::placeholder {
color: var(--text-muted);
}
.name-input-actions {
display: flex;
gap: 10px;
margin-top: 24px;
}
/* Meal search within editor */
.meal-search-results {
max-height: 50vh;
overflow-y: auto;
}
.meal-search-item {
display: flex;
align-items: flex-start;
justify-content: space-between;
padding: 12px 14px;
border: 1px solid var(--border);
border-radius: var(--radius-xs);
margin-bottom: 4px;
cursor: pointer;
min-height: var(--touch-min);
transition: background 0.15s;
flex-direction: column;
gap: 2px;
}
.meal-search-item:active {
background: var(--primary-lighter);
}
/* ===== Add to Meal Modal ===== */
.add-meal-select {
width: 100%;
padding: 12px;
border: 2px solid var(--border);
border-radius: var(--radius-sm);
font-size: var(--font-md);
background: var(--white);
color: var(--text);
margin-bottom: 16px;
min-height: var(--touch-min);
-webkit-appearance: none;
appearance: none;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath fill='%23546E7A' d='M6 8L1 3h10z'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 12px center;
padding-right: 36px;
}
.add-meal-label {
font-weight: 500;
font-size: var(--font-sm);
margin-bottom: 6px;
color: var(--text-secondary);
}
.portion-input-group {
margin-bottom: 16px;
}
.portion-input-wrapper {
display: flex;
align-items: center;
gap: 8px;
}
/* ===== Standard portion quick-select buttons ===== */
.standaard-porties {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-bottom: 12px;
}
/* Stepper-based portion selectors */
.standaard-porties {
display: flex;
flex-direction: column;
gap: 6px;
margin: 12px 0;
}
.portie-stepper {
display: flex;
align-items: center;
gap: 6px;
padding: 8px 10px;
border-radius: var(--radius-sm);
background: var(--bg);
border: 1.5px solid var(--border);
transition: all 0.15s;
user-select: none;
-webkit-user-select: none;
}
.portie-stepper.active {
background: var(--primary-light);
border-color: var(--primary);
}
.portie-stepper-btn {
width: 36px;
height: 36px;
border-radius: 50%;
border: 1.5px solid var(--border);
background: var(--white);
color: var(--text-secondary);
font-size: 1.1rem;
font-weight: 600;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.15s;
flex-shrink: 0;
line-height: 1;
padding: 0;
}
.portie-stepper-btn:active {
background: var(--primary-lighter);
border-color: var(--primary);
color: var(--primary-dark);
transform: scale(0.94);
}
.portie-stepper-btn:disabled {
opacity: 0.3;
cursor: not-allowed;
}
.portie-stepper-count {
font-weight: 700;
font-size: var(--font-md);
color: var(--text);
min-width: 28px;
text-align: center;
}
.portie-stepper-label {
font-size: var(--font-sm);
font-weight: 500;
color: var(--text);
flex: 1;
}
.portie-stepper-unit {
font-size: var(--font-xs);
color: var(--text-muted);
font-weight: 600;
min-width: 36px;
text-align: right;
}
/* Old button style kept for backward compat */
.standaard-portie-btn {
padding: 8px 14px;
border: 1.5px solid var(--border);
border-radius: var(--radius-sm);
background: var(--white);
color: var(--text-secondary);
font-size: var(--font-sm);
font-weight: 500;
cursor: pointer;
transition: all 0.15s;
min-height: 38px;
-webkit-tap-highlight-color: transparent;
user-select: none;
-webkit-user-select: none;
flex: 1 0 auto;
max-width: 160px;
}
.standaard-portie-btn:active {
background: var(--primary-lighter);
border-color: var(--primary);
color: var(--primary-dark);
transform: scale(0.97);
}
.standaard-portie-btn .portie-label {
display: block;
font-size: var(--font-xs);
color: var(--text-muted);
font-weight: 400;
}
.standaard-portie-btn .portie-grams {
font-weight: 600;
color: var(--text);
}
.portion-input {
flex: 1;
padding: 12px;
border: 2px solid var(--border);
border-radius: var(--radius-sm);
font-size: var(--font-lg);
text-align: center;
min-height: var(--touch-min);
max-width: 120px;
font-weight: 600;
}
.portion-input:focus {
border-color: var(--accent);
outline: none;
box-shadow: 0 0 0 3px rgba(250,232,146,0.25);
}
.portion-unit {
font-size: var(--font-md);
color: var(--text-secondary);
font-weight: 500;
}
.portion-preview {
margin-top: 12px;
padding: 12px;
background: var(--accent-light);
border-radius: var(--radius-sm);
text-align: center;
border: 1px solid var(--accent);
}
.portion-preview-label {
font-size: var(--font-xs);
color: var(--text-secondary);
}
.portion-preview-value {
font-size: var(--font-xl);
font-weight: 800;
color: var(--primary-dark);
}
.add-meal-submit {
width: 100%;
padding: 14px 24px;
background: var(--accent);
color: var(--text);
border: none;
border-radius: var(--radius-sm);
font-size: var(--font-md);
font-weight: 600;
cursor: pointer;
min-height: var(--touch-min);
transition: background 0.15s;
}
.add-meal-submit:active {
background: var(--primary-dark);
}
.add-meal-submit:hover {
background: var(--accent-dark);
}
.add-meal-submit:active {
background: #B8960A;
}
.add-meal-submit:disabled {
background: #BDBDBD;
cursor: not-allowed;
}
/* ===== Utility ===== */
.hidden {
display: none !important;
}
/* ===== Loading Spinner ===== */
.loading {
text-align: center;
padding: 40px 20px;
color: var(--text-muted);
}
.loading-spinner {
display: inline-block;
width: 32px;
height: 32px;
border: 3px solid var(--border);
border-top-color: var(--accent);
border-radius: 50%;
animation: spin 0.6s linear infinite;
margin-bottom: 8px;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
/* ===== Error State ===== */
.error-state {
text-align: center;
padding: 20px;
color: var(--danger);
font-size: var(--font-sm);
}
/* Date picker in add-meal-to-moment modal */
.meal-date-section {
padding: 0 20px 12px;
border-bottom: 1px solid var(--border);
margin-bottom: 8px;
}
.meal-date-section .day-navigator {
padding: 4px 0;
gap: 8px;
}
.meal-date-section .day-nav-btn {
width: 34px;
height: 34px;
font-size: 0.9rem;
}
.meal-date-section .day-label {
min-width: auto;
font-size: var(--font-sm);
cursor: default;
}
.meal-date-section .day-nav-today {
padding: 4px 10px;
font-size: 0.7rem;
min-height: 30px;
}
/* Responsive adjustments */
@media (max-width: 360px) {
.detail-nutrition {
grid-template-columns: repeat(2, 1fr);
}
}
/* ===== Animation System ===== */
/* -- Keyframes -- */
@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }
@keyframes fadeOut { from { opacity: 1; } to { opacity: 0; } }
@keyframes slideUp { from { transform: translateY(100%); opacity: 0; } to { transform: translateY(0); opacity: 1; } }
@keyframes slideDown { from { transform: translateY(0); opacity: 1; } to { transform: translateY(100%); opacity: 0; } }
@keyframes slideInRight { from { transform: translateX(120%); opacity: 0; } to { transform: translateX(0); opacity: 1; } }
@keyframes slideOutRight{ from { transform: translateX(0); opacity: 1; } to { transform: translateX(120%); opacity: 0; } }
@keyframes scaleIn { from { transform: scale(0.85); opacity: 0; } to { transform: scale(1); opacity: 1; } }
@keyframes pulse { 0%,100% { transform: scale(1); } 50% { transform: scale(1.12); } }
@keyframes pulseCount { 0%,100% { transform: scale(1); } 50% { transform: scale(1.3); color: var(--primary); } }
@keyframes fadeInDown { from { opacity: 0; max-height: 0; padding-top: 0; padding-bottom: 0; } to { opacity: 1; max-height: 800px; } }
@keyframes fadeOutUp { from { opacity: 1; max-height: 800px; } to { opacity: 0; max-height: 0; padding-top: 0; padding-bottom: 0; } }
@keyframes checkPop { 0% { transform: scale(1); } 50% { transform: scale(1.3); } 100% { transform: scale(1); } }
@keyframes saveFlash { 0% { box-shadow: 0 0 0 0 rgba(56,161,105,0.4); } 70% { box-shadow: 0 0 0 12px rgba(56,161,105,0); } 100% { box-shadow: 0 0 0 0 rgba(56,161,105,0); } }
@keyframes shimmer { 0% { background-position: -200% 0; } 100% { background-position: 200% 0; } }
/* -- Button press -- */
button, .btn, .meal-card-main, .dagboek-item, .food-item,
.standaard-portie-btn, .portie-stepper-plus, .portie-stepper-minus {
transition: transform 0.12s ease, box-shadow 0.12s ease, opacity 0.12s ease;
}
button:active, .btn:active, .meal-card-main:active, .dagboek-item:active, .food-item:active,
.portie-stepper-plus:active, .portie-stepper-minus:active {
transform: scale(0.96);
}
/* -- Modal / Bottom Sheet -- */
.modal-overlay {
animation: fadeIn 0.2s ease both;
}
.modal-overlay.closing {
animation: fadeOut 0.2s ease both;
}
.modal-sheet {
animation: slideUp 0.3s cubic-bezier(0.16, 1, 0.3, 1) both;
}
.modal-overlay.closing .modal-sheet {
animation: slideDown 0.25s ease-in both;
}
/* -- Tab content transition — crossfade effect on switch only -- */
.tab-content {
/* no default animation; applied via JS .switching class during tab switches */
}
.tab-content.switching {
animation: fadeIn 0.25s cubic-bezier(0.16, 1, 0.3, 1) both;
}
/* -- Page content transition on tab switch -- */
.main-content.page-transition {
animation: fadeIn 0.3s ease both;
}
/* -- Staggered list fade-in -- */
.search-result-item, .food-item, .meal-card, .dagboek-item, .maaltijd-item,
.eetmoment-card, .eetmoment-item, .meal-add-option {
animation: fadeIn 0.35s cubic-bezier(0.16, 1, 0.3, 1) both;
animation-delay: calc(var(--i, 0) * 50ms);
}
/* -- Toast notification -- */
.toast {
position: fixed;
top: auto;
bottom: auto;
right: 16px;
max-width: calc(100% - 32px);
background: var(--text);
color: var(--white);
padding: 10px 20px;
border-radius: 20px;
font-size: var(--font-sm);
font-weight: 500;
z-index: 300;
animation: slideInRight 0.35s cubic-bezier(0.16, 1, 0.3, 1) both;
text-align: center;
pointer-events: none;
}
.toast.hiding {
animation: slideOutRight 0.3s ease-in both;
}
/* -- Stepper -- */
.portie-stepper.active {
transition: background 0.2s ease, border-color 0.2s ease;
}
.portie-stepper-plus.tapped, .portie-stepper-minus.tapped {
animation: pulse 0.3s ease;
}
/* -- Chip / Pill select -- */
.chip, .categorie-chip, .tab-chip {
transition: transform 0.15s ease, background 0.2s ease, color 0.2s ease;
}
.chip:active, .categorie-chip:active, .tab-chip:active {
transform: scale(0.93);
}
/* -- Diary entry add animation -- */
.eetmoment-entry {
animation: fadeIn 0.3s ease both, slideUp 0.3s ease both;
}
/* -- Page transitions between main views -- */
.page-slide-right { animation: slideInRight 0.3s ease both; }
.page-fade-in { animation: fadeIn 0.3s ease both; }
/* -- Loading skeleton shimmer -- */
.skeleton {
background: linear-gradient(90deg, var(--border) 25%, #e8e8e8 50%, var(--border) 75%);
background-size: 200% 100%;
animation: shimmer 1.5s ease-in-out infinite;
}
/* ===== Collapsible sections — max-height transition ===== */
.collapsible {
overflow: hidden;
transition: max-height 0.35s cubic-bezier(0.4, 0, 0.2, 1), opacity 0.3s ease, padding 0.3s ease, margin 0.3s ease;
max-height: 2000px;
opacity: 1;
padding-top: var(--collapsible-pt, 4px);
padding-bottom: var(--collapsible-pb, 4px);
margin-top: var(--collapsible-mt, 0);
margin-bottom: var(--collapsible-mb, 0);
}
.collapsible.collapsed {
max-height: 0 !important;
opacity: 0 !important;
padding-top: 0 !important;
padding-bottom: 0 !important;
margin-top: 0 !important;
margin-bottom: 0 !important;
overflow: hidden !important;
}
/* ===== Item delete fade-out ===== */
.removing {
animation: fadeOutUp 0.3s ease forwards;
overflow: hidden;
pointer-events: none;
}
/* ===== Footer buttons: slide in/out ===== */
.footer-btn-visible {
animation: fadeInDown 0.3s cubic-bezier(0.16, 1, 0.3, 1) forwards;
}
.footer-btn-hidden {
animation: fadeOutUp 0.25s ease forwards;
pointer-events: none;
}
/* ===== Checkbox check animation ===== */
.item-checkbox-wrapper.just-checked {
animation: checkPop 0.25s ease;
}
/* ===== Glucose save flash ===== */
.glucose-inline.save-flash {
animation: saveFlash 0.6s ease;
}
/* ===== Stepper count bump ===== */
.portie-stepper-count.count-bump {
animation: pulseCount 0.3s ease;
}
</style>
</head>
<body>
<div id="app">
<!-- Header -->
<header class="app-header">
<div class="app-logo">
<svg viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg">
<circle cx="50" cy="50" r="46" fill="none" stroke="#E8B730" stroke-width="8"/>
<ellipse cx="38" cy="38" rx="10" ry="18" fill="#E8B730" transform="rotate(-30 38 38)"/>
<ellipse cx="62" cy="62" rx="10" ry="18" fill="#E8B730" transform="rotate(-30 62 62)"/>
<line x1="50" y1="50" x2="50" y2="78" stroke="#E8B730" stroke-width="6" stroke-linecap="round"/>
<circle cx="50" cy="22" r="6" fill="#E8B730"/>
</svg>
<span class="app-logo-text">Karby</span>
</div>
<button class="settings-btn" id="settingsBtn" aria-label="Instellingen"><i class="fas fa-gear"></i></button>
</header>
<!-- Search Bar -->
<div class="search-container" id="searchContainer">
<div class="search-wrapper">
<span class="search-icon"><i class="fas fa-magnifying-glass"></i></span>
<input type="text" class="search-input" id="searchInput" placeholder="Zoek voedingsmiddel..." autocomplete="off">
<button class="search-clear" id="searchClear" aria-label="Wis zoekopdracht"><i class="fas fa-xmark"></i></button>
</div>
</div>
<!-- Category Filter -->
<div class="category-filter" id="categoryFilter">
<button class="cat-btn active" data-cat="">Alle</button>
<button class="cat-btn" data-cat="Brood">🍞 Brood</button>
<button class="cat-btn" data-cat="Zuivel">🥛 Zuivel</button>
<button class="cat-btn" data-cat="Fruit">🍎 Fruit</button>
<button class="cat-btn" data-cat="Groente">🥦 Groente</button>
<button class="cat-btn" data-cat="Vlees/Vis">🥩 Vlees/Vis</button>
<button class="cat-btn" data-cat="Dranken">🥤 Dranken</button>
<button class="cat-btn" data-cat="Tussendoortjes">🍪 Tussendoor</button>
<button class="cat-btn" data-cat="Snoep/Koek">🍬 Snoep/Koek</button>
<button class="cat-btn" data-cat="Overig">📦 Overig</button>
</div>
<!-- Main Content -->
<main class="main-content" id="mainContent">
<!-- Zoeken Tab -->
<div id="tabZoeken">
<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>
</div>
<div class="empty-state hidden" id="emptyState">
<div class="empty-state-icon"><i class="fas fa-magnifying-glass"></i></div>
<div class="empty-state-text">Geen resultaten gevonden</div>
</div>
<div class="error-state hidden" id="errorState">
<i class="fas fa-triangle-exclamation"></i> Kon voedingsdata niet laden. Controleer je verbinding.
</div>
</div>
</div>
<!-- Dagboek Tab -->
<div id="tabDagboek" class="hidden">
<div class="diary-section">
<div class="day-navigator">
<button class="day-nav-btn" id="dayPrev" aria-label="Vorige dag"><i class="fas fa-chevron-left"></i></button>
<div>
<div class="day-label" id="dayLabel"></div>
</div>
<button class="day-nav-btn" id="dayNext" aria-label="Volgende dag"><i class="fas fa-chevron-right"></i></button>
<button class="day-nav-today" id="dayToday">Vandaag</button>
</div>
<!-- Daily Carb Limit Progress Bar -->
<div class="total-carb-bar" id="carbProgressBar">
<div class="total-carb-bar-top">
<span class="total-carb-bar-label"><i class="fas fa-gauge-high"></i> Daglimiet</span>
<span class="total-carb-bar-numbers">
<span id="totalCarbValue">0</span>
<span class="total-carb-bar-unit"> g kh</span>
</span>
</div>
<div class="progress-track">
<div class="progress-fill color-green" id="progressFill"></div>
</div>
<div class="carb-warning" id="carbWarning"></div>
</div>
<!-- Glucose Section removed — inline in eetmoment cards -->
<ul class="eetmoment-list" id="eetmomentList"></ul>
<div class="empty-state hidden" id="diaryEmpty">
<div class="empty-state-icon"><i class="fas fa-clipboard-list"></i></div>
<div class="empty-state-text">Nog geen voedingsmiddelen toegevoegd vandaag.<br>Ga naar Zoeken om items toe te voegen.</div>
</div>
</div>
</div>
<!-- Maaltijden Tab -->
<div id="tabMaaltijden" class="hidden">
<div class="diary-section">
<h2 style="font-family:var(--heading-font);font-size:var(--font-xl);margin-bottom:16px;">Opgeslagen maaltijden</h2>
<div class="maaltijden-list" id="maaltijdenList"></div>
<div class="empty-state" id="mealsEmpty">
<div class="empty-state-icon"><i class="fas fa-utensils"></i></div>
<div class="empty-state-text">Nog geen maaltijden opgeslagen.<br>Sla een eetmoment op in je Dagboek.</div>
</div>
</div>
</div>
</main>
<!-- Bottom Navigation -->
<nav class="bottom-nav">
<button class="nav-btn active" id="navZoeken">
<span class="nav-btn-icon"><i class="fas fa-magnifying-glass"></i></span>
Zoeken
</button>
<button class="nav-btn" id="navDagboek">
<span class="nav-btn-icon"><i class="fas fa-clipboard-list"></i></span>
Dagboek
</button>
<button class="nav-btn" id="navMaaltijden">
<span class="nav-btn-icon"><i class="fas fa-utensils"></i></span>
Maaltijden
</button>
</nav>
</div>
<!-- Detail Modal -->
<div class="modal-overlay hidden" id="detailModal">
<div class="modal-sheet">
<div class="modal-handle"></div>
<div class="modal-header">
<span class="modal-title">Voedingswaarde</span>
<button class="modal-close" id="detailModalClose"><i class="fas fa-xmark"></i></button>
</div>
<div class="modal-body" id="detailModalBody"></div>
</div>
</div>
<!-- Add to Meal Modal -->
<div class="modal-overlay hidden" id="addMealModal">
<div class="modal-sheet">
<div class="modal-handle"></div>
<div class="modal-header">
<span class="modal-title">Toevoegen aan eetmoment</span>
<button class="modal-close" id="addMealModalClose"><i class="fas fa-xmark"></i></button>
</div>
<div class="modal-body" id="addMealModalBody"></div>
</div>
</div>
<!-- Settings Modal -->
<div class="modal-overlay hidden" id="settingsModal">
<div class="modal-sheet">
<div class="modal-handle"></div>
<div class="modal-header">
<span class="modal-title"><i class="fas fa-gear"></i> Instellingen</span>
<button class="modal-close" id="settingsModalClose"><i class="fas fa-xmark"></i></button>
</div>
<div class="modal-body" id="settingsModalBody">
<div class="settings-option">
<label for="carbLimitInput">Dagelijkse koolhydratenlimiet</label>
<div class="portion-input-wrapper">
<input type="number" class="portion-input" id="carbLimitInput" value="175" min="1" max="999" inputmode="numeric" pattern="[0-9]*">
<span class="portion-unit">gram per dag</span>
</div>
<div class="help-text">De standaard limiet van 175g is gebaseerd op de zwangerschapsdiabetes richtlijn. Pas aan op advies van je diëtist.</div>
</div>
<button class="add-meal-submit" id="saveCarbLimitBtn"><i class="fas fa-check"></i> Opslaan</button>
</div>
</div>
</div>
<!-- Toast Container -->
<div id="toastContainer"></div>
<script>
// ===== Service Worker + Main App (single guard) =====
(function() {
// Detect Service Worker context: no 'document' object means we're running
// inside a ServiceWorkerGlobalScope (the browser loaded this file as a SW).
if (typeof document === 'undefined') {
var CACHE_NAME = 'koolhydraat-app-v1';
var ASSETS = ['/', '/index.html', '/data/voedingsmiddelen.json'];
self.addEventListener('install', function(event) {
event.waitUntil(
caches.open(CACHE_NAME).then(function(cache) {
return cache.addAll(ASSETS).catch(function() {});
}).then(function() { return self.skipWaiting(); })
);
});
self.addEventListener('activate', function(event) {
event.waitUntil(
caches.keys().then(function(keys) {
return Promise.all(
keys.filter(function(key) { return key !== CACHE_NAME; })
.map(function(key) { return caches.delete(key); })
);
}).then(function() { return self.clients.claim(); })
);
});
self.addEventListener('fetch', function(event) {
event.respondWith(
caches.match(event.request).then(function(cached) {
var fetchPromise = fetch(event.request).then(function(response) {
if (response && response.status === 200) {
var clone = response.clone();
caches.open(CACHE_NAME).then(function(cache) {
cache.put(event.request, clone);
});
}
return response;
}).catch(function() { return cached; });
return cached || fetchPromise;
})
);
});
return; // Stop — we're in SW context, don't run main app
}
// ===== Main Application Code (runs only in browser context) =====
// ===== Constants =====
const EETMOMENTEN = [
{ id: 'Ontbijt', name: 'Ontbijt', icon: '🥐' },
{ id: 'Tussendoor 1', name: 'Tussendoor 1', icon: '☕' },
{ id: 'Lunch', name: 'Lunch', icon: '🥪' },
{ id: 'Tussendoor 2', name: 'Tussendoor 2', icon: '🍎' },
{ id: 'Avondeten', name: 'Avondeten', icon: '🍲' },
{ id: 'Tussendoor 3', name: 'Tussendoor 3', icon: '🍪' }
];
const CATEGORIES = ['Brood', 'Zuivel', 'Fruit', 'Groente', 'Vlees/Vis', 'Dranken', 'Tussendoortjes', 'Snoep/Koek', 'Overig'];
// Map NEVO categories to app categories for filtering
const CATEGORY_MAP = {
'Brood': 'Brood',
'Melk en melkproducten': 'Zuivel',
'Kaas': 'Zuivel',
'Fruit': 'Fruit',
'Groente': 'Groente',
'Aardappelen en knolgewassen': 'Groente',
'Peulvruchten': 'Groente',
'Vlees en gevogelte': 'Vlees/Vis',
'Vis, schaal- en schelpdieren': 'Vlees/Vis',
'Vleeswaren': 'Vlees/Vis',
'Eieren': 'Vlees/Vis',
'Alcoholische dranken': 'Dranken',
'Niet-alcoholische dranken': 'Dranken',
'Hartige snacks en zoutjes': 'Tussendoortjes',
'Noten en zaden': 'Tussendoortjes',
'Gebak en koek': 'Snoep/Koek',
'Suiker, snoep, zoet beleg en zoete sauzen': 'Snoep/Koek',
'Graanproducten en meelsoorten': 'Overig',
'Vleesvervangers en zuivelvervangers': 'Overig',
'Samengestelde gerechten': 'Overig',
'Soepen': 'Overig',
'Hartige sauzen': 'Overig',
'Kruiden en specerijen': 'Overig',
'Vetten en oliën': 'Overig',
'Diversen': 'Overig',
'Flesvoeding en preparaten': 'Overig',
'Hartig broodbeleg': 'Overig'
};
function mapCategory(itemCat) {
return CATEGORY_MAP[itemCat] || 'Overig';
}
// ===== Prikmomenten voor glucose =====
const PRICKMOMENTEN = [
{ key: 'nuchter', label: 'Nuchter (voor ontbijt)', targetMin: 0, targetMax: 5.4, momentId: null },
{ key: 'naOntbijt', label: '2 uur na ontbijt', targetMin: 5.4, targetMax: 6.7, momentId: 'Ontbijt' },
{ key: 'naLunch', label: '2 uur na lunch', targetMin: 5.4, targetMax: 6.7, momentId: 'Lunch' },
{ key: 'naAvondeten', label: '2 uur na avondeten', targetMin: 5.4, targetMax: 6.7, momentId: 'Avondeten' }
];
// ===== Standaard portiegroottes per categorie =====
// Gebaseerd op Voedingscentrum / NEVO richtlijnen
/** Drie praktische porties per categorie: een intuïtieve eenheid + 100g + 50g */
const STANDARD_PORTIES = {
'Brood': [{ label:'1 snee', grams:35 }, { label:'100g', grams:100 }, { label:'50g', grams:50 }],
'Groente': [{ label:'1 portie', grams:200 }, { label:'100g', grams:100 }, { label:'50g', grams:50 }],
'Fruit': [{ label:'1 stuk', grams:130 }, { label:'100g', grams:100 }, { label:'50g', grams:50 }],
'Melk en melkproducten':[{ label:'1 glas', grams:150 }, { label:'100g', grams:100 }, { label:'50g', grams:50 }],
'Kaas': [{ label:'1 plak', grams:20 }, { label:'100g', grams:100 }, { label:'50g', grams:50 }],
'Vlees en gevogelte': [{ label:'1 portie', grams:100 }, { label:'100g', grams:100 }, { label:'50g', grams:50 }],
'Vis, schaal- en schelpdieren': [{ label:'1 portie', grams:125 }, { label:'100g', grams:100 }, { label:'50g', grams:50 }],
'Eieren': [{ label:'1 ei', grams:50 }, { label:'100g', grams:100 }, { label:'50g', grams:50 }],
'Niet-alcoholische dranken': [{ label:'1 glas', grams:150 }, { label:'100g', grams:100 }, { label:'50g', grams:50 }],
'Alcoholische dranken':[{ label:'1 glas', grams:150 }, { label:'100g', grams:100 }, { label:'50g', grams:50 }],
'Graanproducten en meelsoorten': [{ label:'1 portie', grams:75 }, { label:'100g', grams:100 }, { label:'50g', grams:50 }],
'Aardappelen en knolgewassen': [{ label:'1 portie', grams:200 }, { label:'100g', grams:100 }, { label:'50g', grams:50 }],
'Noten en zaden': [{ label:'1 handje', grams:25 }, { label:'100g', grams:100 }, { label:'50g', grams:50 }],
'Peulvruchten': [{ label:'1 portie', grams:200 }, { label:'100g', grams:100 }, { label:'50g', grams:50 }],
'Gebak en koek': [{ label:'1 stuk', grams:40 }, { label:'100g', grams:100 }, { label:'50g', grams:50 }],
'Suiker, snoep, zoet beleg en zoete sauzen': [{ label:'1 eetlepel', grams:15 }, { label:'100g', grams:100 }, { label:'50g', grams:50 }],
'Hartige snacks en zoutjes': [{ label:'1 stuk', grams:75 }, { label:'100g', grams:100 }, { label:'50g', grams:50 }],
'Samengestelde gerechten': [{ label:'1 portie', grams:350 }, { label:'100g', grams:100 }, { label:'50g', grams:50 }],
'Soepen': [{ label:'1 kom', grams:250 }, { label:'100g', grams:100 }, { label:'50g', grams:50 }],
'Hartige sauzen': [{ label:'1 eetlepel', grams:15 }, { label:'100g', grams:100 }, { label:'50g', grams:50 }],
'Vetten en oliën': [{ label:'1 eetlepel', grams:15 }, { label:'100g', grams:100 }, { label:'50g', grams:50 }],
'Vleeswaren': [{ label:'1 plakje', grams:15 }, { label:'100g', grams:100 }, { label:'50g', grams:50 }],
'Vleesvervangers en zuivelvervangers': [{ label:'1 portie', grams:100 }, { label:'100g', grams:100 }, { label:'50g', grams:50 }],
'Hartig broodbeleg': [{ label:'1 boterham', grams:25 }, { label:'100g', grams:100 }, { label:'50g', grams:50 }],
'Kruiden en specerijen':[{ label:'1 theelepel', grams:2 }, { label:'100g', grams:100 }, { label:'50g', grams:50 }],
'Flesvoeding en preparaten': [{ label:'1 portie', grams:90 }, { label:'100g', grams:100 }, { label:'50g', grams:50 }],
'Diversen': [{ label:'1 portie', grams:100 }, { label:'100g', grams:100 }, { label:'50g', grams:50 }],
};
/** Porties op basis van NEVO-categorie — géén keyword-guessing meer */
function getStandardPorties(item) {
const cat = (item.cat || '').trim();
// Direct lookup by exact NEVO category name
if (STANDARD_PORTIES[cat]) return STANDARD_PORTIES[cat];
// Partial match fallback
for (const [key, val] of Object.entries(STANDARD_PORTIES)) {
if (cat.includes(key) || key.includes(cat)) return val;
}
return STANDARD_PORTIES['Diversen'];
}
// ===== Sample Data (fallback) =====
const SAMPLE_DATA = [
{ n: "1", naam: "Volkorenbrood", cat: "Brood", kcal: 230, kh: "41,0", eiwit: "9,0", vet: "3,0", vezel: "7,0" },
{ n: "2", naam: "Wit brood", cat: "Brood", kcal: 250, kh: "48,0", eiwit: "8,0", vet: "2,5", vezel: "3,0" },
{ n: "3", naam: "Bruin brood", cat: "Brood", kcal: 235, kh: "43,0", eiwit: "8,5", vet: "2,8", vezel: "5,5" },
{ n: "4", naam: "Halfvolle melk", cat: "Zuivel", kcal: 47, kh: "4,8", eiwit: "3,4", vet: "1,5", vezel: "0,0" },
{ n: "5", naam: "Goudse kaas 48+", cat: "Zuivel", kcal: 375, kh: "0,0", eiwit: "25,0", vet: "30,0", vezel: "0,0" },
{ n: "6", naam: "Yoghurt (vol)", cat: "Zuivel", kcal: 62, kh: "4,0", eiwit: "3,5", vet: "3,0", vezel: "0,0" },
{ n: "7", naam: "Appel (met schil)", cat: "Fruit", kcal: 55, kh: "12,0", eiwit: "0,3", vet: "0,1", vezel: "2,0" },
{ n: "8", naam: "Banaan", cat: "Fruit", kcal: 92, kh: "20,0", eiwit: "1,1", vet: "0,3", vezel: "2,0" },
{ n: "9", naam: "Sinaasappel", cat: "Fruit", kcal: 51, kh: "9,2", eiwit: "1,0", vet: "0,2", vezel: "1,7" },
{ n: "10", naam: "Broccoli (gekookt)", cat: "Groente", kcal: 29, kh: "2,6", eiwit: "3,0", vet: "0,3", vezel: "3,3" },
{ n: "11", naam: "Wortel (gekookt)", cat: "Groente", kcal: 33, kh: "5,0", eiwit: "0,8", vet: "0,2", vezel: "3,0" },
{ n: "12", naam: "Bloemkool (gekookt)", cat: "Groente", kcal: 25, kh: "2,0", eiwit: "2,0", vet: "0,3", vezel: "2,5" },
{ n: "13", naam: "Kipfilet (bereid)", cat: "Vlees/Vis", kcal: 130, kh: "0,0", eiwit: "31,0", vet: "1,5", vezel: "0,0" },
{ n: "14", naam: "Zalm (gekookt)", cat: "Vlees/Vis", kcal: 195, kh: "0,0", eiwit: "25,0", vet: "10,0", vezel: "0,0" },
{ n: "15", naam: "Rundvlees (mager)", cat: "Vlees/Vis", kcal: 170, kh: "0,0", eiwit: "28,0", vet: "6,0", vezel: "0,0" },
{ n: "16", naam: "Cola (regulier)", cat: "Dranken", kcal: 42, kh: "10,6", eiwit: "0,0", vet: "0,0", vezel: "0,0" },
{ n: "17", naam: "Sinaasappelsap", cat: "Dranken", kcal: 45, kh: "9,3", eiwit: "0,7", vet: "0,1", vezel: "0,2" },
{ n: "18", naam: "Mueslireep", cat: "Tussendoortjes", kcal: 390, kh: "62,0", eiwit: "7,0", vet: "11,0", vezel: "5,0" },
{ n: "19", naam: "Melkchocolade", cat: "Snoep/Koek", kcal: 540, kh: "55,0", eiwit: "6,0", vet: "32,0", vezel: "2,5" },
{ n: "20", naam: "Ontbijtkoek", cat: "Snoep/Koek", kcal: 300, kh: "64,0", eiwit: "3,0", vet: "2,0", vezel: "2,0" },
{ n: "21", naam: "Gekookt ei", cat: "Overig", kcal: 145, kh: "0,0", eiwit: "13,0", vet: "10,0", vezel: "0,0" }
];
// ===== State =====
let voedingsmiddelen = [];
let dagboek = {}; // { "2026-07-23": { "Ontbijt": [{item, portie}, ...], ... } }
let activeDate = formatDate(new Date());
let activeTab = 'zoeken';
let lastClickY = null; // tracked for toast positioning
let selectedCategory = null; // null = alle
let searchQuery = '';
let searchIndex = null; // Map<item, expanded search tokens string>
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;
// ===== Utility Functions =====
function formatDate(d) {
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, '0');
const day = String(d.getDate()).padStart(2, '0');
return `${y}-${m}-${day}`;
}
function parseDate(str) {
const [y, m, d] = str.split('-').map(Number);
return new Date(y, m - 1, d);
}
function formatDisplayDate(str) {
const d = parseDate(str);
const today = formatDate(new Date());
const yesterday = formatDate(new Date(Date.now() - 86400000));
const tomorrow = formatDate(new Date(Date.now() + 86400000));
const months = ['jan', 'feb', 'mrt', 'apr', 'mei', 'jun', 'jul', 'aug', 'sep', 'okt', 'nov', 'dec'];
const days = ['zondag', 'maandag', 'dinsdag', 'woensdag', 'donderdag', 'vrijdag', 'zaterdag'];
let label = `${days[d.getDay()]} ${d.getDate()} ${months[d.getMonth()]}`;
if (str === today) label = 'Vandaag — ' + label;
else if (str === yesterday) label = 'Gisteren — ' + label;
else if (str === tomorrow) label = 'Morgen — ' + label;
return label;
}
function parseKh(val) {
if (val === undefined || val === null || val === '') return 0;
if (typeof val === 'number') return val;
return parseFloat(String(val).replace(',', '.')) || 0;
}
function getOrCreateDay(dateStr) {
if (!dagboek[dateStr]) {
dagboek[dateStr] = {};
EETMOMENTEN.forEach(m => { dagboek[dateStr][m.id] = []; });
}
return dagboek[dateStr];
}
function berekenEetmomentKh(dateStr, momentId) {
const day = dagboek[dateStr];
if (!day || !day[momentId]) return 0;
return day[momentId].reduce((sum, entry) => {
if (entry.type === 'meal') {
return sum + entry.items.reduce((s, e) => {
return s + (parseKh(e.item.kh) / 100) * (e.portie || 0);
}, 0);
}
const khPer100 = parseKh(entry.item.kh);
const portie = entry.portie || 0;
return sum + (khPer100 / 100) * portie;
}, 0);
}
function berekenDagTotaal(dateStr) {
const day = dagboek[dateStr];
if (!day) return 0;
return EETMOMENTEN.reduce((sum, m) => sum + berekenEetmomentKh(dateStr, m.id), 0);
}
// ===== LocalStorage =====
function saveToStorage() {
try {
localStorage.setItem('koolhydraat-dagboek', JSON.stringify(dagboek));
} catch (e) {
// Storage full - silently ignore
}
}
function loadFromStorage() {
try {
const raw = localStorage.getItem('koolhydraat-dagboek');
if (raw) {
dagboek = JSON.parse(raw);
}
} catch (e) {
dagboek = {};
}
// Ensure current date exists
getOrCreateDay(activeDate);
// Load maaltijden
loadMaaltijdenFromStorage();
// Load glucose data
loadGlucoseFromStorage();
}
function saveMaaltijden() {
try {
localStorage.setItem('koolhydraat-maaltijden', JSON.stringify(maaltijden));
} catch (e) {
// Storage full
}
}
function loadMaaltijdenFromStorage() {
try {
const raw = localStorage.getItem('koolhydraat-maaltijden');
if (raw) {
maaltijden = JSON.parse(raw);
}
} catch (e) {
maaltijden = [];
}
}
// ===== 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; });
}
}
// ===== Glucose — Inline per eetmoment =====
function renderNuchterCard() {
const m = PRICKMOMENTEN.find(p => p.key === 'nuchter');
if (!m) return '';
ensureGlucoseDay(activeDate);
const value = glucose[activeDate]?.['nuchter'] ?? null;
const status = isGlucoseInRange(value, 'nuchter');
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 : '';
return `
<li class="nuchter-card ${statusClass}">
<div class="nuchter-card-header">
<span class="nuchter-card-icon">🩸</span>
<span class="nuchter-card-label">Nuchter (voor ontbijt)</span>
<span class="nuchter-card-range">< ${m.targetMax}</span>
</div>
<div class="nuchter-card-body">
<div class="glucose-input-wrapper">
<input type="number" class="glucose-input"
value="${displayValue}"
data-moment="nuchter"
step="0.1" min="0" max="30"
placeholder="—"
inputmode="decimal">
<span class="glucose-unit">mmol/L</span>
</div>
<div class="glucose-status ${statusClass}">
<span class="glucose-status-icon">${statusIcon}</span>
<span class="glucose-status-text">${statusText}</span>
</div>
</div>
</li>
`;
}
function renderInlineGlucoseItem(m) {
ensureGlucoseDay(activeDate);
const value = glucose[activeDate]?.[m.key] ?? null;
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();
shortLabel = shortLabel.charAt(0).toUpperCase() + shortLabel.slice(1);
return `
<div class="glucose-inline ${statusClass}" data-moment-key="${m.key}">
<div class="glucose-inline-left">
<span class="glucose-inline-icon">🩸</span>
<span class="glucose-inline-label">${shortLabel}</span>
<span class="glucose-inline-range">${m.targetMin}${m.targetMax}</span>
</div>
<div class="glucose-inline-right">
<div class="glucose-input-wrapper">
<input type="number" class="glucose-input"
value="${displayValue}"
data-moment="${m.key}"
step="0.1" min="0" max="30"
placeholder="—"
inputmode="decimal">
<span class="glucose-unit">mmol/L</span>
</div>
<div class="glucose-status ${statusClass}">
<span class="glucose-status-icon">${statusIcon}</span>
<span class="glucose-status-text">${statusText}</span>
</div>
</div>
</div>
`;
}
function attachGlucoseInputHandlers() {
document.querySelectorAll('.glucose-input').forEach(input => {
// Avoid double-binding
if (input.dataset.glucoseBound) return;
input.dataset.glucoseBound = 'true';
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)) {
hapticError();
input.value = glucose[activeDate][moment] !== null && glucose[activeDate][moment] !== undefined ? glucose[activeDate][moment] : '';
return;
}
if (glucose[activeDate][moment] !== val) {
glucose[activeDate][moment] = val;
saveGlucoseToStorage();
updateGlucoseUI(moment);
}
});
input.addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
input.blur();
}
});
});
}
function updateGlucoseUI(momentKey) {
const m = PRICKMOMENTEN.find(p => p.key === momentKey);
if (!m) return;
if (m.momentId === null) {
// Update nuchter card
const card = document.querySelector('.nuchter-card');
if (card) {
card.outerHTML = renderNuchterCard();
}
} else {
// Update inline glucose in eetmoment card
const el = document.querySelector(`.glucose-inline[data-moment-key="${momentKey}"]`);
if (el) {
el.outerHTML = renderInlineGlucoseItem(m);
// Flash feedback on the new element
requestAnimationFrame(() => {
const newEl = document.querySelector(`.glucose-inline[data-moment-key="${momentKey}"]`);
if (newEl) {
newEl.classList.add('save-flash');
setTimeout(() => newEl.classList.remove('save-flash'), 600);
}
});
}
}
// Re-attach handlers on replaced elements
attachGlucoseInputHandlers();
}
// ===== Daily Carb Limit =====
const DEFAULT_CARB_LIMIT = 175; // grams per day
function getCarbLimit() {
try {
const stored = localStorage.getItem('koolhydraat-limiet');
if (stored !== null) {
const val = parseInt(stored, 10);
if (val > 0) return val;
}
} catch (e) { /* ignore */ }
return DEFAULT_CARB_LIMIT;
}
function setCarbLimit(val) {
try {
localStorage.setItem('koolhydraat-limiet', String(val));
} catch (e) { /* ignore */ }
}
// ===== Data Loading =====
async function loadVoedingsData() {
const loadingEl = document.getElementById('loadingState');
const errorEl = document.getElementById('errorState');
loadingEl.classList.remove('hidden');
try {
const response = await fetch('data/voedingsmiddelen.json');
if (!response.ok) throw new Error('HTTP ' + response.status);
const data = await response.json();
if (Array.isArray(data) && data.length > 0) {
voedingsmiddelen = data;
buildSearchIndex();
console.log(`${data.length} voedingsmiddelen geladen uit data/voedingsmiddelen.json`);
} else {
throw new Error('Lege of ongeldige data');
}
} catch (err) {
console.warn('⚠️ Externe data niet geladen, gebruik sample data:', err.message);
voedingsmiddelen = [...SAMPLE_DATA];
buildSearchIndex();
// Show error only if we haven't loaded data before
if (!dataLoaded) {
const toast = document.createElement('div');
toast.className = 'toast';
toast.textContent = 'Offline modus — sample data gebruikt';
document.getElementById('toastContainer').appendChild(toast);
// Position at last click Y or default bottom
const y2 = lastClickY || window.innerHeight - 120;
const h2 = toast.offsetHeight || 50;
const maxY2 = window.innerHeight - h2 - 16;
toast.style.top = Math.min(Math.max(y2, 16), maxY2) + 'px';
toast.style.bottom = 'auto';
setTimeout(() => toast.remove(), 2000);
}
}
loadingEl.classList.add('hidden');
errorEl.classList.add('hidden');
dataLoaded = true;
renderZoeken();
}
// ===== Smart Search — Normalize, Meervoud/Enkelvoud, fuzzy =====
/** Normaliseer tekst: lowercase, trim, diakritische tekens verwijderen */
function normalize(str) {
return String(str).toLowerCase().trim()
.normalize('NFD').replace(/[\u0300-\u036f]/g, '')
.replace(/[^a-z0-9\s-]/g, '');
}
/** Genereer varianten van een Nederlands woord (enkelvoud ↔ meervoud) */
function getWordVariants(word) {
const forms = new Set();
forms.add(word);
if (word.length < 2) return forms;
const vowels = 'aeiouy';
// --- Van meervoud naar enkelvoud ---
// Suffix -en (bv. bananen → banaan, appelen → appel, tomaten → tomaat)
if (word.endsWith('en') && word.length > 4) {
const stem = word.slice(0, -2);
forms.add(stem);
// Vouwherstel: bananen → banaan, tomaten → tomaat
// Als de stam eindigt op medeklinker + enkele klinker + medeklinker
for (let i = stem.length - 2; i >= 1; i--) {
if (vowels.includes(stem[i]) && !vowels.includes(stem[i - 1])) {
const c = stem[i];
// Check dat er een medeklinker volgt na deze klinker
if (i + 1 < stem.length && !vowels.includes(stem[i + 1])) {
const variant = stem.slice(0, i) + c + c + stem.slice(i + 1);
forms.add(variant);
}
break;
}
}
}
// Suffix -s (bv. appels → appel)
// Alleen voor woorden die niet op -ss eindigen
if (word.endsWith('s') && !word.endsWith('ss') && word.length > 3) {
forms.add(word.slice(0, -1));
}
// --- Van enkelvoud naar meervoud ---
// Als het woord niet op -en eindigt, probeer meervoudsvormen toe te voegen
if (!word.endsWith('en')) {
// Standaard: woord + en
forms.add(word + 'en');
// Woorden met dubbele klinker: banaan → bananen (aa → a + en)
const dm = word.match(/^(.+[^aeiouy])([aeiou])\2([^aeiouy].*)$/);
if (dm) {
const before = dm[1];
const v = dm[2];
const after = dm[3];
forms.add(before + v + after + 'en');
}
}
// Suffix -s voor bepaalde woordtypen
if (!word.endsWith('s')) {
forms.add(word + 's');
}
return [...forms];
}
/** Bouw zoekindex: voor elk item een uitgebreide string met woordvarianten */
function buildSearchIndex() {
searchIndex = new Map();
for (const item of voedingsmiddelen) {
const norm = normalize(item.naam);
const tokens = norm.split(/\s+/).filter(t => t.length >= 2);
const expandedTokens = new Set(tokens);
for (const token of tokens) {
const variants = getWordVariants(token);
for (const v of variants) {
expandedTokens.add(v);
}
}
// Bewaar de uitgebreide zoekstring (alfabetisch gesorteerd)
searchIndex.set(item, [...expandedTokens].sort().join(' '));
}
}
/** Controleer of een zoekterm matcht met een item in de zoekindex */
function matchesSearch(item, queryTokens) {
const expanded = searchIndex.get(item);
if (!expanded) return false;
// Fast path: gehele query in expanded string
const fullQuery = queryTokens.join(' ');
if (expanded.includes(fullQuery)) return true;
// Token-level matching: elke zoekterm moet matchen met ten minste één token
// in de expanded string
const indexTokens = expanded.split(/\s+/);
for (const qt of queryTokens) {
let found = false;
for (const it of indexTokens) {
if (it === qt || it.startsWith(qt) || qt.startsWith(it)) {
found = true;
break;
}
}
if (!found) return false;
}
return true;
}
// ===== Filter & Search =====
function getFilteredItems() {
let items = [...voedingsmiddelen];
// Category filter (using mapped categories)
if (selectedCategory) {
items = items.filter(item => mapCategory(item.cat) === selectedCategory);
}
// Search filter
if (searchQuery.trim() && searchIndex) {
const q = normalize(searchQuery);
const queryTokens = q.split(/\s+/).filter(t => t.length >= 1);
if (queryTokens.length > 0) {
items = items.filter(item => matchesSearch(item, queryTokens));
}
}
// Sort by name
items.sort((a, b) => a.naam.localeCompare(b.naam, 'nl'));
return items;
}
// ===== 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 sentinelEl = document.getElementById('searchSentinel');
// Reset pagination on new search/filter
searchVisibleCount = SEARCH_PAGE_SIZE;
disconnectSearchObserver();
// Show results count
countEl.textContent = items.length === 0
? 'Geen resultaten'
: `${items.length} ${items.length === 1 ? 'resultaat' : 'resultaten'}`;
// Render list
if (items.length === 0) {
listEl.innerHTML = '';
emptyEl.classList.remove('hidden');
sentinelEl.classList.add('hidden');
return;
}
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 kh = parseKh(item.kh);
// Cap --i at batch size: max animation delay = 49 × 50ms = 2.45s
const animIdx = idx % SEARCH_PAGE_SIZE;
return `
<li class="food-item" data-index="${voedingsmiddelen.indexOf(item)}" style="--i:${animIdx}" 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) {
// Load up to 3 batches at once when user scrolls fast
// This keeps the sentinel pushed far ahead of the viewport
let batchesLoaded = 0;
const maxBatches = 3;
while (batchesLoaded < maxBatches && searchVisibleCount < allItems.length) {
appendSearchBatch(allItems);
batchesLoaded++;
}
}
}, { rootMargin: '1200px' });
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) {
const limiet = getCarbLimit();
const rawPct = (totaal / limiet) * 100;
const clampedPct = Math.min(rawPct, 100);
// Update numbers: show "87 / 175 g · 50%"
const numbersEl = document.querySelector('.total-carb-bar-numbers');
if (numbersEl) {
numbersEl.innerHTML = `
<span id="totalCarbValue">${Math.round(totaal)}</span>
<span class="total-carb-bar-unit"> / ${limiet} g · ${Math.round(clampedPct)}%</span>
`;
}
// Update progress bar fill
const fill = document.getElementById('progressFill');
const warning = document.getElementById('carbWarning');
let colorClass = 'color-green';
if (rawPct > 100) {
colorClass = 'color-red';
} else if (rawPct >= 71) {
colorClass = 'color-amber';
}
fill.className = 'progress-fill ' + colorClass;
fill.style.width = clampedPct + '%';
// Warning text
if (rawPct > 100) {
const over = Math.round(totaal - limiet);
warning.innerHTML = `<i class="fas fa-triangle-exclamation"></i> Je zit vandaag ${over}g boven je limiet van ${limiet}g`;
warning.classList.add('visible');
} else {
warning.classList.remove('visible');
}
}
function renderDagboek() {
if (activeTab !== 'dagboek') return;
const dayData = getOrCreateDay(activeDate);
const totaal = berekenDagTotaal(activeDate);
const limiet = getCarbLimit();
const overLimit = totaal > limiet;
// Day label
document.getElementById('dayLabel').textContent = formatDisplayDate(activeDate);
// Progress bar
renderProgressBar(totaal);
// Eating moments — prepend nuchter card, then eetmoment cards with inline glucose
const listEl = document.getElementById('eetmomentList');
const nuchterHtml = renderNuchterCard();
listEl.innerHTML = nuchterHtml + EETMOMENTEN.map((moment, mi) => {
const items = dayData[moment.id] || [];
const momentKh = berekenEetmomentKh(activeDate, moment.id);
const roundedKh = Math.round(momentKh);
const itemsHtml = items.length === 0
? ''
: items.map((entry, i) => {
// Nested meal group from saved meal
if (entry.type === 'meal') {
const mealItemsHtml = entry.items.map((mealItem, mi) => {
const khPer100 = parseKh(mealItem.item.kh);
const miPortie = mealItem.portie || 0;
const miKh = (khPer100 / 100) * miPortie;
return `
<div class="eetmoment-meal-item">
<div class="eetmoment-item-info">
<div class="eetmoment-item-name">${escapeHtml(displayNaam(mealItem.item.naam))}</div>
<div class="eetmoment-item-portion">
<span class="portion-badge" title="Portie wijzigen"
data-action="edit-portie"
data-moment="${moment.id}"
data-entry="${i}"
data-mi="${mi}">
${miPortie} g <i class="fas fa-pen"></i>
</span>
</div>
</div>
<span class="eetmoment-item-kh">${formatKh(miKh)} g</span>
<button class="eetmoment-remove" data-haptic="strong" data-moment="${moment.id}" data-entry="${i}" data-mi="${mi}" aria-label="Verwijderen"><i class="fas fa-trash-can"></i></button>
</div>
`;
}).join('');
const mealKh = entry.items.reduce((sum, ei) => {
return sum + (parseKh(ei.item.kh) / 100) * (ei.portie || 0);
}, 0);
return `
<li class="eetmoment-meal-group">
<div class="eetmoment-meal-header" data-action="toggle-meal" data-entry="${i}">
<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>
</div>
<div class="eetmoment-meal-items-wrap collapsible">${mealItemsHtml}</div>
<div class="eetmoment-meal-actions collapsible">
<button class="eetmoment-meal-action-btn" data-action="edit-porties" data-entry="${i}" data-moment="${moment.id}">
<i class="fas fa-pen"></i> Porties
</button>
<button class="eetmoment-meal-action-btn" data-action="move-meal" data-entry="${i}" data-moment="${moment.id}">
<i class="fas fa-arrow-right"></i> Verplaatsen
</button>
<button class="eetmoment-meal-action-btn eetmoment-delete" data-haptic="strong" data-action="delete-meal" data-entry="${i}" data-moment="${moment.id}">
<i class="fas fa-trash"></i> Verwijderen
</button>
</div>
</li>
`;
}
// Single food item (backward compat: no type or type === 'item')
const khPer100 = parseKh(entry.item.kh);
const portie = entry.portie || 0;
const itemKh = (khPer100 / 100) * portie;
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}">
</span>
<div class="eetmoment-item-info">
<div class="eetmoment-item-name">${escapeHtml(displayNaam(entry.item.naam))}</div>
<div class="eetmoment-item-portion">
<span class="portion-badge" title="Portie wijzigen"
data-action="edit-portie"
data-moment="${moment.id}"
data-entry="${i}">
${portie} g <i class="fas fa-pen"></i>
</span>
</div>
</div>
<span class="eetmoment-item-kh">${formatKh(itemKh)} g</span>
<button class="eetmoment-remove" data-haptic="strong" data-moment="${moment.id}" data-index="${i}" aria-label="Verwijderen" title="Verwijderen"><i class="fas fa-trash-can"></i></button>
</li>
`;
}).join('');
const cardOverLimit = overLimit && momentKh > 0;
// Check if this eetmoment has a matching glucose moment
const glucoseMoment = PRICKMOMENTEN.find(p => p.momentId === moment.id);
const inlineGlucoseHtml = glucoseMoment ? renderInlineGlucoseItem(glucoseMoment) : '';
return `
<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>
<div style="display:flex;align-items:center;gap:2px;">
<span class="eetmoment-kh">${roundedKh > 0 ? formatKh(roundedKh) + ' g' : '—'}</span>
<span style="font-size:0.8rem;color:var(--text-muted)">${items.length > 0 ? '<i class="fas fa-chevron-down"></i>' : ''}</span>
</div>
</div>
${items.length > 0 ? `<ul class="eetmoment-items collapsible">${itemsHtml}</ul>` : ''}
${inlineGlucoseHtml}
<div class="eetmoment-add-btn" data-moment="${moment.id}">
<span>+</span> Voedingsmiddel toevoegen
</div>
${items.length > 1 ? `
<div class="save-meal-footer" data-moment="${moment.id}">
<button class="save-meal-footer-btn footer-hidden" data-moment="${moment.id}">
<i class="fas fa-star"></i> Selecteer ${items.length} items
</button>
<button class="delete-selected-footer-btn footer-hidden" data-haptic="strong" data-moment="${moment.id}">
<i class="fas fa-trash"></i> Verwijder geselecteerde
</button>
</div>` : ''}
</li>
`;
}).join('');
// Add event listeners — remove from diary
listEl.querySelectorAll('.eetmoment-remove').forEach(btn => {
btn.addEventListener('click', async (e) => {
e.stopPropagation();
const momentId = btn.dataset.moment;
const entry = parseInt(btn.dataset.index);
const groupIdx = btn.dataset.entry;
const mi = btn.dataset.mi;
if (mi !== undefined) {
// Removing item from within a meal group
const itemEl = btn.closest('.eetmoment-meal-item');
await removeMealItemFromDiary(activeDate, momentId, parseInt(groupIdx), parseInt(mi), itemEl);
} else {
const itemEl = btn.closest('.eetmoment-item');
await removeFromMeal(activeDate, momentId, entry, itemEl);
}
});
});
// Portion display click → edit dialog
listEl.querySelectorAll('[data-action="edit-portie"]').forEach(el => {
el.addEventListener('click', (e) => {
e.stopPropagation();
const momentId = el.dataset.moment;
const entryIdx = parseInt(el.dataset.entry);
const mi = el.dataset.mi; // undefined for single items
showPortieEditDialog(activeDate, momentId, entryIdx, mi !== undefined ? parseInt(mi) : null);
});
});
listEl.querySelectorAll('.eetmoment-add-btn').forEach(btn => {
btn.addEventListener('click', (e) => {
e.stopPropagation();
const momentId = btn.dataset.moment;
openAddToMealForMoment(momentId);
});
});
listEl.querySelectorAll('.eetmoment-header').forEach(header => {
header.addEventListener('click', () => {
const momentId = header.dataset.moment;
// Toggle items visibility
const card = header.closest('.eetmoment-card');
const itemsList = card.querySelector('.eetmoment-items');
if (itemsList) {
itemsList.classList.toggle('collapsed');
const arrow = header.querySelector('span:last-child');
if (arrow) {
arrow.innerHTML = itemsList.classList.contains('collapsed') ? '<i class="fas fa-chevron-down"></i>' : '<i class="fas fa-chevron-up"></i>';
}
}
});
});
// Save meal footer button
listEl.querySelectorAll('.save-meal-footer-btn').forEach(btn => {
btn.addEventListener('click', (e) => {
e.stopPropagation();
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/delete button state
listEl.querySelectorAll('.item-checkbox').forEach(cb => {
cb.addEventListener('change', () => {
// Check animation on wrapper
const wrapper = cb.closest('.item-checkbox-wrapper');
if (wrapper && cb.checked) {
wrapper.classList.add('just-checked');
setTimeout(() => wrapper.classList.remove('just-checked'), 250);
}
const card = cb.closest('.eetmoment-card');
if (!card) return;
const footer = card.querySelector('.save-meal-footer');
if (!footer) return;
const saveBtn = footer.querySelector('.save-meal-footer-btn');
const delBtn = footer.querySelector('.delete-selected-footer-btn');
const checked = card.querySelectorAll('.item-checkbox:checked').length;
const total = card.querySelectorAll('.item-checkbox').length;
if (checked < 2) {
saveBtn.classList.add('footer-hidden');
if (delBtn) delBtn.classList.add('footer-hidden');
} else {
saveBtn.classList.remove('footer-hidden');
saveBtn.disabled = false;
saveBtn.innerHTML = `<i class='fas fa-star'></i> Opslaan ${checked} items als maaltijd`;
if (delBtn) delBtn.classList.remove('footer-hidden');
}
});
});
// Delete selected items button
listEl.querySelectorAll('.delete-selected-footer-btn').forEach(btn => {
btn.addEventListener('click', async (e) => {
e.stopPropagation();
const momentId = btn.dataset.moment;
const card = btn.closest('.eetmoment-card');
if (!card) return;
const checked = card.querySelectorAll('.item-checkbox:checked');
const count = checked.length;
if (count === 0) return;
const confirmed = await confirmAction({
message: `Weet je zeker dat je ${count} geselecteerde ${count === 1 ? 'item' : 'items'} wilt verwijderen?`
});
if (!confirmed) return;
// Collect indices in reverse order to avoid index shift during splice
const indices = Array.from(checked).map(cb => parseInt(cb.dataset.index)).sort((a, b) => b - a);
const dayData = getOrCreateDay(activeDate);
indices.forEach(idx => {
dayData[momentId].splice(idx, 1);
});
saveToStorage();
renderDagboek();
showToast(`${count} ${count === 1 ? 'item verwijderd' : 'items verwijderd'}`);
});
});
// 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, .item-checkbox')) return;
header.classList.toggle('expanded');
});
});
// Meal group edit button
listEl.querySelectorAll('.meal-group-edit').forEach(btn => {
btn.addEventListener('click', (e) => {
e.stopPropagation();
const momentId = btn.dataset.moment;
const idx = parseInt(btn.dataset.mealIdx);
editMealGroupInDiary(momentId, idx);
});
});
// Empty state
const hasItems = EETMOMENTEN.some(m => (dayData[m.id] || []).length > 0);
document.getElementById('diaryEmpty').classList.toggle('hidden', hasItems);
// Attach glucose input handlers (nuchter card + inline glucose)
attachGlucoseInputHandlers();
}
function showDetail(item) {
const modal = document.getElementById('detailModal');
const body = document.getElementById('detailModalBody');
const kh = parseKh(item.kh);
body.innerHTML = `
<div class="detail-name">${escapeHtml(displayNaam(item.naam))}</div>
<div class="detail-category">${escapeHtml(item.cat)}</div>
<div class="detail-links">
<a class="detail-info-link" href="https://www.voedingscentrum.nl/nl/zoek.aspx?query=${encodeURIComponent(displayNaam(item.naam))}" target="_blank" rel="noopener">Voedingscentrum <i class="fas fa-external-link-alt"></i></a>
</div>
<div class="detail-kh-big">
<div class="detail-kh-number">${formatKh(kh)}</div>
<div class="detail-kh-label">gram koolhydraten per 100g</div>
</div>
<div class="detail-nutrition">
<div class="detail-nutrition-item">
<div class="detail-nutrition-value">${item.kcal || '—'}</div>
<div class="detail-nutrition-label">kcal</div>
</div>
<div class="detail-nutrition-item">
<div class="detail-nutrition-value">${typeof item.eiwit === 'number' ? item.eiwit : (item.eiwit || '—')}</div>
<div class="detail-nutrition-label">Eiwit (g)</div>
</div>
<div class="detail-nutrition-item">
<div class="detail-nutrition-value">${typeof item.vet === 'number' ? item.vet : (item.vet || '—')}</div>
<div class="detail-nutrition-label">Vet (g)</div>
</div>
<div class="detail-nutrition-item">
<div class="detail-nutrition-value">${typeof item.vezel === 'number' ? item.vezel : (item.vezel || '—')}</div>
<div class="detail-nutrition-label">Vezel (g)</div>
</div>
</div>
<button class="detail-add-btn" id="detailAddBtn">
<i class="fas fa-plus"></i> Toevoegen aan eetmoment
</button>
`;
modal.classList.remove('hidden');
document.body.style.overflow = 'hidden';
document.getElementById('detailAddBtn').addEventListener('click', () => {
closeModal('detailModal');
showAddToMeal(item);
});
}
function showAddToMeal(item, preselectedMoment) {
const modal = document.getElementById('addMealModal');
const body = document.getElementById('addMealModalBody');
const kh = parseKh(item.kh);
const momentOptions = EETMOMENTEN.map(m =>
`<option value="${m.id}" ${m.id === preselectedMoment ? 'selected' : ''}>${m.icon} ${m.name}</option>`
).join('');
body.innerHTML = `
<div style="margin-bottom:12px;font-weight:500;">${escapeHtml(displayNaam(item.naam))} <span style="color:var(--primary-dark);">(${formatKh(kh)} g kh / 100g)</span></div>
<div class="add-meal-label">Eetmoment</div>
<select class="add-meal-select" id="addMealSelect">
${momentOptions}
</select>
<div class="portion-input-group">
<div class="add-meal-label">Portie (gram)</div>
<div class="portion-input-wrapper">
<input type="number" class="portion-input" id="portionInput" value="100" min="1" max="9999" inputmode="numeric" pattern="[0-9]*">
<span class="portion-unit">gram</span>
</div>
<div class="standaard-porties" id="standaardPorties">
${getStandardPorties(item).map((p, pi) => `
<div class="portie-stepper" data-grams="${p.grams}" data-label="${escapeHtml(p.label)}">
<button class="portie-stepper-btn portie-stepper-minus" type="button" ${pi === 0 ? '' : 'disabled'}></button>
<span class="portie-stepper-count">${pi === 0 ? 1 : 0}</span>
<button class="portie-stepper-btn portie-stepper-plus" type="button">+</button>
<span class="portie-stepper-label">${escapeHtml(p.label)}</span>
<span class="portie-stepper-unit">${p.grams}g</span>
</div>
`).join('')}
</div>
<div class="portion-preview">
<div class="portion-preview-label">Koolhydraten in deze portie</div>
<div class="portion-preview-value" id="portionPreview">${formatKh(kh)} g</div>
</div>
</div>
<button class="add-meal-submit" id="addMealSubmit">
<i class="fas fa-check"></i> Toevoegen
</button>
`;
modal.classList.remove('hidden');
document.body.style.overflow = 'hidden';
const portionInput = document.getElementById('portionInput');
const previewEl = document.getElementById('portionPreview');
portionInput.addEventListener('input', () => {
const portie = parseFloat(portionInput.value) || 0;
const totalKh = (kh / 100) * portie;
previewEl.textContent = formatKh(totalKh) + ' g';
});
// Stepper-based portion quick-select
const steppers = document.querySelectorAll('.portie-stepper');
let activeStepper = steppers[0]; // Default: first stepper active with count 1
function updatePortionFromSteppers() {
let totalGrams = 0;
steppers.forEach(stepper => {
const count = parseInt(stepper.querySelector('.portie-stepper-count').textContent, 10) || 0;
const grams = parseInt(stepper.dataset.grams, 10) || 0;
totalGrams += count * grams;
// Toggle active styling
stepper.classList.toggle('active', count > 0);
const minus = stepper.querySelector('.portie-stepper-minus');
minus.disabled = count <= 0;
if (count > 0) activeStepper = stepper;
});
portionInput.value = totalGrams || 100;
const totalKh = (kh / 100) * totalGrams;
previewEl.textContent = formatKh(totalKh) + ' g';
}
steppers.forEach(stepper => {
const countEl = stepper.querySelector('.portie-stepper-count');
const grams = parseInt(stepper.dataset.grams, 10);
stepper.querySelector('.portie-stepper-plus').addEventListener('click', function() {
// Reset all other steppers to 0
steppers.forEach(s => {
if (s !== stepper) { s.querySelector('.portie-stepper-count').textContent = '0'; }
});
let count = parseInt(countEl.textContent, 10) || 0;
countEl.textContent = count + 1;
updatePortionFromSteppers();
// Bounce feedback
this.classList.add('tapped');
setTimeout(() => this.classList.remove('tapped'), 300);
// Count bump animation
countEl.classList.add('count-bump');
setTimeout(() => countEl.classList.remove('count-bump'), 300);
});
stepper.querySelector('.portie-stepper-minus').addEventListener('click', function() {
let count = parseInt(countEl.textContent, 10) || 0;
if (count > 0) countEl.textContent = count - 1;
updatePortionFromSteppers();
// Bounce feedback
this.classList.add('tapped');
setTimeout(() => this.classList.remove('tapped'), 300);
// Count bump animation
countEl.classList.add('count-bump');
setTimeout(() => countEl.classList.remove('count-bump'), 300);
});
});
portionInput.focus();
portionInput.select();
document.getElementById('addMealSubmit').addEventListener('click', () => {
const momentId = document.getElementById('addMealSelect').value;
const portie = parseFloat(portionInput.value) || 0;
if (portie <= 0) {
portionInput.focus();
portionInput.style.borderColor = 'var(--danger)';
setTimeout(() => { portionInput.style.borderColor = ''; }, 1500);
return;
}
addToMeal(item, portie, momentId);
closeModal('addMealModal');
showToast(`"${item.naam}" toegevoegd aan ${EETMOMENTEN.find(m => m.id === momentId).name}`);
switchTab('dagboek');
});
}
function openAddToMealForMoment(momentId) {
// Open a mini search-to-add flow from diary
switchTab('zoeken');
// Give user a hint
const moment = EETMOMENTEN.find(m => m.id === momentId);
showToast(`Selecteer een voedingsmiddel voor ${moment.name}`);
// Store the target moment so next food tap adds to it
window._targetMoment = momentId;
// Use event delegation so dynamically loaded items also work
const listEl = document.getElementById('foodList');
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;
listEl.removeEventListener('click', handler);
}
};
listEl.addEventListener('click', handler);
}
function addToMeal(item, portie, momentId) {
const day = getOrCreateDay(activeDate);
if (!day[momentId]) day[momentId] = [];
day[momentId].push({
type: 'item',
item: item,
portie: portie
});
saveToStorage();
renderDagboek();
}
async function removeFromMeal(dateStr, momentId, index, itemEl) {
const day = dagboek[dateStr];
if (day && day[momentId]) {
const target = day[momentId][index];
if (!target) return;
const name = target.type === 'meal' ? target.mealNaam : target.item.naam;
const confirmed = await confirmAction({
message: `Weet je zeker dat je "${name}" wilt verwijderen?`
});
if (!confirmed) return;
// Animate fade-out before removing data
if (itemEl) {
itemEl.classList.add('removing');
await new Promise(r => setTimeout(r, 280));
}
day[momentId].splice(index, 1);
saveToStorage();
renderDagboek();
showToast('"' + name + '" verwijderd');
}
}
/**
* Remove a single item from within a meal group in the diary.
* If the last item is removed, removes the entire meal group.
*/
async function removeMealItemFromDiary(dateStr, momentId, entryIdx, mi, itemEl) {
const day = dagboek[dateStr];
if (!day || !day[momentId]) return;
const entry = day[momentId][entryIdx];
if (!entry || entry.type !== 'meal') return;
const item = entry.items[mi];
if (!item) return;
const confirmed = await confirmAction({
message: `Weet je zeker dat je "${item.item.naam}" wilt verwijderen uit deze maaltijd?`
});
if (!confirmed) return;
// Animate fade-out before removing data
if (itemEl) {
itemEl.classList.add('removing');
await new Promise(r => setTimeout(r, 280));
}
entry.items.splice(mi, 1);
// If last item — remove the whole meal group entry
if (entry.items.length === 0) {
day[momentId].splice(entryIdx, 1);
}
saveToStorage();
renderDagboek();
showToast('"' + item.item.naam + '" verwijderd uit maaltijd');
}
/** Show portion edit dialog for a diary item — with eenmalig/permanent choice for meal items */
function showPortieEditDialog(dateStr, momentId, entryIdx, mi) {
const day = dagboek[dateStr];
if (!day || !day[momentId]) return;
const entry = day[momentId][entryIdx];
if (!entry) return;
const isMeal = entry.type === 'meal';
const targetItem = isMeal ? entry.items[mi] : entry;
if (!targetItem) return;
const khPer100 = parseKh(targetItem.item.kh);
const currentPortie = targetItem.portie || 0;
const foodName = displayNaam(targetItem.item.naam);
const overlay = document.createElement('div');
overlay.className = 'modal-overlay';
overlay.style.zIndex = '400';
overlay.innerHTML = `
<div class="modal-sheet">
<div class="modal-handle"></div>
<div class="modal-header">
<span class="modal-title">Portie wijzigen</span>
<button class="modal-close" id="portieEditClose"><i class="fas fa-xmark"></i></button>
</div>
<div class="modal-body">
<div style="margin-bottom:12px;font-weight:500;">${escapeHtml(foodName)}</div>
<div class="portion-input-group">
<div class="add-meal-label">Portie (gram)</div>
<div class="portion-input-wrapper">
<input type="number" class="portion-input" id="portieEditInput" value="${currentPortie}" min="1" max="9999" inputmode="numeric" pattern="[0-9]*">
<span class="portion-unit">gram</span>
</div>
<div class="portion-preview">
<div class="portion-preview-label">Koolhydraten</div>
<div class="portion-preview-value" id="portieEditPreview">${formatKh((khPer100 / 100) * currentPortie)} g</div>
</div>
</div>
${isMeal ? `
<div class="keuze-dialog">
<div class="keuze-dialog-title">Wijziging opslaan</div>
<div class="keuze-dialog-desc">Wil je deze portieaanpassing alleen voor vandaag of ook in de opgeslagen maaltijd?</div>
<div class="keuze-dialog-buttons">
<button class="keuze-btn keuze-btn-secondary" id="portieEenmalig">
<i class="fas fa-calendar-day"></i> Eenmalig
</button>
<button class="keuze-btn keuze-btn-primary" id="portiePermanent">
<i class="fas fa-floppy-disk"></i> Permanent opslaan
</button>
</div>
</div>
` : `
<button class="add-meal-submit" id="portieSaveBtn">
<i class="fas fa-check"></i> Opslaan
</button>
`}
</div>
</div>
`;
document.body.appendChild(overlay);
document.body.style.overflow = 'hidden';
function closeDialog() {
overlay.classList.add('closing');
setTimeout(() => { overlay.remove(); document.body.style.overflow = ''; }, 280);
}
overlay.querySelector('#portieEditClose').addEventListener('click', closeDialog);
overlay.addEventListener('click', (e) => { if (e.target === overlay) closeDialog(); });
setupSwipeDown(overlay, closeDialog);
const input = overlay.querySelector('#portieEditInput');
const preview = overlay.querySelector('#portieEditPreview');
function updatePreview() {
const p = parseFloat(input.value) || 0;
preview.textContent = formatKh((khPer100 / 100) * p) + ' g';
}
input.addEventListener('input', updatePreview);
// Standard portion steppers
const stdPorties = getStandardPorties(targetItem.item);
if (stdPorties && stdPorties.length > 0) {
const container = overlay.querySelector('.portion-input-group');
const btnWrap = document.createElement('div');
btnWrap.className = 'standaard-porties';
btnWrap.innerHTML = stdPorties.map((p, pi) => `
<div class="portie-stepper" data-grams="${p.grams}" data-label="${escapeHtml(p.label)}">
<button class="portie-stepper-btn portie-stepper-minus" type="button" ${pi === 0 ? '' : 'disabled'}></button>
<span class="portie-stepper-count">${pi === 0 ? 1 : 0}</span>
<button class="portie-stepper-btn portie-stepper-plus" type="button">+</button>
<span class="portie-stepper-label">${escapeHtml(p.label)}</span>
<span class="portie-stepper-unit">${p.grams}g</span>
</div>
`).join('');
container.appendChild(btnWrap);
// Bind stepper handlers
const steppers = btnWrap.querySelectorAll('.portie-stepper');
function updateStepperTotal() {
let totalGrams = 0;
steppers.forEach(stepper => {
const count = parseInt(stepper.querySelector('.portie-stepper-count').textContent, 10) || 0;
const grams = parseInt(stepper.dataset.grams, 10) || 0;
totalGrams += count * grams;
stepper.classList.toggle('active', count > 0);
stepper.querySelector('.portie-stepper-minus').disabled = count <= 0;
});
input.value = totalGrams || 100;
updatePreview();
}
steppers.forEach(stepper => {
const countEl = stepper.querySelector('.portie-stepper-count');
stepper.querySelector('.portie-stepper-plus').addEventListener('click', function() {
steppers.forEach(s => { if (s !== stepper) s.querySelector('.portie-stepper-count').textContent = '0'; });
countEl.textContent = (parseInt(countEl.textContent, 10) || 0) + 1;
updateStepperTotal();
this.classList.add('tapped');
setTimeout(() => this.classList.remove('tapped'), 300);
});
stepper.querySelector('.portie-stepper-minus').addEventListener('click', function() {
const c = parseInt(countEl.textContent, 10) || 0;
if (c > 0) countEl.textContent = c - 1;
updateStepperTotal();
this.classList.add('tapped');
setTimeout(() => this.classList.remove('tapped'), 300);
});
});
}
function savePortie(newPortie) {
if (newPortie <= 0) return;
if (isMeal) {
entry.items[mi].portie = newPortie;
} else {
entry.portie = newPortie;
}
saveToStorage();
closeDialog();
renderDagboek();
showToast('Portie aangepast ✅');
}
// For single items
const saveBtn = overlay.querySelector('#portieSaveBtn');
if (saveBtn) {
saveBtn.addEventListener('click', () => {
savePortie(parseFloat(input.value) || 0);
});
}
// For meal items: eenmalig / permanent buttons
const eenmaligBtn = overlay.querySelector('#portieEenmalig');
const permanentBtn = overlay.querySelector('#portiePermanent');
if (eenmaligBtn) {
eenmaligBtn.addEventListener('click', () => {
savePortie(parseFloat(input.value) || 0);
});
}
if (permanentBtn && entry.mealId) {
permanentBtn.addEventListener('click', () => {
const newPortie = parseFloat(input.value) || 0;
if (newPortie <= 0) return;
entry.items[mi].portie = newPortie;
// Also update the original saved meal
const mealIdx = maaltijden.findIndex(m => m.id === entry.mealId);
if (mealIdx >= 0) {
const originalMeal = maaltijden[mealIdx];
if (originalMeal.items[mi]) {
originalMeal.items[mi].portie = newPortie;
// Recalculate khTotaal
let khTotaal = 0;
originalMeal.items.forEach(e => {
khTotaal += (parseKh(e.item.kh) / 100) * (e.portie || 0);
});
originalMeal.khTotaal = Math.round(khTotaal);
saveMaaltijden();
showToast('Portie permanent opgeslagen in maaltijd 💾');
} else {
showToast('Originele maaltijd niet gevonden');
}
} else {
showToast('Originele maaltijd niet meer beschikbaar');
}
saveToStorage();
closeDialog();
renderDagboek();
});
}
setTimeout(() => { input.focus(); input.select(); }, 100);
}
/** Show a modal that lets the user edit all portions of a meal group in the diary at once */
function showBulkPortieEdit(dateStr, momentId, entryIdx) {
const day = dagboek[dateStr];
if (!day || !day[momentId]) return;
const entry = day[momentId][entryIdx];
if (!entry || entry.type !== 'meal') return;
const itemsHtml = entry.items.map((mealItem, mi) => {
const kh = parseKh(mealItem.item.kh);
const itemKh = (kh / 100) * (mealItem.portie || 0);
return `
<div class="meal-edit-item">
<div class="meal-edit-item-info">
<div class="meal-edit-item-name">${escapeHtml(displayNaam(mealItem.item.naam))}</div>
<div class="meal-edit-item-cat">${escapeHtml(mealItem.item.cat || '')} · ${formatKh(itemKh)} g kh</div>
</div>
<div class="meal-edit-item-actions">
<div class="portion-input-wrapper" style="margin:0;">
<input type="number" class="portion-input" value="${mealItem.portie}" min="1" max="9999" data-mi="${mi}" style="width:72px;min-width:72px;">
<span class="portion-unit">g</span>
</div>
</div>
</div>
`;
}).join('');
const overlay = document.createElement('div');
overlay.className = 'modal-overlay';
overlay.style.zIndex = '400';
overlay.innerHTML = `
<div class="modal-sheet">
<div class="modal-handle"></div>
<div class="modal-header">
<span class="modal-title">Porties wijzigen</span>
<button class="modal-close" id="bulkEditClose"><i class="fas fa-xmark"></i></button>
</div>
<div class="modal-body">
<div style="margin-bottom:12px;font-weight:500;">${escapeHtml(entry.mealNaam)}</div>
<div class="meal-edit-items">${itemsHtml}</div>
<div class="keuze-dialog">
<div class="keuze-dialog-title">Wijzigingen opslaan</div>
<div class="keuze-dialog-desc">Alleen vandaag of ook de originele maaltijd bijwerken?</div>
<div class="keuze-dialog-buttons">
<button class="keuze-btn keuze-btn-secondary" id="bulkEenmalig">
<i class="fas fa-calendar-day"></i> Eenmalig
</button>
<button class="keuze-btn keuze-btn-primary" id="bulkPermanent">
<i class="fas fa-floppy-disk"></i> Permanent opslaan
</button>
<button class="keuze-btn keuze-btn-cancel" id="bulkCancel">Annuleren</button>
</div>
</div>
</div>
</div>
`;
document.body.appendChild(overlay);
document.body.style.overflow = 'hidden';
function closeDialog() {
overlay.classList.add('closing');
setTimeout(() => { overlay.remove(); document.body.style.overflow = ''; }, 280);
}
overlay.querySelector('#bulkEditClose').addEventListener('click', closeDialog);
overlay.querySelector('#bulkCancel').addEventListener('click', closeDialog);
overlay.addEventListener('click', (e) => { if (e.target === overlay) closeDialog(); });
setupSwipeDown(overlay, closeDialog);
function collectPortions() {
const portions = [];
overlay.querySelectorAll('.meal-edit-item .portion-input').forEach(inp => {
const mi = parseInt(inp.dataset.mi);
const portie = parseInt(inp.value, 10) || 0;
portions.push({ mi, portie });
});
return portions;
}
function applyPortions(savePermanent) {
const portions = collectPortions();
let changed = false;
portions.forEach(({ mi, portie }) => {
if (portie > 0 && entry.items[mi]) {
entry.items[mi].portie = portie;
changed = true;
if (savePermanent && entry.mealId) {
const mealIdx = maaltijden.findIndex(m => m.id === entry.mealId);
if (mealIdx >= 0 && maaltijden[mealIdx].items[mi]) {
maaltijden[mealIdx].items[mi].portie = portie;
}
}
}
});
if (!changed) { showToast('Geen wijzigingen'); return; }
if (savePermanent && entry.mealId) {
const mealIdx = maaltijden.findIndex(m => m.id === entry.mealId);
if (mealIdx >= 0) {
let khTotaal = 0;
maaltijden[mealIdx].items.forEach(e => {
khTotaal += (parseKh(e.item.kh) / 100) * (e.portie || 0);
});
maaltijden[mealIdx].khTotaal = Math.round(khTotaal);
saveMaaltijden();
}
}
saveToStorage();
closeDialog();
renderDagboek();
showToast(savePermanent ? 'Porties permanent opgeslagen 💾' : 'Porties aangepast ✅');
}
overlay.querySelector('#bulkEenmalig').addEventListener('click', () => applyPortions(false));
overlay.querySelector('#bulkPermanent').addEventListener('click', () => applyPortions(true));
}
// ===== Tab Switching =====
function switchTab(tab) {
activeTab = tab;
const panels = ['tabZoeken','tabDagboek','tabMaaltijden'];
panels.forEach(id => {
const el = document.getElementById(id);
if (id === 'tab' + tab.charAt(0).toUpperCase() + tab.slice(1)) {
el.classList.add('switching');
el.classList.remove('hidden');
} else {
el.classList.add('hidden');
el.classList.remove('switching');
}
});
document.getElementById('searchContainer').classList.toggle('hidden', tab !== 'zoeken');
document.getElementById('categoryFilter').classList.toggle('hidden', tab !== 'zoeken');
document.getElementById('navZoeken').classList.toggle('active', tab === 'zoeken');
document.getElementById('navDagboek').classList.toggle('active', tab === 'dagboek');
document.getElementById('navMaaltijden').classList.toggle('active', tab === 'maaltijden');
const mainContent = document.getElementById('mainContent');
mainContent.classList.add('page-transition');
setTimeout(() => mainContent.classList.remove('page-transition'), 350);
if (tab === 'zoeken') {
resetSearchPagination();
renderZoeken();
} else if (tab === 'dagboek') {
disconnectSearchObserver();
renderDagboek();
} else if (tab === 'maaltijden') {
disconnectSearchObserver();
renderMaaltijden();
}
}
// ===== Day Navigation =====
function navigateDay(direction) {
const current = parseDate(activeDate);
current.setDate(current.getDate() + direction);
activeDate = formatDate(current);
getOrCreateDay(activeDate);
ensureGlucoseDay(activeDate);
renderDagboek();
}
function goToToday() {
activeDate = formatDate(new Date());
getOrCreateDay(activeDate);
ensureGlucoseDay(activeDate);
renderDagboek();
}
// ===== Maaltijden =====
/**
* Suggest a meal name based on the food items, using category-aware grouping.
* No LLM dependency — pure rule-based Dutch meal naming.
*/
// ===== Maaltijdnaam-suggestie (slim, NEVO-bewust) =====
/** NEVO descriptor words to strip — prep/storage/quality only, not actual foods */
const NEVO_DESC = new Set([
'rauw','rauwe','gekookt','gekookte','onbereid','onbereide','bereid','bereide',
'vers','verse','diepvries','diepgevroren','gedroogd','gedroogde',
'gem','gemixt','gesneden','gebakken','gebraden','gegrild','gestoofd','gefrituurd',
'ongezouten','gezouten','gerookt','gepekeld',
'gekonserveerd','geconserveerd','gezeefd','uitgesmolten',
'gepaneerd','gevuld','voorgekookt','koelvers',
'halfvolle','magere','volle','halfvol',
'scharrel','biologisch','biologische','mais',
'witte','bruine','zwarte','rode','groene','groen','gele','oranje','geel','rood','paars','paarse',
'zoete','zure','bittere','zoute',
'schil','vliesje','vel','niet','panklaar',
'kruimig','vastkokend','eigen','mnd','ongekookt','gt','kippen','naturel',
]);
/** Maak van een ruwe NEVO-naam een korte, menselijke naam */
function shortItemName(naam) {
if (!naam) return '';
let name = naam.replace(/^\d+\s+/, ''); // NEVO-nummer eraf
const dsi = name.indexOf(' '); // dubbel-space = alt tekst
if (dsi > 0) name = name.substring(dsi).trim();
name = name.split('/')[0].trim(); // eerste /-variant
name = name.replace(/\([^)]*\)/g, '').trim(); // haakjes-notities
name = name.split(',')[0].trim(); // eerste komma-segment
name = name.replace(/\b\d+[%+]?\b/g, '').trim(); // getallen/percentages
name = name.replace(/&gt;/g, '').trim(); // HTML entities
name = name.replace(/\s+/g, ' ').trim();
if (!name) return '';
const words = name.split(/\s+/);
let keep = words.slice(0, 2);
// NEVO omgekeerde samenstelling: "Kool bloem-" → "Bloemkool"
if (keep.length >= 2 && keep[1].endsWith('-') && keep[0].toLowerCase() !== 'ei') {
const mod = keep[1].slice(0, -1);
const base = keep[0];
const comp = mod.toLowerCase() + base.toLowerCase();
keep = [comp.charAt(0).toUpperCase() + comp.slice(1)];
}
keep = keep.map(w => w.replace(/-$/, '')); // losse streepjes weg
const result = [];
const seen = new Set();
for (const w of keep) {
if (w.length <= 1) continue;
const wl = w.toLowerCase();
if (NEVO_DESC.has(wl)) continue;
if (seen.has(wl)) continue;
seen.add(wl);
result.push(w);
}
return result.join(' ');
}
/** NEVO sub-categorie → maaltijd-type voor herkenning */
function nevoDinerType(nevoCat) {
const map = {
'Aardappelen en knolgewassen': 'aardappel',
'Groente': 'groente',
'Vlees en gevogelte': 'vlees',
'Vis, schaal- en schelpdieren': 'vlees',
'Vleeswaren': 'vlees',
'Eieren': 'vlees',
'Brood': 'brood',
'Kaas': 'kaas',
'Melk en melkproducten': 'zuivel',
'Fruit': 'fruit',
};
return map[nevoCat] || 'overig';
}
/** Flatten mixed entries (items + meal-groups) naar uniform {item, portie} array */
function flattenEntries(entries) {
const result = [];
entries.forEach(entry => {
if (entry.type === 'meal') {
entry.items.forEach(e => result.push({ item: e.item, portie: e.portie }));
} else {
result.push({ item: entry.item, portie: entry.portie });
}
});
return result;
}
/** Genereer een slimme, menselijke maaltijdnaam op basis van de categorieën */
function suggestMealName(items) {
// Unwrap meal-group entries first
items = flattenEntries(items);
if (!items || items.length === 0) return '';
if (items.length === 1) return shortItemName(items[0].item.naam);
// Verrijk met categorien en korte namen
const enriched = items.map(e => ({
name: shortItemName(e.item.naam),
cat: mapCategory(e.item.cat || 'Overig'),
nevoCat: e.item.cat || 'Overig',
type: nevoDinerType(e.item.cat || 'Overig'),
}));
// Groepeer per app-categorie
const byCat = {};
enriched.forEach(info => {
if (!byCat[info.cat]) byCat[info.cat] = [];
byCat[info.cat].push(info);
});
const cats = Object.keys(byCat);
// === AVG / PRAKKIE ===
const hasAardappel = enriched.some(e => e.type === 'aardappel');
const hasGroente = enriched.some(e => e.type === 'groente');
const hasVlees = enriched.some(e => e.type === 'vlees');
if (hasAardappel && (hasGroente || hasVlees)) {
const groenteNamen = enriched
.filter(e => e.type === 'groente')
.map(e => e.name);
const vleesNamen = enriched
.filter(e => e.type === 'vlees')
.sort((a, b) => {
const prio = { 'Vlees en gevogelte': 0, 'Vis, schaal- en schelpdieren': 0, 'Vleeswaren': 0, 'Eieren': 1 };
return (prio[a.nevoCat] ?? 2) - (prio[b.nevoCat] ?? 2);
})
.map(e => e.name);
const groente = groenteNamen[0] || '';
const vlees = vleesNamen[0] || '';
if (hasGroente && hasVlees && groente && vlees) {
return 'AVG ' + groente + ' en ' + vlees;
}
if (hasGroente && groente) {
return 'Prakkie ' + groente + (vlees ? ' met ' + vlees : '');
}
const other = enriched.find(e => e.type !== 'aardappel');
if (other) return 'Aardappel met ' + other.name;
}
// === Groente + vlees (zonder aardappel) ===
if (hasGroente && hasVlees) {
const gr = enriched.filter(e => e.type === 'groente').map(e => e.name)[0] || '';
const vl = enriched.filter(e => e.type === 'vlees').map(e => e.name)[0] || '';
if (gr && vl) return gr + ' met ' + vl;
if (gr) return 'Groente met ' + vl;
}
// === Brood met beleg ===
if (cats.includes('Brood')) {
const beleg = enriched.filter(e => e.cat !== 'Brood').map(e => e.name);
if (beleg.length === 1) return 'Broodje ' + beleg[0];
if (beleg.length === 2) return 'Broodje ' + beleg[0] + ' en ' + beleg[1];
if (beleg.length <= 4) return 'Brood met ' + beleg.slice(0, -1).join(', ') + ' en ' + beleg[beleg.length - 1];
return 'Brood met beleg';
}
// === Eén categorie ===
if (cats.length === 1) {
const cat = cats[0];
const catNamen = byCat[cat].map(i => i.name);
if (catNamen.length <= 3) {
if (catNamen.length === 2) return catNamen[0] + ' en ' + catNamen[1];
if (catNamen.length === 3) return catNamen[0] + ', ' + catNamen[1] + ' en ' + catNamen[2];
return catNamen[0];
}
return cat + ' (' + catNamen.length + ' stuks)';
}
// === Twee categorieën ===
if (cats.length === 2) {
const [a, b] = cats;
const na = byCat[a].map(i => i.name);
const nb = byCat[b].map(i => i.name);
if (na.length === 1 && nb.length === 1) return na[0] + ' & ' + nb[0];
const parts = cats.map(c => {
const n = byCat[c].map(i => i.name);
if (n.length <= 2) return n.join(' & ');
return n[0] + ' (+' + (n.length - 1) + ')';
});
return parts.join(' & ');
}
// === 3+ categorieën: top 2 + rest ===
const sorted = cats.sort((a, b) => byCat[b].length - byCat[a].length);
const [top1, top2] = sorted;
const rest = enriched.length - byCat[top1].length - byCat[top2].length;
let r = top1 + ' & ' + top2;
if (rest > 0) r += ' +' + rest + ' meer';
return r;
}
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.length
? 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(filteredRaw);
// Calculate total kh
let khTotaal = 0;
items.forEach(entry => {
const khPer100 = parseKh(entry.item.kh);
khTotaal += (khPer100 / 100) * (entry.portie || 0);
});
// Smart meal name suggestion based on item categories
const defaultName = suggestMealName(items);
// Build preview text
const previewItems = items.slice(0, 4).map(e => {
const kh = Math.round((parseKh(e.item.kh) / 100) * (e.portie || 0));
return `${displayNaam(e.item.naam)} (${e.portie || 0}g, ${kh}g kh)`;
});
let previewText = previewItems.join('<br>');
if (items.length > 4) previewText += '<br><em>+' + (items.length - 4) + ' meer items</em>';
// Create modal
const overlay = document.createElement('div');
overlay.className = 'modal-overlay';
overlay.style.zIndex = '300';
overlay.innerHTML = `
<div class="modal-sheet">
<div class="modal-handle"></div>
<div class="modal-header">
<span class="modal-title">Naam van deze maaltijd</span>
<button class="modal-close"><i class="fas fa-xmark"></i></button>
</div>
<div class="modal-body">
<label class="meal-name-label">Maaltijdnaam</label>
<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>
<button class="meal-name-save" id="mealNameSave"><i class="fas fa-check"></i> Opslaan</button>
</div>
</div>
</div>
`;
document.body.appendChild(overlay);
document.body.style.overflow = 'hidden';
const input = overlay.querySelector('#mealNameInput');
function close() {
overlay.classList.add('closing');
setTimeout(() => { overlay.remove(); document.body.style.overflow = ''; }, 280);
}
function save() {
const name = input.value.trim();
if (!name) {
hapticError();
input.style.borderColor = 'var(--danger)';
input.focus();
return;
}
maaltijden.push({
id: 'm' + Date.now(),
naam: name,
items: items,
khTotaal: Math.round(khTotaal)
});
saveMaaltijden();
close();
showToast('"' + escapeHtml(name) + '" opgeslagen als maaltijd ⭐');
}
// Close handlers
overlay.querySelector('.modal-close').addEventListener('click', close);
overlay.addEventListener('click', (e) => {
if (e.target === overlay) close();
});
overlay.querySelector('#mealNameCancel').addEventListener('click', close);
overlay.querySelector('#mealNameSave').addEventListener('click', save);
setupSwipeDown(overlay, close);
// Enter key saves, Escape closes
input.addEventListener('keydown', (e) => {
if (e.key === 'Enter') save();
if (e.key === 'Escape') close();
});
// Focus and select the default text
setTimeout(() => {
input.focus();
input.select();
}, 300);
}
function renderMaaltijden() {
if (activeTab !== 'maaltijden') return;
const listEl = document.getElementById('maaltijdenList');
const emptyEl = document.getElementById('mealsEmpty');
if (maaltijden.length === 0) {
listEl.innerHTML = '';
emptyEl.classList.remove('hidden');
return;
}
emptyEl.classList.add('hidden');
listEl.innerHTML = maaltijden.map((meal, idx) => {
const itemCount = meal.items.length;
return `
<div class="meal-card" style="--i:${idx}">
<div class="meal-card-main" data-idx="${idx}">
<div class="meal-card-info">
<div class="meal-card-name">${escapeHtml(meal.naam)}</div>
<div class="meal-card-meta">${itemCount} item${itemCount !== 1 ? 's' : ''} · ${meal.khTotaal} g kh</div>
</div>
<button class="meal-card-delete" data-haptic="strong" data-idx="${idx}" title="Verwijderen"><i class="fas fa-trash"></i></button>
</div>
<div class="meal-card-add" data-idx="${idx}">
<i class="fas fa-plus-circle"></i> Toevoegen aan dagboek
</div>
</div>
`;
}).join('');
// Add event listeners
listEl.querySelectorAll('.meal-card-main').forEach(el => {
el.addEventListener('click', (e) => {
if (e.target.closest('.meal-card-delete')) return;
const idx = parseInt(el.dataset.idx);
openMealEditor(idx);
});
});
listEl.querySelectorAll('.meal-card-delete').forEach(btn => {
btn.addEventListener('click', async (e) => {
e.stopPropagation();
const idx = parseInt(btn.dataset.idx);
const meal = maaltijden[idx];
const confirmed = await confirmAction({
title: 'Maaltijd verwijderen',
message: `Weet je zeker dat je "${meal.naam}" wilt verwijderen?`,
confirmText: 'Ja, verwijderen'
});
if (!confirmed) return;
maaltijden.splice(idx, 1);
saveMaaltijden();
renderMaaltijden();
});
});
listEl.querySelectorAll('.meal-card-add').forEach(addBtn => {
addBtn.addEventListener('click', (e) => {
e.stopPropagation();
const idx = parseInt(addBtn.dataset.idx);
promptAddMealToMoment(maaltijden[idx]);
});
});
}
function promptAddMealToMoment(meal) {
// Local date state for this dialog — defaults to the diary's current date
let selectedDate = activeDate;
function renderDatePicker() {
return `
<div class="day-navigator">
<button class="day-nav-btn meal-date-nav" data-dir="-1"><i class="fas fa-chevron-left"></i></button>
<div class="day-label">${formatDisplayDate(selectedDate)}</div>
<button class="day-nav-btn meal-date-nav" data-dir="1"><i class="fas fa-chevron-right"></i></button>
<button class="day-nav-today meal-date-today">Vandaag</button>
</div>
`;
}
// Build buttons for each eetmoment
const options = EETMOMENTEN.map(m => `
<button class="meal-add-option" data-moment="${m.id}">
<span class="meal-add-moment-icon">${m.icon}</span>
${m.name}
</button>
`).join('');
const overlay = document.createElement('div');
overlay.className = 'modal-overlay';
overlay.style.zIndex = '300';
overlay.innerHTML = `
<div class="modal-sheet">
<div class="modal-handle"></div>
<div class="modal-header">
<span class="modal-title">${escapeHtml(meal.naam)} toevoegen aan...</span>
<button class="modal-close"><i class="fas fa-xmark"></i></button>
</div>
<div class="modal-body" style="padding:8px 0;">
<div style="padding:0 20px 12px;color:var(--text-muted);font-size:var(--font-sm);">${meal.items.length} items · ${meal.khTotaal} g kh</div>
<div class="meal-date-section">
${renderDatePicker()}
</div>
<div class="meal-option-list">${options}</div>
</div>
</div>
`;
document.body.appendChild(overlay);
document.body.style.overflow = 'hidden';
// Close handlers
function closeAddToMeal() {
overlay.classList.add('closing');
setTimeout(() => { overlay.remove(); document.body.style.overflow = ''; }, 280);
}
overlay.querySelector('.modal-close').addEventListener('click', closeAddToMeal);
overlay.addEventListener('click', (e) => {
if (e.target === overlay) closeAddToMeal();
});
setupSwipeDown(overlay, closeAddToMeal);
// Date picker navigation (event delegation on overlay)
overlay.addEventListener('click', (e) => {
const navBtn = e.target.closest('.meal-date-nav');
if (navBtn) {
const dir = parseInt(navBtn.dataset.dir);
const current = parseDate(selectedDate);
current.setDate(current.getDate() + dir);
selectedDate = formatDate(current);
const dateSection = overlay.querySelector('.meal-date-section');
if (dateSection) dateSection.innerHTML = renderDatePicker();
return;
}
const todayBtn = e.target.closest('.meal-date-today');
if (todayBtn) {
selectedDate = formatDate(new Date());
const dateSection = overlay.querySelector('.meal-date-section');
if (dateSection) dateSection.innerHTML = renderDatePicker();
return;
}
});
// Add to moment handlers
overlay.querySelectorAll('.meal-add-option').forEach(btn => {
btn.addEventListener('click', () => {
const momentId = btn.dataset.moment;
const day = getOrCreateDay(selectedDate);
if (!day[momentId]) day[momentId] = [];
day[momentId].push({
type: 'meal',
mealId: meal.id,
mealNaam: meal.naam,
items: meal.items.map(e => ({ item: e.item, portie: e.portie }))
});
saveToStorage();
overlay.remove();
document.body.style.overflow = '';
showToast('"' + escapeHtml(meal.naam) + '" toegevoegd aan dagboek ✅');
activeDate = selectedDate;
switchTab('dagboek');
});
});
}
function editMealGroupInDiary(momentId, idx) {
const day = getOrCreateDay(activeDate);
const entry = day[momentId][idx];
if (!entry || entry.type !== 'meal') return;
let editItems = entry.items.map((e, i) => ({
id: i, item: e.item, portie: e.portie, _remove: false
}));
function renderEditList() {
return editItems.map((e, i) => {
if (e._remove) return '';
const kh = parseKh(e.item.kh);
const ikh = Math.round((kh / 100) * e.portie);
return `
<div class="meal-edit-item">
<div class="meal-edit-item-info">
<div class="meal-edit-item-name">${escapeHtml(displayNaam(e.item.naam))}</div>
<div class="meal-edit-item-cat">${escapeHtml(e.item.cat)} · ${ikh} g kh</div>
</div>
<div class="meal-edit-item-actions">
<div class="portion-input-wrapper" style="margin:0;">
<input type="number" class="portion-input meal-edit-portie" value="${e.portie}" min="1" max="9999" data-edit-idx="${i}">
<span class="portion-unit">g</span>
</div>
<button class="meal-edit-item-remove" data-edit-idx="${i}" title="Verwijderen"><i class="fas fa-xmark"></i></button>
</div>
</div>
`;
}).join('');
}
const overlay = document.createElement('div');
overlay.className = 'modal-overlay';
overlay.style.zIndex = '400';
overlay.innerHTML = `
<div class="modal-sheet">
<div class="modal-handle"></div>
<div class="modal-header">
<span class="modal-title">${escapeHtml(entry.mealNaam)} aanpassen</span>
<button class="modal-close" id="mealDiaryClose"><i class="fas fa-xmark"></i></button>
</div>
<div class="modal-body">
<div class="meal-edit-name-hint">Porties aanpassen of items verwijderen</div>
<div class="meal-edit-items" id="mealDiaryItems">${renderEditList()}</div>
<div class="meal-edit-actions">
<button class="btn-save" id="mealDiarySaveOnce">💾 Eenmalig opslaan</button>
<button class="btn-save" id="mealDiarySavePerm" style="background:var(--primary-dark);color:var(--white);">📝 Permanent opslaan</button>
</div>
</div>
</div>
`;
document.body.appendChild(overlay);
document.body.style.overflow = 'hidden';
function close() {
overlay.classList.add('closing');
setTimeout(() => { overlay.remove(); document.body.style.overflow = ''; }, 280);
}
function applyChanges(toPermanent) {
const newItems = [];
editItems.forEach((e, i) => {
if (e._remove) return;
const input = overlay.querySelector(`.meal-edit-portie[data-edit-idx="${i}"]`);
const portie = input ? parseInt(input.value, 10) || e.portie : e.portie;
if (portie > 0) newItems.push({ item: e.item, portie: portie });
});
if (newItems.length === 0) {
hapticError();
showToast('Een maaltijd moet minimaal 1 item hebben');
return;
}
// Update diary entry
entry.items = newItems;
entry.khTotaal = Math.round(newItems.reduce((sum, e) => sum + (parseKh(e.item.kh) / 100) * e.portie, 0));
if (toPermanent && entry.mealId) {
const saved = maaltijden.find(m => m.id === entry.mealId);
if (saved) {
saved.items = newItems.map(e => ({ item: e.item, portie: e.portie }));
saved.khTotaal = entry.khTotaal;
saveMaaltijden();
}
}
saveToStorage();
close();
renderDagboek();
showToast(toPermanent ? 'Maaltijd permanent bijgewerkt ✅' : 'Maaltijd eenmalig aangepast ✅');
}
overlay.querySelector('#mealDiaryClose').addEventListener('click', close);
overlay.addEventListener('click', (e) => { if (e.target === overlay) close(); });
setupSwipeDown(overlay, close);
overlay.querySelectorAll('.meal-edit-item-remove').forEach(btn => {
btn.addEventListener('click', () => {
editItems[parseInt(btn.dataset.editIdx)]._remove = true;
overlay.querySelector('#mealDiaryItems').innerHTML = renderEditList();
});
});
overlay.querySelector('#mealDiarySaveOnce').addEventListener('click', () => applyChanges(false));
overlay.querySelector('#mealDiarySavePerm').addEventListener('click', () => applyChanges(true));
}
// ===== Modal Helpers =====
function closeModal(modalId) {
const el = document.getElementById(modalId);
if (!el) return;
el.classList.add('closing');
setTimeout(() => {
el.classList.add('hidden');
el.classList.remove('closing');
document.body.style.overflow = '';
}, 250);
}
/**
* Add swipe-down-to-close gesture to a bottom sheet overlay.
* @param {HTMLElement} overlay - The .modal-overlay element containing a .modal-sheet
* @param {Function} closeCallback - Called when swipe-down completes
*/
function setupSwipeDown(overlay, closeCallback) {
const sheet = overlay.querySelector('.modal-sheet');
if (!sheet) return;
let startY = 0;
let currentY = 0;
let isDragging = false;
let startTime = 0;
let hasSwiped = false;
function resetInlineStyles() {
sheet.style.transform = '';
sheet.style.transition = '';
overlay.style.opacity = '';
overlay.style.background = '';
overlay.style.transition = '';
}
sheet.addEventListener('touchstart', (e) => {
if (e.touches.length !== 1) return;
// Only initiate swipe-down when scrolled to top (native feel)
if (sheet.scrollTop > 0) return;
const touch = e.touches[0];
startY = touch.clientY;
currentY = startY;
isDragging = true;
hasSwiped = false;
startTime = Date.now();
sheet.style.transition = 'none';
overlay.style.transition = 'none';
}, { passive: true });
sheet.addEventListener('touchmove', (e) => {
if (!isDragging) return;
const touch = e.touches[0];
currentY = touch.clientY;
const deltaY = currentY - startY;
if (deltaY > 0) {
hasSwiped = true;
e.preventDefault();
// Follow finger, capped at 80% of viewport
const translateY = Math.min(deltaY, window.innerHeight * 0.8);
sheet.style.transform = `translateY(${translateY}px)`;
// Dim overlay as the sheet goes down
overlay.style.background = `rgba(0,0,0,${Math.max(0, 0.4 * (1 - translateY / window.innerHeight))})`;
}
}, { passive: false });
sheet.addEventListener('touchend', () => {
if (!isDragging) return;
isDragging = false;
if (!hasSwiped) return;
const deltaY = currentY - startY;
const elapsed = Date.now() - startTime || 1;
const velocity = deltaY / elapsed; // px/ms
if (deltaY > 80 || velocity > 0.5) {
// Swiped far enough — animate the rest of the way down and close
sheet.style.transition = 'transform 0.25s ease-in';
overlay.style.transition = 'opacity 0.2s ease, background 0.25s ease';
sheet.style.transform = `translateY(${sheet.offsetHeight}px)`;
overlay.style.opacity = '0';
setTimeout(() => {
resetInlineStyles();
closeCallback();
}, 260);
} else {
// Not enough swipe — snap back
sheet.style.transition = 'transform 0.3s cubic-bezier(0.16, 1, 0.3, 1)';
overlay.style.transition = 'background 0.3s ease';
sheet.style.transform = '';
overlay.style.background = '';
setTimeout(() => {
overlay.style.transition = '';
}, 350);
}
}, { passive: true });
}
function showToast(message) {
const container = document.getElementById('toastContainer');
const toast = document.createElement('div');
toast.className = 'toast';
toast.textContent = message;
container.appendChild(toast);
// Position at last click Y, or default bottom
const y = lastClickY != null ? lastClickY : window.innerHeight - 120;
const h = toast.offsetHeight || 50;
const maxY = window.innerHeight - h - 16;
const top = Math.min(Math.max(y, 16), maxY);
toast.style.top = top + 'px';
toast.style.bottom = 'auto';
setTimeout(() => {
toast.classList.add('hiding');
setTimeout(() => toast.remove(), 300);
}, 2000);
}
/**
* Show a confirmation dialog with Ja/Nee buttons.
* @param {string} title - Dialog title (default: 'Weet je het zeker?')
* @param {string} message - Description of what will be deleted
* @param {string} confirmText - Text for confirm button (default: 'Ja, verwijderen')
* @returns {Promise<boolean>} true if confirmed, false if cancelled
*/
function confirmAction({ title = 'Weet je het zeker?', message = '', confirmText = 'Ja, verwijderen' } = {}) {
return new Promise(resolve => {
const overlay = document.createElement('div');
overlay.className = 'modal-overlay';
overlay.style.zIndex = '500';
overlay.innerHTML = `
<div class="modal-sheet" style="max-width:360px;border-radius:var(--radius);margin:auto;max-height:none;padding:0;">
<div class="modal-handle"></div>
<div class="modal-header">
<h2 class="modal-title">${escapeHtml(title)}</h2>
</div>
<div class="modal-body" style="text-align:center;padding:0 20px 20px;">
<p style="color:var(--text-muted);font-size:var(--font-base);margin:0;">${escapeHtml(message)}</p>
</div>
<div style="display:flex;gap:10px;padding:0 20px 20px;">
<button class="btn-cancel" style="flex:1;" id="confirmNo">Nee</button>
<button class="btn-danger-outline" data-haptic="strong" style="flex:1;" id="confirmYes">${escapeHtml(confirmText)}</button>
</div>
</div>
`;
document.body.appendChild(overlay);
const close = () => {
overlay.classList.add('closing');
setTimeout(() => overlay.remove(), 250);
};
overlay.querySelector('#confirmNo').addEventListener('click', () => { close(); resolve(false); });
overlay.querySelector('#confirmYes').addEventListener('click', () => { close(); resolve(true); });
overlay.addEventListener('click', (e) => { if (e.target === overlay) { close(); resolve(false); } });
setupSwipeDown(overlay, () => { close(); resolve(false); });
});
}
/** @deprecated Use confirmAction() instead */
function showConfirmModal(title, message, onConfirm) {
const overlay = document.createElement('div');
overlay.className = 'modal-overlay';
overlay.style.zIndex = '500';
overlay.innerHTML = `
<div class="modal-sheet" style="max-width:360px;border-radius:var(--radius);margin:auto;max-height:none;padding:0;">
<div class="modal-handle"></div>
<div class="modal-header">
<h2 class="modal-title">${escapeHtml(title)}</h2>
</div>
<div class="modal-body" style="text-align:center;padding:0 20px 20px;">
<p style="color:var(--text-muted);font-size:var(--font-base);margin:0;">${escapeHtml(message)}</p>
</div>
<div style="display:flex;gap:10px;padding:0 20px 20px;">
<button class="btn-cancel" style="flex:1;" id="confirmCancel">Annuleren</button>
<button class="btn-danger-outline" style="flex:1;" id="confirmOk">Verwijderen</button>
</div>
</div>
`;
document.body.appendChild(overlay);
const close = () => {
overlay.classList.add('closing');
setTimeout(() => overlay.remove(), 250);
};
overlay.querySelector('#confirmCancel').addEventListener('click', close);
overlay.querySelector('#confirmOk').addEventListener('click', () => {
close();
onConfirm();
});
overlay.addEventListener('click', (e) => {
if (e.target === overlay) close();
});
setupSwipeDown(overlay, close);
}
function showNameInputModal(title, defaultValue, onSave) {
const overlay = document.createElement('div');
overlay.className = 'modal-overlay';
overlay.style.zIndex = '400';
overlay.innerHTML = `
<div class="modal-sheet">
<div class="modal-handle"></div>
<div class="modal-header">
<span class="modal-title">${escapeHtml(title)}</span>
<button class="modal-close" id="nameInputClose"><i class="fas fa-xmark"></i></button>
</div>
<div class="modal-body" style="padding:16px 20px;">
<input type="text" class="name-input-field" id="nameInputField" value="${escapeHtml(defaultValue || '')}" placeholder="Naam van de maaltijd" maxlength="60">
<div class="name-input-actions">
<button class="btn-cancel" id="nameInputCancel">Annuleren</button>
<button class="btn-save" id="nameInputSave">Opslaan</button>
</div>
</div>
</div>
`;
document.body.appendChild(overlay);
document.body.style.overflow = 'hidden';
const input = overlay.querySelector('#nameInputField');
function close() {
overlay.classList.add('closing');
setTimeout(() => { overlay.remove(); document.body.style.overflow = ''; }, 280);
}
function submit() { const val = input.value.trim(); close(); onSave(val || null); }
overlay.querySelector('#nameInputClose').addEventListener('click', close);
overlay.querySelector('#nameInputCancel').addEventListener('click', close);
overlay.querySelector('#nameInputSave').addEventListener('click', submit);
overlay.addEventListener('click', (e) => { if (e.target === overlay) close(); });
setupSwipeDown(overlay, close);
input.addEventListener('keydown', (e) => { if (e.key === 'Enter') submit(); });
setTimeout(() => { input.focus(); input.select(); }, 100);
}
function openMealEditor(idx) {
const meal = maaltijden[idx];
if (!meal) return;
const itemsHtml = meal.items.map((entry, i) => {
const khPer100 = parseKh(entry.item.kh);
const itemKh = (khPer100 / 100) * (entry.portie || 0);
return `
<div class="meal-edit-item">
<div class="meal-edit-item-info">
<div class="meal-edit-item-name">${escapeHtml(displayNaam(entry.item.naam))}</div>
<div class="meal-edit-item-cat">${escapeHtml(entry.item.cat)} · ${formatKh(itemKh)} g kh</div>
</div>
<div class="meal-edit-item-actions">
<div class="portion-input-wrapper" style="margin:0;">
<input type="number" class="portion-input" value="${entry.portie}" min="1" max="9999" data-item-idx="${i}" style="width:72px;min-width:72px;">
<span class="portion-unit">g</span>
</div>
<button class="meal-edit-item-remove" data-haptic="strong" data-item-idx="${i}" title="Verwijderen"><i class="fas fa-xmark"></i></button>
</div>
</div>
`;
}).join('');
const overlay = document.createElement('div');
overlay.className = 'modal-overlay';
overlay.style.zIndex = '400';
overlay.innerHTML = `
<div class="modal-sheet">
<div class="modal-handle"></div>
<div class="modal-header">
<span class="modal-title meal-edit-title" id="mealEditTitle">${escapeHtml(meal.naam)}</span>
<button class="modal-close" id="mealEditClose"><i class="fas fa-xmark"></i></button>
</div>
<div class="modal-body">
<div class="meal-edit-name-hint">Tik op de naam om te wijzigen</div>
<div class="meal-edit-items" id="mealEditItems">${itemsHtml}</div>
<button class="meal-edit-add-item" id="mealEditAddItem">
<span>+</span> Item toevoegen
</button>
<div class="meal-edit-actions">
<button class="btn-save" id="mealEditSave">Wijzigingen opslaan</button>
<button class="btn-danger-outline" data-haptic="strong" id="mealEditDelete">Maaltijd verwijderen</button>
</div>
</div>
</div>
`;
document.body.appendChild(overlay);
document.body.style.overflow = 'hidden';
function close() {
overlay.classList.add('closing');
setTimeout(() => { overlay.remove(); document.body.style.overflow = ''; }, 280);
}
function save() {
const newItems = [];
overlay.querySelectorAll('.meal-edit-item').forEach((el, i) => {
const input = el.querySelector('.portion-input');
const removeBtn = el.querySelector('.meal-edit-item-remove');
if (input && !removeBtn.classList.contains('marked-removed')) {
const portie = parseInt(input.value, 10) || 0;
if (portie > 0) {
newItems.push({ item: meal.items[i].item, portie: portie });
}
}
});
if (newItems.length === 0) {
hapticError();
showToast('Een maaltijd moet minimaal 1 item hebben');
return;
}
let khTotaal = 0;
newItems.forEach(e => {
khTotaal += (parseKh(e.item.kh) / 100) * (e.portie || 0);
});
meal.items = newItems;
meal.khTotaal = Math.round(khTotaal);
saveMaaltijden();
close();
renderMaaltijden();
showToast('"' + escapeHtml(meal.naam) + '" bijgewerkt ✅');
}
overlay.querySelector('#mealEditTitle').addEventListener('click', () => {
showNameInputModal('Maaltijd hernoemen', meal.naam, (newName) => {
if (newName && newName.trim()) {
meal.naam = newName.trim();
overlay.querySelector('#mealEditTitle').textContent = newName.trim();
}
});
});
overlay.querySelector('#mealEditClose').addEventListener('click', close);
overlay.addEventListener('click', (e) => { if (e.target === overlay) close(); });
setupSwipeDown(overlay, close);
overlay.querySelectorAll('.meal-edit-item-remove').forEach(btn => {
btn.addEventListener('click', () => {
const itemEl = btn.closest('.meal-edit-item');
itemEl.style.opacity = '0.3';
itemEl.style.textDecoration = 'line-through';
btn.classList.add('marked-removed');
const input = itemEl.querySelector('.portion-input');
if (input) input.disabled = true;
});
});
overlay.querySelector('#mealEditAddItem').addEventListener('click', () => {
const searchOverlay = document.createElement('div');
searchOverlay.className = 'modal-overlay';
searchOverlay.style.zIndex = '500';
searchOverlay.innerHTML = `
<div class="modal-sheet">
<div class="modal-handle"></div>
<div class="modal-header">
<span class="modal-title">Item toevoegen</span>
<button class="modal-close"><i class="fas fa-xmark"></i></button>
</div>
<div class="modal-body">
<div class="search-wrapper" style="margin:0 0 12px 0;">
<span class="search-icon"><i class="fas fa-magnifying-glass"></i></span>
<input type="text" class="search-input" id="mealItemSearch" placeholder="Zoek voedingsmiddel..." autocomplete="off">
</div>
<div class="meal-search-results" id="mealSearchResults" style="max-height:50vh;overflow-y:auto;"></div>
</div>
</div>
`;
document.body.appendChild(searchOverlay);
function closeSearch() {
searchOverlay.classList.add('closing');
setTimeout(() => searchOverlay.remove(), 280);
}
searchOverlay.querySelector('.modal-close').addEventListener('click', closeSearch);
searchOverlay.addEventListener('click', (e) => { if (e.target === searchOverlay) closeSearch(); });
setupSwipeDown(searchOverlay, closeSearch);
const searchInput = searchOverlay.querySelector('#mealItemSearch');
const resultsEl = searchOverlay.querySelector('#mealSearchResults');
searchInput.addEventListener('input', () => {
const q = searchInput.value.trim().toLowerCase();
if (q.length < 1) { resultsEl.innerHTML = ''; return; }
const keywords = q.split(/\s+/);
const matches = voedingsmiddelen.filter(item => {
const t = (item.naam + ' ' + item.cat).toLowerCase();
return keywords.every(kw => t.includes(kw));
}).slice(0, 20);
if (matches.length === 0) {
resultsEl.innerHTML = '<div style="padding:20px;text-align:center;color:var(--text-muted);">Geen resultaten</div>';
return;
}
resultsEl.innerHTML = matches.map((item, i) => `
<div class="meal-search-item" data-item-idx="${i}">
<div style="font-weight:500;">${escapeHtml(displayNaam(item.naam))}</div>
<div style="font-size:var(--font-xs);color:var(--text-muted);">${escapeHtml(item.cat)} · ${escapeHtml(item.kh)} g kh/100g</div>
</div>
`).join('');
resultsEl.querySelectorAll('.meal-search-item').forEach(el => {
el.addEventListener('click', () => {
const item = matches[parseInt(el.dataset.itemIdx)];
closeSearch();
showPortionPicker(item);
});
});
function showPortionPicker(item) {
const kh = parseKh(item.kh);
const po = document.createElement('div');
po.className = 'modal-overlay';
po.style.zIndex = '600';
po.innerHTML = `
<div class="modal-sheet">
<div class="modal-handle"></div>
<div class="modal-header">
<span class="modal-title">Portie kiezen</span>
<button class="modal-close" id="mealPortionClose"><i class="fas fa-xmark"></i></button>
</div>
<div class="modal-body">
<div style="margin-bottom:12px;font-weight:500;">${escapeHtml(displayNaam(item.naam))} <span style="color:var(--primary-dark);">(${formatKh(kh)} g kh / 100g)</span></div>
<div class="portion-input-group">
<div class="add-meal-label">Portie (gram)</div>
<div class="portion-input-wrapper">
<input type="number" class="portion-input" id="mealPortionInput" value="100" min="1" max="9999" inputmode="numeric" pattern="[0-9]*">
<span class="portion-unit">gram</span>
</div>
<div class="standaard-porties">
${getStandardPorties(item).map((p, pi) => `
<div class="portie-stepper" data-grams="${p.grams}" data-label="${escapeHtml(p.label)}">
<button class="portie-stepper-btn portie-stepper-minus" type="button" ${pi === 0 ? '' : 'disabled'}></button>
<span class="portie-stepper-count">${pi === 0 ? 1 : 0}</span>
<button class="portie-stepper-btn portie-stepper-plus" type="button">+</button>
<span class="portie-stepper-label">${escapeHtml(p.label)}</span>
<span class="portie-stepper-unit">${p.grams}g</span>
</div>
`).join('')}
</div>
<div class="portion-preview">
<div class="portion-preview-label">Koolhydraten in deze portie</div>
<div class="portion-preview-value" id="mealPortionPreview">${formatKh(kh)} g</div>
</div>
</div>
<div style="display:flex;gap:8px;margin-top:16px;">
<button class="btn-cancel" id="mealPortionCancel" style="flex:1;">Annuleren</button>
<button class="add-meal-submit" id="mealPortionConfirm" style="flex:1;"><i class="fas fa-check"></i> Toevoegen</button>
</div>
</div>
</div>
`;
document.body.appendChild(po);
document.body.style.overflow = 'hidden';
function closePicker() {
po.classList.add('closing');
setTimeout(() => { po.remove(); document.body.style.overflow = ''; }, 280);
}
po.querySelector('#mealPortionClose').addEventListener('click', closePicker);
po.querySelector('#mealPortionCancel').addEventListener('click', closePicker);
po.addEventListener('click', (e) => { if (e.target === po) closePicker(); });
setupSwipeDown(po, closePicker);
const input = po.querySelector('#mealPortionInput');
const preview = po.querySelector('#mealPortionPreview');
input.addEventListener('input', () => {
const p = parseFloat(input.value) || 0;
preview.textContent = formatKh((kh / 100) * p) + ' g';
});
// Stepper handlers
const poSteppers = po.querySelectorAll('.portie-stepper');
function updatePoTotal() {
let totalGrams = 0;
poSteppers.forEach(stepper => {
const cnt = parseInt(stepper.querySelector('.portie-stepper-count').textContent, 10) || 0;
const g = parseInt(stepper.dataset.grams, 10) || 0;
totalGrams += cnt * g;
stepper.classList.toggle('active', cnt > 0);
stepper.querySelector('.portie-stepper-minus').disabled = cnt <= 0;
});
input.value = totalGrams || 100;
preview.textContent = formatKh((kh / 100) * totalGrams) + ' g';
}
poSteppers.forEach(stepper => {
const ce = stepper.querySelector('.portie-stepper-count');
stepper.querySelector('.portie-stepper-plus').addEventListener('click', function() {
poSteppers.forEach(s => { if (s !== stepper) s.querySelector('.portie-stepper-count').textContent = '0'; });
ce.textContent = (parseInt(ce.textContent, 10) || 0) + 1;
updatePoTotal();
this.classList.add('tapped');
setTimeout(() => this.classList.remove('tapped'), 300);
});
stepper.querySelector('.portie-stepper-minus').addEventListener('click', function() {
const c = parseInt(ce.textContent, 10) || 0;
if (c > 0) ce.textContent = c - 1;
updatePoTotal();
this.classList.add('tapped');
setTimeout(() => this.classList.remove('tapped'), 300);
});
});
po.querySelector('#mealPortionConfirm').addEventListener('click', () => {
const portie = parseFloat(input.value) || 0;
if (portie <= 0) {
hapticError();
input.focus(); input.style.borderColor = 'var(--danger)';
setTimeout(() => { input.style.borderColor = ''; }, 1500);
return;
}
meal.items.push({ item: item, portie: portie });
closePicker();
close();
openMealEditor(idx);
});
setTimeout(() => { input.focus(); input.select(); }, 100);
}
});
setTimeout(() => searchInput.focus(), 100);
});
overlay.querySelector('#mealEditSave').addEventListener('click', save);
overlay.querySelector('#mealEditDelete').addEventListener('click', async () => {
const confirmed = await confirmAction({
title: 'Maaltijd verwijderen',
message: `Weet je zeker dat je "${meal.naam}" wilt verwijderen?`,
confirmText: 'Ja, verwijderen'
});
if (!confirmed) return;
maaltijden.splice(idx, 1);
saveMaaltijden();
close();
renderMaaltijden();
});
}
// ===== Utilities =====
function escapeHtml(str) {
if (!str) return '';
return String(str)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
}
function displayNaam(naam) {
if (!naam) return '';
return naam.replace(/^\d+\s+/, '');
}
function formatKh(val) {
return Math.round(val * 10) / 10;
}
// ===== PWA: Manifest (blob URL) =====
function setupManifest() {
const manifest = {
name: "Karby",
short_name: "Karby",
description: "Houd je koolhydraten bij — voor zwangerschapsdiabetes",
start_url: "/",
display: "standalone",
orientation: "portrait",
background_color: "#FDFDFD",
theme_color: "#E8B730",
icons: [{
src: "data:image/svg+xml," + encodeURIComponent('<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 192 192"><rect width="192" height="192" rx="32" fill="#E8B730"/><text x="96" y="128" text-anchor="middle" font-size="112" font-weight="bold" font-family="system-ui" fill="#191919">K</text></svg>'),
sizes: "192x192",
type: "image/svg+xml",
purpose: "any maskable"
}]
};
const blob = new Blob([JSON.stringify(manifest)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const link = document.createElement('link');
link.rel = 'manifest';
link.href = url;
document.head.appendChild(link);
}
// ===== PWA: Service Worker Registration =====
function registerServiceWorker() {
if (!('serviceWorker' in navigator)) return;
// Strategy 1: Try blob URL (works in some browsers/dev environments)
try {
var swCode = [
'var CACHE_NAME="koolhydraat-app-v1";',
'var ASSETS=["/","/index.html","/data/voedingsmiddelen.json"];',
'self.addEventListener("install",function(e){e.waitUntil(caches.open(CACHE_NAME).then(function(c){return c.addAll(ASSETS).catch(function(){})}).then(function(){return self.skipWaiting()}))});',
'self.addEventListener("activate",function(e){e.waitUntil(caches.keys().then(function(k){return Promise.all(k.filter(function(x){return x!==CACHE_NAME}).map(function(x){return caches.delete(x)}))}).then(function(){return self.clients.claim()}))});',
'self.addEventListener("fetch",function(e){e.respondWith(caches.match(e.request).then(function(c){var p=fetch(e.request).then(function(r){if(r&&r.status===200){var cl=r.clone();caches.open(CACHE_NAME).then(function(ca){ca.put(e.request,cl)})}return r}).catch(function(){return c});return c||p}))});'
].join('\n');
var blob = new Blob([swCode], {type: 'application/javascript'});
var blobUrl = URL.createObjectURL(blob);
navigator.serviceWorker.register(blobUrl, {scope: '/'})
.then(function(reg) {
console.log('✅ Service Worker geregistreerd (blob):', reg.scope);
})
.catch(function() {
// Blob URL failed, try ?sw query param approach
URL.revokeObjectURL(blobUrl);
});
} catch(e) {
// Blob URL creation failed, continue to ?sw approach
}
// Strategy 2: ?sw query param — works when served behind nginx/etc
// that returns JS-only for ?sw requests
var swUrl = window.location.pathname + '?sw';
navigator.serviceWorker.register(swUrl, {scope: '/'})
.then(function(reg) {
console.log('✅ Service Worker geregistreerd:', reg.scope);
})
.catch(function(err) {
// Expected with static file serving — app works fully without SW
console.log(' Service Worker niet geladen (app werkt zonder offline caching):', err.message);
});
}
// ===== Event Listeners =====
function setupEventListeners() {
// Search input
const searchInput = document.getElementById('searchInput');
const searchClear = document.getElementById('searchClear');
searchInput.addEventListener('input', () => {
searchQuery = searchInput.value;
searchClear.classList.toggle('visible', searchQuery.length > 0);
resetSearchPagination();
renderZoeken();
});
searchClear.addEventListener('click', () => {
searchInput.value = '';
searchQuery = '';
searchClear.classList.remove('visible');
resetSearchPagination();
renderZoeken();
searchInput.focus();
});
// Category filter
document.getElementById('categoryFilter').addEventListener('click', (e) => {
const btn = e.target.closest('.cat-btn');
if (!btn) return;
document.querySelectorAll('.cat-btn').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
selectedCategory = btn.dataset.cat || null;
resetSearchPagination();
renderZoeken();
});
// Tab navigation
document.getElementById('navZoeken').addEventListener('click', () => switchTab('zoeken'));
document.getElementById('navDagboek').addEventListener('click', () => switchTab('dagboek'));
document.getElementById('navMaaltijden').addEventListener('click', () => switchTab('maaltijden'));
// Day navigation
document.getElementById('dayPrev').addEventListener('click', () => navigateDay(-1));
document.getElementById('dayNext').addEventListener('click', () => navigateDay(1));
document.getElementById('dayToday').addEventListener('click', () => goToToday());
// Modal close buttons
document.getElementById('detailModalClose').addEventListener('click', () => closeModal('detailModal'));
document.getElementById('addMealModalClose').addEventListener('click', () => closeModal('addMealModal'));
// Close modals on overlay click
document.getElementById('detailModal').addEventListener('click', (e) => {
if (e.target === document.getElementById('detailModal')) closeModal('detailModal');
});
document.getElementById('addMealModal').addEventListener('click', (e) => {
if (e.target === document.getElementById('addMealModal')) closeModal('addMealModal');
});
// Swipe-down to close for static modals
setupSwipeDown(document.getElementById('detailModal'), () => closeModal('detailModal'));
setupSwipeDown(document.getElementById('addMealModal'), () => closeModal('addMealModal'));
// Settings button & modal
document.getElementById('settingsBtn').addEventListener('click', () => {
// Populate with current limit
document.getElementById('carbLimitInput').value = getCarbLimit();
document.getElementById('settingsModal').classList.remove('hidden');
document.body.style.overflow = 'hidden';
});
document.getElementById('settingsModalClose').addEventListener('click', () => closeModal('settingsModal'));
document.getElementById('settingsModal').addEventListener('click', (e) => {
if (e.target === document.getElementById('settingsModal')) closeModal('settingsModal');
});
setupSwipeDown(document.getElementById('settingsModal'), () => closeModal('settingsModal'));
document.getElementById('saveCarbLimitBtn').addEventListener('click', () => {
const input = document.getElementById('carbLimitInput');
const val = parseInt(input.value, 10);
if (val && val > 0 && val < 1000) {
setCarbLimit(val);
closeModal('settingsModal');
// Re-render diary if active
renderDagboek();
showToast(`Limiet ingesteld op ${val}g per dag`);
} else {
input.style.borderColor = 'var(--danger)';
setTimeout(() => { input.style.borderColor = ''; }, 1500);
input.focus();
}
});
// Keyboard shortcuts
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
closeModal('detailModal');
closeModal('addMealModal');
closeModal('settingsModal');
}
// Ctrl+1..6 to jump to eating moments
if (e.ctrlKey && e.key >= '1' && e.key <= '6') {
e.preventDefault();
switchTab('dagboek');
const idx = parseInt(e.key) - 1;
const momentId = EETMOMENTEN[idx].id;
// Scroll to the moment
setTimeout(() => {
const cards = document.querySelectorAll('.eetmoment-card');
if (cards[idx]) {
cards[idx].scrollIntoView({ behavior: 'smooth', block: 'start' });
cards[idx].style.boxShadow = '0 0 0 3px var(--primary)';
setTimeout(() => { cards[idx].style.boxShadow = ''; }, 1500);
}
}, 100);
}
});
}
// ===== Init =====
function init() {
setupManifest();
loadFromStorage();
getOrCreateDay(activeDate);
setupEventListeners();
loadVoedingsData();
registerServiceWorker();
// === Global meal-group action delegation (setup once, not per render) ===
const listEl = document.getElementById('eetmomentList');
if (listEl && !listEl._delegationSetup) {
listEl._delegationSetup = true;
listEl.addEventListener('click', (e) => {
const btn = e.target.closest('[data-action]');
if (!btn) return;
const action = btn.dataset.action;
const momentId = btn.dataset.moment;
const entryIdx = btn.dataset.entry != null ? parseInt(btn.dataset.entry) : null;
if (action === 'toggle-meal') {
const group = btn.closest('.eetmoment-meal-group');
if (!group) return;
const wrap = group.querySelector('.eetmoment-meal-items-wrap');
const actions = group.querySelector('.eetmoment-meal-actions');
if (wrap) {
wrap.classList.toggle('collapsed');
if (actions) actions.classList.toggle('collapsed');
const arrow = btn.querySelector('span:last-child i');
if (arrow) {
arrow.className = wrap.classList.contains('collapsed') ? 'fas fa-chevron-down' : 'fas fa-chevron-up';
}
}
}
else if (action === 'edit-porties') {
showBulkPortieEdit(activeDate, momentId, entryIdx);
}
else if (action === 'delete-meal') {
const dayData = getOrCreateDay(activeDate);
const entry = (dayData[momentId] || [])[entryIdx];
const mealName = (entry && entry.mealNaam) || 'maaltijd';
confirmAction({
title: 'Maaltijd verwijderen',
message: 'Deze hele maaltijd verwijderen uit je dagboek?',
confirmText: 'Ja, verwijderen'
}).then(confirmed => {
if (!confirmed) return;
dayData[momentId].splice(entryIdx, 1);
saveToStorage();
renderDagboek();
});
}
else if (action === 'move-meal') {
const overlay = document.createElement('div');
overlay.className = 'modal-overlay';
overlay.style.zIndex = '500';
const options = EETMOMENTEN.map(m =>
`<option value="${m.id}" ${m.id === momentId ? 'selected' : ''}>${m.icon} ${m.name}</option>`
).join('');
overlay.innerHTML = `
<div class="modal-sheet" style="max-width:360px;border-radius:var(--radius);margin:auto;max-height:none;padding:0;">
<div class="modal-handle"></div>
<div class="modal-header"><h2 class="modal-title">Verplaatsen naar</h2></div>
<div class="modal-body" style="padding:0 20px 20px;">
<select class="add-meal-select" id="moveTarget">${options}</select>
<div style="display:flex;gap:10px;margin-top:16px;">
<button class="btn-cancel" id="moveCancel">Annuleren</button>
<button class="btn-save" id="moveOk" style="flex:1;">Verplaatsen</button>
</div>
</div>
</div>
`;
document.body.appendChild(overlay);
const close = () => {
overlay.classList.add('closing');
setTimeout(() => overlay.remove(), 250);
};
overlay.querySelector('#moveCancel').addEventListener('click', close);
overlay.querySelector('#moveOk').addEventListener('click', () => {
const target = overlay.querySelector('#moveTarget').value;
const dayData = getOrCreateDay(activeDate);
const entry = (dayData[momentId] || [])[entryIdx];
if (!entry) return close();
dayData[momentId].splice(entryIdx, 1);
if (!dayData[target]) dayData[target] = [];
dayData[target].push(entry);
saveToStorage();
close();
renderDagboek();
});
overlay.addEventListener('click', (ev) => { if (ev.target === overlay) close(); });
setupSwipeDown(overlay, close);
}
});
}
}
// Start the app
// Track last click position for toast positioning + haptic feedback
// Supports data-haptic="strong" (18ms) for destructive actions
(function setupHaptics() {
document.addEventListener('click', (e) => {
lastClickY = e.clientY;
if (typeof navigator !== 'undefined' && navigator.vibrate) {
// Haptic feedback on buttons, checkboxes, toggles, and [data-haptic] elements
const btn = e.target.closest('button, input[type="checkbox"], input[type="radio"], .item-checkbox, [role="button"], [data-haptic]');
if (btn) {
const strength = btn.dataset.haptic || (btn.closest('[data-haptic]') && btn.closest('[data-haptic]').dataset.haptic) || 'normal';
navigator.vibrate(strength === 'strong' ? 18 : 8);
}
}
}, { passive: true });
})();
/**
* Short double-buzz on validation errors (empty name, invalid portion, etc.)
*/
function hapticError() {
if (typeof navigator !== 'undefined' && navigator.vibrate) {
navigator.vibrate([30, 50, 30]);
}
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
})(); // End of main application IIFE
</script>
</body>
</html>