feat: add liquid tracking card to nutrition page with water cups and beverage detection
Track water intake via interactive SVG cups (fill/drain animations) using BLS Trinkwasser entries for mineral tracking. Detect beverages from food log (BLS N-codes + name patterns) and include in liquid totals. Configurable daily goal stored in localStorage. Cups show beverage fills (amber) as non-removable and water fills (blue) as adjustable.
This commit is contained in:
@@ -3,7 +3,7 @@ import mongoose from 'mongoose';
|
||||
interface IFoodLogEntry {
|
||||
_id?: string;
|
||||
date: Date;
|
||||
mealType: 'breakfast' | 'lunch' | 'dinner' | 'snack';
|
||||
mealType: 'breakfast' | 'lunch' | 'dinner' | 'snack' | 'water';
|
||||
name: string;
|
||||
source: 'bls' | 'usda' | 'recipe' | 'custom' | 'off';
|
||||
sourceId?: string;
|
||||
@@ -45,7 +45,7 @@ const NutritionSnapshotSchema = new mongoose.Schema({
|
||||
const FoodLogEntrySchema = new mongoose.Schema(
|
||||
{
|
||||
date: { type: Date, required: true },
|
||||
mealType: { type: String, enum: ['breakfast', 'lunch', 'dinner', 'snack'], required: true },
|
||||
mealType: { type: String, enum: ['breakfast', 'lunch', 'dinner', 'snack', 'water'], required: true },
|
||||
name: { type: String, required: true, trim: true },
|
||||
source: { type: String, enum: ['bls', 'usda', 'recipe', 'custom', 'off'], required: true },
|
||||
sourceId: { type: String },
|
||||
|
||||
@@ -4,7 +4,7 @@ import { requireAuth } from '$lib/server/middleware/auth';
|
||||
import { dbConnect } from '$utils/db';
|
||||
import { FoodLogEntry } from '$models/FoodLogEntry';
|
||||
|
||||
const VALID_MEALS = ['breakfast', 'lunch', 'dinner', 'snack'];
|
||||
const VALID_MEALS = ['breakfast', 'lunch', 'dinner', 'snack', 'water'];
|
||||
|
||||
export const GET: RequestHandler = async ({ locals, url }) => {
|
||||
const user = await requireAuth(locals);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script>
|
||||
import { page } from '$app/stores';
|
||||
import { goto, invalidateAll } from '$app/navigation';
|
||||
import { ChevronLeft, ChevronRight, Plus, Trash2, ChevronDown, Settings, Coffee, Sun, Moon, Cookie, Utensils, Info, UtensilsCrossed, AlertTriangle, Check } from '@lucide/svelte';
|
||||
import { ChevronLeft, ChevronRight, Plus, Trash2, ChevronDown, Settings, Coffee, Sun, Moon, Cookie, Utensils, Info, UtensilsCrossed, AlertTriangle, Check, GlassWater } from '@lucide/svelte';
|
||||
import { detectFitnessLang, fitnessSlugs, t } from '$lib/js/fitnessI18n';
|
||||
import AddButton from '$lib/components/AddButton.svelte';
|
||||
import FoodSearch from '$lib/components/fitness/FoodSearch.svelte';
|
||||
@@ -205,11 +205,124 @@
|
||||
/** @type {Record<string, any[]>} */
|
||||
const g = { breakfast: [], lunch: [], dinner: [], snack: [] };
|
||||
for (const e of entries) {
|
||||
if (e.mealType === 'water') continue;
|
||||
if (g[e.mealType]) g[e.mealType].push(e);
|
||||
}
|
||||
return g;
|
||||
});
|
||||
|
||||
// --- Water & liquid tracking ---
|
||||
const WATER_CUP_ML = 250;
|
||||
|
||||
/** BLS Trinkwasser (N110000) per100g */
|
||||
const WATER_PER100G = {
|
||||
calories: 0, protein: 0, fat: 0, saturatedFat: 0, carbs: 0, fiber: 0, sugars: 0,
|
||||
calcium: 5.3, iron: 0, magnesium: 0.9, phosphorus: 0.011, potassium: 0.2,
|
||||
sodium: 2.3, zinc: 0.001, vitaminA: 0, vitaminC: 0, vitaminD: 0, vitaminE: 0,
|
||||
vitaminK: 0, thiamin: 0, riboflavin: 0, niacin: 0, vitaminB6: 0, vitaminB12: 0,
|
||||
folate: 0, cholesterol: 0,
|
||||
};
|
||||
|
||||
/** Detect if a food log entry is a beverage (non-water) */
|
||||
const DRINK_PATTERNS = /^(milch|kaffee|coffee|tee|tea|cola|fanta|sprite|saft|juice|limo|smoothie|kakao|cocoa|bier|beer|wein|wine|eistee|ice tea|energy|redbull|red bull|mate|schorle|sprudel|mineral|orangensaft|apfelsaft|multivitamin|iso|gatorade|powerade)/i;
|
||||
function isBeverage(e) {
|
||||
if (e.mealType === 'water') return false;
|
||||
if (e.source === 'bls' && e.sourceId?.startsWith('N')) return true;
|
||||
return DRINK_PATTERNS.test(e.name);
|
||||
}
|
||||
|
||||
let waterGoalMl = $state(2000);
|
||||
let editingGoal = $state(false);
|
||||
let goalInputL = $state(2);
|
||||
|
||||
$effect(() => {
|
||||
const saved = localStorage.getItem('water_goal_ml');
|
||||
if (saved) {
|
||||
const v = parseInt(saved);
|
||||
if (v > 0) waterGoalMl = v;
|
||||
}
|
||||
});
|
||||
|
||||
function saveGoal() {
|
||||
const ml = Math.max(250, Math.round(goalInputL * 1000));
|
||||
waterGoalMl = ml;
|
||||
localStorage.setItem('water_goal_ml', String(ml));
|
||||
editingGoal = false;
|
||||
}
|
||||
|
||||
let waterEntries = $derived(entries.filter(e => e.mealType === 'water'));
|
||||
let beverageEntries = $derived(entries.filter(isBeverage));
|
||||
let waterMl = $derived(waterEntries.reduce((s, e) => s + e.amountGrams, 0));
|
||||
let beverageMl = $derived(beverageEntries.reduce((s, e) => s + e.amountGrams, 0));
|
||||
let totalLiquidMl = $derived(waterMl + beverageMl);
|
||||
let beverageCups = $derived(Math.round(beverageMl / WATER_CUP_ML));
|
||||
let waterCups = $derived(Math.round(waterMl / WATER_CUP_ML));
|
||||
let totalCups = $derived(beverageCups + waterCups);
|
||||
let goalCups = $derived(Math.round(waterGoalMl / WATER_CUP_ML));
|
||||
let displayCups = $derived(Math.max(goalCups, totalCups + 1));
|
||||
|
||||
/** @type {Set<number>} cups currently animating fill/drain */
|
||||
let fillingCups = $state(new Set());
|
||||
let drainingCups = $state(new Set());
|
||||
let lastTotalCups = $state(-1);
|
||||
|
||||
$effect(() => {
|
||||
const cur = totalCups;
|
||||
if (lastTotalCups === -1) {
|
||||
lastTotalCups = cur;
|
||||
return;
|
||||
}
|
||||
if (cur > lastTotalCups) {
|
||||
const s = new Set(fillingCups);
|
||||
for (let i = lastTotalCups; i < cur; i++) s.add(i);
|
||||
fillingCups = s;
|
||||
setTimeout(() => { fillingCups = new Set(); }, 1500);
|
||||
} else if (cur < lastTotalCups) {
|
||||
const s = new Set(drainingCups);
|
||||
for (let i = cur; i < lastTotalCups; i++) s.add(i);
|
||||
drainingCups = s;
|
||||
setTimeout(() => { drainingCups = new Set(); }, 700);
|
||||
}
|
||||
lastTotalCups = cur;
|
||||
});
|
||||
|
||||
async function setWaterCups(target) {
|
||||
const current = waterCups;
|
||||
if (target === current) return;
|
||||
try {
|
||||
if (target > current) {
|
||||
const toAdd = target - current;
|
||||
const promises = Array.from({ length: toAdd }, () =>
|
||||
fetch('/api/fitness/food-log', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
date: currentDate,
|
||||
mealType: 'water',
|
||||
name: 'Trinkwasser',
|
||||
source: 'bls',
|
||||
sourceId: 'N110000',
|
||||
amountGrams: WATER_CUP_ML,
|
||||
per100g: WATER_PER100G,
|
||||
})
|
||||
}).then(r => r.ok ? r.json() : null)
|
||||
);
|
||||
const results = await Promise.all(promises);
|
||||
const newEntries = results.filter(Boolean);
|
||||
if (newEntries.length) entries = [...entries, ...newEntries];
|
||||
} else {
|
||||
const toRemove = waterEntries.slice(target);
|
||||
const ids = toRemove.map(e => e._id);
|
||||
await Promise.all(ids.map(id =>
|
||||
fetch(`/api/fitness/food-log/${id}`, { method: 'DELETE' })
|
||||
));
|
||||
entries = entries.filter(e => !ids.includes(e._id));
|
||||
}
|
||||
} catch {
|
||||
toast.error(isEn ? 'Failed to update water' : 'Fehler beim Aktualisieren');
|
||||
}
|
||||
}
|
||||
|
||||
function entryCalories(e) {
|
||||
return (e.per100g?.calories ?? 0) * e.amountGrams / 100;
|
||||
}
|
||||
@@ -943,6 +1056,83 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Liquid Tracking Card -->
|
||||
<div class="water-card">
|
||||
<div class="water-header">
|
||||
<div class="water-title">
|
||||
<GlassWater size={16} />
|
||||
<h3>{isEn ? 'Liquids' : 'Flüssigkeit'}</h3>
|
||||
</div>
|
||||
<div class="water-stats">
|
||||
<span class="water-amount">{parseFloat((totalLiquidMl / 1000).toFixed(2))} L</span>
|
||||
{#if editingGoal}
|
||||
<form class="goal-edit-inline" onsubmit={e => { e.preventDefault(); saveGoal(); }}>
|
||||
<span class="goal-slash">/</span>
|
||||
<input type="number" class="goal-input-inline" bind:value={goalInputL} min="0.25" step="0.25" />
|
||||
<span class="goal-unit">L</span>
|
||||
<button type="submit" class="goal-save-inline"><Check size={12} /></button>
|
||||
</form>
|
||||
{:else}
|
||||
<button class="water-goal-btn" onclick={() => { goalInputL = parseFloat((waterGoalMl / 1000).toFixed(2)); editingGoal = true; }}>
|
||||
/ {parseFloat((waterGoalMl / 1000).toFixed(2))} L
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<div class="water-cups">
|
||||
{#each Array(displayCups) as _, i}
|
||||
{@const isBev = i < beverageCups}
|
||||
{@const isFilled = i < totalCups}
|
||||
{@const showWater = isFilled || drainingCups.has(i)}
|
||||
{@const isNextEmpty = i === totalCups && !drainingCups.has(i)}
|
||||
<button
|
||||
class="water-cup"
|
||||
class:filled={isFilled}
|
||||
class:beverage={isBev}
|
||||
class:filling={fillingCups.has(i)}
|
||||
class:draining={drainingCups.has(i)}
|
||||
class:next-empty={isNextEmpty}
|
||||
disabled={isBev}
|
||||
onclick={() => {
|
||||
if (isBev) return;
|
||||
const waterTarget = i < totalCups ? i - beverageCups : i - beverageCups + 1;
|
||||
setWaterCups(Math.max(0, waterTarget));
|
||||
}}
|
||||
title="{isBev ? (isEn ? 'Beverage' : 'Getränk') : (i + 1) * WATER_CUP_ML + ' ml'}"
|
||||
>
|
||||
<svg viewBox="0 0 24 32" class="cup-svg" overflow="hidden">
|
||||
<defs>
|
||||
<clipPath id="cup-clip-{i}">
|
||||
<path d="M4 4 L6 28 C6 30 8 30 8 30 L16 30 C16 30 18 30 18 28 L20 4 Z" />
|
||||
</clipPath>
|
||||
</defs>
|
||||
<path d="M4 4 L6 28 C6 30 8 30 8 30 L16 30 C16 30 18 30 18 28 L20 4 Z" fill="var(--color-bg-tertiary)" stroke="var(--color-border)" stroke-width="1.2" />
|
||||
{#if showWater}
|
||||
<g clip-path="url(#cup-clip-{i})" class="water-body">
|
||||
<path class="water-wave w1" d="M-8 10 Q-2 6 4 10 T16 10 T28 10 T40 10 V34 H-8 Z" fill={isBev ? 'var(--nord15)' : 'var(--nord10)'} opacity="0.85" />
|
||||
<path class="water-wave w2" d="M-4 12 Q2 8 8 12 T20 12 T32 12 V34 H-4 Z" fill={isBev ? 'var(--nord13)' : 'var(--nord9)'} opacity="0.5" />
|
||||
<path class="water-wave w3" d="M0 11 Q6 7 12 11 T24 11 T36 11 V34 H0 Z" fill={isBev ? 'var(--nord15)' : 'var(--nord10)'} opacity="0.35" />
|
||||
</g>
|
||||
{/if}
|
||||
{#if isNextEmpty}
|
||||
<text x="12" y="20" text-anchor="middle" font-size="14" font-weight="bold" fill="var(--color-text-secondary)">+</text>
|
||||
{/if}
|
||||
</svg>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{#if beverageEntries.length > 0}
|
||||
<div class="beverage-list">
|
||||
{#each beverageEntries as bev}
|
||||
<div class="beverage-item">
|
||||
<span class="beverage-name">{bev.name}</span>
|
||||
<span class="beverage-ml">{Math.round(bev.amountGrams)} ml</span>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Meal Sections -->
|
||||
{#each mealTypes as meal, mi}
|
||||
{@const mealEntries = grouped[meal]}
|
||||
@@ -1840,6 +2030,202 @@
|
||||
}
|
||||
|
||||
/* ── Meal Sections ── */
|
||||
/* Water card */
|
||||
.water-card {
|
||||
background: var(--color-surface);
|
||||
border-radius: 12px;
|
||||
padding: 0.75rem 1rem;
|
||||
margin-bottom: 0.75rem;
|
||||
border: 1px solid var(--color-border);
|
||||
}
|
||||
.water-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
.water-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
color: var(--nord10);
|
||||
}
|
||||
.water-title h3 {
|
||||
margin: 0;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
.water-stats {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.15rem;
|
||||
}
|
||||
.water-amount {
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
color: var(--nord10);
|
||||
}
|
||||
.water-goal-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-size: 0.8rem;
|
||||
color: var(--color-text-secondary);
|
||||
padding: 0;
|
||||
}
|
||||
.goal-edit-inline {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
.goal-slash {
|
||||
font-size: 0.8rem;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
.goal-input-inline {
|
||||
width: 48px;
|
||||
padding: 0.15rem 0.3rem;
|
||||
background: var(--color-bg-tertiary);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 4px;
|
||||
color: var(--color-text-primary);
|
||||
font-size: 0.8rem;
|
||||
text-align: right;
|
||||
}
|
||||
.goal-input-inline:focus {
|
||||
outline: none;
|
||||
border-color: var(--nord10);
|
||||
}
|
||||
.goal-unit {
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
.goal-save-inline {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid var(--color-border);
|
||||
background: transparent;
|
||||
color: var(--color-text-secondary);
|
||||
cursor: pointer;
|
||||
transition: all 200ms;
|
||||
padding: 0;
|
||||
}
|
||||
.goal-save-inline:hover {
|
||||
border-color: var(--nord14);
|
||||
background: var(--nord14);
|
||||
color: white;
|
||||
box-shadow: 0 2px 8px rgba(163, 190, 140, 0.35);
|
||||
transform: scale(1.08);
|
||||
}
|
||||
.goal-save-inline:active {
|
||||
transform: scale(0.95);
|
||||
background: #8fad7a;
|
||||
border-color: #8fad7a;
|
||||
color: white;
|
||||
}
|
||||
.water-cups {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.2rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.water-cup {
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
width: 26px;
|
||||
height: 34px;
|
||||
}
|
||||
@media (min-width: 500px) {
|
||||
.water-cup {
|
||||
width: 34px;
|
||||
height: 44px;
|
||||
}
|
||||
}
|
||||
.cup-svg {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
.water-cup:disabled {
|
||||
cursor: default;
|
||||
opacity: 1;
|
||||
}
|
||||
.water-cup.next-empty:hover .cup-svg > path:first-of-type {
|
||||
fill: color-mix(in srgb, var(--nord10) 20%, var(--color-bg-tertiary));
|
||||
}
|
||||
|
||||
/* Water fill animation — only on newly filled cups */
|
||||
.water-cup.filling .water-body {
|
||||
animation: water-rise 800ms ease-out both;
|
||||
}
|
||||
.water-cup.filling .water-wave.w1 {
|
||||
animation: wave-slosh 1.2s ease-in-out both;
|
||||
}
|
||||
.water-cup.filling .water-wave.w2 {
|
||||
animation: wave-slosh 1s ease-in-out 0.1s both reverse;
|
||||
}
|
||||
.water-cup.filling .water-wave.w3 {
|
||||
animation: wave-slosh 1.4s ease-in-out 0.05s both;
|
||||
}
|
||||
.water-cup.draining .water-body {
|
||||
animation: water-drain 400ms ease-in both;
|
||||
}
|
||||
.water-cup.draining .water-wave.w1 {
|
||||
animation: wave-slosh 500ms ease-in-out both;
|
||||
}
|
||||
.water-cup.draining .water-wave.w2 {
|
||||
animation: wave-slosh 400ms ease-in-out both reverse;
|
||||
}
|
||||
.water-cup.draining .water-wave.w3 {
|
||||
animation: wave-slosh 550ms ease-in-out both;
|
||||
}
|
||||
@keyframes water-rise {
|
||||
from { transform: translateY(24px); }
|
||||
to { transform: translateY(0); }
|
||||
}
|
||||
@keyframes water-drain {
|
||||
from { transform: translateY(0); }
|
||||
to { transform: translateY(24px); }
|
||||
}
|
||||
@keyframes wave-slosh {
|
||||
0% { transform: translateX(0); }
|
||||
20% { transform: translateX(-6px); }
|
||||
40% { transform: translateX(5px); }
|
||||
60% { transform: translateX(-3px); }
|
||||
80% { transform: translateX(2px); }
|
||||
100% { transform: translateX(0); }
|
||||
}
|
||||
|
||||
/* Beverage list */
|
||||
.beverage-list {
|
||||
margin-top: 0.5rem;
|
||||
padding-top: 0.5rem;
|
||||
border-top: 1px solid var(--color-border);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.2rem;
|
||||
}
|
||||
.beverage-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
.beverage-name {
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
.beverage-ml {
|
||||
color: var(--nord10);
|
||||
font-weight: 500;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.meal-section {
|
||||
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user