5820 lines
187 KiB
HTML
5820 lines
187 KiB
HTML
<!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(--danger);
|
||
cursor: pointer;
|
||
font-size: 1.2rem;
|
||
padding: 4px 8px;
|
||
min-width: var(--touch-min);
|
||
min-height: var(--touch-min);
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
border-radius: var(--radius-xs);
|
||
transition: background 0.15s;
|
||
}
|
||
|
||
.eetmoment-remove:active {
|
||
background: var(--danger-light);
|
||
}
|
||
|
||
.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(/>/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, '&')
|
||
.replace(/</g, '<')
|
||
.replace(/>/g, '>')
|
||
.replace(/"/g, '"')
|
||
.replace(/'/g, ''');
|
||
}
|
||
|
||
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>
|