From 189249ba018a8dd1b88ace9ec6e28091b67b5175 Mon Sep 17 00:00:00 2001 From: Till JS Date: Thu, 9 Apr 2026 15:14:07 +0200 Subject: [PATCH] feat(mana/web/nutriphi): photo capture + AI meal recognition flow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wires the new backend endpoints into the unified Mana app and rebuilds the meal-add page around two modes: - Text mode: free-text description + optional "✨ KI-Vorschlag" button that runs Gemini on the description and prefills all six nutrient fields. The badge auto-clears if the user edits the description so stale estimates can't be silently saved. - Foto mode: file picker (accept=image/*, capture=environment for mobile camera) → preview → upload to mana-media → Gemini Vision on the stored URL. Result prefills the same form fields for review. Re-analyze without re-upload is supported. Both modes show a confidence badge (green ≥50 %, yellow with a "prüfen" warning below). Save is disabled in foto mode until the upload+analysis has completed, so a meal can never be persisted with a dangling photo reference. New module files: - api.ts server-only client (uploadMealPhoto, analyzeMealPhoto, analyzeMealText) - mutations.ts mealMutations.create / .createFromPhoto / .delete + photoMutations keeps the encryption pattern explicit (clone → encrypt → write, return plaintext snapshot) Touched: - queries.ts propagate photoMediaId/photoUrl through toMealWithNutrition - index.ts export the new mutations + types - registry.ts extend the meals comment to document why nutrition, photoMediaId, photoUrl and confidence stay plaintext - +page.svelte / history/+page.svelte show 64×64 / 48×48 thumbnail + 📷 indicator for photo-mode meals Co-Authored-By: Claude Opus 4.6 (1M context) --- .../apps/web/src/lib/data/crypto/registry.ts | 57 ++- .../apps/web/src/lib/modules/nutriphi/api.ts | 96 +++++ .../web/src/lib/modules/nutriphi/index.ts | 3 + .../web/src/lib/modules/nutriphi/mutations.ts | 130 +++++++ .../web/src/lib/modules/nutriphi/queries.ts | 2 + .../src/routes/(app)/nutriphi/+page.svelte | 13 +- .../routes/(app)/nutriphi/add/+page.svelte | 363 ++++++++++++++++-- .../(app)/nutriphi/history/+page.svelte | 11 + 8 files changed, 630 insertions(+), 45 deletions(-) create mode 100644 apps/mana/apps/web/src/lib/modules/nutriphi/api.ts create mode 100644 apps/mana/apps/web/src/lib/modules/nutriphi/mutations.ts diff --git a/apps/mana/apps/web/src/lib/data/crypto/registry.ts b/apps/mana/apps/web/src/lib/data/crypto/registry.ts index 9a04342bc..785c4d2f5 100644 --- a/apps/mana/apps/web/src/lib/data/crypto/registry.ts +++ b/apps/mana/apps/web/src/lib/data/crypto/registry.ts @@ -124,11 +124,20 @@ export const ENCRYPTION_REGISTRY: Record = { cycleDayLogs: { enabled: true, fields: ['notes', 'mood'] }, // ─── NutriPhi ──────────────────────────────────────────── - // LocalMeal only has `description` as user-typed text (mealType / - // inputType / nutrition numbers stay plaintext for the daily-summary - // aggregations and the calorie-progress widget). portionSize is a - // short label like "1 Tasse" — same sensitivity as description, so - // we encrypt it too. + // LocalMeal user-typed text → encrypted: description, portionSize. + // Plaintext (intentional): + // - mealType / inputType / date / createdAt: structural, used for + // filtering and the daily-summary aggregations + calorie-progress + // widget. Encrypting would force decrypt-then-aggregate on every + // liveQuery refresh. + // - nutrition (object of numbers): same — calorie totals are summed + // in pure $derived helpers; encrypting them would defeat the + // local-first reactive layer. + // - photoMediaId / photoUrl: opaque pointers to mana-media; the URL + // alone is not PII (anyone with the URL already has the bytes), + // and CAS-deduped media IDs leak no user content. Same rationale + // planta uses for plantPhotos. + // - confidence (float 0-1): pure metadata about the AI run. meals: { enabled: true, fields: ['description', 'portionSize'] }, // ─── Planta ────────────────────────────────────────────── @@ -278,6 +287,44 @@ export const ENCRYPTION_REGISTRY: Record = { // columns (isPinned, order) stay plaintext for sort. playgroundSnippets: { enabled: true, fields: ['name', 'systemPrompt'] }, + // ─── News ──────────────────────────────────────────────── + // Saved articles are reading-behavior data (sensitive). The body + // fields (title/excerpt/content/htmlContent/author) are encrypted + // at rest. The structural columns — type, isRead, isArchived, + // originalUrl, sourceCuratedId, sourceSlug, categoryId, image, the + // numeric metrics — stay plaintext for indexing, dedupe, and the + // reader's reading-progress logic. + // + // `newsCategories.name` is the user-named folder label and gets the + // same treatment as note titles. + // + // `newsPreferences` holds selected topics + blocklist + learned + // weights. The lists themselves leak less than the *contents* of + // the user's reading; still, the topic-weight map is a noisy proxy + // for interests, so we encrypt it. + // + // `newsReactions` records "what did the user say about article X"; + // the meaningful payload is the (articleId, reaction) tuple. We + // encrypt the reaction enum to avoid leaking aggregate "user thumbs + // down N% of articles from source X" signals to anyone with raw DB + // access. The articleId itself stays plaintext because it's used as + // the join key to suppress already-rated articles in the feed scorer. + // + // `newsCachedFeed` is intentionally NOT registered — it's a local + // mirror of the public server pool, the same content already lives + // unencrypted in news.curated_articles, and encrypting it would + // break the [topic+publishedAt] index used for the feed query. + newsArticles: { + enabled: true, + fields: ['title', 'excerpt', 'content', 'htmlContent', 'author'], + }, + newsCategories: { enabled: true, fields: ['name'] }, + newsPreferences: { + enabled: true, + fields: ['selectedTopics', 'blockedSources', 'topicWeights', 'sourceWeights'], + }, + newsReactions: { enabled: true, fields: ['reaction', 'sourceSlug', 'topic'] }, + // ─── TimeBlocks (cross-module hub) ─────────────────────── // Phase 7.1: encrypted alongside tasks + calendar.events + habits // because the consumer modules denormalize their title/description diff --git a/apps/mana/apps/web/src/lib/modules/nutriphi/api.ts b/apps/mana/apps/web/src/lib/modules/nutriphi/api.ts new file mode 100644 index 000000000..99b64a778 --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/nutriphi/api.ts @@ -0,0 +1,96 @@ +/** + * NutriPhi — server-only API client + * + * CRUD lives in IndexedDB + sync. This module talks to mana-api for the + * three server-only operations: photo upload (S3 via mana-media), AI + * meal analysis from a photo URL (Gemini Vision via mana-llm), and + * AI meal analysis from a text description. + */ + +import { authStore } from '$lib/stores/auth.svelte'; +import { getManaApiUrl } from '$lib/api/config'; +import type { NutritionData } from './types'; + +export interface UploadMealPhotoResult { + mediaId: string; + publicUrl: string; + thumbnailUrl: string; + storagePath: string; +} + +export interface AnalyzedFood { + name: string; + quantity?: string; + calories?: number; +} + +export interface MealAnalysisResult { + foods?: AnalyzedFood[]; + totalNutrition?: NutritionData; + description?: string; + confidence?: number; + warnings?: string[]; + suggestions?: string[]; +} + +async function authHeader(): Promise> { + const token = await authStore.getAccessToken(); + return token ? { Authorization: `Bearer ${token}` } : {}; +} + +/** Upload a meal photo to mana-api → S3 (mana-media). */ +export async function uploadMealPhoto(file: File): Promise { + const formData = new FormData(); + formData.append('file', file); + + const res = await fetch(`${getManaApiUrl()}/api/v1/nutriphi/photos/upload`, { + method: 'POST', + headers: await authHeader(), + body: formData, + }); + + if (!res.ok) { + const body = await res.text().catch(() => ''); + throw new Error(`Upload failed (${res.status}): ${body || res.statusText}`); + } + + return res.json() as Promise; +} + +/** Run Gemini Vision analysis on a previously uploaded photo URL. */ +export async function analyzeMealPhoto(photoUrl: string): Promise { + const res = await fetch(`${getManaApiUrl()}/api/v1/nutriphi/analysis/photo`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + ...(await authHeader()), + }, + body: JSON.stringify({ photoUrl }), + }); + + if (!res.ok) { + const body = await res.text().catch(() => ''); + throw new Error(`Analysis failed (${res.status}): ${body || res.statusText}`); + } + + return res.json() as Promise; +} + +/** Run Gemini analysis on a free-text meal description. */ +export async function analyzeMealText(description: string): Promise { + const res = await fetch(`${getManaApiUrl()}/api/v1/nutriphi/analysis/text`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + ...(await authHeader()), + }, + body: JSON.stringify({ description }), + }); + + if (!res.ok) { + const body = await res.text().catch(() => ''); + throw new Error(`Analysis failed (${res.status}): ${body || res.statusText}`); + } + + return res.json() as Promise; +} diff --git a/apps/mana/apps/web/src/lib/modules/nutriphi/index.ts b/apps/mana/apps/web/src/lib/modules/nutriphi/index.ts index bee130a23..bc03ef054 100644 --- a/apps/mana/apps/web/src/lib/modules/nutriphi/index.ts +++ b/apps/mana/apps/web/src/lib/modules/nutriphi/index.ts @@ -4,6 +4,9 @@ export { mealTable, goalTable, nutriFavoriteTable, NUTRIPHI_GUEST_SEED } from './collections'; export * from './queries'; +export { mealMutations, photoMutations, textAnalysisMutations } from './mutations'; +export type { CreateMealDto, CreateMealFromPhotoDto, PhotoAnalysisOutcome } from './mutations'; +export type { UploadMealPhotoResult, MealAnalysisResult, AnalyzedFood } from './api'; export type { LocalMeal, LocalGoal, diff --git a/apps/mana/apps/web/src/lib/modules/nutriphi/mutations.ts b/apps/mana/apps/web/src/lib/modules/nutriphi/mutations.ts new file mode 100644 index 000000000..bcf3f95fd --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/nutriphi/mutations.ts @@ -0,0 +1,130 @@ +/** + * NutriPhi — Mutation Helpers (Local-First) + * + * All writes go to IndexedDB first, sync handles the rest. Mutations throw + * on failure so UI callers can surface errors via toasts. Server-only + * operations (photo upload, AI analysis) live in ./api. + * + * Encryption pattern: build the LocalMeal as plaintext, shallow-clone it, + * run encryptRecord on the clone (mutates only the allow-listed fields — + * see crypto/registry.ts), then write the clone to Dexie. The original + * plaintext object is returned to the caller. nutrition / photoMediaId / + * photoUrl / confidence are NOT encrypted by design (see registry comment). + */ + +import { db } from '$lib/data/database'; +import { encryptRecord } from '$lib/data/crypto'; +import { + uploadMealPhoto, + analyzeMealPhoto, + analyzeMealText, + type MealAnalysisResult, + type UploadMealPhotoResult, +} from './api'; +import type { LocalMeal, MealType, NutritionData } from './types'; + +export interface CreateMealDto { + mealType: MealType; + description: string; + nutrition?: NutritionData | null; + portionSize?: string | null; + date?: string; // YYYY-MM-DD, defaults to today +} + +export interface CreateMealFromPhotoDto extends CreateMealDto { + photoMediaId: string; + photoUrl: string; + confidence: number; +} + +function todayStr(): string { + return new Date().toISOString().split('T')[0]; +} + +export const mealMutations = { + /** Persist a text-only meal entry. */ + async create(dto: CreateMealDto): Promise { + const now = new Date().toISOString(); + const row: LocalMeal = { + id: crypto.randomUUID(), + date: dto.date ?? todayStr(), + mealType: dto.mealType, + inputType: 'text', + description: dto.description.trim(), + portionSize: dto.portionSize ?? null, + confidence: dto.nutrition ? 0.8 : 0, + nutrition: dto.nutrition ?? null, + photoMediaId: null, + photoUrl: null, + createdAt: now, + updatedAt: now, + }; + const encrypted: Record = { ...row }; + await encryptRecord('meals', encrypted); + await db.table('meals').add(encrypted); + return row; + }, + + /** Persist a meal entry that originated from a photo + AI analysis. */ + async createFromPhoto(dto: CreateMealFromPhotoDto): Promise { + const now = new Date().toISOString(); + const row: LocalMeal = { + id: crypto.randomUUID(), + date: dto.date ?? todayStr(), + mealType: dto.mealType, + inputType: 'photo', + description: dto.description.trim(), + portionSize: dto.portionSize ?? null, + confidence: dto.confidence, + nutrition: dto.nutrition ?? null, + photoMediaId: dto.photoMediaId, + photoUrl: dto.photoUrl, + createdAt: now, + updatedAt: now, + }; + const encrypted: Record = { ...row }; + await encryptRecord('meals', encrypted); + await db.table('meals').add(encrypted); + return row; + }, + + async delete(id: string): Promise { + const now = new Date().toISOString(); + await db.table('meals').update(id, { deletedAt: now, updatedAt: now }); + }, +}; + +export interface PhotoAnalysisOutcome { + upload: UploadMealPhotoResult; + analysis: MealAnalysisResult; +} + +export const photoMutations = { + /** + * Upload a meal photo to mana-media and immediately run AI analysis on it. + * Does NOT persist a meal — the caller (usually the add page) shows the + * result to the user for review and then calls mealMutations.createFromPhoto. + */ + async uploadAndAnalyze(file: File): Promise { + const upload = await uploadMealPhoto(file); + const analysis = await analyzeMealPhoto(upload.publicUrl); + return { upload, analysis }; + }, + + /** Just upload a photo, no analysis. Useful when re-running analysis later. */ + async upload(file: File): Promise { + return uploadMealPhoto(file); + }, + + /** Re-run analysis on an already-uploaded photo URL. */ + async analyze(photoUrl: string): Promise { + return analyzeMealPhoto(photoUrl); + }, +}; + +export const textAnalysisMutations = { + /** Run Gemini analysis on a free-text meal description (no persistence). */ + async analyze(description: string): Promise { + return analyzeMealText(description); + }, +}; diff --git a/apps/mana/apps/web/src/lib/modules/nutriphi/queries.ts b/apps/mana/apps/web/src/lib/modules/nutriphi/queries.ts index 907a62777..2e123a947 100644 --- a/apps/mana/apps/web/src/lib/modules/nutriphi/queries.ts +++ b/apps/mana/apps/web/src/lib/modules/nutriphi/queries.ts @@ -30,6 +30,8 @@ export function toMealWithNutrition(local: LocalMeal): MealWithNutrition { portionSize: local.portionSize, confidence: local.confidence, nutrition: local.nutrition ?? null, + photoMediaId: local.photoMediaId ?? null, + photoUrl: local.photoUrl ?? null, createdAt: local.createdAt ?? new Date().toISOString(), }; } diff --git a/apps/mana/apps/web/src/routes/(app)/nutriphi/+page.svelte b/apps/mana/apps/web/src/routes/(app)/nutriphi/+page.svelte index 126f7c357..9c56c5061 100644 --- a/apps/mana/apps/web/src/routes/(app)/nutriphi/+page.svelte +++ b/apps/mana/apps/web/src/routes/(app)/nutriphi/+page.svelte @@ -199,7 +199,15 @@
-
+
+ {#if meal.photoUrl} + {meal.description} + {/if}
{formatTime(meal.createdAt)} + {#if meal.inputType === 'photo'} + 📷 + {/if}

{meal.description} diff --git a/apps/mana/apps/web/src/routes/(app)/nutriphi/add/+page.svelte b/apps/mana/apps/web/src/routes/(app)/nutriphi/add/+page.svelte index 892d56379..f43b8d138 100644 --- a/apps/mana/apps/web/src/routes/(app)/nutriphi/add/+page.svelte +++ b/apps/mana/apps/web/src/routes/(app)/nutriphi/add/+page.svelte @@ -1,9 +1,12 @@ @@ -105,14 +241,120 @@

Mahlzeit hinzufuegen

+ +
+ + +
+ {#if error}
{error}
{/if} - - {#if favorites.length > 0} + + {#if mode === 'photo'} +
+ + + {#if !photoPreviewUrl} + + {:else} +
+
+ Mahlzeit +
+
+ + {#if !analyzed} + + {:else} + + {/if} +
+ + {#if analyzed && confidencePct !== null} +
+ KI-Analyse + · + {confidencePct}% sicher + {#if lowConfidence} + ⚠ Bitte Werte prüfen + {/if} +
+ {/if} +
+ {/if} +
+ {/if} + + + {#if mode === 'text' && favorites.length > 0}

Favoriten

@@ -152,9 +394,26 @@
- +
+ + {#if mode === 'text'} + + {/if} +
+ {#if mode === 'text' && textAnalyzed && textConfidencePct !== null} +
+ KI-Schätzung + · + {textConfidencePct}% sicher + {#if textLowConfidence} + ⚠ Bitte Werte prüfen + {/if} +
+ {/if}

- Naehrwerte (optional) + Naehrwerte + {#if mode === 'photo' && analyzed} + (KI-Schätzung, editierbar) + {:else if mode === 'text' && textAnalyzed} + (KI-Schätzung, editierbar) + {:else} + (optional) + {/if}

@@ -262,7 +547,7 @@