feat: auto-track liquids from custom meal ingredients in hydration tracker

When logging a custom meal, liquid ingredients (BLS drinks, water, beverages)
are detected and their volume stored as `liquidMl` on the food log entry.
The liquid tracker cups and list now include these meal-sourced liquids.
This commit is contained in:
2026-04-08 16:06:11 +02:00
parent 9af36b0c14
commit 47b690257e
4 changed files with 45 additions and 12 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "homepage", "name": "homepage",
"version": "1.12.0", "version": "1.13.0",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
+2
View File
@@ -22,6 +22,7 @@ interface IFoodLogEntry {
cysteine?: number; glutamicAcid?: number; glycine?: number; proline?: number; cysteine?: number; glutamicAcid?: number; glycine?: number; proline?: number;
serine?: number; tyrosine?: number; serine?: number; tyrosine?: number;
}; };
liquidMl?: number;
createdBy: string; createdBy: string;
createdAt?: Date; createdAt?: Date;
updatedAt?: Date; updatedAt?: Date;
@@ -51,6 +52,7 @@ const FoodLogEntrySchema = new mongoose.Schema(
sourceId: { type: String }, sourceId: { type: String },
amountGrams: { type: Number, required: true, min: 0 }, amountGrams: { type: Number, required: true, min: 0 },
per100g: { type: NutritionSnapshotSchema, required: true }, per100g: { type: NutritionSnapshotSchema, required: true },
liquidMl: { type: Number, min: 0 },
createdBy: { type: String, required: true }, createdBy: { type: String, required: true },
}, },
{ timestamps: true } { timestamps: true }
+2 -1
View File
@@ -40,7 +40,7 @@ export const POST: RequestHandler = async ({ request, locals }) => {
await dbConnect(); await dbConnect();
const body = await request.json(); const body = await request.json();
const { date, mealType, name, source, sourceId, amountGrams, per100g } = body; const { date, mealType, name, source, sourceId, amountGrams, per100g, liquidMl } = body;
if (!date || !name?.trim()) throw error(400, 'date and name are required'); if (!date || !name?.trim()) throw error(400, 'date and name are required');
if (!VALID_MEALS.includes(mealType)) throw error(400, 'Invalid mealType'); if (!VALID_MEALS.includes(mealType)) throw error(400, 'Invalid mealType');
@@ -55,6 +55,7 @@ export const POST: RequestHandler = async ({ request, locals }) => {
sourceId, sourceId,
amountGrams, amountGrams,
per100g, per100g,
...(liquidMl > 0 && { liquidMl }),
createdBy: user.nickname, createdBy: user.nickname,
}); });
@@ -242,6 +242,12 @@
return DRINK_PATTERNS.test(e.name); return DRINK_PATTERNS.test(e.name);
} }
/** Detect if a custom meal ingredient is a liquid (for hydration auto-logging) */
function isLiquidIngredient(ing) {
if (ing.source === 'bls' && ing.sourceId?.startsWith('N')) return true;
return DRINK_PATTERNS.test(ing.name) || /^(wasser|water|trinkwasser)/i.test(ing.name);
}
let waterGoalMl = $state(2000); let waterGoalMl = $state(2000);
let editingGoal = $state(false); let editingGoal = $state(false);
let goalInputL = $state(2); let goalInputL = $state(2);
@@ -265,10 +271,12 @@
let beverageEntries = $derived(entries.filter(isBeverage)); let beverageEntries = $derived(entries.filter(isBeverage));
let waterMl = $derived(waterEntries.reduce((s, e) => s + e.amountGrams, 0)); let waterMl = $derived(waterEntries.reduce((s, e) => s + e.amountGrams, 0));
let beverageMl = $derived(beverageEntries.reduce((s, e) => s + e.amountGrams, 0)); let beverageMl = $derived(beverageEntries.reduce((s, e) => s + e.amountGrams, 0));
let totalLiquidMl = $derived(waterMl + beverageMl); let mealLiquidMl = $derived(entries.reduce((s, e) => s + (e.liquidMl ?? 0), 0));
let totalLiquidMl = $derived(waterMl + beverageMl + mealLiquidMl);
let beverageCups = $derived(Math.round(beverageMl / WATER_CUP_ML)); let beverageCups = $derived(Math.round(beverageMl / WATER_CUP_ML));
let waterCups = $derived(Math.round(waterMl / WATER_CUP_ML)); let waterCups = $derived(Math.round(waterMl / WATER_CUP_ML));
let totalCups = $derived(beverageCups + waterCups); let mealLiquidCups = $derived(Math.round(mealLiquidMl / WATER_CUP_ML));
let totalCups = $derived(beverageCups + waterCups + mealLiquidCups);
let goalCups = $derived(Math.round(waterGoalMl / WATER_CUP_ML)); let goalCups = $derived(Math.round(waterGoalMl / WATER_CUP_ML));
let displayCups = $derived(Math.max(goalCups, totalCups + 1)); let displayCups = $derived(Math.max(goalCups, totalCups + 1));
@@ -578,6 +586,10 @@
const scale = totalGrams > 0 ? 100 / totalGrams : 0; const scale = totalGrams > 0 ? 100 / totalGrams : 0;
for (const k of nutrientKeys) per100g[k] = totals[k] * scale; for (const k of nutrientKeys) per100g[k] = totals[k] * scale;
const liquidMl = meal.ingredients
.filter(isLiquidIngredient)
.reduce((sum, ing) => sum + ing.amountGrams, 0);
await fetch('/api/fitness/food-log', { await fetch('/api/fitness/food-log', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
@@ -589,8 +601,10 @@
sourceId: meal._id, sourceId: meal._id,
amountGrams: totalGrams, amountGrams: totalGrams,
per100g, per100g,
...(liquidMl > 0 && { liquidMl }),
}) })
}); });
await goto(`/fitness/${s.nutrition}?date=${currentDate}`, { replaceState: true, noScroll: true }); await goto(`/fitness/${s.nutrition}?date=${currentDate}`, { replaceState: true, noScroll: true });
closeFabModal(); closeFabModal();
toast.success(isEn ? `Logged "${meal.name}"` : `"${meal.name}" eingetragen`); toast.success(isEn ? `Logged "${meal.name}"` : `"${meal.name}" eingetragen`);
@@ -631,6 +645,10 @@
const scale = totalGrams > 0 ? 100 / totalGrams : 0; const scale = totalGrams > 0 ? 100 / totalGrams : 0;
for (const k of nutrientKeys) per100g[k] = totals[k] * scale; for (const k of nutrientKeys) per100g[k] = totals[k] * scale;
const liquidMl = meal.ingredients
.filter(isLiquidIngredient)
.reduce((sum, ing) => sum + ing.amountGrams, 0);
await fetch('/api/fitness/food-log', { await fetch('/api/fitness/food-log', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
@@ -642,8 +660,10 @@
sourceId: meal._id, sourceId: meal._id,
amountGrams: totalGrams, amountGrams: totalGrams,
per100g, per100g,
...(liquidMl > 0 && { liquidMl }),
}) })
}); });
await goto(`/fitness/${s.nutrition}?date=${currentDate}`, { replaceState: true, noScroll: true }); await goto(`/fitness/${s.nutrition}?date=${currentDate}`, { replaceState: true, noScroll: true });
cancelAdd(); cancelAdd();
toast.success(isEn ? `Logged "${meal.name}"` : `"${meal.name}" eingetragen`); toast.success(isEn ? `Logged "${meal.name}"` : `"${meal.name}" eingetragen`);
@@ -1191,6 +1211,8 @@
<div class="water-cups"> <div class="water-cups">
{#each Array(displayCups) as _, i} {#each Array(displayCups) as _, i}
{@const isBev = i < beverageCups} {@const isBev = i < beverageCups}
{@const isMealLiquid = !isBev && i < beverageCups + mealLiquidCups}
{@const isAuto = isBev || isMealLiquid}
{@const isFilled = i < totalCups} {@const isFilled = i < totalCups}
{@const showWater = isFilled || drainingCups.has(i)} {@const showWater = isFilled || drainingCups.has(i)}
{@const isNextEmpty = i === totalCups && !drainingCups.has(i)} {@const isNextEmpty = i === totalCups && !drainingCups.has(i)}
@@ -1198,16 +1220,18 @@
class="water-cup" class="water-cup"
class:filled={isFilled} class:filled={isFilled}
class:beverage={isBev} class:beverage={isBev}
class:meal-liquid={isMealLiquid}
class:filling={fillingCups.has(i)} class:filling={fillingCups.has(i)}
class:draining={drainingCups.has(i)} class:draining={drainingCups.has(i)}
class:next-empty={isNextEmpty} class:next-empty={isNextEmpty}
disabled={isBev} disabled={isAuto}
onclick={() => { onclick={() => {
if (isBev) return; if (isAuto) return;
const waterTarget = i < totalCups ? i - beverageCups : i - beverageCups + 1; const autoOffset = beverageCups + mealLiquidCups;
const waterTarget = i < totalCups ? i - autoOffset : i - autoOffset + 1;
setWaterCups(Math.max(0, waterTarget)); setWaterCups(Math.max(0, waterTarget));
}} }}
title="{isBev ? (isEn ? 'Beverage' : 'Getränk') : (i + 1) * WATER_CUP_ML + ' ml'}" title="{isAuto ? (isEn ? (isBev ? 'Beverage' : 'From meal') : (isBev ? 'Getränk' : 'Aus Mahlzeit')) : (i + 1) * WATER_CUP_ML + ' ml'}"
> >
<svg viewBox="0 0 24 32" class="cup-svg" overflow="hidden"> <svg viewBox="0 0 24 32" class="cup-svg" overflow="hidden">
<defs> <defs>
@@ -1218,9 +1242,9 @@
<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" /> <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} {#if showWater}
<g clip-path="url(#cup-clip-{i})" class="water-body"> <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 w1" d="M-8 10 Q-2 6 4 10 T16 10 T28 10 T40 10 V34 H-8 Z" fill={isAuto ? 'var(--nord7)' : '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 w2" d="M-4 12 Q2 8 8 12 T20 12 T32 12 V34 H-4 Z" fill={isAuto ? 'var(--nord8)' : '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" /> <path class="water-wave w3" d="M0 11 Q6 7 12 11 T24 11 T36 11 V34 H0 Z" fill={isAuto ? 'var(--nord7)' : 'var(--nord10)'} opacity="0.35" />
</g> </g>
{/if} {/if}
{#if isNextEmpty} {#if isNextEmpty}
@@ -1230,7 +1254,7 @@
</button> </button>
{/each} {/each}
</div> </div>
{#if beverageEntries.length > 0} {#if beverageEntries.length > 0 || mealLiquidMl > 0}
<div class="beverage-list"> <div class="beverage-list">
{#each beverageEntries as bev} {#each beverageEntries as bev}
<div class="beverage-item"> <div class="beverage-item">
@@ -1238,6 +1262,12 @@
<span class="beverage-ml">{Math.round(bev.amountGrams)} ml</span> <span class="beverage-ml">{Math.round(bev.amountGrams)} ml</span>
</div> </div>
{/each} {/each}
{#each entries.filter(e => (e.liquidMl ?? 0) > 0) as e}
<div class="beverage-item">
<span class="beverage-name">{e.name}</span>
<span class="beverage-ml">{Math.round(e.liquidMl)} ml</span>
</div>
{/each}
</div> </div>
{/if} {/if}
</div> </div>