feat(mana/web/nutriphi): inline text + photo quick-add in workbench ListView

The workbench card was read-only — users had to navigate to /nutriphi/add
to log anything. Now the card has a quick-add bar in the toolbar slot:

  - Text input → Enter or send button → mealMutations.create() with
    suggestMealType() (no AI round-trip; users get instant persistence
    and can edit nutrition later from the detail page)
  - 📷 button → file picker (capture=environment for mobile camera) →
    photoMutations.uploadAndAnalyze → mealMutations.createFromPhoto with
    the full Gemini result (foods + thumbnail + confidence)
  - Toast on success ("📷 Mahlzeit hinzugefügt · KI 87%") and on error

Item rendering also got a small upgrade:
  - Each row is now a link to /nutriphi/[id] (matches the rest of the
    nutriphi pages now that the detail route exists)
  - Thumbnail shown next to the row when present (uses photoThumbnailUrl
    for bandwidth)
  - 📷 indicator badge for photo-mode meals

Pre-existing bug fix in passing: the goals query was reading from the
non-existent table 'nutriphiGoals' instead of 'goals' (the actual table
name from module.config.ts), so the calorie target was never visible
in the workbench card. Switched to 'goals'.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-09 16:05:13 +02:00
parent e579e292cc
commit c184991b3a

View file

@ -1,12 +1,16 @@
<!--
NutriPhi — Workbench ListView
Today's nutrition progress with meal log.
Today's nutrition progress with meal log + inline quick-add bar
(text input + photo upload, both write straight to mealMutations).
-->
<script lang="ts">
import { useLiveQueryWithDefault } from '@mana/local-store/svelte';
import { db } from '$lib/data/database';
import { decryptRecords } from '$lib/data/crypto';
import { BaseListView } from '@mana/shared-ui';
import { toast } from '$lib/stores/toast.svelte';
import { mealMutations, photoMutations } from './mutations';
import { suggestMealType } from './constants';
import type { LocalMeal, LocalGoal } from './types';
const mealsQuery = useLiveQueryWithDefault(async () => {
@ -15,8 +19,10 @@
return decryptRecords('meals', visible);
}, [] as LocalMeal[]);
// NOTE: the table is `goals`, not `nutriphiGoals`. The unified Mana DB
// only prefixes table names when two modules collide; goals is unique.
const goalsQuery = useLiveQueryWithDefault(async () => {
const all = await db.table<LocalGoal>('nutriphiGoals').toArray();
const all = await db.table<LocalGoal>('goals').toArray();
return all.filter((g) => !g.deletedAt);
}, [] as LocalGoal[]);
@ -44,6 +50,76 @@
dinner: 'Abendessen',
snack: 'Snack',
};
// ─── Quick-add state ──────────────────────────────────────────
let quickText = $state('');
let quickSaving = $state(false);
let fileInput: HTMLInputElement | undefined = $state();
let photoUploading = $state(false);
async function submitText() {
const text = quickText.trim();
if (!text || quickSaving) return;
quickSaving = true;
try {
await mealMutations.create({
mealType: suggestMealType(),
description: text,
});
quickText = '';
} catch (err) {
console.error('quick add failed:', err);
toast.error('Mahlzeit konnte nicht gespeichert werden');
} finally {
quickSaving = false;
}
}
function onTextKeydown(e: KeyboardEvent) {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
void submitText();
}
}
async function onPhotoSelected(event: Event) {
const input = event.target as HTMLInputElement;
const file = input.files?.[0];
if (!file) return;
photoUploading = true;
try {
const { upload, analysis } = await photoMutations.uploadAndAnalyze(file);
const nutrition = analysis.totalNutrition
? {
calories: analysis.totalNutrition.calories ?? 0,
protein: analysis.totalNutrition.protein ?? 0,
carbohydrates: analysis.totalNutrition.carbohydrates ?? 0,
fat: analysis.totalNutrition.fat ?? 0,
fiber: analysis.totalNutrition.fiber ?? 0,
sugar: analysis.totalNutrition.sugar ?? 0,
}
: null;
await mealMutations.createFromPhoto({
mealType: suggestMealType(),
description: analysis.description ?? 'Mahlzeit aus Foto',
nutrition,
photoMediaId: upload.mediaId,
photoUrl: upload.publicUrl,
photoThumbnailUrl: upload.thumbnailUrl,
confidence: analysis.confidence ?? 0.8,
foods: analysis.foods?.length ? analysis.foods : null,
});
const pct =
analysis.confidence != null ? ` · KI ${Math.round(analysis.confidence * 100)}%` : '';
toast.success(`📷 Mahlzeit hinzugefügt${pct}`);
} catch (err) {
console.error('photo quick add failed:', err);
toast.error('Foto-Analyse fehlgeschlagen');
} finally {
photoUploading = false;
if (fileInput) fileInput.value = '';
}
}
</script>
<BaseListView items={todayMeals} getKey={(m) => m.id} emptyTitle="Noch keine Mahlzeiten heute">
@ -69,6 +145,46 @@
</div>
{/if}
</div>
<!-- Quick-add bar -->
<div class="flex items-center gap-2">
<input
type="text"
bind:value={quickText}
onkeydown={onTextKeydown}
placeholder="Was hast du gegessen?"
disabled={quickSaving}
class="flex-1 rounded-md border border-white/10 bg-white/5 px-3 py-2 text-sm text-white/90 placeholder:text-white/30 focus:border-white/20 focus:outline-none disabled:opacity-50"
/>
<button
type="button"
onclick={() => void submitText()}
disabled={!quickText.trim() || quickSaving}
aria-label="Mahlzeit speichern"
title="Speichern"
class="rounded-md border border-white/10 bg-white/5 px-3 py-2 text-sm text-white/70 transition-colors hover:bg-white/10 disabled:opacity-30"
>
{quickSaving ? '…' : '↵'}
</button>
<button
type="button"
onclick={() => fileInput?.click()}
disabled={photoUploading}
aria-label="Foto aufnehmen"
title="Foto"
class="rounded-md border border-white/10 bg-white/5 px-3 py-2 text-sm text-white/70 transition-colors hover:bg-white/10 disabled:opacity-30"
>
{photoUploading ? '…' : '📷'}
</button>
<input
bind:this={fileInput}
type="file"
accept="image/*"
capture="environment"
class="hidden"
onchange={onPhotoSelected}
/>
</div>
{/snippet}
{#snippet header()}
@ -77,14 +193,36 @@
{/snippet}
{#snippet item(meal)}
<div class="mb-1 min-h-[44px] rounded-md px-3 py-2 transition-colors hover:bg-white/5">
<div class="flex items-center justify-between">
<span class="text-xs text-white/50">{mealTypeLabels[meal.mealType] ?? meal.mealType}</span>
<a
href="/nutriphi/{meal.id}"
class="mb-1 block min-h-[44px] rounded-md px-3 py-2 transition-colors hover:bg-white/5"
>
<div class="flex items-center justify-between gap-3">
<div class="min-w-0 flex-1">
<div class="flex items-center gap-2">
<span class="text-xs text-white/50"
>{mealTypeLabels[meal.mealType] ?? meal.mealType}</span
>
{#if meal.inputType === 'photo'}
<span class="text-xs text-white/40">📷</span>
{/if}
</div>
<p class="truncate text-sm text-white/70">{meal.description}</p>
</div>
{#if meal.photoThumbnailUrl || meal.photoUrl}
<img
src={meal.photoThumbnailUrl ?? meal.photoUrl}
alt={meal.description}
class="h-10 w-10 flex-shrink-0 rounded object-cover"
loading="lazy"
/>
{/if}
{#if meal.nutrition}
<span class="text-xs text-white/50">{Math.round(meal.nutrition.calories)} kcal</span>
<span class="whitespace-nowrap text-xs text-white/50"
>{Math.round(meal.nutrition.calories)} kcal</span
>
{/if}
</div>
<p class="truncate text-sm text-white/70">{meal.description}</p>
</div>
</a>
{/snippet}
</BaseListView>