commit 1affadb05d94c5159423e623d32c1ac77ea03757 Author: 4aiman Date: Wed May 13 10:21:24 2026 +0300 Initial commit: BalDav health tracker Tauri 2 + pure JS + SQLite personal health tracking app. Nutrition module: - 9299 products in DB (Magnit SSR scraping + KDV) - Product search with case-insensitive Cyrillic (name_lower column) - Food logging from product DB or manual entry - Supplement tracking with per-pill micronutrients - Micronutrient % strip on Today screen Fitness/GPS module: - FitoTrack .ftb and GPX import with seq-based ordering - Session detail view: map, speed/elevation charts (Chart.js) - Offline tile caching (IndexedDB) with region download - Track rendering with colored time-gradient points UI: - Fullscreen modals with back-button (history API) - Dark theme, desktop-responsive layout - 5 tabs: Today, Products, Track, Supplements, Profile Tools: - Magnit/KDV HTML parsers - Dedup import with discrepancy detection - FitoTrack backup analysis diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f197bca --- /dev/null +++ b/.gitignore @@ -0,0 +1,14 @@ +node_modules/ +target/ +src-tauri/target/ +src-tauri/gen/ +data/stores/ +*.har +*.ftb +*.gpx +debug_*.html +debug_*.json +fitotrack_src/ +fitotrack_v156/ +fitotrack_*.apk +de.tadris.fitness_*.tar.gz diff --git a/frontend/css/styles.css b/frontend/css/styles.css new file mode 100644 index 0000000..5e192c7 --- /dev/null +++ b/frontend/css/styles.css @@ -0,0 +1,1157 @@ +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +:root { + --bg: #121212; + --surface: #1e1e1e; + --primary: #bb86fc; + --on-bg: #e0e0e0; + --on-surface: #ffffff; + --positive: #cf6679; + --negative: #03dac6; + --muted: #888; + --border: #333; + --radius: 8px; + --safe-area-top: env(safe-area-inset-top, 0px); + --safe-area-bottom: env(safe-area-inset-bottom, 0px); +} + +html, body { + height: 100%; +} + +body { + font-family: system-ui, -apple-system, sans-serif; + background: var(--bg); + color: var(--on-bg); + font-size: 16px; + line-height: 1.4; + overflow-x: hidden; + -webkit-tap-highlight-color: transparent; +} + +#app { + max-width: 600px; + margin: 0 auto; + height: 100%; + display: flex; + flex-direction: column; +} + +@media (min-width: 768px) { + #app { max-width: 100%; padding: 0 24px; } + .summary-row { gap: 12px; padding: 14px 16px; } + .gps-map { height: 450px; } + .sd-map { height: 400px; } + .modal { max-width: 100%; } + .sd-row { gap: 16px; } + .sd-val { font-size: 22px; } + .products-screen, .settings-screen { max-width: 700px; margin: 0 auto; } + .log-section { max-width: 700px; margin: 0 auto; } + .day-header, .summary-row, .micro-strip { max-width: 700px; margin-left: auto; margin-right: auto; } +} + +/* Header / date navigation */ +.day-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 12px 16px; + background: var(--surface); + border-bottom: 1px solid var(--border); + position: sticky; + top: 0; + z-index: 10; +} + +.day-header button { + background: none; + border: none; + color: var(--primary); + font-size: 24px; + padding: 8px; + cursor: pointer; + border-radius: var(--radius); +} + +.day-header button:active { + background: rgba(187, 134, 252, 0.15); +} + +.day-header .date-text { + font-size: 18px; + font-weight: 500; + color: var(--on-surface); +} + +/* Weight display */ +.weight-row { + padding: 8px 16px; + background: var(--surface); + border-bottom: 1px solid var(--border); + font-size: 14px; + color: var(--muted); +} + +.weight-row span { + color: var(--on-surface); + font-weight: 500; +} + +/* Entry list */ +.entries { + flex: 1; + overflow-y: auto; + padding: 8px 0; +} + +.entry-item { + display: grid; + grid-template-columns: 50px 1fr 70px; + align-items: center; + padding: 10px 16px; + border-bottom: 1px solid var(--border); +} + +.entry-item:active { + background: rgba(255, 255, 255, 0.05); +} + +.entry-time { + font-size: 13px; + color: var(--muted); +} + +.entry-desc { + font-size: 15px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.entry-kcal { + text-align: right; + font-size: 14px; + font-weight: 500; +} + +.entry-kcal.intake { + color: var(--positive); +} + +.entry-kcal.expenditure { + color: var(--negative); +} + +/* Balance summary */ +.balance-bar { + display: flex; + justify-content: space-around; + padding: 12px 16px; + background: var(--surface); + border-top: 1px solid var(--border); + font-size: 14px; +} + +.balance-bar .label { + color: var(--muted); + font-size: 12px; +} + +.balance-bar .value { + font-size: 18px; + font-weight: 600; +} + +.balance-bar .value.deficit { + color: var(--negative); +} + +.balance-bar .value.surplus { + color: var(--positive); +} + +/* FAB (add button) */ +.fab { + position: fixed; + bottom: 80px; + right: 24px; + width: 56px; + height: 56px; + border-radius: 50%; + background: var(--primary); + color: #000; + border: none; + font-size: 28px; + font-weight: 300; + cursor: pointer; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4); + display: flex; + align-items: center; + justify-content: center; +} + +.fab:active { + transform: scale(0.95); +} + +/* Bottom nav */ +.bottom-nav { + display: flex; + justify-content: space-around; + padding: 10px 0; + background: var(--surface); + border-top: 1px solid var(--border); + position: sticky; + bottom: 0; +} + +.bottom-nav button { + background: none; + border: none; + color: var(--muted); + font-size: 12px; + padding: 4px 16px; + cursor: pointer; +} + +.bottom-nav button.active { + color: var(--primary); +} + +/* Modal / add entry overlay */ +.modal-overlay { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.7); + z-index: 100; + display: flex; + align-items: flex-end; + justify-content: center; +} + +.modal-sheet { + background: var(--surface); + width: 100%; + max-width: 480px; + border-radius: 16px 16px 0 0; + padding: 20px 16px; + max-height: 85vh; + overflow-y: auto; +} + +.modal-sheet h2 { + font-size: 18px; + margin-bottom: 16px; + color: var(--on-surface); +} + +/* Form elements */ +.form-group { + margin-bottom: 14px; +} + +.form-group label { + display: block; + font-size: 13px; + color: var(--muted); + margin-bottom: 4px; +} + +.form-group input, +.form-group select, +.form-group textarea { + width: 100%; + padding: 10px 12px; + background: var(--bg); + border: 1px solid var(--border); + border-radius: var(--radius); + color: var(--on-surface); + font-size: 16px; + font-family: inherit; +} + +.form-group input:focus, +.form-group select:focus, +.form-group textarea:focus { + outline: none; + border-color: var(--primary); +} + +.type-chips { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.type-chip { + padding: 6px 14px; + border-radius: 20px; + border: 1px solid var(--border); + background: none; + color: var(--on-bg); + font-size: 14px; + cursor: pointer; +} + +.type-chip.selected { + background: var(--primary); + color: #000; + border-color: var(--primary); +} + +.btn-row { + display: flex; + gap: 12px; + margin-top: 16px; +} + +.btn { + flex: 1; + padding: 12px; + border-radius: var(--radius); + border: none; + font-size: 16px; + font-family: inherit; + cursor: pointer; +} + +.btn-primary { + background: var(--primary); + color: #000; + font-weight: 600; +} + +.btn-secondary { + background: var(--border); + color: var(--on-bg); +} + +/* Setup screen */ +.setup-screen { + padding: 24px 16px; + display: flex; + flex-direction: column; + gap: 20px; + min-height: 100vh; + justify-content: center; +} + +.setup-screen h1 { + font-size: 24px; + color: var(--on-surface); + text-align: center; +} + +.setup-screen p { + color: var(--muted); + text-align: center; + font-size: 14px; +} + +/* Kcal preview */ +.kcal-preview { + padding: 10px 12px; + background: rgba(187, 134, 252, 0.1); + border-radius: var(--radius); + text-align: center; + font-size: 18px; + font-weight: 500; + color: var(--primary); +} + +/* Empty state */ +.empty-state { + flex: 1; + display: flex; + align-items: center; + justify-content: center; + color: var(--muted); + font-size: 15px; + padding: 40px; + text-align: center; +} + +/* Screen layout */ + +.bottom-nav { + order: 2; + position: sticky; + bottom: 0; + padding-bottom: var(--safe-area-bottom); +} + +.screen-content { + order: 1; + flex: 1; + overflow-y: auto; + padding-bottom: 8px; +} + +.nav-btn { + background: none; + border: none; + color: var(--muted); + font-size: 13px; + padding: 8px 12px; + cursor: pointer; + font-family: inherit; +} + +.nav-btn.active { + color: var(--primary); + font-weight: 600; +} + +/* Day navigation */ +.day-header h2 { + font-size: 15px; + font-weight: 500; + text-align: center; +} + +.day-nav { + background: none; + border: none; + color: var(--primary); + font-size: 20px; + padding: 4px 10px; + cursor: pointer; +} + +/* Summary cards */ +.summary-row { + display: flex; + gap: 8px; + padding: 10px 12px; + background: var(--surface); + border-bottom: 1px solid var(--border); +} + +.summary-card { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + padding: 6px 4px; + border-radius: var(--radius); + background: var(--bg); +} + +.summary-val { + font-size: 18px; + font-weight: 700; +} + +.summary-lbl { + font-size: 10px; + color: var(--muted); + text-align: center; +} + +.summary-card.surplus .summary-val { color: #cf6679; } +.summary-card.deficit .summary-val { color: #03dac6; } +.summary-card.balanced .summary-val { color: var(--on-bg); } + +/* Log sections */ +.log-section { + padding: 0 0 4px; + border-bottom: 1px solid var(--border); +} + +.section-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 8px 14px 4px; +} + +.section-header h3 { + font-size: 13px; + color: var(--muted); + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.add-btn { + background: none; + border: none; + color: var(--primary); + font-size: 22px; + line-height: 1; + cursor: pointer; + padding: 0 4px; +} + +.log-list { + list-style: none; + padding: 0; + margin: 0; +} + +.log-item { + display: flex; + align-items: center; + gap: 8px; + padding: 7px 14px; + border-top: 1px solid rgba(255,255,255,0.04); +} + +.log-time { + font-size: 12px; + color: var(--muted); + min-width: 38px; +} + +.log-desc { + flex: 1; + font-size: 14px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.log-desc small { + color: var(--muted); + margin-left: 4px; + font-size: 11px; +} + +.log-kcal { + font-size: 13px; + color: var(--muted); + min-width: 52px; + text-align: right; +} + +.log-empty { + padding: 6px 14px; + font-size: 13px; + color: var(--muted); + font-style: italic; +} + +.del-btn { + background: none; + border: none; + color: #555; + font-size: 14px; + cursor: pointer; + padding: 2px 4px; + flex-shrink: 0; +} + +.del-btn:active { color: #cf6679; } + +.food-edit-item { cursor: pointer; } +.food-edit-item:active { background: rgba(255,255,255,0.05); } + +/* Sleep info */ +.sleep-info { + padding: 6px 14px 8px; + display: flex; + flex-direction: column; + gap: 2px; +} + +.sleep-line { + font-size: 13px; + color: var(--on-bg); +} + +/* Modal */ +.modal-overlay { + position: fixed; + inset: 0; + background: var(--bg); + z-index: 500; + display: flex; + align-items: stretch; + justify-content: center; +} + +.modal { + background: var(--surface); + width: 100%; + border-radius: 0; + padding: 0; + height: 100%; + padding-top: var(--safe-area-top); + padding-bottom: var(--safe-area-bottom); + display: flex; + flex-direction: column; +} + +.modal-header { + display: flex; + align-items: center; + padding: 12px 16px; + border-bottom: 1px solid var(--border); + flex-shrink: 0; +} + +.modal-back { + background: none; + border: none; + color: var(--primary); + font-size: 20px; + padding: 4px 8px 4px 0; + cursor: pointer; +} + +.modal-body { + flex: 1; + overflow-y: auto; + padding: 16px 16px 8px; +} + +.modal h3 { + font-size: 17px; + margin-bottom: 16px; +} + +.modal-btns { + display: flex; + gap: 10px; + padding: 12px 16px; + border-top: 1px solid var(--border); + flex-shrink: 0; +} + +.btn-primary, .btn-secondary { + flex: 1; + padding: 12px; + border-radius: var(--radius); + border: none; + font-size: 16px; + font-family: inherit; + cursor: pointer; +} + +.btn-primary { + background: var(--primary); + color: #000; + font-weight: 600; +} + +.btn-secondary { + background: var(--border); + color: var(--on-bg); +} + +.full-width { width: 100%; } + +/* Products screen */ +.products-screen, .stub-screen, .settings-screen { + padding: 16px; +} + +.products-screen h2, .stub-screen h2, .settings-screen h2 { + font-size: 20px; + margin-bottom: 16px; +} + +.stub-note { + color: var(--muted); + font-size: 14px; + margin-top: 8px; +} + +.search-input { + width: 100%; + padding: 10px 14px; + background: var(--bg); + border: 1px solid var(--border); + border-radius: var(--radius); + color: var(--on-surface); + font-size: 16px; + font-family: inherit; + margin-bottom: 8px; +} + +.search-input:focus { + outline: none; + border-color: var(--primary); +} + +/* Product count & hint */ +.prod-count { + font-size: 12px; + color: var(--muted); + padding: 0 0 8px; +} + +.prod-hint { + color: var(--muted); + font-size: 14px; + font-style: italic; + padding: 20px 0; + text-align: center; +} + +.prod-list { + display: flex; + flex-direction: column; + gap: 6px; +} + +/* Product card — collapsed */ +.prod-card { + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius); + overflow: hidden; + transition: border-color 0.15s; +} + +.prod-card.open { + border-color: var(--primary); +} + +.prod-card-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 10px 12px; + cursor: pointer; + gap: 8px; +} + +.prod-card-header:active { + background: rgba(255,255,255,0.03); +} + +.prod-card-name { + flex: 1; + font-size: 14px; + line-height: 1.3; +} + +.prod-brand { + display: inline; + color: var(--muted); + font-size: 12px; + margin-left: 4px; +} + +.prod-weight { + display: inline; + color: var(--muted); + font-size: 11px; + margin-left: 4px; +} + +.prod-card-kcal { + font-size: 13px; + color: var(--muted); + white-space: nowrap; + flex-shrink: 0; +} + +/* Product card — expanded */ +.prod-details { + padding: 0 12px 12px; + border-top: 1px solid var(--border); +} + +.prod-macros { + display: flex; + gap: 4px; + padding: 10px 0 8px; +} + +.macro { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + padding: 6px 4px; + background: var(--bg); + border-radius: var(--radius); +} + +.macro-val { + font-size: 15px; + font-weight: 600; + color: var(--on-surface); +} + +.macro-lbl { + font-size: 10px; + color: var(--muted); + margin-top: 2px; +} + +.prod-composition { + font-size: 12px; + color: var(--muted); + line-height: 1.4; + margin-bottom: 10px; + max-height: 80px; + overflow-y: auto; +} + +.prod-add-btn { + width: 100%; + padding: 10px; + font-size: 14px; +} + +/* Modal product search */ +.modal-search-results { + max-height: 180px; + overflow-y: auto; + margin-top: 4px; +} + +.modal-prod-item { + display: flex; + justify-content: space-between; + align-items: center; + padding: 8px 10px; + border-bottom: 1px solid var(--border); + cursor: pointer; + font-size: 13px; + gap: 8px; +} + +.modal-prod-item:active { + background: rgba(187, 134, 252, 0.1); +} + +.modal-selected { + display: flex; + align-items: center; + justify-content: space-between; + padding: 8px 10px; + margin-bottom: 10px; + background: rgba(187, 134, 252, 0.12); + border-radius: var(--radius); + font-size: 14px; + color: var(--primary); + cursor: pointer; +} + +.modal-selected:active { + background: rgba(187, 134, 252, 0.2); +} + +/* Micronutrient strip on Today */ +.micro-strip { + display: flex; + gap: 6px; + padding: 8px 12px; + overflow-x: auto; + flex-shrink: 0; + scrollbar-width: none; +} + +.micro-strip::-webkit-scrollbar { display: none; } + +.micro-chip { + display: inline-block; + padding: 3px 8px; + border-radius: 12px; + font-size: 11px; + font-weight: 600; + flex-shrink: 0; +} + +.micro-low { background: rgba(207,102,121,0.2); color: #cf6679; } +.micro-mid { background: rgba(255,183,77,0.2); color: #ffb74d; } +.micro-ok { background: rgba(3,218,198,0.2); color: #03dac6; } + +/* GPS screen */ +.gps-screen { + display: flex; + flex-direction: column; + height: 100%; +} + +.gps-map { + height: 250px; + width: 100%; + background: #1a1a1a; + flex-shrink: 0; + z-index: 1; +} + +.gps-controls { + padding: 10px 12px; + background: var(--surface); + border-bottom: 1px solid var(--border); +} + +.gps-stats { + display: flex; + justify-content: space-around; + padding: 6px 0 10px; + font-size: 16px; + font-weight: 600; +} + +.gps-btns { + display: flex; + gap: 8px; + align-items: center; +} + +.gps-type-sel { + background: var(--bg); + border: 1px solid var(--border); + border-radius: var(--radius); + color: var(--on-bg); + padding: 8px 10px; + font-size: 14px; + font-family: inherit; + color-scheme: dark; +} + +.gps-type-sel option { + background: var(--surface); + color: var(--on-bg); +} + +.gps-rec { + flex: 1; + padding: 10px; + font-size: 16px; +} + +.gps-rec.recording { + background: #cf6679; +} + +.gps-menu-wrap { + position: relative; +} + +.gps-menu-btn { + background: var(--border); + border: none; + color: var(--on-bg); + font-size: 20px; + padding: 8px 12px; + border-radius: var(--radius); + cursor: pointer; + line-height: 1; +} + +.gps-menu { + position: absolute; + right: 0; + top: 100%; + margin-top: 4px; + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius); + z-index: 50; + min-width: 200px; + box-shadow: 0 4px 12px rgba(0,0,0,0.4); +} + +.gps-menu.hidden { display: none; } + +.gps-menu-item { + padding: 10px 14px; + font-size: 14px; + cursor: pointer; + white-space: nowrap; +} + +.gps-menu-item:active { + background: rgba(187, 134, 252, 0.1); +} + +/* Session detail */ +.sd-section { + margin-bottom: 16px; +} + +.sd-title { + font-size: 13px; + color: var(--primary); + text-transform: uppercase; + letter-spacing: 0.5px; + margin-bottom: 8px; + font-weight: 600; +} + +.sd-row { + display: flex; + gap: 8px; + margin-bottom: 6px; +} + +.sd-stat { + flex: 1; + display: flex; + flex-direction: column; +} + +.sd-val { + font-size: 18px; + font-weight: 700; + color: var(--on-surface); +} + +.sd-lbl { + font-size: 11px; + color: var(--muted); + text-transform: uppercase; +} + +.sd-map { + height: 200px; + border-radius: var(--radius); + overflow: hidden; + cursor: pointer; +} + +.sd-map-full { + position: fixed !important; + inset: 0 !important; + height: 100vh !important; + width: 100vw !important; + max-width: none !important; + z-index: 9999; + border-radius: 0; +} + +.sd-map-fs-btn { + position: relative; + z-index: 10000; +} + +.sd-map-fs-btn { + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius); + color: var(--on-bg); + width: 32px; + height: 32px; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + font-size: 16px; +} + +.gps-download-status { + text-align: center; + font-size: 12px; + color: var(--primary); + padding: 6px 0; +} + +.gps-sessions { + flex: 1; + overflow-y: auto; + padding: 8px 12px; +} + +.gps-sessions h3 { + font-size: 13px; + color: var(--muted); + text-transform: uppercase; + letter-spacing: 0.5px; + margin-bottom: 8px; +} + +.gps-session-item { + padding: 10px; + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius); + margin-bottom: 6px; + cursor: pointer; +} + +.gps-session-item:active { + border-color: var(--primary); +} + +.gps-session-head { + display: flex; + justify-content: space-between; + font-size: 13px; + margin-bottom: 4px; +} + +.gps-session-type { + color: var(--primary); + font-size: 12px; +} + +.gps-session-stats { + font-size: 12px; + color: var(--muted); +} + +.supp-summary { + padding: 0 12px 8px; + font-size: 11px; + color: var(--muted); + line-height: 1.3; +} + +/* Supplement pick list (in modal) */ +.supp-pick-list { + display: flex; + flex-direction: column; + gap: 4px; + max-height: 200px; + overflow-y: auto; +} + +.supp-pick-item { + padding: 8px 10px; + border: 1px solid var(--border); + border-radius: var(--radius); + cursor: pointer; + font-size: 14px; +} + +.supp-pick-item.selected { + border-color: var(--primary); + background: rgba(187, 134, 252, 0.12); + color: var(--primary); +} + +.supp-pick-item:active { + background: rgba(187, 134, 252, 0.08); +} + +.supp-pick-divider { + padding: 6px 10px 2px; + font-size: 11px; + color: var(--muted); + text-transform: uppercase; + letter-spacing: 0.5px; +} + +/* Micronutrient grid in supplement edit modal */ +.micro-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 8px 12px; +} + +.micro-field label { + font-size: 11px; +} + +.micro-field input { + padding: 6px 8px; + font-size: 14px; +} + +/* Loading */ +.loading { + text-align: center; + padding: 40px; + color: var(--muted); +} diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..91dfff8 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,24 @@ + + + + + + BalDav + + + + +
+ + + + + + + + + + + + diff --git a/frontend/js/bmr.js b/frontend/js/bmr.js new file mode 100644 index 0000000..43c6e9d --- /dev/null +++ b/frontend/js/bmr.js @@ -0,0 +1,14 @@ +// Mifflin-St Jeor BMR formula. +// Males: BMR = 10*weight + 6.25*height - 5*age - 5 +// Females: BMR = 10*weight + 6.25*height - 5*age + 161 + +function calculateBmr(weightKg, heightCm, age, sex) { + const base = 10 * weightKg + 6.25 * heightCm - 5 * age; + return Math.round(sex === "m" ? base - 5 : base + 161); +} + +function getAge(birthYear) { + return new Date().getFullYear() - birthYear; +} + +window.BMR = { calculateBmr, getAge }; diff --git a/frontend/js/db.js b/frontend/js/db.js new file mode 100644 index 0000000..d97e54b --- /dev/null +++ b/frontend/js/db.js @@ -0,0 +1,492 @@ +// Database layer — talks to tauri-plugin-sql via raw Tauri IPC. +// No imports, no npm. window.__TAURI__ is injected by Tauri (withGlobalTauri: true). +// The DB is preloaded at startup via tauri.conf.json plugins.sql.preload. + +const DB_PATH = "sqlite:baldav.db"; + +function inv(cmd, args) { + return window.__TAURI__.core.invoke(`plugin:sql|${cmd}`, args); +} + +async function execute(query, params) { + return inv("execute", { db: DB_PATH, query, values: params ?? [] }); +} + +async function select(query, params) { + return inv("select", { db: DB_PATH, query, values: params ?? [] }); +} + +// --------------------------------------------------------------------------- +// Profile (key-value store) +// --------------------------------------------------------------------------- + +async function getProfileValue(key) { + const rows = await select("SELECT value FROM profile WHERE key = ?1", [key]); + return rows.length ? rows[0].value : null; +} + +async function setProfileValue(key, value) { + await execute( + "INSERT OR REPLACE INTO profile (key, value) VALUES (?1, ?2)", + [key, String(value)] + ); +} + +async function getFullProfile() { + const rows = await select("SELECT key, value FROM profile"); + const p = {}; + for (const r of rows) p[r.key] = r.value; + return p; +} + +// --------------------------------------------------------------------------- +// Weight log +// --------------------------------------------------------------------------- + +async function addWeight(date, time, kg) { + await execute( + "INSERT INTO weight_log (date, time, weight_kg) VALUES (?1, ?2, ?3)", + [date, time, kg] + ); +} + +async function deleteWeight(id) { + await execute("DELETE FROM weight_log WHERE id = ?1", [id]); +} + +async function getWeightsForDate(date) { + return select("SELECT * FROM weight_log WHERE date = ?1 ORDER BY time ASC", [date]); +} + +async function getLatestWeight() { + const rows = await select( + "SELECT * FROM weight_log ORDER BY date DESC, time DESC LIMIT 1" + ); + return rows.length ? rows[0] : null; +} + +async function getRecentWeights(days) { + return select( + `SELECT date, MIN(weight_kg) as weight_kg + FROM weight_log + WHERE date >= date('now', ?1) + GROUP BY date ORDER BY date ASC`, + [`-${days ?? 30} days`] + ); +} + +// --------------------------------------------------------------------------- +// Sleep log +// --------------------------------------------------------------------------- + +async function getSleepForDate(date) { + const rows = await select("SELECT * FROM sleep_log WHERE date = ?1", [date]); + return rows.length ? rows[0] : null; +} + +async function saveSleep(date, bedTime, asleepTime, wakeTime, napMinutes, notes) { + const existing = await getSleepForDate(date); + if (existing) { + await execute( + `UPDATE sleep_log SET bed_time=?1, asleep_time=?2, wake_time=?3, + nap_minutes=?4, notes=?5 WHERE date=?6`, + [bedTime, asleepTime, wakeTime, napMinutes ?? 0, notes ?? null, date] + ); + } else { + await execute( + `INSERT INTO sleep_log (date, bed_time, asleep_time, wake_time, nap_minutes, notes) + VALUES (?1, ?2, ?3, ?4, ?5, ?6)`, + [date, bedTime, asleepTime, wakeTime, napMinutes ?? 0, notes ?? null] + ); + } +} + +// --------------------------------------------------------------------------- +// Products +// --------------------------------------------------------------------------- + +async function searchProducts(query, limit) { + const q = query.toLowerCase(); + return select( + `SELECT id, name, brand, category, weight_g, kcal_per_100g, + protein_per_100g, fat_per_100g, carbs_per_100g, fiber_per_100g, + composition + FROM products + WHERE name_lower LIKE ?1 + GROUP BY COALESCE(store_id, id) + ORDER BY + CASE WHEN name_lower LIKE ?1 THEN 0 ELSE 1 END, + name COLLATE NOCASE + LIMIT ?2`, + [`%${q}%`, limit ?? 30] + ); +} + +async function getProduct(id) { + const rows = await select("SELECT * FROM products WHERE id = ?1", [id]); + return rows.length ? rows[0] : null; +} + +async function addProduct(p) { + const r = await execute( + `INSERT INTO products + (name, brand, category, weight_g, kcal_per_100g, protein_per_100g, + fat_per_100g, carbs_per_100g, fiber_per_100g, + vit_a_mcg, vit_c_mg, vit_d_mcg, vit_e_mg, vit_k_mcg, + vit_b1_mg, vit_b2_mg, vit_b3_mg, vit_b6_mg, vit_b12_mcg, folate_mcg, + calcium_mg, iron_mg, magnesium_mg, zinc_mg, copper_mg, + selenium_mcg, potassium_mg, sodium_mg, source) + VALUES (?1,?2,?3,?4,?5,?6,?7,?8,?9,?10,?11,?12,?13,?14,?15,?16,?17,?18,?19,?20,?21,?22,?23,?24,?25,?26,?27,?28,?29)`, + [ + p.name, p.brand ?? null, p.category ?? null, p.weight_g ?? null, + p.kcal_per_100g ?? null, p.protein_per_100g ?? null, + p.fat_per_100g ?? null, p.carbs_per_100g ?? null, p.fiber_per_100g ?? null, + p.vit_a_mcg ?? null, p.vit_c_mg ?? null, p.vit_d_mcg ?? null, + p.vit_e_mg ?? null, p.vit_k_mcg ?? null, + p.vit_b1_mg ?? null, p.vit_b2_mg ?? null, p.vit_b3_mg ?? null, + p.vit_b6_mg ?? null, p.vit_b12_mcg ?? null, p.folate_mcg ?? null, + p.calcium_mg ?? null, p.iron_mg ?? null, p.magnesium_mg ?? null, + p.zinc_mg ?? null, p.copper_mg ?? null, + p.selenium_mcg ?? null, p.potassium_mg ?? null, p.sodium_mg ?? null, + p.source ?? "manual", + ] + ); + return r.lastInsertId; +} + +// --------------------------------------------------------------------------- +// Food log +// --------------------------------------------------------------------------- + +async function getFoodForDate(date) { + return select( + `SELECT fl.*, p.name as product_name, p.brand as product_brand, + r.name as recipe_name + FROM food_log fl + LEFT JOIN products p ON fl.product_id = p.id + LEFT JOIN recipes r ON fl.recipe_id = r.id + WHERE fl.date = ?1 ORDER BY fl.time ASC`, + [date] + ); +} + +async function addFoodManual(date, time, desc, kcal) { + await execute( + "INSERT INTO food_log (date, time, note, amount_g, kcal) VALUES (?1,?2,?3,0,?4)", + [date, time, desc, kcal ?? null] + ); +} + +async function addFoodEntry(date, time, productId, amountG, note) { + let kcal = null, protein = null, fat = null, carbs = null, fiber = null; + if (productId) { + const p = await getProduct(productId); + if (p && p.kcal_per_100g != null) { + const f = amountG / 100; + kcal = Math.round((p.kcal_per_100g ?? 0) * f); + protein = Math.round((p.protein_per_100g ?? 0) * f * 10) / 10; + fat = Math.round((p.fat_per_100g ?? 0) * f * 10) / 10; + carbs = Math.round((p.carbs_per_100g ?? 0) * f * 10) / 10; + fiber = Math.round((p.fiber_per_100g ?? 0) * f * 10) / 10; + } + } + await execute( + `INSERT INTO food_log + (date, time, product_id, amount_g, kcal, protein_g, fat_g, carbs_g, fiber_g, note) + VALUES (?1,?2,?3,?4,?5,?6,?7,?8,?9,?10)`, + [date, time, productId ?? null, amountG, + kcal, protein, fat, carbs, fiber, note ?? null] + ); +} + +async function updateFoodEntry(id, time, amountG, note, kcalOverride) { + // Recalculate nutrition from product if linked + const rows = await select("SELECT product_id FROM food_log WHERE id = ?1", [id]); + if (!rows.length) return; + const productId = rows[0].product_id; + + let kcal = kcalOverride, protein = null, fat = null, carbs = null, fiber = null; + if (productId && kcalOverride == null) { + const p = await getProduct(productId); + if (p && p.kcal_per_100g != null) { + const f = amountG / 100; + kcal = Math.round((p.kcal_per_100g ?? 0) * f); + protein = Math.round((p.protein_per_100g ?? 0) * f * 10) / 10; + fat = Math.round((p.fat_per_100g ?? 0) * f * 10) / 10; + carbs = Math.round((p.carbs_per_100g ?? 0) * f * 10) / 10; + fiber = Math.round((p.fiber_per_100g ?? 0) * f * 10) / 10; + } + } + await execute( + `UPDATE food_log SET time=?1, amount_g=?2, note=?3, + kcal=?4, protein_g=?5, fat_g=?6, carbs_g=?7, fiber_g=?8 + WHERE id=?9`, + [time, amountG, note ?? null, kcal, protein, fat, carbs, fiber, id] + ); +} + +async function deleteFoodEntry(id) { + await execute("DELETE FROM food_log WHERE id = ?1", [id]); +} + +// --------------------------------------------------------------------------- +// Activity log +// --------------------------------------------------------------------------- + +async function getActivitiesForDate(date) { + return select("SELECT * FROM activity_log WHERE date = ?1 ORDER BY time ASC", [date]); +} + +async function addActivity(date, time, type, desc, minutes, kcal) { + await execute( + "INSERT INTO activity_log (date, time, type, desc, minutes, kcal) VALUES (?1,?2,?3,?4,?5,?6)", + [date, time, type ?? "manual", desc, minutes ?? null, kcal ?? null] + ); +} + +async function deleteActivity(id) { + await execute("DELETE FROM activity_log WHERE id = ?1", [id]); +} + +// --------------------------------------------------------------------------- +// Supplements +// --------------------------------------------------------------------------- + +async function getSupplements() { + return select("SELECT * FROM supplements ORDER BY name COLLATE NOCASE"); +} + +async function addSupplement(s) { + const r = await execute( + `INSERT INTO supplements + (name, daily_dose_g, vit_a_mcg, vit_c_mg, vit_d_mcg, vit_e_mg, vit_k_mcg, + vit_b1_mg, vit_b2_mg, vit_b3_mg, vit_b6_mg, vit_b12_mcg, folate_mcg, + calcium_mg, iron_mg, magnesium_mg, zinc_mg, copper_mg, selenium_mcg, + potassium_mg, sodium_mg) + VALUES (?1,?2,?3,?4,?5,?6,?7,?8,?9,?10,?11,?12,?13,?14,?15,?16,?17,?18,?19,?20,?21)`, + [ + s.name, s.daily_dose_g ?? null, + s.vit_a_mcg ?? null, s.vit_c_mg ?? null, s.vit_d_mcg ?? null, + s.vit_e_mg ?? null, s.vit_k_mcg ?? null, + s.vit_b1_mg ?? null, s.vit_b2_mg ?? null, s.vit_b3_mg ?? null, + s.vit_b6_mg ?? null, s.vit_b12_mcg ?? null, s.folate_mcg ?? null, + s.calcium_mg ?? null, s.iron_mg ?? null, s.magnesium_mg ?? null, + s.zinc_mg ?? null, s.copper_mg ?? null, s.selenium_mcg ?? null, + s.potassium_mg ?? null, s.sodium_mg ?? null, + ] + ); + return r.lastInsertId; +} + +async function getSupplement(id) { + const rows = await select("SELECT * FROM supplements WHERE id = ?1", [id]); + return rows.length ? rows[0] : null; +} + +async function deleteSupplement(id) { + await execute("DELETE FROM supplement_log WHERE supplement_id = ?1", [id]); + await execute("DELETE FROM supplements WHERE id = ?1", [id]); +} + +async function logSupplement(date, time, supplementId, doseG) { + await execute( + "INSERT INTO supplement_log (date, time, supplement_id, dose_g) VALUES (?1,?2,?3,?4)", + [date, time, supplementId, doseG ?? null] + ); +} + +async function getSupplementLogForDate(date) { + return select( + `SELECT sl.*, s.name as supplement_name, s.daily_dose_g + FROM supplement_log sl + JOIN supplements s ON sl.supplement_id = s.id + WHERE sl.date = ?1 ORDER BY sl.time ASC`, + [date] + ); +} + +async function deleteSupplementLog(id) { + await execute("DELETE FROM supplement_log WHERE id = ?1", [id]); +} + +async function getSupplementTotalsForDate(date) { + const rows = await select( + `SELECT + SUM(COALESCE(sl.dose_g,1) * s.vit_a_mcg) as vit_a_mcg, + SUM(COALESCE(sl.dose_g,1) * s.vit_c_mg) as vit_c_mg, + SUM(COALESCE(sl.dose_g,1) * s.vit_d_mcg) as vit_d_mcg, + SUM(COALESCE(sl.dose_g,1) * s.vit_e_mg) as vit_e_mg, + SUM(COALESCE(sl.dose_g,1) * s.vit_k_mcg) as vit_k_mcg, + SUM(COALESCE(sl.dose_g,1) * s.vit_b1_mg) as vit_b1_mg, + SUM(COALESCE(sl.dose_g,1) * s.vit_b2_mg) as vit_b2_mg, + SUM(COALESCE(sl.dose_g,1) * s.vit_b3_mg) as vit_b3_mg, + SUM(COALESCE(sl.dose_g,1) * s.vit_b6_mg) as vit_b6_mg, + SUM(COALESCE(sl.dose_g,1) * s.vit_b12_mcg) as vit_b12_mcg, + SUM(COALESCE(sl.dose_g,1) * s.folate_mcg) as folate_mcg, + SUM(COALESCE(sl.dose_g,1) * s.calcium_mg) as calcium_mg, + SUM(COALESCE(sl.dose_g,1) * s.iron_mg) as iron_mg, + SUM(COALESCE(sl.dose_g,1) * s.magnesium_mg) as magnesium_mg, + SUM(COALESCE(sl.dose_g,1) * s.zinc_mg) as zinc_mg, + SUM(COALESCE(sl.dose_g,1) * s.copper_mg) as copper_mg, + SUM(COALESCE(sl.dose_g,1) * s.selenium_mcg) as selenium_mcg, + SUM(COALESCE(sl.dose_g,1) * s.potassium_mg) as potassium_mg, + SUM(COALESCE(sl.dose_g,1) * s.sodium_mg) as sodium_mg + FROM supplement_log sl + JOIN supplements s ON sl.supplement_id = s.id + WHERE sl.date = ?1`, + [date] + ); + return rows[0] || {}; +} + +// --------------------------------------------------------------------------- +// Analytics +// --------------------------------------------------------------------------- + +async function getFoodTotalsForDate(date) { + const rows = await select( + `SELECT SUM(kcal) as kcal, SUM(protein_g) as protein_g, + SUM(fat_g) as fat_g, SUM(carbs_g) as carbs_g, SUM(fiber_g) as fiber_g + FROM food_log WHERE date = ?1`, + [date] + ); + return rows[0] ?? { kcal: 0, protein_g: 0, fat_g: 0, carbs_g: 0, fiber_g: 0 }; +} + +async function getActivityTotalsForDate(date) { + const rows = await select( + "SELECT SUM(kcal) as kcal, SUM(minutes) as minutes FROM activity_log WHERE date = ?1", + [date] + ); + return rows[0] ?? { kcal: 0, minutes: 0 }; +} + +// --------------------------------------------------------------------------- +// GPS sessions & points +// --------------------------------------------------------------------------- + +async function createGpsSession(date, startTime, activityType) { + await execute( + `INSERT INTO gps_sessions (date, start_time, activity_type, source) + VALUES (?1, ?2, ?3, 'recorded')`, + [date, startTime, activityType] + ); + const rows = await select("SELECT last_insert_rowid() as id"); + return rows[0].id; +} + +async function addGpsPoint(sessionId, ts, lat, lon, altGps, speedMs) { + await execute( + `INSERT INTO gps_points (session_id, ts, lat, lon, altitude_gps_m, speed_ms) + VALUES (?1, ?2, ?3, ?4, ?5, ?6)`, + [sessionId, ts, lat, lon, altGps ?? null, speedMs ?? null] + ); +} + +async function finishGpsSession(id, endTime, distM, kcal, avgSpd, maxSpd, elevGain, elevLoss) { + await execute( + `UPDATE gps_sessions SET end_time=?1, total_distance_m=?2, total_kcal=?3, + avg_speed_kmh=?4, max_speed_kmh=?5, elevation_gain_m=?6, elevation_loss_m=?7 + WHERE id=?8`, + [endTime, distM, kcal, avgSpd, maxSpd, elevGain, elevLoss, id] + ); +} + +async function getGpsSessions(limit) { + return select( + "SELECT * FROM gps_sessions ORDER BY date DESC, start_time DESC LIMIT ?1", + [limit ?? 50] + ); +} + +async function getGpsSession(id) { + const rows = await select("SELECT * FROM gps_sessions WHERE id = ?1", [id]); + return rows.length ? rows[0] : null; +} + +async function getGpsPoints(sessionId) { + return select("SELECT * FROM gps_points WHERE session_id = ?1 ORDER BY COALESCE(seq, 999999), ts ASC", [sessionId]); +} + +async function deleteGpsSession(id) { + await execute("DELETE FROM gps_points WHERE session_id = ?1", [id]); + await execute("DELETE FROM gps_sessions WHERE id = ?1", [id]); +} + +async function importGpxSession(session, points) { + await execute( + `INSERT INTO gps_sessions (date, start_time, end_time, activity_type, + total_distance_m, total_kcal, avg_speed_kmh, max_speed_kmh, + elevation_gain_m, elevation_loss_m, source) + VALUES (?1,?2,?3,?4,?5,?6,?7,?8,?9,?10,?11)`, + [session.date, session.start_time, session.end_time, session.activity_type, + session.total_distance_m, session.total_kcal, session.avg_speed_kmh, + session.max_speed_kmh, session.elevation_gain_m, session.elevation_loss_m, + session.source ?? "gpx_import"] + ); + const rows = await select("SELECT last_insert_rowid() as id"); + const sid = rows[0].id; + // Batch insert points — build multi-row INSERT to avoid 1 IPC per point + const BATCH = 50; + for (let i = 0; i < points.length; i += BATCH) { + const chunk = points.slice(i, i + BATCH); + const placeholders = []; + const vals = []; + let idx = 1; + for (const p of chunk) { + placeholders.push(`(?${idx},?${idx+1},?${idx+2},?${idx+3},?${idx+4},?${idx+5},?${idx+6})`); + vals.push(sid, p.ts, p.lat, p.lon, p.altitude ?? null, p.speed ?? null, p.seq ?? null); + idx += 7; + } + await execute( + `INSERT INTO gps_points (session_id, ts, lat, lon, altitude_gps_m, speed_ms, seq) VALUES ${placeholders.join(",")}`, + vals + ); + } + return sid; +} + +// Expose as globals — no ES module system, loaded via