diff --git a/.env.development b/.env.development index 97d621a17..e718f2f26 100644 --- a/.env.development +++ b/.env.development @@ -143,10 +143,12 @@ PICTURE_APPLE_CLIENT_ID= # NUTRIPHI PROJECT # ============================================ -NUTRIPHI_BACKEND_PORT=3012 -NUTRIPHI_DATABASE_URL=postgresql://nutriphi:nutriphi_dev_password@localhost:5435/nutriphi +NUTRIPHI_BACKEND_PORT=3023 +NUTRIPHI_DATABASE_URL=postgresql://manacore:devpassword@localhost:5432/nutriphi NUTRIPHI_APP_ID=nutriphi -NUTRIPHI_GEMINI_API_KEY=your-gemini-api-key-here + +# Google Gemini API for food image analysis +GEMINI_API_KEY=AIzaSyBR9iP74hlo-mhI-Cl4QEvKprRzPPMb-GA # S3 Storage (uses MinIO locally via shared S3_* variables, Hetzner in production) NUTRIPHI_S3_PUBLIC_URL=http://localhost:9000/nutriphi-storage diff --git a/apps/nutriphi/CLAUDE.md b/apps/nutriphi/CLAUDE.md new file mode 100644 index 000000000..52567e6a9 --- /dev/null +++ b/apps/nutriphi/CLAUDE.md @@ -0,0 +1,366 @@ +# NutriPhi Project Guide + +## Overview + +**NutriPhi** is an AI-powered nutrition tracking app that allows users to photograph their meals and receive instant nutritional analysis. It uses Google Gemini for image analysis and provides personalized recommendations. + +| App | Port | URL | +|-----|------|-----| +| Backend | 3023 | http://localhost:3023 | +| Web App | 5180 | http://localhost:5180 | +| Landing Page | 4323 | http://localhost:4323 | + +## Project Structure + +``` +apps/nutriphi/ +├── apps/ +│ ├── backend/ # NestJS API server (@nutriphi/backend) +│ │ └── src/ +│ │ ├── main.ts +│ │ ├── app.module.ts +│ │ ├── db/ # Drizzle schemas +│ │ │ ├── schema/index.ts +│ │ │ └── db.ts +│ │ ├── meal/ # Meal CRUD +│ │ ├── goals/ # User goals +│ │ ├── favorites/ # Favorite meals +│ │ ├── analysis/ # Gemini AI integration +│ │ ├── stats/ # Daily/weekly statistics +│ │ ├── recommendations/ # AI hints & coaching +│ │ └── health/ +│ │ +│ ├── web/ # SvelteKit web application (@nutriphi/web) +│ │ └── src/ +│ │ ├── lib/ +│ │ │ ├── api/client.ts +│ │ │ ├── stores/ +│ │ │ │ ├── auth.svelte.ts +│ │ │ │ └── meals.svelte.ts +│ │ │ └── components/ +│ │ │ ├── Header.svelte +│ │ │ ├── DailySummary.svelte +│ │ │ ├── MealList.svelte +│ │ │ ├── AddMealButton.svelte +│ │ │ └── ProgressRing.svelte +│ │ └── routes/ +│ │ ├── +layout.svelte +│ │ ├── +page.svelte # Dashboard +│ │ ├── login/+page.svelte +│ │ └── add/+page.svelte # Photo/text input +│ │ +│ └── landing/ # Astro marketing page (@nutriphi/landing) +│ +├── packages/ +│ └── shared/ # Shared types, utils, constants (@nutriphi/shared) +│ └── src/ +│ ├── types/index.ts +│ ├── constants/index.ts +│ └── utils/index.ts +│ +├── package.json +└── CLAUDE.md +``` + +## Commands + +### Root Level (from monorepo root) + +```bash +# Start all apps +pnpm nutriphi:dev + +# Individual apps +pnpm dev:nutriphi:backend # Backend (port 3015) +pnpm dev:nutriphi:web # Web app (port 5180) +pnpm dev:nutriphi:landing # Landing page (port 4323) +pnpm dev:nutriphi:app # Web + backend together + +# Database +pnpm nutriphi:db:push # Push schema to database +pnpm nutriphi:db:studio # Open Drizzle Studio +``` + +### Backend (apps/nutriphi/apps/backend) + +```bash +pnpm dev # Start with hot reload +pnpm build # Build for production +pnpm db:push # Push schema to database +pnpm db:studio # Open Drizzle Studio +``` + +### Web App (apps/nutriphi/apps/web) + +```bash +pnpm dev # Start dev server (port 5180) +pnpm build # Build for production +``` + +### Landing Page (apps/nutriphi/apps/landing) + +```bash +pnpm dev # Start dev server (port 4323) +pnpm build # Build for production +``` + +## Technology Stack + +| Layer | Technology | +|-------|------------| +| **Backend** | NestJS 10, Drizzle ORM, PostgreSQL | +| **AI** | Google Gemini 2.0 Flash | +| **Web** | SvelteKit 2.x, Svelte 5 (runes mode), Tailwind CSS 4 | +| **Landing** | Astro 5.x, Tailwind CSS | +| **Auth** | Mana Core Auth (JWT) | + +## Architecture + +### Core Features + +1. **Photo Analysis** - Take a photo, Gemini identifies foods and calculates nutrition +2. **Text Input** - Alternative: describe your meal in text +3. **Full Nutrition** - Calories, macros, vitamins, minerals +4. **Daily Goals** - Set and track calorie/macro targets +5. **AI Coaching** - Personalized tips based on eating patterns +6. **Favorites** - Save frequently eaten meals +7. **Privacy-First** - Photos are never stored, only analysis results + +### API Endpoints + +#### Health +| Endpoint | Method | Description | +|----------|--------|-------------| +| `/api/v1/health` | GET | Health check | + +#### Analysis +| Endpoint | Method | Description | +|----------|--------|-------------| +| `/api/v1/analysis/photo` | POST | Analyze photo (Base64) | +| `/api/v1/analysis/text` | POST | Analyze text description | + +#### Meals +| Endpoint | Method | Description | +|----------|--------|-------------| +| `/api/v1/meals` | GET | List meals (query by date) | +| `/api/v1/meals` | POST | Create meal | +| `/api/v1/meals/:id` | GET | Get meal details | +| `/api/v1/meals/:id` | PATCH | Update meal | +| `/api/v1/meals/:id` | DELETE | Delete meal | + +#### Goals +| Endpoint | Method | Description | +|----------|--------|-------------| +| `/api/v1/goals` | GET | Get user goals | +| `/api/v1/goals` | POST | Set/update goals | +| `/api/v1/goals` | DELETE | Delete goals | + +#### Favorites +| Endpoint | Method | Description | +|----------|--------|-------------| +| `/api/v1/favorites` | GET | List favorites | +| `/api/v1/favorites` | POST | Create favorite | +| `/api/v1/favorites/:id/use` | POST | Increment usage count | +| `/api/v1/favorites/:id` | DELETE | Delete favorite | + +#### Stats +| Endpoint | Method | Description | +|----------|--------|-------------| +| `/api/v1/stats/daily` | GET | Daily summary | +| `/api/v1/stats/weekly` | GET | Weekly stats | + +#### Recommendations +| Endpoint | Method | Description | +|----------|--------|-------------| +| `/api/v1/recommendations` | GET | List active recommendations | +| `/api/v1/recommendations/:id/dismiss` | POST | Dismiss recommendation | + +### Database Schema + +#### user_goals +| Column | Type | Description | +|--------|------|-------------| +| id | UUID | Primary key | +| user_id | UUID | User ID | +| daily_calories | INTEGER | Daily calorie target | +| daily_protein | INTEGER | Protein target (g) | +| daily_carbs | INTEGER | Carbs target (g) | +| daily_fat | INTEGER | Fat target (g) | + +#### meals +| Column | Type | Description | +|--------|------|-------------| +| id | UUID | Primary key | +| user_id | UUID | User ID | +| date | TIMESTAMP | Meal date/time | +| meal_type | VARCHAR | breakfast/lunch/dinner/snack | +| input_type | VARCHAR | photo/text | +| description | TEXT | AI-generated description | +| confidence | REAL | AI confidence (0-1) | + +#### meal_nutrition +| Column | Type | Description | +|--------|------|-------------| +| id | UUID | Primary key | +| meal_id | UUID | FK to meals | +| calories | REAL | Calories (kcal) | +| protein | REAL | Protein (g) | +| carbohydrates | REAL | Carbs (g) | +| fat | REAL | Fat (g) | +| fiber | REAL | Fiber (g) | +| sugar | REAL | Sugar (g) | +| vitamin_* | REAL | Various vitamins | +| calcium, iron, etc. | REAL | Minerals | + +#### favorite_meals +| Column | Type | Description | +|--------|------|-------------| +| id | UUID | Primary key | +| user_id | UUID | User ID | +| name | VARCHAR | Favorite name | +| nutrition | JSONB | Cached nutrition data | +| usage_count | INTEGER | Times used | + +#### recommendations +| Column | Type | Description | +|--------|------|-------------| +| id | UUID | Primary key | +| user_id | UUID | User ID | +| type | VARCHAR | hint/coaching | +| message | TEXT | Recommendation text | +| dismissed | BOOLEAN | User dismissed | + +## Environment Variables + +### Backend (.env) + +```env +NODE_ENV=development +PORT=3023 +DATABASE_URL=postgresql://manacore:devpassword@localhost:5432/nutriphi +MANA_CORE_AUTH_URL=http://localhost:3001 +CORS_ORIGINS=http://localhost:5180,http://localhost:4323 + +# Gemini AI +GEMINI_API_KEY=your-gemini-api-key +``` + +### Web (.env) + +```env +PUBLIC_BACKEND_URL=http://localhost:3023 +PUBLIC_MANA_CORE_AUTH_URL=http://localhost:3001 +``` + +## Shared Package (@nutriphi/shared) + +**Types:** +- `UserGoals` - Daily nutrition targets +- `Meal`, `MealNutrition` - Meal data +- `FavoriteMeal` - Saved favorites +- `DailySummary`, `WeeklyStats` - Statistics +- `AIAnalysisResult` - Gemini response format +- `Recommendation` - AI hints/coaching + +**Constants:** +- `DEFAULT_DAILY_VALUES` - Reference daily values +- `MEAL_TYPE_LABELS` - Localized meal names +- `NUTRIENT_INFO` - Labels, units, colors +- `CREDIT_COSTS` - Credit pricing + +**Utils:** +- `calculateProgress()` - Progress towards goals +- `sumNutrition()` - Sum multiple meals +- `formatNutrient()` - Display formatting +- `detectDeficiencies()` - Find nutrient gaps +- `suggestMealType()` - Based on time of day + +## Quick Start + +### 1. Create Database + +```bash +# PostgreSQL must be running +docker compose -f docker-compose.dev.yml up -d postgres + +# Create database +PGPASSWORD=devpassword psql -h localhost -U manacore -d postgres -c "CREATE DATABASE nutriphi;" + +# Push schema +pnpm nutriphi:db:push +``` + +### 2. Set Gemini API Key + +Add to `.env.development`: +```env +GEMINI_API_KEY=your-gemini-api-key +``` + +### 3. Start Apps + +```bash +# Backend + Web together +pnpm dev:nutriphi:app + +# Or individually: +pnpm dev:nutriphi:backend # Terminal 1 +pnpm dev:nutriphi:web # Terminal 2 +pnpm dev:nutriphi:landing # Terminal 3 +``` + +### 4. Open URLs + +- Web App: http://localhost:5180 +- Landing: http://localhost:4323 +- API Health: http://localhost:3023/api/v1/health + +## Testing API + +```bash +# Health Check +curl http://localhost:3023/api/v1/health + +# Login (get token) +TOKEN=$(curl -s -X POST http://localhost:3001/api/v1/auth/login \ + -H "Content-Type: application/json" \ + -d '{"email": "test@example.com", "password": "password"}' | jq -r '.accessToken') + +# Analyze text +curl -X POST http://localhost:3023/api/v1/analysis/text \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + -d '{"description": "Spaghetti Bolognese mit Parmesan"}' + +# Get daily summary +curl http://localhost:3023/api/v1/stats/daily \ + -H "Authorization: Bearer $TOKEN" +``` + +## Credit System + +| Action | Credits | +|--------|---------| +| Photo Analysis | 5 | +| Text Analysis | 2 | +| AI Coaching | 10 | + +## Privacy Features + +- Photos are NEVER stored on servers +- Photos are sent directly to Gemini, analyzed, then discarded +- Only nutrition results are saved +- Full data export available (GDPR) +- One-click account deletion + +## Color Theme + +| Color | Value | Usage | +|-------|-------|-------| +| Primary | #22C55E | Main actions, progress | +| Secondary | #F97316 | Accent, warnings | +| Accent | #14B8A6 | Highlights | +| Calories | #F59E0B | Calorie displays | +| Protein | #EF4444 | Protein displays | +| Carbs | #3B82F6 | Carb displays | +| Fat | #8B5CF6 | Fat displays | diff --git a/apps/nutriphi/apps/backend/drizzle.config.ts b/apps/nutriphi/apps/backend/drizzle.config.ts new file mode 100644 index 000000000..13cf72e58 --- /dev/null +++ b/apps/nutriphi/apps/backend/drizzle.config.ts @@ -0,0 +1,15 @@ +import { defineConfig } from 'drizzle-kit'; +import * as dotenv from 'dotenv'; + +dotenv.config(); + +export default defineConfig({ + schema: './src/db/schema/index.ts', + out: './src/db/migrations', + dialect: 'postgresql', + dbCredentials: { + url: process.env.DATABASE_URL!, + }, + verbose: true, + strict: true, +}); diff --git a/apps/nutriphi/apps/backend/nest-cli.json b/apps/nutriphi/apps/backend/nest-cli.json new file mode 100644 index 000000000..b4a4fa09c --- /dev/null +++ b/apps/nutriphi/apps/backend/nest-cli.json @@ -0,0 +1,10 @@ +{ + "$schema": "https://json.schemastore.org/nest-cli", + "collection": "@nestjs/schematics", + "sourceRoot": "src", + "compilerOptions": { + "deleteOutDir": false, + "assets": [], + "watchAssets": false + } +} diff --git a/apps/nutriphi/apps/backend/package.json b/apps/nutriphi/apps/backend/package.json new file mode 100644 index 000000000..eaedba472 --- /dev/null +++ b/apps/nutriphi/apps/backend/package.json @@ -0,0 +1,56 @@ +{ + "name": "@nutriphi/backend", + "version": "1.0.0", + "type": "commonjs", + "private": true, + "scripts": { + "build": "nest build", + "start": "nest start", + "dev": "nest start --watch", + "start:dev": "nest start --watch", + "start:debug": "nest start --debug --watch", + "start:prod": "node dist/main", + "lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix", + "type-check": "tsc --noEmit", + "migration:generate": "drizzle-kit generate", + "migration:run": "tsx src/db/migrate.ts", + "db:push": "drizzle-kit push", + "db:studio": "drizzle-kit studio", + "db:seed": "tsx src/db/seed.ts" + }, + "dependencies": { + "@nutriphi/shared": "workspace:*", + "@manacore/shared-nestjs-auth": "workspace:*", + "@google/generative-ai": "^0.21.0", + "@nestjs/common": "^10.4.15", + "@nestjs/config": "^3.3.0", + "@nestjs/core": "^10.4.15", + "@nestjs/platform-express": "^10.4.15", + "class-transformer": "^0.5.1", + "class-validator": "^0.14.1", + "dotenv": "^16.4.7", + "drizzle-kit": "^0.30.2", + "drizzle-orm": "^0.38.3", + "postgres": "^3.4.5", + "reflect-metadata": "^0.2.2", + "rxjs": "^7.8.1" + }, + "devDependencies": { + "@nestjs/cli": "^10.4.9", + "@nestjs/schematics": "^10.2.3", + "@types/express": "^5.0.0", + "@types/node": "^22.10.2", + "@typescript-eslint/eslint-plugin": "^8.18.1", + "@typescript-eslint/parser": "^8.18.1", + "eslint": "^9.17.0", + "eslint-config-prettier": "^9.1.0", + "eslint-plugin-prettier": "^5.2.1", + "prettier": "^3.4.2", + "source-map-support": "^0.5.21", + "ts-loader": "^9.5.1", + "ts-node": "^10.9.2", + "tsconfig-paths": "^4.2.0", + "tsx": "^4.19.2", + "typescript": "^5.7.2" + } +} diff --git a/apps/nutriphi/apps/backend/src/analysis/analysis.controller.ts b/apps/nutriphi/apps/backend/src/analysis/analysis.controller.ts new file mode 100644 index 000000000..9bda4fcb2 --- /dev/null +++ b/apps/nutriphi/apps/backend/src/analysis/analysis.controller.ts @@ -0,0 +1,48 @@ +import { Controller, Post, Body, UseGuards, BadRequestException } from '@nestjs/common'; +import { JwtAuthGuard, CurrentUser, type CurrentUserData } from '@manacore/shared-nestjs-auth'; +import { AnalysisService } from './analysis.service'; +import { IsString, IsOptional } from 'class-validator'; + +class AnalyzePhotoDto { + @IsString() + imageBase64!: string; + + @IsOptional() + @IsString() + mimeType?: string; +} + +class AnalyzeTextDto { + @IsString() + description!: string; +} + +@Controller('analysis') +@UseGuards(JwtAuthGuard) +export class AnalysisController { + constructor(private readonly analysisService: AnalysisService) {} + + @Post('photo') + async analyzePhoto(@CurrentUser() _user: CurrentUserData, @Body() dto: AnalyzePhotoDto) { + // TODO: Deduct credits from user account + try { + return await this.analysisService.analyzePhoto(dto.imageBase64, dto.mimeType); + } catch (error) { + throw new BadRequestException( + `Failed to analyze image: ${error instanceof Error ? error.message : 'Unknown error'}` + ); + } + } + + @Post('text') + async analyzeText(@CurrentUser() _user: CurrentUserData, @Body() dto: AnalyzeTextDto) { + // TODO: Deduct credits from user account + try { + return await this.analysisService.analyzeText(dto.description); + } catch (error) { + throw new BadRequestException( + `Failed to analyze text: ${error instanceof Error ? error.message : 'Unknown error'}` + ); + } + } +} diff --git a/apps/nutriphi/apps/backend/src/analysis/analysis.module.ts b/apps/nutriphi/apps/backend/src/analysis/analysis.module.ts new file mode 100644 index 000000000..56f9cec14 --- /dev/null +++ b/apps/nutriphi/apps/backend/src/analysis/analysis.module.ts @@ -0,0 +1,11 @@ +import { Module } from '@nestjs/common'; +import { AnalysisController } from './analysis.controller'; +import { AnalysisService } from './analysis.service'; +import { GeminiService } from './gemini.service'; + +@Module({ + controllers: [AnalysisController], + providers: [AnalysisService, GeminiService], + exports: [AnalysisService], +}) +export class AnalysisModule {} diff --git a/apps/nutriphi/apps/backend/src/analysis/analysis.service.ts b/apps/nutriphi/apps/backend/src/analysis/analysis.service.ts new file mode 100644 index 000000000..517965004 --- /dev/null +++ b/apps/nutriphi/apps/backend/src/analysis/analysis.service.ts @@ -0,0 +1,16 @@ +import { Injectable } from '@nestjs/common'; +import { GeminiService } from './gemini.service'; +import type { AIAnalysisResult } from '../types/nutrition.types'; + +@Injectable() +export class AnalysisService { + constructor(private geminiService: GeminiService) {} + + async analyzePhoto(imageBase64: string, mimeType?: string): Promise { + return this.geminiService.analyzeImage(imageBase64, mimeType); + } + + async analyzeText(description: string): Promise { + return this.geminiService.analyzeText(description); + } +} diff --git a/apps/nutriphi/apps/backend/src/analysis/gemini.service.ts b/apps/nutriphi/apps/backend/src/analysis/gemini.service.ts new file mode 100644 index 000000000..391777da7 --- /dev/null +++ b/apps/nutriphi/apps/backend/src/analysis/gemini.service.ts @@ -0,0 +1,139 @@ +import { Injectable, OnModuleInit } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { GoogleGenerativeAI, type GenerativeModel } from '@google/generative-ai'; +import type { AIAnalysisResult } from '../types/nutrition.types'; + +const ANALYSIS_PROMPT = `Du bist ein Ernährungsexperte. Analysiere das Bild dieser Mahlzeit und liefere eine detaillierte Nährwertanalyse. + +Aufgaben: +1. Identifiziere alle sichtbaren Lebensmittel +2. Schätze die Portionsgröße (in Gramm) basierend auf visuellen Hinweisen +3. Berechne die Nährwerte für jedes Lebensmittel +4. Summiere die Gesamtnährwerte + +Antworte NUR mit einem validen JSON-Objekt im folgenden Format: +{ + "foods": [ + { + "name": "Lebensmittelname", + "quantity": "geschätzte Menge (z.B. '150g', '1 Tasse')", + "calories": 123, + "confidence": 0.85 + } + ], + "totalNutrition": { + "calories": 500, + "protein": 25, + "carbohydrates": 60, + "fat": 15, + "fiber": 5, + "sugar": 10, + "vitaminA": 100, + "vitaminC": 30, + "vitaminD": 2, + "calcium": 150, + "iron": 3, + "magnesium": 50 + }, + "description": "Kurze Beschreibung der Mahlzeit auf Deutsch", + "confidence": 0.8, + "warnings": ["Optional: Warnungen falls etwas unklar ist"], + "suggestions": ["Optional: Verbesserungsvorschläge"] +} + +Wichtig: +- Alle Nährwerte als Zahlen (keine Strings) +- Kalorien in kcal +- Protein, Kohlenhydrate, Fett, Ballaststoffe, Zucker in Gramm +- Vitamine und Mineralstoffe in den üblichen Einheiten (mg oder µg) +- Confidence-Werte zwischen 0 und 1 +- Beschreibung auf Deutsch`; + +const TEXT_ANALYSIS_PROMPT = `Du bist ein Ernährungsexperte. Analysiere die folgende Mahlzeitbeschreibung und liefere eine Nährwertschätzung. + +Mahlzeit: {INPUT} + +Antworte NUR mit einem validen JSON-Objekt im folgenden Format: +{ + "foods": [ + { + "name": "Lebensmittelname", + "quantity": "geschätzte Menge", + "calories": 123, + "confidence": 0.85 + } + ], + "totalNutrition": { + "calories": 500, + "protein": 25, + "carbohydrates": 60, + "fat": 15, + "fiber": 5, + "sugar": 10 + }, + "description": "Aufbereitete Beschreibung der Mahlzeit", + "confidence": 0.75 +}`; + +@Injectable() +export class GeminiService implements OnModuleInit { + private model: GenerativeModel | null = null; + + constructor(private configService: ConfigService) {} + + onModuleInit() { + const apiKey = this.configService.get('GEMINI_API_KEY'); + if (apiKey) { + const genAI = new GoogleGenerativeAI(apiKey); + // Use Gemini 2.0 Flash - fast and cost-effective + this.model = genAI.getGenerativeModel({ model: 'gemini-2.0-flash-exp' }); + } + } + + async analyzeImage(imageBase64: string, mimeType = 'image/jpeg'): Promise { + if (!this.model) { + throw new Error('Gemini API not configured'); + } + + const result = await this.model.generateContent([ + ANALYSIS_PROMPT, + { + inlineData: { + mimeType, + data: imageBase64, + }, + }, + ]); + + const response = result.response; + const text = response.text(); + + // Extract JSON from response + const jsonMatch = text.match(/\{[\s\S]*\}/); + if (!jsonMatch) { + throw new Error('Failed to parse AI response'); + } + + return JSON.parse(jsonMatch[0]) as AIAnalysisResult; + } + + async analyzeText(description: string): Promise { + if (!this.model) { + throw new Error('Gemini API not configured'); + } + + const prompt = TEXT_ANALYSIS_PROMPT.replace('{INPUT}', description); + const result = await this.model.generateContent(prompt); + + const response = result.response; + const text = response.text(); + + // Extract JSON from response + const jsonMatch = text.match(/\{[\s\S]*\}/); + if (!jsonMatch) { + throw new Error('Failed to parse AI response'); + } + + return JSON.parse(jsonMatch[0]) as AIAnalysisResult; + } +} diff --git a/apps/nutriphi/apps/backend/src/app.module.ts b/apps/nutriphi/apps/backend/src/app.module.ts new file mode 100644 index 000000000..5f0d7db53 --- /dev/null +++ b/apps/nutriphi/apps/backend/src/app.module.ts @@ -0,0 +1,28 @@ +import { Module } from '@nestjs/common'; +import { ConfigModule } from '@nestjs/config'; +import { DatabaseModule } from './db/database.module'; +import { HealthModule } from './health/health.module'; +import { MealModule } from './meal/meal.module'; +import { GoalsModule } from './goals/goals.module'; +import { FavoritesModule } from './favorites/favorites.module'; +import { AnalysisModule } from './analysis/analysis.module'; +import { StatsModule } from './stats/stats.module'; +import { RecommendationsModule } from './recommendations/recommendations.module'; + +@Module({ + imports: [ + ConfigModule.forRoot({ + isGlobal: true, + envFilePath: ['.env', '.env.development'], + }), + DatabaseModule, + HealthModule, + MealModule, + GoalsModule, + FavoritesModule, + AnalysisModule, + StatsModule, + RecommendationsModule, + ], +}) +export class AppModule {} diff --git a/apps/nutriphi/apps/backend/src/db/database.module.ts b/apps/nutriphi/apps/backend/src/db/database.module.ts new file mode 100644 index 000000000..e9c4721e5 --- /dev/null +++ b/apps/nutriphi/apps/backend/src/db/database.module.ts @@ -0,0 +1,29 @@ +import { Module, Global, OnModuleDestroy } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { getDb, closeConnection } from './db'; +import type { Database } from './db'; + +export const DATABASE_CONNECTION = 'DATABASE_CONNECTION'; + +@Global() +@Module({ + providers: [ + { + provide: DATABASE_CONNECTION, + useFactory: (configService: ConfigService): Database => { + const databaseUrl = configService.get('DATABASE_URL'); + if (!databaseUrl) { + throw new Error('DATABASE_URL environment variable is not set'); + } + return getDb(databaseUrl); + }, + inject: [ConfigService], + }, + ], + exports: [DATABASE_CONNECTION], +}) +export class DatabaseModule implements OnModuleDestroy { + async onModuleDestroy() { + await closeConnection(); + } +} diff --git a/apps/nutriphi/apps/backend/src/db/db.ts b/apps/nutriphi/apps/backend/src/db/db.ts new file mode 100644 index 000000000..fccc63f4a --- /dev/null +++ b/apps/nutriphi/apps/backend/src/db/db.ts @@ -0,0 +1,38 @@ +import { drizzle } from 'drizzle-orm/postgres-js'; +import * as schema from './schema'; + +// Use require for postgres to avoid ESM/CommonJS interop issues +// eslint-disable-next-line @typescript-eslint/no-var-requires +const postgres = require('postgres'); + +let connection: ReturnType | null = null; +let db: ReturnType | null = null; + +export function getConnection(databaseUrl: string) { + if (!connection) { + connection = postgres(databaseUrl, { + max: 10, + idle_timeout: 20, + connect_timeout: 10, + }); + } + return connection; +} + +export function getDb(databaseUrl: string) { + if (!db) { + const conn = getConnection(databaseUrl); + db = drizzle(conn, { schema }); + } + return db; +} + +export async function closeConnection() { + if (connection) { + await connection.end(); + connection = null; + db = null; + } +} + +export type Database = ReturnType; diff --git a/apps/nutriphi/apps/backend/src/db/schema/index.ts b/apps/nutriphi/apps/backend/src/db/schema/index.ts new file mode 100644 index 000000000..6aca5498d --- /dev/null +++ b/apps/nutriphi/apps/backend/src/db/schema/index.ts @@ -0,0 +1,124 @@ +import { + pgTable, + uuid, + varchar, + text, + timestamp, + integer, + real, + boolean, + jsonb, +} from 'drizzle-orm/pg-core'; + +// User Goals +export const userGoals = pgTable('user_goals', { + id: uuid('id').primaryKey().defaultRandom(), + userId: uuid('user_id').notNull(), + dailyCalories: integer('daily_calories').notNull().default(2000), + dailyProtein: integer('daily_protein'), // grams + dailyCarbs: integer('daily_carbs'), // grams + dailyFat: integer('daily_fat'), // grams + dailyFiber: integer('daily_fiber'), // grams + createdAt: timestamp('created_at').defaultNow().notNull(), + updatedAt: timestamp('updated_at').defaultNow().notNull(), +}); + +// Meals +export const meals = pgTable('meals', { + id: uuid('id').primaryKey().defaultRandom(), + userId: uuid('user_id').notNull(), + date: timestamp('date').notNull(), + mealType: varchar('meal_type', { length: 20 }).notNull(), // breakfast, lunch, dinner, snack + inputType: varchar('input_type', { length: 20 }).notNull(), // photo, text + description: text('description').notNull(), // AI-generated description + portionSize: varchar('portion_size', { length: 50 }), // small, medium, large, or grams + confidence: real('confidence').notNull().default(0), // 0-1 + createdAt: timestamp('created_at').defaultNow().notNull(), +}); + +// Meal Nutrition (one-to-one with meals) +export const mealNutrition = pgTable('meal_nutrition', { + id: uuid('id').primaryKey().defaultRandom(), + mealId: uuid('meal_id') + .notNull() + .references(() => meals.id, { onDelete: 'cascade' }), + // Macros + calories: real('calories').notNull(), + protein: real('protein').notNull(), + carbohydrates: real('carbohydrates').notNull(), + fat: real('fat').notNull(), + fiber: real('fiber').notNull().default(0), + sugar: real('sugar').notNull().default(0), + saturatedFat: real('saturated_fat'), + unsaturatedFat: real('unsaturated_fat'), + // Vitamins (µg or mg as appropriate) + vitaminA: real('vitamin_a'), + vitaminB1: real('vitamin_b1'), + vitaminB2: real('vitamin_b2'), + vitaminB3: real('vitamin_b3'), + vitaminB5: real('vitamin_b5'), + vitaminB6: real('vitamin_b6'), + vitaminB7: real('vitamin_b7'), + vitaminB9: real('vitamin_b9'), + vitaminB12: real('vitamin_b12'), + vitaminC: real('vitamin_c'), + vitaminD: real('vitamin_d'), + vitaminE: real('vitamin_e'), + vitaminK: real('vitamin_k'), + // Minerals (mg) + calcium: real('calcium'), + iron: real('iron'), + magnesium: real('magnesium'), + phosphorus: real('phosphorus'), + potassium: real('potassium'), + sodium: real('sodium'), + zinc: real('zinc'), + copper: real('copper'), + manganese: real('manganese'), + selenium: real('selenium'), + // Water + water: real('water'), +}); + +// Favorite Meals +export const favoriteMeals = pgTable('favorite_meals', { + id: uuid('id').primaryKey().defaultRandom(), + userId: uuid('user_id').notNull(), + name: varchar('name', { length: 255 }).notNull(), + description: text('description').notNull(), + mealType: varchar('meal_type', { length: 20 }).notNull(), + nutrition: jsonb('nutrition').notNull(), // MealNutrition object + usageCount: integer('usage_count').notNull().default(0), + createdAt: timestamp('created_at').defaultNow().notNull(), + updatedAt: timestamp('updated_at').defaultNow().notNull(), +}); + +// Recommendations +export const recommendations = pgTable('recommendations', { + id: uuid('id').primaryKey().defaultRandom(), + userId: uuid('user_id').notNull(), + date: timestamp('date').notNull(), + type: varchar('type', { length: 20 }).notNull(), // hint, coaching + priority: varchar('priority', { length: 20 }).notNull().default('medium'), // low, medium, high + message: text('message').notNull(), + nutrient: varchar('nutrient', { length: 50 }), // e.g., 'protein', 'vitaminC' + actionable: text('actionable'), // e.g., "Add more leafy greens" + dismissed: boolean('dismissed').notNull().default(false), + createdAt: timestamp('created_at').defaultNow().notNull(), +}); + +// Export types +export type UserGoals = typeof userGoals.$inferSelect; +export type NewUserGoals = typeof userGoals.$inferInsert; + +export type Meal = typeof meals.$inferSelect; +export type NewMeal = typeof meals.$inferInsert; + +export type MealNutrition = typeof mealNutrition.$inferSelect; +export type NewMealNutrition = typeof mealNutrition.$inferInsert; + +export type FavoriteMeal = typeof favoriteMeals.$inferSelect; +export type NewFavoriteMeal = typeof favoriteMeals.$inferInsert; + +export type Recommendation = typeof recommendations.$inferSelect; +export type NewRecommendation = typeof recommendations.$inferInsert; diff --git a/apps/nutriphi/apps/backend/src/favorites/favorites.controller.ts b/apps/nutriphi/apps/backend/src/favorites/favorites.controller.ts new file mode 100644 index 000000000..5b82e81e0 --- /dev/null +++ b/apps/nutriphi/apps/backend/src/favorites/favorites.controller.ts @@ -0,0 +1,63 @@ +import { Controller, Get, Post, Delete, Patch, Body, Param, UseGuards } from '@nestjs/common'; +import { JwtAuthGuard, CurrentUser, type CurrentUserData } from '@manacore/shared-nestjs-auth'; +import { FavoritesService } from './favorites.service'; +import { IsString, IsOptional, IsObject, IsEnum } from 'class-validator'; + +class CreateFavoriteDto { + @IsString() + name!: string; + + @IsString() + description!: string; + + @IsEnum(['breakfast', 'lunch', 'dinner', 'snack']) + mealType!: 'breakfast' | 'lunch' | 'dinner' | 'snack'; + + @IsObject() + nutrition!: Record; +} + +class UpdateFavoriteDto { + @IsOptional() + @IsString() + name?: string; + + @IsOptional() + @IsString() + description?: string; +} + +@Controller('favorites') +@UseGuards(JwtAuthGuard) +export class FavoritesController { + constructor(private readonly favoritesService: FavoritesService) {} + + @Get() + async findAll(@CurrentUser() user: CurrentUserData) { + return this.favoritesService.findAll(user.userId); + } + + @Post() + async create(@CurrentUser() user: CurrentUserData, @Body() dto: CreateFavoriteDto) { + return this.favoritesService.create(user.userId, dto); + } + + @Post(':id/use') + async use(@CurrentUser() user: CurrentUserData, @Param('id') id: string) { + return this.favoritesService.incrementUsage(user.userId, id); + } + + @Patch(':id') + async update( + @CurrentUser() user: CurrentUserData, + @Param('id') id: string, + @Body() dto: UpdateFavoriteDto + ) { + return this.favoritesService.update(user.userId, id, dto); + } + + @Delete(':id') + async delete(@CurrentUser() user: CurrentUserData, @Param('id') id: string) { + return this.favoritesService.delete(user.userId, id); + } +} diff --git a/apps/nutriphi/apps/backend/src/favorites/favorites.module.ts b/apps/nutriphi/apps/backend/src/favorites/favorites.module.ts new file mode 100644 index 000000000..7e8ad52dd --- /dev/null +++ b/apps/nutriphi/apps/backend/src/favorites/favorites.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; +import { FavoritesController } from './favorites.controller'; +import { FavoritesService } from './favorites.service'; + +@Module({ + controllers: [FavoritesController], + providers: [FavoritesService], + exports: [FavoritesService], +}) +export class FavoritesModule {} diff --git a/apps/nutriphi/apps/backend/src/favorites/favorites.service.ts b/apps/nutriphi/apps/backend/src/favorites/favorites.service.ts new file mode 100644 index 000000000..60951819b --- /dev/null +++ b/apps/nutriphi/apps/backend/src/favorites/favorites.service.ts @@ -0,0 +1,61 @@ +import { Injectable, Inject } from '@nestjs/common'; +import { DATABASE_CONNECTION } from '../db/database.module'; +import type { Database } from '../db/db'; +import { favoriteMeals, type NewFavoriteMeal } from '../db/schema'; +import { eq, and, desc } from 'drizzle-orm'; + +@Injectable() +export class FavoritesService { + constructor(@Inject(DATABASE_CONNECTION) private db: Database) {} + + async findAll(userId: string) { + return this.db + .select() + .from(favoriteMeals) + .where(eq(favoriteMeals.userId, userId)) + .orderBy(desc(favoriteMeals.usageCount)); + } + + async create(userId: string, data: Omit) { + const [favorite] = await this.db + .insert(favoriteMeals) + .values({ ...data, userId, usageCount: 0 }) + .returning(); + return favorite; + } + + async incrementUsage(userId: string, favoriteId: string) { + const [favorite] = await this.db + .select() + .from(favoriteMeals) + .where(and(eq(favoriteMeals.id, favoriteId), eq(favoriteMeals.userId, userId))) + .limit(1); + + if (!favorite) return null; + + const [updated] = await this.db + .update(favoriteMeals) + .set({ usageCount: favorite.usageCount + 1, updatedAt: new Date() }) + .where(eq(favoriteMeals.id, favoriteId)) + .returning(); + + return updated; + } + + async delete(userId: string, favoriteId: string) { + const [deleted] = await this.db + .delete(favoriteMeals) + .where(and(eq(favoriteMeals.id, favoriteId), eq(favoriteMeals.userId, userId))) + .returning(); + return deleted; + } + + async update(userId: string, favoriteId: string, data: Partial) { + const [updated] = await this.db + .update(favoriteMeals) + .set({ ...data, updatedAt: new Date() }) + .where(and(eq(favoriteMeals.id, favoriteId), eq(favoriteMeals.userId, userId))) + .returning(); + return updated; + } +} diff --git a/apps/nutriphi/apps/backend/src/goals/goals.controller.ts b/apps/nutriphi/apps/backend/src/goals/goals.controller.ts new file mode 100644 index 000000000..fbe4a0500 --- /dev/null +++ b/apps/nutriphi/apps/backend/src/goals/goals.controller.ts @@ -0,0 +1,51 @@ +import { Controller, Get, Post, Delete, Body, UseGuards } from '@nestjs/common'; +import { JwtAuthGuard, CurrentUser, type CurrentUserData } from '@manacore/shared-nestjs-auth'; +import { GoalsService } from './goals.service'; +import { IsNumber, IsOptional, Min } from 'class-validator'; + +class SetGoalsDto { + @IsNumber() + @Min(500) + dailyCalories!: number; + + @IsOptional() + @IsNumber() + @Min(0) + dailyProtein?: number; + + @IsOptional() + @IsNumber() + @Min(0) + dailyCarbs?: number; + + @IsOptional() + @IsNumber() + @Min(0) + dailyFat?: number; + + @IsOptional() + @IsNumber() + @Min(0) + dailyFiber?: number; +} + +@Controller('goals') +@UseGuards(JwtAuthGuard) +export class GoalsController { + constructor(private readonly goalsService: GoalsService) {} + + @Get() + async getGoals(@CurrentUser() user: CurrentUserData) { + return this.goalsService.getGoals(user.userId); + } + + @Post() + async setGoals(@CurrentUser() user: CurrentUserData, @Body() dto: SetGoalsDto) { + return this.goalsService.createOrUpdate(user.userId, dto); + } + + @Delete() + async deleteGoals(@CurrentUser() user: CurrentUserData) { + return this.goalsService.delete(user.userId); + } +} diff --git a/apps/nutriphi/apps/backend/src/goals/goals.module.ts b/apps/nutriphi/apps/backend/src/goals/goals.module.ts new file mode 100644 index 000000000..bd9e5ff90 --- /dev/null +++ b/apps/nutriphi/apps/backend/src/goals/goals.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; +import { GoalsController } from './goals.controller'; +import { GoalsService } from './goals.service'; + +@Module({ + controllers: [GoalsController], + providers: [GoalsService], + exports: [GoalsService], +}) +export class GoalsModule {} diff --git a/apps/nutriphi/apps/backend/src/goals/goals.service.ts b/apps/nutriphi/apps/backend/src/goals/goals.service.ts new file mode 100644 index 000000000..c36284c5b --- /dev/null +++ b/apps/nutriphi/apps/backend/src/goals/goals.service.ts @@ -0,0 +1,47 @@ +import { Injectable, Inject } from '@nestjs/common'; +import { DATABASE_CONNECTION } from '../db/database.module'; +import type { Database } from '../db/db'; +import { userGoals, type NewUserGoals } from '../db/schema'; +import { eq } from 'drizzle-orm'; + +@Injectable() +export class GoalsService { + constructor(@Inject(DATABASE_CONNECTION) private db: Database) {} + + async getGoals(userId: string) { + const [goals] = await this.db + .select() + .from(userGoals) + .where(eq(userGoals.userId, userId)) + .limit(1); + + return goals || null; + } + + async createOrUpdate(userId: string, data: Omit) { + const existing = await this.getGoals(userId); + + if (existing) { + const [updated] = await this.db + .update(userGoals) + .set({ ...data, updatedAt: new Date() }) + .where(eq(userGoals.userId, userId)) + .returning(); + return updated; + } + + const [created] = await this.db + .insert(userGoals) + .values({ ...data, userId }) + .returning(); + return created; + } + + async delete(userId: string) { + const [deleted] = await this.db + .delete(userGoals) + .where(eq(userGoals.userId, userId)) + .returning(); + return deleted; + } +} diff --git a/apps/nutriphi/apps/backend/src/health/health.controller.ts b/apps/nutriphi/apps/backend/src/health/health.controller.ts new file mode 100644 index 000000000..27f7581b7 --- /dev/null +++ b/apps/nutriphi/apps/backend/src/health/health.controller.ts @@ -0,0 +1,13 @@ +import { Controller, Get } from '@nestjs/common'; + +@Controller('health') +export class HealthController { + @Get() + check() { + return { + status: 'ok', + service: 'nutriphi-backend', + timestamp: new Date().toISOString(), + }; + } +} diff --git a/apps/nutriphi/apps/backend/src/health/health.module.ts b/apps/nutriphi/apps/backend/src/health/health.module.ts new file mode 100644 index 000000000..a61d8b044 --- /dev/null +++ b/apps/nutriphi/apps/backend/src/health/health.module.ts @@ -0,0 +1,7 @@ +import { Module } from '@nestjs/common'; +import { HealthController } from './health.controller'; + +@Module({ + controllers: [HealthController], +}) +export class HealthModule {} diff --git a/apps/nutriphi/apps/backend/src/main.ts b/apps/nutriphi/apps/backend/src/main.ts new file mode 100644 index 000000000..bfe7a9140 --- /dev/null +++ b/apps/nutriphi/apps/backend/src/main.ts @@ -0,0 +1,35 @@ +import { NestFactory } from '@nestjs/core'; +import { ValidationPipe } from '@nestjs/common'; +import { AppModule } from './app.module'; + +async function bootstrap() { + const app = await NestFactory.create(AppModule); + + // Enable CORS + app.enableCors({ + origin: process.env.CORS_ORIGINS?.split(',') || [ + 'http://localhost:5180', + 'http://localhost:4323', + 'http://localhost:3001', + ], + credentials: true, + }); + + // Global validation pipe + app.useGlobalPipes( + new ValidationPipe({ + whitelist: true, + transform: true, + forbidNonWhitelisted: true, + }) + ); + + // Global prefix + app.setGlobalPrefix('api/v1'); + + const port = process.env.PORT || 3023; + await app.listen(port); + console.log(`NutriPhi Backend running on http://localhost:${port}`); +} + +bootstrap(); diff --git a/apps/nutriphi/apps/backend/src/meal/meal.controller.ts b/apps/nutriphi/apps/backend/src/meal/meal.controller.ts new file mode 100644 index 000000000..fb97271d9 --- /dev/null +++ b/apps/nutriphi/apps/backend/src/meal/meal.controller.ts @@ -0,0 +1,144 @@ +import { + Controller, + Get, + Post, + Delete, + Patch, + Body, + Param, + Query, + UseGuards, +} from '@nestjs/common'; +import { JwtAuthGuard, CurrentUser, type CurrentUserData } from '@manacore/shared-nestjs-auth'; +import { MealService } from './meal.service'; +import { IsString, IsOptional, IsDateString, IsNumber, IsEnum } from 'class-validator'; + +class CreateMealDto { + @IsDateString() + date!: string; + + @IsEnum(['breakfast', 'lunch', 'dinner', 'snack']) + mealType!: 'breakfast' | 'lunch' | 'dinner' | 'snack'; + + @IsEnum(['photo', 'text']) + inputType!: 'photo' | 'text'; + + @IsString() + description!: string; + + @IsOptional() + @IsString() + portionSize?: string; + + @IsNumber() + confidence!: number; + + // Nutrition data + @IsNumber() + calories!: number; + + @IsNumber() + protein!: number; + + @IsNumber() + carbohydrates!: number; + + @IsNumber() + fat!: number; + + @IsOptional() + @IsNumber() + fiber?: number; + + @IsOptional() + @IsNumber() + sugar?: number; +} + +class QueryMealsDto { + @IsOptional() + @IsDateString() + date?: string; + + @IsOptional() + @IsDateString() + startDate?: string; + + @IsOptional() + @IsDateString() + endDate?: string; +} + +@Controller('meals') +@UseGuards(JwtAuthGuard) +export class MealController { + constructor(private readonly mealService: MealService) {} + + @Post() + async create(@CurrentUser() user: CurrentUserData, @Body() dto: CreateMealDto) { + const { date, mealType, inputType, description, portionSize, confidence, ...nutrition } = dto; + + return this.mealService.create( + user.userId, + { + date: new Date(date), + mealType, + inputType, + description, + portionSize, + confidence, + userId: user.userId, + }, + nutrition + ); + } + + @Get() + async findAll(@CurrentUser() user: CurrentUserData, @Query() query: QueryMealsDto) { + if (query.date) { + return this.mealService.findByDate(user.userId, new Date(query.date)); + } + + const startDate = query.startDate ? new Date(query.startDate) : new Date(); + const endDate = query.endDate ? new Date(query.endDate) : new Date(); + + if (!query.startDate) { + startDate.setDate(startDate.getDate() - 7); + } + + return this.mealService.findByDateRange(user.userId, startDate, endDate); + } + + @Get(':id') + async findOne(@CurrentUser() user: CurrentUserData, @Param('id') id: string) { + return this.mealService.findOne(user.userId, id); + } + + @Delete(':id') + async delete(@CurrentUser() user: CurrentUserData, @Param('id') id: string) { + return this.mealService.delete(user.userId, id); + } + + @Patch(':id') + async update( + @CurrentUser() user: CurrentUserData, + @Param('id') id: string, + @Body() dto: Partial + ) { + const { date, mealType, inputType, description, portionSize, confidence, ...nutrition } = dto; + + return this.mealService.update( + user.userId, + id, + { + ...(date && { date: new Date(date) }), + ...(mealType && { mealType }), + ...(inputType && { inputType }), + ...(description && { description }), + ...(portionSize && { portionSize }), + ...(confidence !== undefined && { confidence }), + }, + Object.keys(nutrition).length > 0 ? nutrition : undefined + ); + } +} diff --git a/apps/nutriphi/apps/backend/src/meal/meal.module.ts b/apps/nutriphi/apps/backend/src/meal/meal.module.ts new file mode 100644 index 000000000..cad942e3d --- /dev/null +++ b/apps/nutriphi/apps/backend/src/meal/meal.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; +import { MealController } from './meal.controller'; +import { MealService } from './meal.service'; + +@Module({ + controllers: [MealController], + providers: [MealService], + exports: [MealService], +}) +export class MealModule {} diff --git a/apps/nutriphi/apps/backend/src/meal/meal.service.ts b/apps/nutriphi/apps/backend/src/meal/meal.service.ts new file mode 100644 index 000000000..7b2397f1d --- /dev/null +++ b/apps/nutriphi/apps/backend/src/meal/meal.service.ts @@ -0,0 +1,102 @@ +import { Injectable, Inject } from '@nestjs/common'; +import { DATABASE_CONNECTION } from '../db/database.module'; +import type { Database } from '../db/db'; +import { meals, mealNutrition, type NewMeal, type NewMealNutrition } from '../db/schema'; +import { eq, and, gte, lte, desc } from 'drizzle-orm'; + +@Injectable() +export class MealService { + constructor(@Inject(DATABASE_CONNECTION) private db: Database) {} + + async create(userId: string, data: NewMeal, nutrition: Omit) { + const [meal] = await this.db + .insert(meals) + .values({ ...data, userId }) + .returning(); + + const [nutritionData] = await this.db + .insert(mealNutrition) + .values({ ...nutrition, mealId: meal.id }) + .returning(); + + return { ...meal, nutrition: nutritionData }; + } + + async findByDate(userId: string, date: Date) { + const startOfDay = new Date(date); + startOfDay.setHours(0, 0, 0, 0); + + const endOfDay = new Date(date); + endOfDay.setHours(23, 59, 59, 999); + + const result = await this.db + .select() + .from(meals) + .leftJoin(mealNutrition, eq(meals.id, mealNutrition.mealId)) + .where(and(eq(meals.userId, userId), gte(meals.date, startOfDay), lte(meals.date, endOfDay))) + .orderBy(meals.date); + + return result.map((row) => ({ + ...row.meals, + nutrition: row.meal_nutrition, + })); + } + + async findByDateRange(userId: string, startDate: Date, endDate: Date) { + const result = await this.db + .select() + .from(meals) + .leftJoin(mealNutrition, eq(meals.id, mealNutrition.mealId)) + .where(and(eq(meals.userId, userId), gte(meals.date, startDate), lte(meals.date, endDate))) + .orderBy(desc(meals.date)); + + return result.map((row) => ({ + ...row.meals, + nutrition: row.meal_nutrition, + })); + } + + async findOne(userId: string, mealId: string) { + const result = await this.db + .select() + .from(meals) + .leftJoin(mealNutrition, eq(meals.id, mealNutrition.mealId)) + .where(and(eq(meals.id, mealId), eq(meals.userId, userId))) + .limit(1); + + if (result.length === 0) return null; + + return { + ...result[0].meals, + nutrition: result[0].meal_nutrition, + }; + } + + async delete(userId: string, mealId: string) { + const [deleted] = await this.db + .delete(meals) + .where(and(eq(meals.id, mealId), eq(meals.userId, userId))) + .returning(); + + return deleted; + } + + async update( + userId: string, + mealId: string, + data: Partial, + nutrition?: Partial + ) { + const [meal] = await this.db + .update(meals) + .set(data) + .where(and(eq(meals.id, mealId), eq(meals.userId, userId))) + .returning(); + + if (nutrition) { + await this.db.update(mealNutrition).set(nutrition).where(eq(mealNutrition.mealId, mealId)); + } + + return this.findOne(userId, mealId); + } +} diff --git a/apps/nutriphi/apps/backend/src/recommendations/recommendations.controller.ts b/apps/nutriphi/apps/backend/src/recommendations/recommendations.controller.ts new file mode 100644 index 000000000..a43070082 --- /dev/null +++ b/apps/nutriphi/apps/backend/src/recommendations/recommendations.controller.ts @@ -0,0 +1,27 @@ +import { Controller, Get, Post, Param, Query, UseGuards } from '@nestjs/common'; +import { JwtAuthGuard, CurrentUser, type CurrentUserData } from '@manacore/shared-nestjs-auth'; +import { RecommendationsService } from './recommendations.service'; +import { IsOptional, IsDateString } from 'class-validator'; + +class QueryDto { + @IsOptional() + @IsDateString() + date?: string; +} + +@Controller('recommendations') +@UseGuards(JwtAuthGuard) +export class RecommendationsController { + constructor(private readonly recommendationsService: RecommendationsService) {} + + @Get() + async findAll(@CurrentUser() user: CurrentUserData, @Query() query: QueryDto) { + const date = query.date ? new Date(query.date) : new Date(); + return this.recommendationsService.findByDate(user.userId, date); + } + + @Post(':id/dismiss') + async dismiss(@CurrentUser() user: CurrentUserData, @Param('id') id: string) { + return this.recommendationsService.dismiss(user.userId, id); + } +} diff --git a/apps/nutriphi/apps/backend/src/recommendations/recommendations.module.ts b/apps/nutriphi/apps/backend/src/recommendations/recommendations.module.ts new file mode 100644 index 000000000..e3007aacd --- /dev/null +++ b/apps/nutriphi/apps/backend/src/recommendations/recommendations.module.ts @@ -0,0 +1,9 @@ +import { Module } from '@nestjs/common'; +import { RecommendationsController } from './recommendations.controller'; +import { RecommendationsService } from './recommendations.service'; + +@Module({ + controllers: [RecommendationsController], + providers: [RecommendationsService], +}) +export class RecommendationsModule {} diff --git a/apps/nutriphi/apps/backend/src/recommendations/recommendations.service.ts b/apps/nutriphi/apps/backend/src/recommendations/recommendations.service.ts new file mode 100644 index 000000000..e61368eb1 --- /dev/null +++ b/apps/nutriphi/apps/backend/src/recommendations/recommendations.service.ts @@ -0,0 +1,90 @@ +import { Injectable, Inject } from '@nestjs/common'; +import { DATABASE_CONNECTION } from '../db/database.module'; +import type { Database } from '../db/db'; +import { recommendations, type NewRecommendation } from '../db/schema'; +import { eq, and, desc } from 'drizzle-orm'; + +@Injectable() +export class RecommendationsService { + constructor(@Inject(DATABASE_CONNECTION) private db: Database) {} + + async findByDate(userId: string, date: Date) { + const startOfDay = new Date(date); + startOfDay.setHours(0, 0, 0, 0); + + const endOfDay = new Date(date); + endOfDay.setHours(23, 59, 59, 999); + + return this.db + .select() + .from(recommendations) + .where(and(eq(recommendations.userId, userId), eq(recommendations.dismissed, false))) + .orderBy(desc(recommendations.createdAt)) + .limit(10); + } + + async create(userId: string, data: Omit) { + const [recommendation] = await this.db + .insert(recommendations) + .values({ ...data, userId, dismissed: false }) + .returning(); + return recommendation; + } + + async dismiss(userId: string, recommendationId: string) { + const [dismissed] = await this.db + .update(recommendations) + .set({ dismissed: true }) + .where(and(eq(recommendations.id, recommendationId), eq(recommendations.userId, userId))) + .returning(); + return dismissed; + } + + async generateHints(userId: string, nutritionSummary: Record) { + const hints: Array> = []; + + // Check for low protein + if (nutritionSummary.protein && nutritionSummary.protein < 25) { + hints.push({ + date: new Date(), + type: 'hint', + priority: 'medium', + message: + 'Deine Proteinaufnahme ist heute niedrig. Versuche, mehr proteinreiche Lebensmittel einzubauen.', + nutrient: 'protein', + actionable: 'Füge Hühnchen, Fisch, Eier oder Hülsenfrüchte hinzu', + }); + } + + // Check for low fiber + if (nutritionSummary.fiber && nutritionSummary.fiber < 10) { + hints.push({ + date: new Date(), + type: 'hint', + priority: 'low', + message: 'Du könntest mehr Ballaststoffe zu dir nehmen.', + nutrient: 'fiber', + actionable: 'Vollkornprodukte, Obst und Gemüse sind gute Quellen', + }); + } + + // Check for high sugar + if (nutritionSummary.sugar && nutritionSummary.sugar > 50) { + hints.push({ + date: new Date(), + type: 'hint', + priority: 'high', + message: 'Deine Zuckeraufnahme ist heute hoch.', + nutrient: 'sugar', + actionable: 'Reduziere Süßigkeiten und zuckerhaltige Getränke', + }); + } + + // Save hints + for (const hint of hints) { + await this.create(userId, hint); + } + + return hints; + } +} diff --git a/apps/nutriphi/apps/backend/src/stats/stats.controller.ts b/apps/nutriphi/apps/backend/src/stats/stats.controller.ts new file mode 100644 index 000000000..d5b6dab36 --- /dev/null +++ b/apps/nutriphi/apps/backend/src/stats/stats.controller.ts @@ -0,0 +1,28 @@ +import { Controller, Get, Query, UseGuards } from '@nestjs/common'; +import { JwtAuthGuard, CurrentUser, type CurrentUserData } from '@manacore/shared-nestjs-auth'; +import { StatsService } from './stats.service'; +import { IsOptional, IsDateString } from 'class-validator'; + +class StatsQueryDto { + @IsOptional() + @IsDateString() + date?: string; +} + +@Controller('stats') +@UseGuards(JwtAuthGuard) +export class StatsController { + constructor(private readonly statsService: StatsService) {} + + @Get('daily') + async getDailySummary(@CurrentUser() user: CurrentUserData, @Query() query: StatsQueryDto) { + const date = query.date ? new Date(query.date) : new Date(); + return this.statsService.getDailySummary(user.userId, date); + } + + @Get('weekly') + async getWeeklyStats(@CurrentUser() user: CurrentUserData, @Query() query: StatsQueryDto) { + const endDate = query.date ? new Date(query.date) : new Date(); + return this.statsService.getWeeklyStats(user.userId, endDate); + } +} diff --git a/apps/nutriphi/apps/backend/src/stats/stats.module.ts b/apps/nutriphi/apps/backend/src/stats/stats.module.ts new file mode 100644 index 000000000..373259ba1 --- /dev/null +++ b/apps/nutriphi/apps/backend/src/stats/stats.module.ts @@ -0,0 +1,12 @@ +import { Module } from '@nestjs/common'; +import { StatsController } from './stats.controller'; +import { StatsService } from './stats.service'; +import { MealModule } from '../meal/meal.module'; +import { GoalsModule } from '../goals/goals.module'; + +@Module({ + imports: [MealModule, GoalsModule], + controllers: [StatsController], + providers: [StatsService], +}) +export class StatsModule {} diff --git a/apps/nutriphi/apps/backend/src/stats/stats.service.ts b/apps/nutriphi/apps/backend/src/stats/stats.service.ts new file mode 100644 index 000000000..c8c8632da --- /dev/null +++ b/apps/nutriphi/apps/backend/src/stats/stats.service.ts @@ -0,0 +1,131 @@ +import { Injectable } from '@nestjs/common'; +import { MealService } from '../meal/meal.service'; +import { GoalsService } from '../goals/goals.service'; +import { calculateProgress, sumNutrition } from '../utils/nutrition.utils'; +import type { DailySummary, WeeklyStats, DailyStats } from '../types/nutrition.types'; + +@Injectable() +export class StatsService { + constructor( + private mealService: MealService, + private goalsService: GoalsService + ) {} + + async getDailySummary(userId: string, date: Date): Promise { + const meals = await this.mealService.findByDate(userId, date); + const goals = await this.goalsService.getGoals(userId); + + const totalNutrition = sumNutrition(meals); + const progress = calculateProgress(totalNutrition, goals || undefined); + + return { + date, + meals: meals as any, + totalNutrition: totalNutrition as any, + goals: goals || undefined, + progress, + }; + } + + async getWeeklyStats(userId: string, endDate: Date = new Date()): Promise { + const startDate = new Date(endDate); + startDate.setDate(startDate.getDate() - 6); + startDate.setHours(0, 0, 0, 0); + + const endOfDay = new Date(endDate); + endOfDay.setHours(23, 59, 59, 999); + + const meals = await this.mealService.findByDateRange(userId, startDate, endOfDay); + const goals = await this.goalsService.getGoals(userId); + + // Group meals by date + const mealsByDate = new Map(); + for (const meal of meals) { + const dateKey = new Date(meal.date).toISOString().split('T')[0]; + if (!mealsByDate.has(dateKey)) { + mealsByDate.set(dateKey, []); + } + mealsByDate.get(dateKey)!.push(meal); + } + + // Calculate daily stats + const days: DailyStats[] = []; + let totalCalories = 0; + let totalProtein = 0; + let totalCarbs = 0; + let totalFat = 0; + let daysWithData = 0; + + for (let i = 0; i < 7; i++) { + const date = new Date(startDate); + date.setDate(date.getDate() + i); + const dateKey = date.toISOString().split('T')[0]; + const dayMeals = mealsByDate.get(dateKey) || []; + + const nutrition = sumNutrition(dayMeals); + const dayCalories = nutrition.calories || 0; + const dayProtein = nutrition.protein || 0; + const dayCarbs = nutrition.carbohydrates || 0; + const dayFat = nutrition.fat || 0; + + if (dayMeals.length > 0) { + daysWithData++; + totalCalories += dayCalories; + totalProtein += dayProtein; + totalCarbs += dayCarbs; + totalFat += dayFat; + } + + const goalsMet = goals + ? dayCalories >= goals.dailyCalories * 0.9 && dayCalories <= goals.dailyCalories * 1.1 + : false; + + days.push({ + date, + totalCalories: dayCalories, + totalProtein: dayProtein, + totalCarbs: dayCarbs, + totalFat: dayFat, + mealCount: dayMeals.length, + goalsMet, + }); + } + + // Calculate averages + const divisor = daysWithData || 1; + const averages = { + calories: Math.round(totalCalories / divisor), + protein: Math.round(totalProtein / divisor), + carbs: Math.round(totalCarbs / divisor), + fat: Math.round(totalFat / divisor), + }; + + // Calculate trends (comparing last 3 days to first 3 days) + const firstHalf = days.slice(0, 3); + const secondHalf = days.slice(4); + + const firstCalories = firstHalf.reduce((sum, d) => sum + d.totalCalories, 0) / 3; + const secondCalories = secondHalf.reduce((sum, d) => sum + d.totalCalories, 0) / 3; + const firstProtein = firstHalf.reduce((sum, d) => sum + d.totalProtein, 0) / 3; + const secondProtein = secondHalf.reduce((sum, d) => sum + d.totalProtein, 0) / 3; + + const getTrend = (first: number, second: number): 'up' | 'down' | 'stable' => { + const diff = second - first; + const threshold = first * 0.1; + if (diff > threshold) return 'up'; + if (diff < -threshold) return 'down'; + return 'stable'; + }; + + return { + startDate, + endDate: endOfDay, + days, + averages, + trends: { + caloriesTrend: getTrend(firstCalories, secondCalories), + proteinTrend: getTrend(firstProtein, secondProtein), + }, + }; + } +} diff --git a/apps/nutriphi/apps/backend/src/types/nutrition.types.ts b/apps/nutriphi/apps/backend/src/types/nutrition.types.ts new file mode 100644 index 000000000..aae4e088f --- /dev/null +++ b/apps/nutriphi/apps/backend/src/types/nutrition.types.ts @@ -0,0 +1,142 @@ +// User Goals +export interface UserGoals { + id: string; + userId: string; + dailyCalories: number; + dailyProtein?: number | null; + dailyCarbs?: number | null; + dailyFat?: number | null; + dailyFiber?: number | null; + createdAt: Date; + updatedAt: Date; +} + +// Meal Types +export type MealType = 'breakfast' | 'lunch' | 'dinner' | 'snack'; +export type InputType = 'photo' | 'text'; + +// Meal +export interface Meal { + id: string; + userId: string; + date: Date; + mealType: MealType; + inputType: InputType; + description: string; + portionSize?: string; + confidence: number; + createdAt: Date; +} + +// Nutrition Data +export interface MealNutrition { + id: string; + mealId: string; + calories: number; + protein: number; + carbohydrates: number; + fat: number; + fiber: number; + sugar: number; + saturatedFat?: number | null; + unsaturatedFat?: number | null; + vitaminA?: number | null; + vitaminB1?: number | null; + vitaminB2?: number | null; + vitaminB3?: number | null; + vitaminB5?: number | null; + vitaminB6?: number | null; + vitaminB7?: number | null; + vitaminB9?: number | null; + vitaminB12?: number | null; + vitaminC?: number | null; + vitaminD?: number | null; + vitaminE?: number | null; + vitaminK?: number | null; + calcium?: number | null; + iron?: number | null; + magnesium?: number | null; + phosphorus?: number | null; + potassium?: number | null; + sodium?: number | null; + zinc?: number | null; + copper?: number | null; + manganese?: number | null; + selenium?: number | null; + water?: number | null; +} + +// Daily Summary +export interface DailySummary { + date: Date; + meals: Meal[]; + totalNutrition: Omit; + goals?: UserGoals; + progress: NutritionProgress; +} + +export interface NutritionProgress { + calories: { current: number; target: number; percentage: number }; + protein?: { current: number; target: number; percentage: number }; + carbs?: { current: number; target: number; percentage: number }; + fat?: { current: number; target: number; percentage: number }; +} + +// Weekly Stats +export interface WeeklyStats { + startDate: Date; + endDate: Date; + days: DailyStats[]; + averages: { + calories: number; + protein: number; + carbs: number; + fat: number; + }; + trends: { + caloriesTrend: 'up' | 'down' | 'stable'; + proteinTrend: 'up' | 'down' | 'stable'; + }; +} + +export interface DailyStats { + date: Date; + totalCalories: number; + totalProtein: number; + totalCarbs: number; + totalFat: number; + mealCount: number; + goalsMet: boolean; +} + +// AI Analysis +export interface AIAnalysisResult { + foods: DetectedFood[]; + totalNutrition: Omit; + description: string; + confidence: number; + warnings?: string[]; + suggestions?: string[]; +} + +export interface DetectedFood { + name: string; + quantity: string; + calories: number; + confidence: number; + source?: 'usda' | 'openfoodfacts' | 'ai_estimate'; +} + +// Default daily values +export const DEFAULT_DAILY_VALUES = { + calories: 2000, + protein: 50, + carbohydrates: 300, + fat: 65, + fiber: 25, + sugar: 50, + vitaminC: 90, + vitaminD: 20, + iron: 18, + calcium: 1000, +}; diff --git a/apps/nutriphi/apps/backend/src/utils/nutrition.utils.ts b/apps/nutriphi/apps/backend/src/utils/nutrition.utils.ts new file mode 100644 index 000000000..baff66374 --- /dev/null +++ b/apps/nutriphi/apps/backend/src/utils/nutrition.utils.ts @@ -0,0 +1,73 @@ +import type { MealNutrition, NutritionProgress, UserGoals } from '../types/nutrition.types'; +import { DEFAULT_DAILY_VALUES } from '../types/nutrition.types'; + +/** + * Calculate nutrition progress towards daily goals + */ +export function calculateProgress( + totalNutrition: Partial, + goals?: UserGoals +): NutritionProgress { + const targetCalories = goals?.dailyCalories ?? DEFAULT_DAILY_VALUES.calories; + const targetProtein = goals?.dailyProtein ?? DEFAULT_DAILY_VALUES.protein; + const targetCarbs = goals?.dailyCarbs ?? DEFAULT_DAILY_VALUES.carbohydrates; + const targetFat = goals?.dailyFat ?? DEFAULT_DAILY_VALUES.fat; + + return { + calories: { + current: totalNutrition.calories ?? 0, + target: targetCalories, + percentage: Math.min( + 100, + Math.round(((totalNutrition.calories ?? 0) / targetCalories) * 100) + ), + }, + protein: { + current: totalNutrition.protein ?? 0, + target: targetProtein, + percentage: Math.min(100, Math.round(((totalNutrition.protein ?? 0) / targetProtein) * 100)), + }, + carbs: { + current: totalNutrition.carbohydrates ?? 0, + target: targetCarbs, + percentage: Math.min( + 100, + Math.round(((totalNutrition.carbohydrates ?? 0) / targetCarbs) * 100) + ), + }, + fat: { + current: totalNutrition.fat ?? 0, + target: targetFat, + percentage: Math.min(100, Math.round(((totalNutrition.fat ?? 0) / targetFat) * 100)), + }, + }; +} + +/** + * Sum up nutrition from multiple meals + */ +export function sumNutrition( + meals: Array<{ nutrition?: Partial | null }> +): Partial { + const sum = { + calories: 0, + protein: 0, + carbohydrates: 0, + fat: 0, + fiber: 0, + sugar: 0, + }; + + for (const meal of meals) { + if (!meal.nutrition) continue; + const n = meal.nutrition; + if (typeof n.calories === 'number') sum.calories += n.calories; + if (typeof n.protein === 'number') sum.protein += n.protein; + if (typeof n.carbohydrates === 'number') sum.carbohydrates += n.carbohydrates; + if (typeof n.fat === 'number') sum.fat += n.fat; + if (typeof n.fiber === 'number') sum.fiber += n.fiber; + if (typeof n.sugar === 'number') sum.sugar += n.sugar; + } + + return sum; +} diff --git a/apps/nutriphi/apps/backend/tsconfig.json b/apps/nutriphi/apps/backend/tsconfig.json new file mode 100644 index 000000000..0c618fb2c --- /dev/null +++ b/apps/nutriphi/apps/backend/tsconfig.json @@ -0,0 +1,25 @@ +{ + "compilerOptions": { + "module": "commonjs", + "moduleResolution": "node", + "declaration": true, + "removeComments": true, + "emitDecoratorMetadata": true, + "experimentalDecorators": true, + "allowSyntheticDefaultImports": true, + "target": "ES2021", + "sourceMap": true, + "outDir": "./dist", + "baseUrl": "./", + "rootDir": "./src", + "incremental": true, + "skipLibCheck": true, + "strictNullChecks": true, + "noImplicitAny": true, + "strictBindCallApply": true, + "forceConsistentCasingInFileNames": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +} diff --git a/apps/nutriphi/apps/landing/astro.config.mjs b/apps/nutriphi/apps/landing/astro.config.mjs new file mode 100644 index 000000000..a1a11c889 --- /dev/null +++ b/apps/nutriphi/apps/landing/astro.config.mjs @@ -0,0 +1,7 @@ +import { defineConfig } from 'astro/config'; +import tailwind from '@astrojs/tailwind'; + +export default defineConfig({ + integrations: [tailwind()], + site: 'https://nutriphi.manacore.app', +}); diff --git a/apps/nutriphi/apps/landing/package.json b/apps/nutriphi/apps/landing/package.json new file mode 100644 index 000000000..52873a203 --- /dev/null +++ b/apps/nutriphi/apps/landing/package.json @@ -0,0 +1,34 @@ +{ + "name": "@nutriphi/landing", + "version": "1.0.0", + "private": true, + "type": "module", + "scripts": { + "dev": "astro dev --port 4323", + "start": "astro dev", + "build": "astro check && astro build", + "preview": "astro preview", + "astro": "astro", + "type-check": "astro check", + "format": "prettier --write .", + "clean": "rm -rf dist .astro node_modules" + }, + "dependencies": { + "@astrojs/check": "^0.9.0", + "@manacore/shared-landing-ui": "workspace:*", + "astro": "^5.16.0", + "typescript": "^5.9.2" + }, + "devDependencies": { + "@astrojs/tailwind": "^6.0.2", + "@tailwindcss/typography": "^0.5.18", + "@types/node": "^20.0.0", + "eslint": "^9.0.0", + "eslint-config-prettier": "^9.1.0", + "eslint-plugin-astro": "^1.0.0", + "prettier": "^3.6.2", + "prettier-plugin-astro": "^0.14.1", + "prettier-plugin-tailwindcss": "^0.6.14", + "tailwindcss": "^3.4.0" + } +} diff --git a/apps/nutriphi/apps/landing/src/layouts/Layout.astro b/apps/nutriphi/apps/landing/src/layouts/Layout.astro new file mode 100644 index 000000000..899189e8f --- /dev/null +++ b/apps/nutriphi/apps/landing/src/layouts/Layout.astro @@ -0,0 +1,44 @@ +--- +interface Props { + title: string; + description?: string; +} + +const { title, description = 'NutriPhi - KI-gestützte Ernährungsanalyse per Foto' } = Astro.props; +--- + + + + + + + + + + {title} + + + + + + + + + + + + + + + diff --git a/apps/nutriphi/apps/landing/src/pages/index.astro b/apps/nutriphi/apps/landing/src/pages/index.astro new file mode 100644 index 000000000..2f96ae57a --- /dev/null +++ b/apps/nutriphi/apps/landing/src/pages/index.astro @@ -0,0 +1,270 @@ +--- +import Layout from '../layouts/Layout.astro'; + +const features = [ + { + icon: '📸', + title: 'Foto-Analyse', + description: 'Mach einfach ein Foto von deinem Essen und lass die KI die Nährwerte berechnen.', + }, + { + icon: '🥗', + title: 'Vollständige Nährwerte', + description: 'Kalorien, Makros, Vitamine und Mineralstoffe auf einen Blick.', + }, + { + icon: '🎯', + title: 'Persönliche Ziele', + description: 'Setze deine Tagesziele und verfolge deinen Fortschritt in Echtzeit.', + }, + { + icon: '🤖', + title: 'KI-Coaching', + description: 'Erhalte personalisierte Empfehlungen basierend auf deinem Ernährungsverlauf.', + }, + { + icon: '⭐', + title: 'Favoriten', + description: 'Speichere häufige Mahlzeiten und füge sie mit einem Klick hinzu.', + }, + { + icon: '🔒', + title: 'Maximaler Datenschutz', + description: 'Deine Fotos werden nie gespeichert. Nur die Analyseergebnisse bleiben.', + }, +]; + +const steps = [ + { + number: '1', + title: 'Foto machen', + description: 'Fotografiere deine Mahlzeit mit der Kamera oder wähle ein bestehendes Bild.', + }, + { + number: '2', + title: 'KI analysiert', + description: 'Unsere KI erkennt die Lebensmittel und berechnet alle Nährwerte in Sekunden.', + }, + { + number: '3', + title: 'Insights erhalten', + description: 'Sieh deine Tagesbilanz, verfolge Trends und erhalte personalisierte Tipps.', + }, +]; + +const faqs = [ + { + question: 'Wie genau ist die KI-Analyse?', + answer: + 'Unsere KI erreicht eine Genauigkeit von 85-95% bei der Erkennung von Lebensmitteln. Bei komplexen Gerichten zeigen wir dir einen Konfidenz-Score an.', + }, + { + question: 'Was passiert mit meinen Fotos?', + answer: + 'Maximaler Datenschutz: Deine Fotos werden nach der Analyse sofort gelöscht und niemals auf unseren Servern gespeichert. Nur die Nährwertdaten werden gesichert.', + }, + { + question: 'Kann ich auch ohne Foto tracken?', + answer: + 'Ja! Du kannst Mahlzeiten auch per Text eingeben. Die KI schätzt dann die Nährwerte basierend auf deiner Beschreibung.', + }, + { + question: 'Funktioniert die App mit allen Gerichten?', + answer: + 'NutriPhi erkennt die meisten Gerichte weltweit, von klassischer deutscher Küche bis zu asiatischen Spezialitäten. Bei unbekannten Gerichten kannst du manuell nachhelfen.', + }, + { + question: 'Wie funktioniert das Credit-System?', + answer: + 'Jede Foto-Analyse kostet 5 Credits, Text-Analysen 2 Credits. Du erhältst täglich kostenlose Credits, oder du nutzt ManaCore Premium für unbegrenzte Analysen.', + }, + { + question: 'Gibt es eine kostenlose Version?', + answer: + 'Ja! Du kannst NutriPhi kostenlos nutzen mit täglich 3 Foto-Analysen. Für mehr Analysen und Premium-Features gibt es ManaCore Credits.', + }, +]; +--- + + + + + +
+ +
+
+ +
+ + 🔒 Datenschutz-First + + + 🤖 Powered by Gemini AI + + + ✨ Kostenlos starten + +
+ +

+ Fotografiere dein Essen. + Verstehe deinen Körper. +

+ +

+ NutriPhi analysiert deine Mahlzeiten per Foto und liefert dir sofort alle Nährwerte. Mit + KI-Coaching erreichst du deine Gesundheitsziele. +

+ + +
+
+ + +
+
+
+

Alles was du brauchst

+

+ NutriPhi macht Ernährungstracking so einfach wie ein Foto. +

+
+ +
+ { + features.map((feature) => ( +
+
{feature.icon}
+

{feature.title}

+

{feature.description}

+
+ )) + } +
+
+
+ + +
+
+
+

So einfach geht's

+

In 3 Schritten zu besserer Ernährung

+
+ +
+ { + steps.map((step, index) => ( +
+
+ {step.number} +
+
+

{step.title}

+

{step.description}

+
+
+ )) + } +
+
+
+ + +
+
+
+

Häufige Fragen

+
+ +
+ { + faqs.map((faq) => ( +
+ + {faq.question} + + +

{faq.answer}

+
+ )) + } +
+
+
+ + +
+
+

Starte jetzt mit NutriPhi

+

+ Kostenlos. Ohne Kreditkarte. Sofort loslegen. +

+ + Jetzt kostenlos registrieren + +
+
+
+ + + +
diff --git a/apps/nutriphi/apps/landing/tailwind.config.cjs b/apps/nutriphi/apps/landing/tailwind.config.cjs new file mode 100644 index 000000000..f90556646 --- /dev/null +++ b/apps/nutriphi/apps/landing/tailwind.config.cjs @@ -0,0 +1,18 @@ +/** @type {import('tailwindcss').Config} */ +module.exports = { + content: ['./src/**/*.{astro,html,js,jsx,md,mdx,svelte,ts,tsx,vue}'], + theme: { + extend: { + colors: { + primary: { + DEFAULT: '#22C55E', + hover: '#16A34A', + light: '#86EFAC', + }, + secondary: '#F97316', + accent: '#14B8A6', + }, + }, + }, + plugins: [require('@tailwindcss/typography')], +}; diff --git a/apps/nutriphi/apps/landing/tsconfig.json b/apps/nutriphi/apps/landing/tsconfig.json new file mode 100644 index 000000000..4b0f22d55 --- /dev/null +++ b/apps/nutriphi/apps/landing/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "astro/tsconfigs/strict", + "compilerOptions": { + "baseUrl": ".", + "paths": { + "@components/*": ["src/components/*"], + "@layouts/*": ["src/layouts/*"] + } + } +} diff --git a/apps/nutriphi/apps/landing/wrangler.toml b/apps/nutriphi/apps/landing/wrangler.toml new file mode 100644 index 000000000..cbaf100c2 --- /dev/null +++ b/apps/nutriphi/apps/landing/wrangler.toml @@ -0,0 +1,3 @@ +name = "nutriphi-landing" +compatibility_date = "2024-12-01" +pages_build_output_dir = "dist" diff --git a/apps/nutriphi/apps/web/package.json b/apps/nutriphi/apps/web/package.json new file mode 100644 index 000000000..9b410d74a --- /dev/null +++ b/apps/nutriphi/apps/web/package.json @@ -0,0 +1,53 @@ +{ + "name": "@nutriphi/web", + "version": "1.0.0", + "private": true, + "scripts": { + "dev": "vite dev --port 5180", + "build": "vite build", + "preview": "vite preview", + "prepare": "svelte-kit sync || echo ''", + "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", + "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", + "lint": "eslint .", + "format": "prettier --write .", + "type-check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json" + }, + "devDependencies": { + "@sveltejs/adapter-node": "^5.0.0", + "@sveltejs/kit": "^2.0.0", + "@sveltejs/vite-plugin-svelte": "^5.0.0", + "@tailwindcss/vite": "^4.1.7", + "@types/node": "^20.0.0", + "prettier": "^3.1.1", + "prettier-plugin-svelte": "^3.1.2", + "svelte": "^5.0.0", + "svelte-check": "^4.0.0", + "tailwindcss": "^4.1.7", + "tslib": "^2.4.1", + "typescript": "^5.0.0", + "vite": "^6.0.0" + }, + "dependencies": { + "@nutriphi/shared": "workspace:*", + "@manacore/shared-auth": "workspace:*", + "@manacore/shared-auth-ui": "workspace:*", + "@manacore/shared-branding": "workspace:*", + "@manacore/shared-feedback-service": "workspace:*", + "@manacore/shared-feedback-ui": "workspace:*", + "@manacore/shared-i18n": "workspace:*", + "@manacore/shared-icons": "workspace:*", + "@manacore/shared-profile-ui": "workspace:*", + "@manacore/shared-subscription-ui": "workspace:*", + "@manacore/shared-tailwind": "workspace:*", + "@manacore/shared-theme": "workspace:*", + "@manacore/shared-theme-ui": "workspace:*", + "@manacore/shared-types": "workspace:*", + "@manacore/shared-ui": "workspace:*", + "@manacore/shared-utils": "workspace:*", + "date-fns": "^4.1.0", + "lucide-svelte": "^0.559.0", + "svelte-i18n": "^4.0.1" + }, + "type": "module" +} diff --git a/apps/nutriphi/apps/web/src/app.css b/apps/nutriphi/apps/web/src/app.css new file mode 100644 index 000000000..f69cb7268 --- /dev/null +++ b/apps/nutriphi/apps/web/src/app.css @@ -0,0 +1,59 @@ +@import 'tailwindcss'; + +:root { + /* NutriPhi Theme - Health/Nature */ + --color-primary: #22C55E; + --color-primary-hover: #16A34A; + --color-primary-light: #86EFAC; + --color-secondary: #F97316; + --color-accent: #14B8A6; + + /* Dark theme */ + --color-background-page: #0F1F0F; + --color-background-card: #1A2F1A; + --color-background-elevated: #243824; + + --color-text-primary: #F0FDF4; + --color-text-secondary: #BBF7D0; + --color-text-muted: #6B8E6B; + + --color-border: #22543D; + --color-border-light: #2D6A4F; + + /* Nutrition colors */ + --color-calories: #F59E0B; + --color-protein: #EF4444; + --color-carbs: #3B82F6; + --color-fat: #8B5CF6; + --color-fiber: #10B981; +} + +html { + background-color: var(--color-background-page); + color: var(--color-text-primary); +} + +body { + font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + min-height: 100vh; + min-height: 100dvh; +} + +/* Mobile-optimized touch targets */ +button, a, input, select, textarea { + min-height: 44px; +} + +/* Smooth scrolling */ +html { + scroll-behavior: smooth; +} + +/* Hide scrollbar but allow scrolling */ +.no-scrollbar { + -ms-overflow-style: none; + scrollbar-width: none; +} +.no-scrollbar::-webkit-scrollbar { + display: none; +} diff --git a/apps/nutriphi/apps/web/src/app.html b/apps/nutriphi/apps/web/src/app.html new file mode 100644 index 000000000..5e33d0427 --- /dev/null +++ b/apps/nutriphi/apps/web/src/app.html @@ -0,0 +1,14 @@ + + + + + + + + + %sveltekit.head% + + +
%sveltekit.body%
+ + diff --git a/apps/nutriphi/apps/web/src/lib/api/client.ts b/apps/nutriphi/apps/web/src/lib/api/client.ts new file mode 100644 index 000000000..afaed66da --- /dev/null +++ b/apps/nutriphi/apps/web/src/lib/api/client.ts @@ -0,0 +1,68 @@ +import { authStore } from '$lib/stores/auth.svelte'; +import { PUBLIC_BACKEND_URL } from '$env/static/public'; + +const BASE_URL = PUBLIC_BACKEND_URL || 'http://localhost:3023'; + +class ApiClient { + private async getHeaders(): Promise { + const token = await authStore.getAccessToken(); + return { + 'Content-Type': 'application/json', + ...(token ? { Authorization: `Bearer ${token}` } : {}), + }; + } + + async get(path: string): Promise { + const response = await fetch(`${BASE_URL}/api/v1${path}`, { + method: 'GET', + headers: await this.getHeaders(), + }); + + if (!response.ok) { + throw new Error(`API Error: ${response.status}`); + } + + return response.json(); + } + + async post(path: string, data: unknown): Promise { + const response = await fetch(`${BASE_URL}/api/v1${path}`, { + method: 'POST', + headers: await this.getHeaders(), + body: JSON.stringify(data), + }); + + if (!response.ok) { + throw new Error(`API Error: ${response.status}`); + } + + return response.json(); + } + + async patch(path: string, data: unknown): Promise { + const response = await fetch(`${BASE_URL}/api/v1${path}`, { + method: 'PATCH', + headers: await this.getHeaders(), + body: JSON.stringify(data), + }); + + if (!response.ok) { + throw new Error(`API Error: ${response.status}`); + } + + return response.json(); + } + + async delete(path: string): Promise { + const response = await fetch(`${BASE_URL}/api/v1${path}`, { + method: 'DELETE', + headers: await this.getHeaders(), + }); + + if (!response.ok) { + throw new Error(`API Error: ${response.status}`); + } + } +} + +export const apiClient = new ApiClient(); diff --git a/apps/nutriphi/apps/web/src/lib/components/AddMealButton.svelte b/apps/nutriphi/apps/web/src/lib/components/AddMealButton.svelte new file mode 100644 index 000000000..1a7f91006 --- /dev/null +++ b/apps/nutriphi/apps/web/src/lib/components/AddMealButton.svelte @@ -0,0 +1,64 @@ + + +
+ + {#if isOpen} + + {/if} + + + {#if isOpen} +
+ + +
+ {/if} + + + +
diff --git a/apps/nutriphi/apps/web/src/lib/components/DailySummary.svelte b/apps/nutriphi/apps/web/src/lib/components/DailySummary.svelte new file mode 100644 index 000000000..1070e018f --- /dev/null +++ b/apps/nutriphi/apps/web/src/lib/components/DailySummary.svelte @@ -0,0 +1,83 @@ + + +
+ +
+

Heute

+ + {new Date().toLocaleDateString('de-DE', { weekday: 'long', day: 'numeric', month: 'long' })} + +
+ + +
+ +
+
+ {progress?.calories?.current ?? 0} +
+
+ / {progress?.calories?.target ?? 2000} +
+
+
+ + +
+
+
+ {progress?.protein?.current ?? 0}g +
+
Protein
+
+
+
+
+ +
+
+ {progress?.carbs?.current ?? 0}g +
+
Carbs
+
+
+
+
+ +
+
+ {progress?.fat?.current ?? 0}g +
+
Fett
+
+
+
+
+
+
+
diff --git a/apps/nutriphi/apps/web/src/lib/components/Header.svelte b/apps/nutriphi/apps/web/src/lib/components/Header.svelte new file mode 100644 index 000000000..3a7f464e3 --- /dev/null +++ b/apps/nutriphi/apps/web/src/lib/components/Header.svelte @@ -0,0 +1,33 @@ + + +
+
+
+
+
+ N +
+ NutriPhi +
+ + +
+
+
diff --git a/apps/nutriphi/apps/web/src/lib/components/MealList.svelte b/apps/nutriphi/apps/web/src/lib/components/MealList.svelte new file mode 100644 index 000000000..777ccfba8 --- /dev/null +++ b/apps/nutriphi/apps/web/src/lib/components/MealList.svelte @@ -0,0 +1,76 @@ + + +
+ {#if mealsStore.loading} +
Laden...
+ {:else if mealsStore.meals.length === 0} +
+

Noch keine Mahlzeiten heute

+

+ Tippe auf + um deine erste Mahlzeit hinzuzufügen +

+
+ {:else} + {#each mealsStore.meals as meal (meal.id)} +
+
+
+
+ {#if meal.inputType === 'photo'} + + {:else} + + {/if} + + {MEAL_TYPE_LABELS[meal.mealType as keyof typeof MEAL_TYPE_LABELS]?.de ?? + meal.mealType} + +
+

+ {meal.description} +

+ {#if meal.nutrition} +
+ + {Math.round(meal.nutrition.calories)} kcal + + + {Math.round(meal.nutrition.protein)}g P + + + {Math.round(meal.nutrition.carbohydrates)}g K + + + {Math.round(meal.nutrition.fat)}g F + +
+ {/if} +
+ +
+
+ {/each} + {/if} +
diff --git a/apps/nutriphi/apps/web/src/lib/components/ProgressRing.svelte b/apps/nutriphi/apps/web/src/lib/components/ProgressRing.svelte new file mode 100644 index 000000000..98e726c45 --- /dev/null +++ b/apps/nutriphi/apps/web/src/lib/components/ProgressRing.svelte @@ -0,0 +1,55 @@ + + +
+ + + + + + + + +
+ {#if children} + {@render children()} + {/if} +
+
diff --git a/apps/nutriphi/apps/web/src/lib/stores/auth.svelte.ts b/apps/nutriphi/apps/web/src/lib/stores/auth.svelte.ts new file mode 100644 index 000000000..86757f1ce --- /dev/null +++ b/apps/nutriphi/apps/web/src/lib/stores/auth.svelte.ts @@ -0,0 +1,213 @@ +/** + * Auth Store - Manages authentication state using Svelte 5 runes + * Uses Mana Core Auth + */ + +import { browser } from '$app/environment'; +import { initializeWebAuth } from '@manacore/shared-auth'; +import type { UserData } from '@manacore/shared-auth'; +import { PUBLIC_MANA_CORE_AUTH_URL } from '$env/static/public'; + +// Get backend URL dynamically at runtime +function getBackendUrl(): string { + if (browser && typeof window !== 'undefined') { + const injectedUrl = (window as unknown as { __PUBLIC_BACKEND_URL__?: string }) + .__PUBLIC_BACKEND_URL__; + return injectedUrl || 'http://localhost:3023'; + } + return process.env.PUBLIC_BACKEND_URL || 'http://localhost:3023'; +} + +// Lazy initialization to avoid SSR issues with localStorage +let _authService: ReturnType['authService'] | null = null; +let _tokenManager: ReturnType['tokenManager'] | null = null; + +function getAuthService() { + if (!browser) return null; + if (!_authService) { + const auth = initializeWebAuth({ + baseUrl: PUBLIC_MANA_CORE_AUTH_URL || 'http://localhost:3001', + backendUrl: getBackendUrl(), + }); + _authService = auth.authService; + _tokenManager = auth.tokenManager; + } + return _authService; +} + +function getTokenManager() { + if (!browser) return null; + getAuthService(); + return _tokenManager; +} + +// State +let user = $state(null); +let loading = $state(true); +let initialized = $state(false); + +export const authStore = { + // Getters + get user() { + return user; + }, + get loading() { + return loading; + }, + get isAuthenticated() { + return !!user; + }, + get initialized() { + return initialized; + }, + + /** + * Initialize auth state from stored tokens + */ + async initialize() { + if (initialized) return; + + const authService = getAuthService(); + if (!authService) { + initialized = true; + loading = false; + return; + } + + loading = true; + try { + const authenticated = await authService.isAuthenticated(); + if (authenticated) { + const userData = await authService.getUserFromToken(); + user = userData; + } + initialized = true; + } catch (error) { + console.error('Failed to initialize auth:', error); + user = null; + } finally { + loading = false; + } + }, + + /** + * Sign in with email and password + */ + async signIn(email: string, password: string) { + const authService = getAuthService(); + if (!authService) { + return { success: false, error: 'Auth not available on server' }; + } + + try { + const result = await authService.signIn(email, password); + + if (!result.success) { + return { success: false, error: result.error || 'Login failed' }; + } + + // Get user data from token + const userData = await authService.getUserFromToken(); + user = userData; + + return { success: true }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Unknown error'; + return { success: false, error: errorMessage }; + } + }, + + /** + * Sign up with email and password + */ + async signUp(email: string, password: string) { + const authService = getAuthService(); + if (!authService) { + return { success: false, error: 'Auth not available on server', needsVerification: false }; + } + + try { + const result = await authService.signUp(email, password); + + if (!result.success) { + return { success: false, error: result.error || 'Signup failed', needsVerification: false }; + } + + if (result.needsVerification) { + return { success: true, needsVerification: true }; + } + + // Auto sign in after successful signup + const signInResult = await this.signIn(email, password); + return { ...signInResult, needsVerification: false }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Unknown error'; + return { success: false, error: errorMessage, needsVerification: false }; + } + }, + + /** + * Sign out + */ + async signOut() { + const authService = getAuthService(); + if (!authService) { + user = null; + return; + } + + try { + await authService.signOut(); + user = null; + } catch (error) { + console.error('Sign out error:', error); + user = null; + } + }, + + /** + * Send password reset email + */ + async resetPassword(email: string) { + const authService = getAuthService(); + if (!authService) { + return { success: false, error: 'Auth not available on server' }; + } + + try { + const result = await authService.forgotPassword(email); + + if (!result.success) { + return { success: false, error: result.error || 'Password reset failed' }; + } + + return { success: true }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Unknown error'; + return { success: false, error: errorMessage }; + } + }, + + /** + * Get access token for API calls + */ + async getAccessToken() { + const authService = getAuthService(); + if (!authService) { + return null; + } + return await authService.getAppToken(); + }, + + /** + * Get a valid access token for API calls + * Automatically refreshes if the token is expired or about to expire + */ + async getValidToken(): Promise { + const tokenManager = getTokenManager(); + if (!tokenManager) { + return null; + } + return await tokenManager.getValidToken(); + }, +}; diff --git a/apps/nutriphi/apps/web/src/lib/stores/meals.svelte.ts b/apps/nutriphi/apps/web/src/lib/stores/meals.svelte.ts new file mode 100644 index 000000000..483e4bcea --- /dev/null +++ b/apps/nutriphi/apps/web/src/lib/stores/meals.svelte.ts @@ -0,0 +1,62 @@ +import { apiClient } from '$lib/api/client'; +import type { Meal, MealNutrition, DailySummary } from '@nutriphi/shared'; + +interface MealWithNutrition extends Meal { + nutrition: MealNutrition | null; +} + +class MealsStore { + meals = $state([]); + loading = $state(false); + error = $state(null); + dailySummary = $state(null); + + async fetchTodaysMeals() { + this.loading = true; + this.error = null; + try { + const today = new Date().toISOString().split('T')[0]; + this.meals = await apiClient.get(`/meals?date=${today}`); + } catch (err) { + this.error = err instanceof Error ? err.message : 'Failed to fetch meals'; + } finally { + this.loading = false; + } + } + + async fetchDailySummary(date?: Date) { + try { + const dateStr = (date || new Date()).toISOString().split('T')[0]; + this.dailySummary = await apiClient.get(`/stats/daily?date=${dateStr}`); + } catch (err) { + console.error('Failed to fetch daily summary:', err); + } + } + + async addMeal(mealData: { + date: string; + mealType: string; + inputType: string; + description: string; + confidence: number; + calories: number; + protein: number; + carbohydrates: number; + fat: number; + fiber?: number; + sugar?: number; + }) { + const meal = await apiClient.post('/meals', mealData); + this.meals = [...this.meals, meal]; + await this.fetchDailySummary(); + return meal; + } + + async deleteMeal(mealId: string) { + await apiClient.delete(`/meals/${mealId}`); + this.meals = this.meals.filter((m) => m.id !== mealId); + await this.fetchDailySummary(); + } +} + +export const mealsStore = new MealsStore(); diff --git a/apps/nutriphi/apps/web/src/routes/(auth)/+layout.svelte b/apps/nutriphi/apps/web/src/routes/(auth)/+layout.svelte new file mode 100644 index 000000000..a54cfdcb7 --- /dev/null +++ b/apps/nutriphi/apps/web/src/routes/(auth)/+layout.svelte @@ -0,0 +1,5 @@ + + +{@render children()} diff --git a/apps/nutriphi/apps/web/src/routes/(auth)/forgot-password/+page.svelte b/apps/nutriphi/apps/web/src/routes/(auth)/forgot-password/+page.svelte new file mode 100644 index 000000000..37e2ac48b --- /dev/null +++ b/apps/nutriphi/apps/web/src/routes/(auth)/forgot-password/+page.svelte @@ -0,0 +1,29 @@ + + + + {translations.titleForm} | NutriPhi + + + diff --git a/apps/nutriphi/apps/web/src/routes/(auth)/login/+page.svelte b/apps/nutriphi/apps/web/src/routes/(auth)/login/+page.svelte new file mode 100644 index 000000000..d86fd4825 --- /dev/null +++ b/apps/nutriphi/apps/web/src/routes/(auth)/login/+page.svelte @@ -0,0 +1,52 @@ + + + + {translations.title} | NutriPhi + + + diff --git a/apps/nutriphi/apps/web/src/routes/(auth)/register/+page.svelte b/apps/nutriphi/apps/web/src/routes/(auth)/register/+page.svelte new file mode 100644 index 000000000..11daa67ac --- /dev/null +++ b/apps/nutriphi/apps/web/src/routes/(auth)/register/+page.svelte @@ -0,0 +1,43 @@ + + + + {translations.title} | NutriPhi + + + diff --git a/apps/nutriphi/apps/web/src/routes/+layout.svelte b/apps/nutriphi/apps/web/src/routes/+layout.svelte new file mode 100644 index 000000000..3d96339b6 --- /dev/null +++ b/apps/nutriphi/apps/web/src/routes/+layout.svelte @@ -0,0 +1,17 @@ + + + + NutriPhi - Ernährung verstehen + + +{@render children()} diff --git a/apps/nutriphi/apps/web/src/routes/+page.svelte b/apps/nutriphi/apps/web/src/routes/+page.svelte new file mode 100644 index 000000000..15e82241a --- /dev/null +++ b/apps/nutriphi/apps/web/src/routes/+page.svelte @@ -0,0 +1,46 @@ + + +{#if authStore.isAuthenticated} +
+
+ +
+ + + + +
+

+ Heutige Mahlzeiten +

+ +
+
+ + +
+ +
+
+{:else} +
+
Laden...
+
+{/if} diff --git a/apps/nutriphi/apps/web/src/routes/add/+page.svelte b/apps/nutriphi/apps/web/src/routes/add/+page.svelte new file mode 100644 index 000000000..279848771 --- /dev/null +++ b/apps/nutriphi/apps/web/src/routes/add/+page.svelte @@ -0,0 +1,287 @@ + + +
+ +
+
+
+ +

+ {inputType === 'photo' ? 'Foto analysieren' : 'Mahlzeit eingeben'} +

+
+
+
+ +
+ +
+ +
+ {#each ['breakfast', 'lunch', 'dinner', 'snack'] as type} + + {/each} +
+
+ + {#if inputType === 'photo'} + +
+ {#if imagePreview} + Vorschau + {:else} + + {/if} +
+ {:else} + +
+ + +
+ {/if} + + {#if error} +

{error}

+ {/if} + + {#if !analysisResult} + + + {:else} + +
+

+ {analysisResult.description} +

+ + + {#if analysisResult.foods.length > 0} +
+

Erkannte Lebensmittel:

+
+ {#each analysisResult.foods as food} + + {food.name} ({food.quantity}) + + {/each} +
+
+ {/if} + + +
+
+
+ {Math.round(analysisResult.totalNutrition.calories)} +
+
Kalorien
+
+
+
+ {Math.round(analysisResult.totalNutrition.protein)}g +
+
Protein
+
+
+
+ {Math.round(analysisResult.totalNutrition.carbohydrates)}g +
+
Carbs
+
+
+
+ {Math.round(analysisResult.totalNutrition.fat)}g +
+
Fett
+
+
+ + +

+ Konfidenz: {Math.round(analysisResult.confidence * 100)}% +

+
+ + + + + + + {/if} +
+
diff --git a/apps/nutriphi/apps/web/svelte.config.js b/apps/nutriphi/apps/web/svelte.config.js new file mode 100644 index 000000000..a7a917e4c --- /dev/null +++ b/apps/nutriphi/apps/web/svelte.config.js @@ -0,0 +1,14 @@ +import adapter from '@sveltejs/adapter-node'; +import { vitePreprocess } from '@sveltejs/vite-plugin-svelte'; + +/** @type {import('@sveltejs/kit').Config} */ +const config = { + preprocess: vitePreprocess(), + kit: { + adapter: adapter({ + out: 'build', + }), + }, +}; + +export default config; diff --git a/apps/nutriphi/apps/web/tsconfig.json b/apps/nutriphi/apps/web/tsconfig.json new file mode 100644 index 000000000..a8f10c8e3 --- /dev/null +++ b/apps/nutriphi/apps/web/tsconfig.json @@ -0,0 +1,14 @@ +{ + "extends": "./.svelte-kit/tsconfig.json", + "compilerOptions": { + "allowJs": true, + "checkJs": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "skipLibCheck": true, + "sourceMap": true, + "strict": true, + "moduleResolution": "bundler" + } +} diff --git a/apps/nutriphi/apps/web/vite.config.ts b/apps/nutriphi/apps/web/vite.config.ts new file mode 100644 index 000000000..0cd501b64 --- /dev/null +++ b/apps/nutriphi/apps/web/vite.config.ts @@ -0,0 +1,47 @@ +import { sveltekit } from '@sveltejs/kit/vite'; +import tailwindcss from '@tailwindcss/vite'; +import { defineConfig } from 'vite'; + +export default defineConfig({ + plugins: [tailwindcss(), sveltekit()], + server: { + port: 5180, + strictPort: true, + }, + ssr: { + noExternal: [ + '@nutriphi/shared', + '@manacore/shared-icons', + '@manacore/shared-ui', + '@manacore/shared-tailwind', + '@manacore/shared-theme', + '@manacore/shared-theme-ui', + '@manacore/shared-feedback-ui', + '@manacore/shared-feedback-service', + '@manacore/shared-feedback-types', + '@manacore/shared-auth', + '@manacore/shared-auth-ui', + '@manacore/shared-branding', + '@manacore/shared-subscription-ui', + '@manacore/shared-i18n', + ], + }, + optimizeDeps: { + exclude: [ + '@nutriphi/shared', + '@manacore/shared-icons', + '@manacore/shared-ui', + '@manacore/shared-tailwind', + '@manacore/shared-theme', + '@manacore/shared-theme-ui', + '@manacore/shared-feedback-ui', + '@manacore/shared-feedback-service', + '@manacore/shared-feedback-types', + '@manacore/shared-auth', + '@manacore/shared-auth-ui', + '@manacore/shared-branding', + '@manacore/shared-subscription-ui', + '@manacore/shared-i18n', + ], + }, +}); diff --git a/apps/nutriphi/package.json b/apps/nutriphi/package.json new file mode 100644 index 000000000..b7607bc90 --- /dev/null +++ b/apps/nutriphi/package.json @@ -0,0 +1,19 @@ +{ + "name": "nutriphi", + "version": "1.0.0", + "private": true, + "description": "NutriPhi - AI-powered nutrition tracking with photo analysis", + "scripts": { + "dev": "pnpm run --filter=@nutriphi/* --parallel dev", + "dev:backend": "pnpm --filter @nutriphi/backend dev", + "dev:web": "pnpm --filter @nutriphi/web dev", + "dev:landing": "pnpm --filter @nutriphi/landing dev", + "db:push": "pnpm --filter @nutriphi/backend db:push", + "db:studio": "pnpm --filter @nutriphi/backend db:studio", + "db:seed": "pnpm --filter @nutriphi/backend db:seed" + }, + "devDependencies": { + "typescript": "^5.9.3" + }, + "packageManager": "pnpm@9.15.0" +} diff --git a/apps/nutriphi/packages/shared/package.json b/apps/nutriphi/packages/shared/package.json new file mode 100644 index 000000000..891330ec9 --- /dev/null +++ b/apps/nutriphi/packages/shared/package.json @@ -0,0 +1,20 @@ +{ + "name": "@nutriphi/shared", + "version": "1.0.0", + "type": "commonjs", + "main": "./src/index.ts", + "types": "./src/index.ts", + "exports": { + ".": "./src/index.ts", + "./types": "./src/types/index.ts", + "./utils": "./src/utils/index.ts", + "./constants": "./src/constants/index.ts" + }, + "scripts": { + "type-check": "tsc --noEmit" + }, + "devDependencies": { + "typescript": "~5.9.2" + }, + "dependencies": {} +} diff --git a/apps/nutriphi/packages/shared/src/constants/index.ts b/apps/nutriphi/packages/shared/src/constants/index.ts new file mode 100644 index 000000000..e8ed9afc8 --- /dev/null +++ b/apps/nutriphi/packages/shared/src/constants/index.ts @@ -0,0 +1,124 @@ +// Default daily recommended values (based on 2000 kcal diet) +export const DEFAULT_DAILY_VALUES = { + calories: 2000, + protein: 50, // grams + carbohydrates: 275, // grams + fat: 78, // grams + fiber: 28, // grams + sugar: 50, // grams (max) + saturatedFat: 20, // grams (max) + // Vitamins + vitaminA: 900, // µg RAE + vitaminB1: 1.2, // mg + vitaminB2: 1.3, // mg + vitaminB3: 16, // mg + vitaminB5: 5, // mg + vitaminB6: 1.7, // mg + vitaminB7: 30, // µg + vitaminB9: 400, // µg + vitaminB12: 2.4, // µg + vitaminC: 90, // mg + vitaminD: 20, // µg + vitaminE: 15, // mg + vitaminK: 120, // µg + // Minerals + calcium: 1000, // mg + iron: 18, // mg + magnesium: 420, // mg + phosphorus: 700, // mg + potassium: 4700, // mg + sodium: 2300, // mg (max) + zinc: 11, // mg + copper: 0.9, // mg + manganese: 2.3, // mg + selenium: 55, // µg +} as const; + +// Meal type labels +export const MEAL_TYPE_LABELS = { + breakfast: { + de: 'Frühstück', + en: 'Breakfast', + }, + lunch: { + de: 'Mittagessen', + en: 'Lunch', + }, + dinner: { + de: 'Abendessen', + en: 'Dinner', + }, + snack: { + de: 'Snack', + en: 'Snack', + }, +} as const; + +// Nutrient categories for UI grouping +export const NUTRIENT_CATEGORIES = { + macros: ['calories', 'protein', 'carbohydrates', 'fat', 'fiber', 'sugar'], + vitamins: [ + 'vitaminA', + 'vitaminB1', + 'vitaminB2', + 'vitaminB3', + 'vitaminB5', + 'vitaminB6', + 'vitaminB7', + 'vitaminB9', + 'vitaminB12', + 'vitaminC', + 'vitaminD', + 'vitaminE', + 'vitaminK', + ], + minerals: [ + 'calcium', + 'iron', + 'magnesium', + 'phosphorus', + 'potassium', + 'sodium', + 'zinc', + 'copper', + 'manganese', + 'selenium', + ], +} as const; + +// Nutrient display info +export const NUTRIENT_INFO = { + calories: { label: 'Kalorien', unit: 'kcal', color: '#F59E0B' }, + protein: { label: 'Protein', unit: 'g', color: '#EF4444' }, + carbohydrates: { label: 'Kohlenhydrate', unit: 'g', color: '#3B82F6' }, + fat: { label: 'Fett', unit: 'g', color: '#8B5CF6' }, + fiber: { label: 'Ballaststoffe', unit: 'g', color: '#10B981' }, + sugar: { label: 'Zucker', unit: 'g', color: '#EC4899' }, + vitaminA: { label: 'Vitamin A', unit: 'µg', color: '#F97316' }, + vitaminC: { label: 'Vitamin C', unit: 'mg', color: '#FBBF24' }, + vitaminD: { label: 'Vitamin D', unit: 'µg', color: '#A3E635' }, + calcium: { label: 'Calcium', unit: 'mg', color: '#E5E7EB' }, + iron: { label: 'Eisen', unit: 'mg', color: '#78716C' }, + magnesium: { label: 'Magnesium', unit: 'mg', color: '#06B6D4' }, +} as const; + +// Credit costs per action +export const CREDIT_COSTS = { + photoAnalysis: 5, + textAnalysis: 2, + aiCoaching: 10, +} as const; + +// Theme colors +export const NUTRIPHI_COLORS = { + primary: '#22C55E', // Green 500 + primaryHover: '#16A34A', // Green 600 + primaryLight: '#86EFAC', // Green 300 + secondary: '#F97316', // Orange 500 + accent: '#14B8A6', // Teal 500 + background: '#0F1F0F', // Dark green tinted + backgroundCard: '#1A2F1A', + textPrimary: '#F0FDF4', // Green 50 + textSecondary: '#BBF7D0', // Green 200 + border: '#22543D', // Green 800 +} as const; diff --git a/apps/nutriphi/packages/shared/src/index.ts b/apps/nutriphi/packages/shared/src/index.ts new file mode 100644 index 000000000..f1ef8c5ed --- /dev/null +++ b/apps/nutriphi/packages/shared/src/index.ts @@ -0,0 +1,8 @@ +// Types +export * from './types'; + +// Constants +export * from './constants'; + +// Utils +export * from './utils'; diff --git a/apps/nutriphi/packages/shared/src/types/index.ts b/apps/nutriphi/packages/shared/src/types/index.ts new file mode 100644 index 000000000..b9152b023 --- /dev/null +++ b/apps/nutriphi/packages/shared/src/types/index.ts @@ -0,0 +1,185 @@ +// User Goals +export interface UserGoals { + id: string; + userId: string; + dailyCalories: number; + dailyProtein?: number | null; // in grams + dailyCarbs?: number | null; + dailyFat?: number | null; + dailyFiber?: number | null; + createdAt: Date; + updatedAt: Date; +} + +export interface CreateUserGoalsDto { + dailyCalories: number; + dailyProtein?: number; + dailyCarbs?: number; + dailyFat?: number; + dailyFiber?: number; +} + +// Meal Types +export type MealType = 'breakfast' | 'lunch' | 'dinner' | 'snack'; +export type InputType = 'photo' | 'text'; + +// Meal +export interface Meal { + id: string; + userId: string; + date: Date; + mealType: MealType; + inputType: InputType; + description: string; // AI-generated description of the meal + portionSize?: string; // e.g., "small", "medium", "large" or grams + confidence: number; // AI confidence score 0-1 + createdAt: Date; +} + +export interface CreateMealDto { + mealType: MealType; + inputType: InputType; + description?: string; // For text input + imageBase64?: string; // For photo input + portionSize?: string; +} + +// Nutrition Data +export interface MealNutrition { + id: string; + mealId: string; + // Macros + calories: number; + protein: number; + carbohydrates: number; + fat: number; + fiber: number; + sugar: number; + saturatedFat?: number | null; + unsaturatedFat?: number | null; + // Vitamins (in mg or µg as appropriate) + vitaminA?: number | null; // µg RAE + vitaminB1?: number | null; // mg (Thiamin) + vitaminB2?: number | null; // mg (Riboflavin) + vitaminB3?: number | null; // mg (Niacin) + vitaminB5?: number | null; // mg (Pantothenic acid) + vitaminB6?: number | null; // mg + vitaminB7?: number | null; // µg (Biotin) + vitaminB9?: number | null; // µg (Folate) + vitaminB12?: number | null; // µg + vitaminC?: number | null; // mg + vitaminD?: number | null; // µg + vitaminE?: number | null; // mg + vitaminK?: number | null; // µg + // Minerals (in mg) + calcium?: number | null; + iron?: number | null; + magnesium?: number | null; + phosphorus?: number | null; + potassium?: number | null; + sodium?: number | null; + zinc?: number | null; + copper?: number | null; + manganese?: number | null; + selenium?: number | null; // µg + // Water + water?: number | null; // ml +} + +// Favorite Meals +export interface FavoriteMeal { + id: string; + userId: string; + name: string; + description: string; + mealType: MealType; + nutrition: Omit; + usageCount: number; + createdAt: Date; + updatedAt: Date; +} + +export interface CreateFavoriteMealDto { + name: string; + mealId?: string; // Create from existing meal + description?: string; + mealType?: MealType; +} + +// Daily Summary +export interface DailySummary { + date: Date; + meals: Meal[]; + totalNutrition: Omit; + goals?: UserGoals; + progress: NutritionProgress; +} + +export interface NutritionProgress { + calories: { current: number; target: number; percentage: number }; + protein?: { current: number; target: number; percentage: number }; + carbs?: { current: number; target: number; percentage: number }; + fat?: { current: number; target: number; percentage: number }; +} + +// Recommendations +export type RecommendationType = 'hint' | 'coaching'; +export type RecommendationPriority = 'low' | 'medium' | 'high'; + +export interface Recommendation { + id: string; + userId: string; + date: Date; + type: RecommendationType; + priority: RecommendationPriority; + message: string; + nutrient?: string; // e.g., 'protein', 'vitaminC' + actionable?: string; // e.g., "Add more leafy greens" + dismissed: boolean; + createdAt: Date; +} + +// Weekly Stats +export interface WeeklyStats { + startDate: Date; + endDate: Date; + days: DailyStats[]; + averages: { + calories: number; + protein: number; + carbs: number; + fat: number; + }; + trends: { + caloriesTrend: 'up' | 'down' | 'stable'; + proteinTrend: 'up' | 'down' | 'stable'; + }; +} + +export interface DailyStats { + date: Date; + totalCalories: number; + totalProtein: number; + totalCarbs: number; + totalFat: number; + mealCount: number; + goalsMet: boolean; +} + +// AI Analysis Response +export interface AIAnalysisResult { + foods: DetectedFood[]; + totalNutrition: Omit; + description: string; + confidence: number; + warnings?: string[]; // e.g., "Could not identify one item" + suggestions?: string[]; // e.g., "Consider adding more vegetables" +} + +export interface DetectedFood { + name: string; + quantity: string; // e.g., "150g", "1 cup" + calories: number; + confidence: number; + source?: 'usda' | 'openfoodfacts' | 'ai_estimate'; +} diff --git a/apps/nutriphi/packages/shared/src/utils/index.ts b/apps/nutriphi/packages/shared/src/utils/index.ts new file mode 100644 index 000000000..b9d6daefa --- /dev/null +++ b/apps/nutriphi/packages/shared/src/utils/index.ts @@ -0,0 +1,174 @@ +import { DEFAULT_DAILY_VALUES, NUTRIENT_INFO } from '../constants'; +import type { MealNutrition, NutritionProgress, UserGoals } from '../types'; + +/** + * Calculate nutrition progress towards daily goals + */ +export function calculateProgress( + totalNutrition: Partial, + goals?: UserGoals +): NutritionProgress { + const targetCalories = goals?.dailyCalories ?? DEFAULT_DAILY_VALUES.calories; + const targetProtein = goals?.dailyProtein ?? DEFAULT_DAILY_VALUES.protein; + const targetCarbs = goals?.dailyCarbs ?? DEFAULT_DAILY_VALUES.carbohydrates; + const targetFat = goals?.dailyFat ?? DEFAULT_DAILY_VALUES.fat; + + return { + calories: { + current: totalNutrition.calories ?? 0, + target: targetCalories, + percentage: Math.min( + 100, + Math.round(((totalNutrition.calories ?? 0) / targetCalories) * 100) + ), + }, + protein: { + current: totalNutrition.protein ?? 0, + target: targetProtein, + percentage: Math.min(100, Math.round(((totalNutrition.protein ?? 0) / targetProtein) * 100)), + }, + carbs: { + current: totalNutrition.carbohydrates ?? 0, + target: targetCarbs, + percentage: Math.min( + 100, + Math.round(((totalNutrition.carbohydrates ?? 0) / targetCarbs) * 100) + ), + }, + fat: { + current: totalNutrition.fat ?? 0, + target: targetFat, + percentage: Math.min(100, Math.round(((totalNutrition.fat ?? 0) / targetFat) * 100)), + }, + }; +} + +/** + * Sum up nutrition from multiple meals + */ +export function sumNutrition( + meals: Array<{ nutrition?: Partial | null }> +): Partial { + const sum = { + calories: 0, + protein: 0, + carbohydrates: 0, + fat: 0, + fiber: 0, + sugar: 0, + }; + + for (const meal of meals) { + if (!meal.nutrition) continue; + const n = meal.nutrition; + if (typeof n.calories === 'number') sum.calories += n.calories; + if (typeof n.protein === 'number') sum.protein += n.protein; + if (typeof n.carbohydrates === 'number') sum.carbohydrates += n.carbohydrates; + if (typeof n.fat === 'number') sum.fat += n.fat; + if (typeof n.fiber === 'number') sum.fiber += n.fiber; + if (typeof n.sugar === 'number') sum.sugar += n.sugar; + } + + return sum; +} + +/** + * Format nutrient value with unit + */ +export function formatNutrient( + nutrient: keyof typeof NUTRIENT_INFO, + value: number | undefined +): string { + if (value === undefined) return '-'; + const info = NUTRIENT_INFO[nutrient]; + if (!info) return `${value}`; + + if (nutrient === 'calories') { + return `${Math.round(value)} ${info.unit}`; + } + + return `${value.toFixed(1)} ${info.unit}`; +} + +/** + * Get color for progress percentage + */ +export function getProgressColor(percentage: number): string { + if (percentage < 50) return '#EF4444'; // Red + if (percentage < 80) return '#F59E0B'; // Orange + if (percentage <= 100) return '#22C55E'; // Green + return '#EF4444'; // Red (over target) +} + +/** + * Detect deficiencies based on daily values + */ +export function detectDeficiencies( + totalNutrition: Partial +): Array<{ nutrient: string; percentage: number; label: string }> { + const deficiencies: Array<{ nutrient: string; percentage: number; label: string }> = []; + + const checks = [ + { key: 'protein', threshold: 0.5 }, + { key: 'fiber', threshold: 0.5 }, + { key: 'vitaminC', threshold: 0.5 }, + { key: 'vitaminD', threshold: 0.5 }, + { key: 'iron', threshold: 0.5 }, + { key: 'calcium', threshold: 0.5 }, + ] as const; + + for (const check of checks) { + const value = totalNutrition[check.key as keyof typeof totalNutrition]; + const dailyValue = DEFAULT_DAILY_VALUES[check.key as keyof typeof DEFAULT_DAILY_VALUES]; + + if ( + typeof value === 'number' && + typeof dailyValue === 'number' && + value < dailyValue * check.threshold + ) { + const info = NUTRIENT_INFO[check.key as keyof typeof NUTRIENT_INFO]; + deficiencies.push({ + nutrient: check.key, + percentage: Math.round((value / dailyValue) * 100), + label: info?.label ?? check.key, + }); + } + } + + return deficiencies; +} + +/** + * Get meal type based on current time + */ +export function suggestMealType(): 'breakfast' | 'lunch' | 'dinner' | 'snack' { + const hour = new Date().getHours(); + + if (hour >= 5 && hour < 11) return 'breakfast'; + if (hour >= 11 && hour < 14) return 'lunch'; + if (hour >= 17 && hour < 21) return 'dinner'; + return 'snack'; +} + +/** + * Format date for display + */ +export function formatDateForDisplay(date: Date, locale = 'de-DE'): string { + return new Intl.DateTimeFormat(locale, { + weekday: 'long', + day: 'numeric', + month: 'long', + }).format(date); +} + +/** + * Check if date is today + */ +export function isToday(date: Date): boolean { + const today = new Date(); + return ( + date.getDate() === today.getDate() && + date.getMonth() === today.getMonth() && + date.getFullYear() === today.getFullYear() + ); +} diff --git a/apps/nutriphi/packages/shared/tsconfig.json b/apps/nutriphi/packages/shared/tsconfig.json new file mode 100644 index 000000000..a9906ffae --- /dev/null +++ b/apps/nutriphi/packages/shared/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "target": "ES2021", + "module": "commonjs", + "lib": ["ES2021"], + "moduleResolution": "node", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "declaration": true, + "declarationMap": true, + "outDir": "./dist", + "rootDir": "./src" + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +} diff --git a/package.json b/package.json index 641cb91a5..a62add4f8 100644 --- a/package.json +++ b/package.json @@ -154,6 +154,16 @@ "planta:db:push": "pnpm --filter @planta/backend db:push", "planta:db:studio": "pnpm --filter @planta/backend db:studio", "planta:db:seed": "pnpm --filter @planta/backend db:seed", + "nutriphi:dev": "turbo run dev --filter=nutriphi...", + "dev:nutriphi:web": "pnpm --filter @nutriphi/web dev", + "dev:nutriphi:landing": "pnpm --filter @nutriphi/landing dev", + "dev:nutriphi:backend": "pnpm --filter @nutriphi/backend dev", + "dev:nutriphi:app": "turbo run dev --filter=@nutriphi/web --filter=@nutriphi/backend", + "dev:nutriphi:full": "./scripts/setup-databases.sh nutriphi && ./scripts/setup-databases.sh auth && concurrently -n auth,backend,web -c blue,green,cyan \"pnpm dev:auth\" \"pnpm dev:nutriphi:backend\" \"pnpm dev:nutriphi:web\"", + "nutriphi:db:push": "pnpm --filter @nutriphi/backend db:push", + "nutriphi:db:studio": "pnpm --filter @nutriphi/backend db:studio", + "nutriphi:db:seed": "pnpm --filter @nutriphi/backend db:seed", + "deploy:landing:nutriphi": "pnpm --filter @nutriphi/landing build && npx wrangler pages deploy apps/nutriphi/apps/landing/dist --project-name=nutriphi-landing", "docker:up": "docker compose -f docker-compose.dev.yml --env-file .env.development up -d postgres redis minio minio-init", "docker:up:infra": "docker compose -f docker-compose.dev.yml --env-file .env.development up -d postgres redis minio minio-init", "docker:up:db": "docker compose -f docker-compose.dev.yml --env-file .env.development up -d postgres redis", @@ -166,6 +176,7 @@ "docker:logs:chat": "docker compose -f docker-compose.dev.yml --env-file .env.development logs -f chat-backend", "docker:ps": "docker compose -f docker-compose.dev.yml --env-file .env.development ps -a", "docker:clean": "docker compose -f docker-compose.dev.yml --env-file .env.development --profile all down -v", + "deploy:landing:calendar": "pnpm --filter @calendar/landing build && npx wrangler pages deploy apps/calendar/apps/landing/dist --project-name=calendars-landing", "deploy:landing:chat": "pnpm --filter @chat/landing build && npx wrangler pages deploy apps/chat/apps/landing/dist --project-name=chat-landing", "deploy:landing:picture": "pnpm --filter @picture/landing build && npx wrangler pages deploy apps/picture/apps/landing/dist --project-name=picture-landing", "deploy:landing:manacore": "pnpm --filter @manacore/landing build && npx wrangler pages deploy apps/manacore/apps/landing/dist --project-name=manacore-landing", @@ -175,7 +186,7 @@ "deploy:landing:clock": "pnpm --filter @clock/landing build && npx wrangler pages deploy apps/clock/apps/landing/dist --project-name=clocks-landing", "deploy:landing:mail": "pnpm --filter @mail/landing build && npx wrangler pages deploy apps/mail/apps/landing/dist --project-name=mail-landing", "deploy:landing:moodlit": "pnpm --filter @moodlit/landing build && npx wrangler pages deploy apps/moodlit/apps/landing/dist --project-name=moodlit-landing", - "deploy:landing:all": "pnpm deploy:landing:chat && pnpm deploy:landing:picture && pnpm deploy:landing:manacore && pnpm deploy:landing:manadeck && pnpm deploy:landing:zitare && pnpm deploy:landing:presi && pnpm deploy:landing:clock && pnpm deploy:landing:mail", + "deploy:landing:all": "pnpm deploy:landing:calendar && pnpm deploy:landing:chat && pnpm deploy:landing:picture && pnpm deploy:landing:manacore && pnpm deploy:landing:manadeck && pnpm deploy:landing:zitare && pnpm deploy:landing:presi && pnpm deploy:landing:clock && pnpm deploy:landing:mail", "cf:login": "npx wrangler login", "cf:projects:list": "npx wrangler pages project list", "cf:projects:create": "echo 'Creating Cloudflare Pages projects...' && npx wrangler pages project create chat-landing --production-branch=main && npx wrangler pages project create picture-landing --production-branch=main && npx wrangler pages project create manacore-landing --production-branch=main && npx wrangler pages project create manadeck-landing --production-branch=main && npx wrangler pages project create zitare-landing --production-branch=main",