mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-15 01:21:09 +02:00
✨ feat(nutriphi): add AI-powered nutrition tracking app
- NestJS backend with Gemini AI for food photo analysis - SvelteKit web app with Svelte 5 runes - Drizzle ORM schema for meals, goals, favorites, recommendations - Unified auth pages using shared-auth-ui components - Landing page with Astro - Shared types and utilities package
This commit is contained in:
parent
b77dd4159b
commit
b6af01ed67
70 changed files with 4256 additions and 4 deletions
|
|
@ -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
|
||||
|
|
|
|||
366
apps/nutriphi/CLAUDE.md
Normal file
366
apps/nutriphi/CLAUDE.md
Normal file
|
|
@ -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 |
|
||||
15
apps/nutriphi/apps/backend/drizzle.config.ts
Normal file
15
apps/nutriphi/apps/backend/drizzle.config.ts
Normal file
|
|
@ -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,
|
||||
});
|
||||
10
apps/nutriphi/apps/backend/nest-cli.json
Normal file
10
apps/nutriphi/apps/backend/nest-cli.json
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
{
|
||||
"$schema": "https://json.schemastore.org/nest-cli",
|
||||
"collection": "@nestjs/schematics",
|
||||
"sourceRoot": "src",
|
||||
"compilerOptions": {
|
||||
"deleteOutDir": false,
|
||||
"assets": [],
|
||||
"watchAssets": false
|
||||
}
|
||||
}
|
||||
56
apps/nutriphi/apps/backend/package.json
Normal file
56
apps/nutriphi/apps/backend/package.json
Normal file
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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'}`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
11
apps/nutriphi/apps/backend/src/analysis/analysis.module.ts
Normal file
11
apps/nutriphi/apps/backend/src/analysis/analysis.module.ts
Normal file
|
|
@ -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 {}
|
||||
16
apps/nutriphi/apps/backend/src/analysis/analysis.service.ts
Normal file
16
apps/nutriphi/apps/backend/src/analysis/analysis.service.ts
Normal file
|
|
@ -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<AIAnalysisResult> {
|
||||
return this.geminiService.analyzeImage(imageBase64, mimeType);
|
||||
}
|
||||
|
||||
async analyzeText(description: string): Promise<AIAnalysisResult> {
|
||||
return this.geminiService.analyzeText(description);
|
||||
}
|
||||
}
|
||||
139
apps/nutriphi/apps/backend/src/analysis/gemini.service.ts
Normal file
139
apps/nutriphi/apps/backend/src/analysis/gemini.service.ts
Normal file
|
|
@ -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<string>('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<AIAnalysisResult> {
|
||||
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<AIAnalysisResult> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
28
apps/nutriphi/apps/backend/src/app.module.ts
Normal file
28
apps/nutriphi/apps/backend/src/app.module.ts
Normal file
|
|
@ -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 {}
|
||||
29
apps/nutriphi/apps/backend/src/db/database.module.ts
Normal file
29
apps/nutriphi/apps/backend/src/db/database.module.ts
Normal file
|
|
@ -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<string>('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();
|
||||
}
|
||||
}
|
||||
38
apps/nutriphi/apps/backend/src/db/db.ts
Normal file
38
apps/nutriphi/apps/backend/src/db/db.ts
Normal file
|
|
@ -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<typeof postgres> | null = null;
|
||||
let db: ReturnType<typeof drizzle> | 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<typeof getDb>;
|
||||
124
apps/nutriphi/apps/backend/src/db/schema/index.ts
Normal file
124
apps/nutriphi/apps/backend/src/db/schema/index.ts
Normal file
|
|
@ -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;
|
||||
|
|
@ -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<string, number>;
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
10
apps/nutriphi/apps/backend/src/favorites/favorites.module.ts
Normal file
10
apps/nutriphi/apps/backend/src/favorites/favorites.module.ts
Normal file
|
|
@ -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 {}
|
||||
|
|
@ -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<NewFavoriteMeal, 'id' | 'userId' | 'usageCount'>) {
|
||||
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<NewFavoriteMeal>) {
|
||||
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;
|
||||
}
|
||||
}
|
||||
51
apps/nutriphi/apps/backend/src/goals/goals.controller.ts
Normal file
51
apps/nutriphi/apps/backend/src/goals/goals.controller.ts
Normal file
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
10
apps/nutriphi/apps/backend/src/goals/goals.module.ts
Normal file
10
apps/nutriphi/apps/backend/src/goals/goals.module.ts
Normal file
|
|
@ -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 {}
|
||||
47
apps/nutriphi/apps/backend/src/goals/goals.service.ts
Normal file
47
apps/nutriphi/apps/backend/src/goals/goals.service.ts
Normal file
|
|
@ -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<NewUserGoals, 'id' | 'userId'>) {
|
||||
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;
|
||||
}
|
||||
}
|
||||
13
apps/nutriphi/apps/backend/src/health/health.controller.ts
Normal file
13
apps/nutriphi/apps/backend/src/health/health.controller.ts
Normal file
|
|
@ -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(),
|
||||
};
|
||||
}
|
||||
}
|
||||
7
apps/nutriphi/apps/backend/src/health/health.module.ts
Normal file
7
apps/nutriphi/apps/backend/src/health/health.module.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { HealthController } from './health.controller';
|
||||
|
||||
@Module({
|
||||
controllers: [HealthController],
|
||||
})
|
||||
export class HealthModule {}
|
||||
35
apps/nutriphi/apps/backend/src/main.ts
Normal file
35
apps/nutriphi/apps/backend/src/main.ts
Normal file
|
|
@ -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();
|
||||
144
apps/nutriphi/apps/backend/src/meal/meal.controller.ts
Normal file
144
apps/nutriphi/apps/backend/src/meal/meal.controller.ts
Normal file
|
|
@ -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<CreateMealDto>
|
||||
) {
|
||||
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
|
||||
);
|
||||
}
|
||||
}
|
||||
10
apps/nutriphi/apps/backend/src/meal/meal.module.ts
Normal file
10
apps/nutriphi/apps/backend/src/meal/meal.module.ts
Normal file
|
|
@ -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 {}
|
||||
102
apps/nutriphi/apps/backend/src/meal/meal.service.ts
Normal file
102
apps/nutriphi/apps/backend/src/meal/meal.service.ts
Normal file
|
|
@ -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<NewMealNutrition, 'mealId'>) {
|
||||
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<NewMeal>,
|
||||
nutrition?: Partial<NewMealNutrition>
|
||||
) {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {}
|
||||
|
|
@ -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<NewRecommendation, 'id' | 'userId' | 'dismissed'>) {
|
||||
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<string, number>) {
|
||||
const hints: Array<Omit<NewRecommendation, 'id' | 'userId' | 'dismissed'>> = [];
|
||||
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
28
apps/nutriphi/apps/backend/src/stats/stats.controller.ts
Normal file
28
apps/nutriphi/apps/backend/src/stats/stats.controller.ts
Normal file
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
12
apps/nutriphi/apps/backend/src/stats/stats.module.ts
Normal file
12
apps/nutriphi/apps/backend/src/stats/stats.module.ts
Normal file
|
|
@ -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 {}
|
||||
131
apps/nutriphi/apps/backend/src/stats/stats.service.ts
Normal file
131
apps/nutriphi/apps/backend/src/stats/stats.service.ts
Normal file
|
|
@ -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<DailySummary> {
|
||||
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<WeeklyStats> {
|
||||
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<string, typeof meals>();
|
||||
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),
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
142
apps/nutriphi/apps/backend/src/types/nutrition.types.ts
Normal file
142
apps/nutriphi/apps/backend/src/types/nutrition.types.ts
Normal file
|
|
@ -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<MealNutrition, 'id' | 'mealId'>;
|
||||
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<MealNutrition, 'id' | 'mealId'>;
|
||||
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,
|
||||
};
|
||||
73
apps/nutriphi/apps/backend/src/utils/nutrition.utils.ts
Normal file
73
apps/nutriphi/apps/backend/src/utils/nutrition.utils.ts
Normal file
|
|
@ -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<MealNutrition>,
|
||||
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<MealNutrition> | null }>
|
||||
): Partial<MealNutrition> {
|
||||
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;
|
||||
}
|
||||
25
apps/nutriphi/apps/backend/tsconfig.json
Normal file
25
apps/nutriphi/apps/backend/tsconfig.json
Normal file
|
|
@ -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"]
|
||||
}
|
||||
7
apps/nutriphi/apps/landing/astro.config.mjs
Normal file
7
apps/nutriphi/apps/landing/astro.config.mjs
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
import { defineConfig } from 'astro/config';
|
||||
import tailwind from '@astrojs/tailwind';
|
||||
|
||||
export default defineConfig({
|
||||
integrations: [tailwind()],
|
||||
site: 'https://nutriphi.manacore.app',
|
||||
});
|
||||
34
apps/nutriphi/apps/landing/package.json
Normal file
34
apps/nutriphi/apps/landing/package.json
Normal file
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
44
apps/nutriphi/apps/landing/src/layouts/Layout.astro
Normal file
44
apps/nutriphi/apps/landing/src/layouts/Layout.astro
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
---
|
||||
interface Props {
|
||||
title: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
const { title, description = 'NutriPhi - KI-gestützte Ernährungsanalyse per Foto' } = Astro.props;
|
||||
---
|
||||
|
||||
<!doctype html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="description" content={description} />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="generator" content={Astro.generator} />
|
||||
<title>{title}</title>
|
||||
|
||||
<!-- Open Graph -->
|
||||
<meta property="og:title" content={title} />
|
||||
<meta property="og:description" content={description} />
|
||||
<meta property="og:type" content="website" />
|
||||
|
||||
<!-- Theme Color -->
|
||||
<meta name="theme-color" content="#22C55E" />
|
||||
</head>
|
||||
<body class="bg-[#0F1F0F] text-gray-100 antialiased">
|
||||
<slot />
|
||||
</body>
|
||||
</html>
|
||||
|
||||
<style is:global>
|
||||
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap');
|
||||
|
||||
html {
|
||||
font-family: 'Inter', system-ui, sans-serif;
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
body {
|
||||
min-height: 100vh;
|
||||
}
|
||||
</style>
|
||||
270
apps/nutriphi/apps/landing/src/pages/index.astro
Normal file
270
apps/nutriphi/apps/landing/src/pages/index.astro
Normal file
|
|
@ -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.',
|
||||
},
|
||||
];
|
||||
---
|
||||
|
||||
<Layout title="NutriPhi - Ernährung verstehen per Foto">
|
||||
<!-- Navigation -->
|
||||
<nav
|
||||
class="fixed top-0 left-0 right-0 z-50 bg-[#0F1F0F]/90 backdrop-blur border-b border-green-900/30"
|
||||
>
|
||||
<div class="container mx-auto px-4 max-w-6xl">
|
||||
<div class="flex items-center justify-between h-16">
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="w-8 h-8 rounded-lg bg-primary flex items-center justify-center">
|
||||
<span class="text-white font-bold">N</span>
|
||||
</div>
|
||||
<span class="font-semibold text-white">NutriPhi</span>
|
||||
</div>
|
||||
<a
|
||||
href="https://app.nutriphi.manacore.app"
|
||||
class="px-4 py-2 bg-primary hover:bg-primary-hover text-white font-medium rounded-lg transition-colors"
|
||||
>
|
||||
Jetzt starten
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<main>
|
||||
<!-- Hero Section -->
|
||||
<section class="pt-32 pb-20 px-4">
|
||||
<div class="container mx-auto max-w-4xl text-center">
|
||||
<!-- Trust Badges -->
|
||||
<div class="flex flex-wrap justify-center gap-3 mb-8">
|
||||
<span
|
||||
class="px-3 py-1 bg-green-900/30 border border-green-800/50 rounded-full text-sm text-green-300"
|
||||
>
|
||||
🔒 Datenschutz-First
|
||||
</span>
|
||||
<span
|
||||
class="px-3 py-1 bg-green-900/30 border border-green-800/50 rounded-full text-sm text-green-300"
|
||||
>
|
||||
🤖 Powered by Gemini AI
|
||||
</span>
|
||||
<span
|
||||
class="px-3 py-1 bg-green-900/30 border border-green-800/50 rounded-full text-sm text-green-300"
|
||||
>
|
||||
✨ Kostenlos starten
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<h1 class="text-4xl md:text-6xl font-bold text-white mb-6 leading-tight">
|
||||
Fotografiere dein Essen.
|
||||
<span class="text-primary">Verstehe deinen Körper.</span>
|
||||
</h1>
|
||||
|
||||
<p class="text-xl text-gray-300 mb-10 max-w-2xl mx-auto">
|
||||
NutriPhi analysiert deine Mahlzeiten per Foto und liefert dir sofort alle Nährwerte. Mit
|
||||
KI-Coaching erreichst du deine Gesundheitsziele.
|
||||
</p>
|
||||
|
||||
<div class="flex flex-col sm:flex-row gap-4 justify-center">
|
||||
<a
|
||||
href="https://app.nutriphi.manacore.app"
|
||||
class="px-8 py-4 bg-primary hover:bg-primary-hover text-white font-semibold rounded-xl transition-colors text-lg"
|
||||
>
|
||||
Kostenlos starten
|
||||
</a>
|
||||
<a
|
||||
href="#features"
|
||||
class="px-8 py-4 bg-white/10 hover:bg-white/20 text-white font-semibold rounded-xl transition-colors text-lg"
|
||||
>
|
||||
Mehr erfahren
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Features Section -->
|
||||
<section id="features" class="py-20 px-4 bg-[#1A2F1A]">
|
||||
<div class="container mx-auto max-w-6xl">
|
||||
<div class="text-center mb-16">
|
||||
<h2 class="text-3xl md:text-4xl font-bold text-white mb-4">Alles was du brauchst</h2>
|
||||
<p class="text-gray-300 max-w-2xl mx-auto">
|
||||
NutriPhi macht Ernährungstracking so einfach wie ein Foto.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="grid md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{
|
||||
features.map((feature) => (
|
||||
<div class="p-6 bg-[#0F1F0F] border border-green-900/30 rounded-2xl hover:border-primary/50 transition-colors">
|
||||
<div class="text-4xl mb-4">{feature.icon}</div>
|
||||
<h3 class="text-xl font-semibold text-white mb-2">{feature.title}</h3>
|
||||
<p class="text-gray-400">{feature.description}</p>
|
||||
</div>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- How it Works -->
|
||||
<section class="py-20 px-4">
|
||||
<div class="container mx-auto max-w-4xl">
|
||||
<div class="text-center mb-16">
|
||||
<h2 class="text-3xl md:text-4xl font-bold text-white mb-4">So einfach geht's</h2>
|
||||
<p class="text-gray-300">In 3 Schritten zu besserer Ernährung</p>
|
||||
</div>
|
||||
|
||||
<div class="space-y-8">
|
||||
{
|
||||
steps.map((step, index) => (
|
||||
<div class="flex items-start gap-6">
|
||||
<div class="flex-shrink-0 w-12 h-12 rounded-full bg-primary flex items-center justify-center text-white font-bold text-xl">
|
||||
{step.number}
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-xl font-semibold text-white mb-2">{step.title}</h3>
|
||||
<p class="text-gray-400">{step.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- FAQ Section -->
|
||||
<section class="py-20 px-4 bg-[#1A2F1A]">
|
||||
<div class="container mx-auto max-w-3xl">
|
||||
<div class="text-center mb-16">
|
||||
<h2 class="text-3xl md:text-4xl font-bold text-white mb-4">Häufige Fragen</h2>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4">
|
||||
{
|
||||
faqs.map((faq) => (
|
||||
<details class="group bg-[#0F1F0F] border border-green-900/30 rounded-xl">
|
||||
<summary class="flex items-center justify-between p-5 cursor-pointer list-none">
|
||||
<span class="font-medium text-white">{faq.question}</span>
|
||||
<span class="text-primary group-open:rotate-180 transition-transform">▼</span>
|
||||
</summary>
|
||||
<p class="px-5 pb-5 text-gray-400">{faq.answer}</p>
|
||||
</details>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- CTA Section -->
|
||||
<section class="py-20 px-4">
|
||||
<div class="container mx-auto max-w-4xl text-center">
|
||||
<h2 class="text-3xl md:text-4xl font-bold text-white mb-6">Starte jetzt mit NutriPhi</h2>
|
||||
<p class="text-xl text-gray-300 mb-10 max-w-2xl mx-auto">
|
||||
Kostenlos. Ohne Kreditkarte. Sofort loslegen.
|
||||
</p>
|
||||
<a
|
||||
href="https://app.nutriphi.manacore.app"
|
||||
class="inline-block px-10 py-4 bg-primary hover:bg-primary-hover text-white font-semibold rounded-xl transition-colors text-lg"
|
||||
>
|
||||
Jetzt kostenlos registrieren
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<!-- Footer -->
|
||||
<footer class="py-10 px-4 border-t border-green-900/30">
|
||||
<div class="container mx-auto max-w-6xl">
|
||||
<div class="flex flex-col md:flex-row items-center justify-between gap-4">
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="w-6 h-6 rounded bg-primary flex items-center justify-center">
|
||||
<span class="text-white font-bold text-xs">N</span>
|
||||
</div>
|
||||
<span class="text-sm text-gray-400">NutriPhi by ManaCore</span>
|
||||
</div>
|
||||
<div class="flex gap-6 text-sm text-gray-400">
|
||||
<a href="/privacy" class="hover:text-white transition-colors">Datenschutz</a>
|
||||
<a href="/terms" class="hover:text-white transition-colors">AGB</a>
|
||||
<a href="/imprint" class="hover:text-white transition-colors">Impressum</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
</Layout>
|
||||
18
apps/nutriphi/apps/landing/tailwind.config.cjs
Normal file
18
apps/nutriphi/apps/landing/tailwind.config.cjs
Normal file
|
|
@ -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')],
|
||||
};
|
||||
10
apps/nutriphi/apps/landing/tsconfig.json
Normal file
10
apps/nutriphi/apps/landing/tsconfig.json
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
{
|
||||
"extends": "astro/tsconfigs/strict",
|
||||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@components/*": ["src/components/*"],
|
||||
"@layouts/*": ["src/layouts/*"]
|
||||
}
|
||||
}
|
||||
}
|
||||
3
apps/nutriphi/apps/landing/wrangler.toml
Normal file
3
apps/nutriphi/apps/landing/wrangler.toml
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
name = "nutriphi-landing"
|
||||
compatibility_date = "2024-12-01"
|
||||
pages_build_output_dir = "dist"
|
||||
53
apps/nutriphi/apps/web/package.json
Normal file
53
apps/nutriphi/apps/web/package.json
Normal file
|
|
@ -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"
|
||||
}
|
||||
59
apps/nutriphi/apps/web/src/app.css
Normal file
59
apps/nutriphi/apps/web/src/app.css
Normal file
|
|
@ -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;
|
||||
}
|
||||
14
apps/nutriphi/apps/web/src/app.html
Normal file
14
apps/nutriphi/apps/web/src/app.html
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
<!doctype html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no" />
|
||||
<meta name="theme-color" content="#22C55E" />
|
||||
<meta name="description" content="NutriPhi - KI-gestützte Ernährungsanalyse per Foto" />
|
||||
%sveltekit.head%
|
||||
</head>
|
||||
<body data-sveltekit-preload-data="hover">
|
||||
<div style="display: contents">%sveltekit.body%</div>
|
||||
</body>
|
||||
</html>
|
||||
68
apps/nutriphi/apps/web/src/lib/api/client.ts
Normal file
68
apps/nutriphi/apps/web/src/lib/api/client.ts
Normal file
|
|
@ -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<HeadersInit> {
|
||||
const token = await authStore.getAccessToken();
|
||||
return {
|
||||
'Content-Type': 'application/json',
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
async get<T>(path: string): Promise<T> {
|
||||
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<T>(path: string, data: unknown): Promise<T> {
|
||||
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<T>(path: string, data: unknown): Promise<T> {
|
||||
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<void> {
|
||||
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();
|
||||
|
|
@ -0,0 +1,64 @@
|
|||
<script lang="ts">
|
||||
import { Camera, PenLine, Plus, X } from 'lucide-svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
|
||||
let isOpen = $state(false);
|
||||
|
||||
function toggleMenu() {
|
||||
isOpen = !isOpen;
|
||||
}
|
||||
|
||||
function handlePhoto() {
|
||||
isOpen = false;
|
||||
goto('/add?type=photo');
|
||||
}
|
||||
|
||||
function handleText() {
|
||||
isOpen = false;
|
||||
goto('/add?type=text');
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="relative">
|
||||
<!-- Backdrop -->
|
||||
{#if isOpen}
|
||||
<button
|
||||
class="fixed inset-0 bg-black/50 z-40"
|
||||
onclick={() => (isOpen = false)}
|
||||
aria-label="Menü schließen"
|
||||
></button>
|
||||
{/if}
|
||||
|
||||
<!-- Options -->
|
||||
{#if isOpen}
|
||||
<div class="absolute bottom-16 left-1/2 -translate-x-1/2 flex flex-col gap-3 z-50">
|
||||
<button
|
||||
onclick={handlePhoto}
|
||||
class="flex items-center gap-3 px-5 py-3 bg-[var(--color-background-card)] border border-[var(--color-border)] rounded-full shadow-lg hover:bg-[var(--color-background-elevated)] transition-all"
|
||||
>
|
||||
<Camera class="w-5 h-5 text-[var(--color-primary)]" />
|
||||
<span class="text-[var(--color-text-primary)] font-medium">Foto</span>
|
||||
</button>
|
||||
<button
|
||||
onclick={handleText}
|
||||
class="flex items-center gap-3 px-5 py-3 bg-[var(--color-background-card)] border border-[var(--color-border)] rounded-full shadow-lg hover:bg-[var(--color-background-elevated)] transition-all"
|
||||
>
|
||||
<PenLine class="w-5 h-5 text-[var(--color-secondary)]" />
|
||||
<span class="text-[var(--color-text-primary)] font-medium">Text</span>
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Main Button -->
|
||||
<button
|
||||
onclick={toggleMenu}
|
||||
class="w-14 h-14 rounded-full bg-[var(--color-primary)] hover:bg-[var(--color-primary-hover)] shadow-lg flex items-center justify-center transition-all z-50 relative"
|
||||
class:rotate-45={isOpen}
|
||||
>
|
||||
{#if isOpen}
|
||||
<X class="w-6 h-6 text-white" />
|
||||
{:else}
|
||||
<Plus class="w-6 h-6 text-white" />
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
|
|
@ -0,0 +1,83 @@
|
|||
<script lang="ts">
|
||||
import { mealsStore } from '$lib/stores/meals.svelte';
|
||||
import { onMount } from 'svelte';
|
||||
import ProgressRing from './ProgressRing.svelte';
|
||||
|
||||
onMount(() => {
|
||||
mealsStore.fetchDailySummary();
|
||||
});
|
||||
|
||||
let progress = $derived(mealsStore.dailySummary?.progress);
|
||||
let caloriePercent = $derived(progress?.calories?.percentage ?? 0);
|
||||
</script>
|
||||
|
||||
<div class="bg-[var(--color-background-card)] rounded-2xl p-4 border border-[var(--color-border)]">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h2 class="text-lg font-semibold text-[var(--color-text-primary)]">Heute</h2>
|
||||
<span class="text-sm text-[var(--color-text-secondary)]">
|
||||
{new Date().toLocaleDateString('de-DE', { weekday: 'long', day: 'numeric', month: 'long' })}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Calories Ring -->
|
||||
<div class="flex items-center gap-6">
|
||||
<ProgressRing
|
||||
percentage={caloriePercent}
|
||||
size={100}
|
||||
strokeWidth={8}
|
||||
color="var(--color-calories)"
|
||||
>
|
||||
<div class="text-center">
|
||||
<div class="text-2xl font-bold text-[var(--color-text-primary)]">
|
||||
{progress?.calories?.current ?? 0}
|
||||
</div>
|
||||
<div class="text-xs text-[var(--color-text-secondary)]">
|
||||
/ {progress?.calories?.target ?? 2000}
|
||||
</div>
|
||||
</div>
|
||||
</ProgressRing>
|
||||
|
||||
<!-- Macros -->
|
||||
<div class="flex-1 grid grid-cols-3 gap-2">
|
||||
<div class="text-center">
|
||||
<div class="text-sm font-medium text-[var(--color-protein)]">
|
||||
{progress?.protein?.current ?? 0}g
|
||||
</div>
|
||||
<div class="text-xs text-[var(--color-text-muted)]">Protein</div>
|
||||
<div class="mt-1 h-1 bg-[var(--color-background-elevated)] rounded-full overflow-hidden">
|
||||
<div
|
||||
class="h-full bg-[var(--color-protein)] transition-all"
|
||||
style="width: {progress?.protein?.percentage ?? 0}%"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="text-center">
|
||||
<div class="text-sm font-medium text-[var(--color-carbs)]">
|
||||
{progress?.carbs?.current ?? 0}g
|
||||
</div>
|
||||
<div class="text-xs text-[var(--color-text-muted)]">Carbs</div>
|
||||
<div class="mt-1 h-1 bg-[var(--color-background-elevated)] rounded-full overflow-hidden">
|
||||
<div
|
||||
class="h-full bg-[var(--color-carbs)] transition-all"
|
||||
style="width: {progress?.carbs?.percentage ?? 0}%"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="text-center">
|
||||
<div class="text-sm font-medium text-[var(--color-fat)]">
|
||||
{progress?.fat?.current ?? 0}g
|
||||
</div>
|
||||
<div class="text-xs text-[var(--color-text-muted)]">Fett</div>
|
||||
<div class="mt-1 h-1 bg-[var(--color-background-elevated)] rounded-full overflow-hidden">
|
||||
<div
|
||||
class="h-full bg-[var(--color-fat)] transition-all"
|
||||
style="width: {progress?.fat?.percentage ?? 0}%"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
33
apps/nutriphi/apps/web/src/lib/components/Header.svelte
Normal file
33
apps/nutriphi/apps/web/src/lib/components/Header.svelte
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
<script lang="ts">
|
||||
import { User, Settings } from 'lucide-svelte';
|
||||
</script>
|
||||
|
||||
<header
|
||||
class="sticky top-0 z-40 bg-[var(--color-background-page)]/95 backdrop-blur border-b border-[var(--color-border)]"
|
||||
>
|
||||
<div class="container mx-auto px-4 max-w-lg">
|
||||
<div class="flex items-center justify-between h-14">
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="w-8 h-8 rounded-lg bg-[var(--color-primary)] flex items-center justify-center">
|
||||
<span class="text-white font-bold text-sm">N</span>
|
||||
</div>
|
||||
<span class="font-semibold text-[var(--color-text-primary)]">NutriPhi</span>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<a
|
||||
href="/settings"
|
||||
class="p-2 rounded-lg hover:bg-[var(--color-background-card)] transition-colors"
|
||||
>
|
||||
<Settings class="w-5 h-5 text-[var(--color-text-secondary)]" />
|
||||
</a>
|
||||
<a
|
||||
href="/profile"
|
||||
class="p-2 rounded-lg hover:bg-[var(--color-background-card)] transition-colors"
|
||||
>
|
||||
<User class="w-5 h-5 text-[var(--color-text-secondary)]" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
76
apps/nutriphi/apps/web/src/lib/components/MealList.svelte
Normal file
76
apps/nutriphi/apps/web/src/lib/components/MealList.svelte
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
<script lang="ts">
|
||||
import { mealsStore } from '$lib/stores/meals.svelte';
|
||||
import { onMount } from 'svelte';
|
||||
import { MEAL_TYPE_LABELS } from '@nutriphi/shared';
|
||||
import { Trash2, Camera, PenLine } from 'lucide-svelte';
|
||||
|
||||
onMount(() => {
|
||||
mealsStore.fetchTodaysMeals();
|
||||
});
|
||||
|
||||
async function deleteMeal(id: string) {
|
||||
if (confirm('Mahlzeit wirklich löschen?')) {
|
||||
await mealsStore.deleteMeal(id);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="space-y-3">
|
||||
{#if mealsStore.loading}
|
||||
<div class="text-center py-8 text-[var(--color-text-secondary)]">Laden...</div>
|
||||
{:else if mealsStore.meals.length === 0}
|
||||
<div class="text-center py-8">
|
||||
<p class="text-[var(--color-text-secondary)] mb-2">Noch keine Mahlzeiten heute</p>
|
||||
<p class="text-sm text-[var(--color-text-muted)]">
|
||||
Tippe auf + um deine erste Mahlzeit hinzuzufügen
|
||||
</p>
|
||||
</div>
|
||||
{:else}
|
||||
{#each mealsStore.meals as meal (meal.id)}
|
||||
<div
|
||||
class="bg-[var(--color-background-card)] rounded-xl p-4 border border-[var(--color-border)]"
|
||||
>
|
||||
<div class="flex items-start justify-between">
|
||||
<div class="flex-1">
|
||||
<div class="flex items-center gap-2 mb-1">
|
||||
{#if meal.inputType === 'photo'}
|
||||
<Camera class="w-4 h-4 text-[var(--color-text-muted)]" />
|
||||
{:else}
|
||||
<PenLine class="w-4 h-4 text-[var(--color-text-muted)]" />
|
||||
{/if}
|
||||
<span class="text-xs text-[var(--color-text-muted)] uppercase tracking-wide">
|
||||
{MEAL_TYPE_LABELS[meal.mealType as keyof typeof MEAL_TYPE_LABELS]?.de ??
|
||||
meal.mealType}
|
||||
</span>
|
||||
</div>
|
||||
<p class="text-[var(--color-text-primary)] font-medium">
|
||||
{meal.description}
|
||||
</p>
|
||||
{#if meal.nutrition}
|
||||
<div class="flex gap-4 mt-2 text-sm">
|
||||
<span class="text-[var(--color-calories)]">
|
||||
{Math.round(meal.nutrition.calories)} kcal
|
||||
</span>
|
||||
<span class="text-[var(--color-protein)]">
|
||||
{Math.round(meal.nutrition.protein)}g P
|
||||
</span>
|
||||
<span class="text-[var(--color-carbs)]">
|
||||
{Math.round(meal.nutrition.carbohydrates)}g K
|
||||
</span>
|
||||
<span class="text-[var(--color-fat)]">
|
||||
{Math.round(meal.nutrition.fat)}g F
|
||||
</span>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<button
|
||||
onclick={() => deleteMeal(meal.id)}
|
||||
class="p-2 rounded-lg hover:bg-[var(--color-background-elevated)] text-[var(--color-text-muted)] hover:text-red-400 transition-colors"
|
||||
>
|
||||
<Trash2 class="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
|
|
@ -0,0 +1,55 @@
|
|||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte';
|
||||
|
||||
let {
|
||||
percentage = 0,
|
||||
size = 100,
|
||||
strokeWidth = 8,
|
||||
color = 'var(--color-primary)',
|
||||
children,
|
||||
}: {
|
||||
percentage?: number;
|
||||
size?: number;
|
||||
strokeWidth?: number;
|
||||
color?: string;
|
||||
children?: Snippet;
|
||||
} = $props();
|
||||
|
||||
let radius = $derived((size - strokeWidth) / 2);
|
||||
let circumference = $derived(2 * Math.PI * radius);
|
||||
let offset = $derived(circumference - (Math.min(percentage, 100) / 100) * circumference);
|
||||
</script>
|
||||
|
||||
<div class="relative" style="width: {size}px; height: {size}px;">
|
||||
<svg class="transform -rotate-90" width={size} height={size}>
|
||||
<!-- Background circle -->
|
||||
<circle
|
||||
cx={size / 2}
|
||||
cy={size / 2}
|
||||
r={radius}
|
||||
fill="none"
|
||||
stroke="var(--color-background-elevated)"
|
||||
stroke-width={strokeWidth}
|
||||
/>
|
||||
<!-- Progress circle -->
|
||||
<circle
|
||||
cx={size / 2}
|
||||
cy={size / 2}
|
||||
r={radius}
|
||||
fill="none"
|
||||
stroke={color}
|
||||
stroke-width={strokeWidth}
|
||||
stroke-linecap="round"
|
||||
stroke-dasharray={circumference}
|
||||
stroke-dashoffset={offset}
|
||||
class="transition-all duration-500"
|
||||
/>
|
||||
</svg>
|
||||
|
||||
<!-- Center content -->
|
||||
<div class="absolute inset-0 flex items-center justify-center">
|
||||
{#if children}
|
||||
{@render children()}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
213
apps/nutriphi/apps/web/src/lib/stores/auth.svelte.ts
Normal file
213
apps/nutriphi/apps/web/src/lib/stores/auth.svelte.ts
Normal file
|
|
@ -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<typeof initializeWebAuth>['authService'] | null = null;
|
||||
let _tokenManager: ReturnType<typeof initializeWebAuth>['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<UserData | null>(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<string | null> {
|
||||
const tokenManager = getTokenManager();
|
||||
if (!tokenManager) {
|
||||
return null;
|
||||
}
|
||||
return await tokenManager.getValidToken();
|
||||
},
|
||||
};
|
||||
62
apps/nutriphi/apps/web/src/lib/stores/meals.svelte.ts
Normal file
62
apps/nutriphi/apps/web/src/lib/stores/meals.svelte.ts
Normal file
|
|
@ -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<MealWithNutrition[]>([]);
|
||||
loading = $state(false);
|
||||
error = $state<string | null>(null);
|
||||
dailySummary = $state<DailySummary | null>(null);
|
||||
|
||||
async fetchTodaysMeals() {
|
||||
this.loading = true;
|
||||
this.error = null;
|
||||
try {
|
||||
const today = new Date().toISOString().split('T')[0];
|
||||
this.meals = await apiClient.get<MealWithNutrition[]>(`/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<DailySummary>(`/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<MealWithNutrition>('/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();
|
||||
5
apps/nutriphi/apps/web/src/routes/(auth)/+layout.svelte
Normal file
5
apps/nutriphi/apps/web/src/routes/(auth)/+layout.svelte
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
<script lang="ts">
|
||||
let { children } = $props();
|
||||
</script>
|
||||
|
||||
{@render children()}
|
||||
|
|
@ -0,0 +1,29 @@
|
|||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { ForgotPasswordPage } from '@manacore/shared-auth-ui';
|
||||
import { getForgotPasswordTranslations } from '@manacore/shared-i18n';
|
||||
import { NutriPhiLogo } from '@manacore/shared-branding';
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
|
||||
const translations = $derived(getForgotPasswordTranslations('de'));
|
||||
|
||||
async function handleForgotPassword(email: string) {
|
||||
return authStore.resetPassword(email);
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{translations.titleForm} | NutriPhi</title>
|
||||
</svelte:head>
|
||||
|
||||
<ForgotPasswordPage
|
||||
appName="NutriPhi"
|
||||
logo={NutriPhiLogo}
|
||||
primaryColor="#22C55E"
|
||||
onForgotPassword={handleForgotPassword}
|
||||
{goto}
|
||||
loginPath="/login"
|
||||
lightBackground="#dcfce7"
|
||||
darkBackground="#052e16"
|
||||
{translations}
|
||||
/>
|
||||
52
apps/nutriphi/apps/web/src/routes/(auth)/login/+page.svelte
Normal file
52
apps/nutriphi/apps/web/src/routes/(auth)/login/+page.svelte
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { page } from '$app/stores';
|
||||
import { browser } from '$app/environment';
|
||||
import { LoginPage } from '@manacore/shared-auth-ui';
|
||||
import { getLoginTranslations } from '@manacore/shared-i18n';
|
||||
import { NutriPhiLogo } from '@manacore/shared-branding';
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
|
||||
// Get redirect URL from query params or sessionStorage
|
||||
const redirectTo = $derived.by(() => {
|
||||
const queryRedirect = $page.url.searchParams.get('redirectTo');
|
||||
if (queryRedirect) return queryRedirect;
|
||||
|
||||
if (browser) {
|
||||
const sessionRedirect = sessionStorage.getItem('auth-return-url');
|
||||
if (sessionRedirect) {
|
||||
sessionStorage.removeItem('auth-return-url');
|
||||
return sessionRedirect;
|
||||
}
|
||||
}
|
||||
|
||||
return '/';
|
||||
});
|
||||
|
||||
// German translations (NutriPhi is German-focused)
|
||||
const translations = $derived(getLoginTranslations('de'));
|
||||
|
||||
async function handleSignIn(email: string, password: string) {
|
||||
return authStore.signIn(email, password);
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{translations.title} | NutriPhi</title>
|
||||
</svelte:head>
|
||||
|
||||
<LoginPage
|
||||
appName="NutriPhi"
|
||||
logo={NutriPhiLogo}
|
||||
primaryColor="#22C55E"
|
||||
onSignIn={handleSignIn}
|
||||
{goto}
|
||||
enableGoogle={false}
|
||||
enableApple={false}
|
||||
successRedirect={redirectTo}
|
||||
registerPath="/register"
|
||||
forgotPasswordPath="/forgot-password"
|
||||
lightBackground="#dcfce7"
|
||||
darkBackground="#052e16"
|
||||
{translations}
|
||||
/>
|
||||
|
|
@ -0,0 +1,43 @@
|
|||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { browser } from '$app/environment';
|
||||
import { RegisterPage } from '@manacore/shared-auth-ui';
|
||||
import { getRegisterTranslations } from '@manacore/shared-i18n';
|
||||
import { NutriPhiLogo } from '@manacore/shared-branding';
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
|
||||
// Get redirect URL from sessionStorage
|
||||
const redirectTo = $derived.by(() => {
|
||||
if (browser) {
|
||||
const sessionRedirect = sessionStorage.getItem('auth-return-url');
|
||||
if (sessionRedirect) {
|
||||
sessionStorage.removeItem('auth-return-url');
|
||||
return sessionRedirect;
|
||||
}
|
||||
}
|
||||
return '/';
|
||||
});
|
||||
|
||||
const translations = $derived(getRegisterTranslations('de'));
|
||||
|
||||
async function handleSignUp(email: string, password: string) {
|
||||
return authStore.signUp(email, password);
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{translations.title} | NutriPhi</title>
|
||||
</svelte:head>
|
||||
|
||||
<RegisterPage
|
||||
appName="NutriPhi"
|
||||
logo={NutriPhiLogo}
|
||||
primaryColor="#22C55E"
|
||||
onSignUp={handleSignUp}
|
||||
{goto}
|
||||
successRedirect={redirectTo}
|
||||
loginPath="/login"
|
||||
lightBackground="#dcfce7"
|
||||
darkBackground="#052e16"
|
||||
{translations}
|
||||
/>
|
||||
17
apps/nutriphi/apps/web/src/routes/+layout.svelte
Normal file
17
apps/nutriphi/apps/web/src/routes/+layout.svelte
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
<script lang="ts">
|
||||
import '../app.css';
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
|
||||
let { children } = $props();
|
||||
|
||||
// Initialize auth on mount
|
||||
$effect(() => {
|
||||
authStore.initialize();
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>NutriPhi - Ernährung verstehen</title>
|
||||
</svelte:head>
|
||||
|
||||
{@render children()}
|
||||
46
apps/nutriphi/apps/web/src/routes/+page.svelte
Normal file
46
apps/nutriphi/apps/web/src/routes/+page.svelte
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
<script lang="ts">
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import Header from '$lib/components/Header.svelte';
|
||||
import DailySummary from '$lib/components/DailySummary.svelte';
|
||||
import MealList from '$lib/components/MealList.svelte';
|
||||
import AddMealButton from '$lib/components/AddMealButton.svelte';
|
||||
import { Camera, PenLine } from 'lucide-svelte';
|
||||
|
||||
// Redirect to login if not authenticated
|
||||
$effect(() => {
|
||||
if (!authStore.loading && !authStore.isAuthenticated) {
|
||||
goto('/login');
|
||||
}
|
||||
});
|
||||
|
||||
let showAddMenu = $state(false);
|
||||
</script>
|
||||
|
||||
{#if authStore.isAuthenticated}
|
||||
<div class="min-h-screen bg-[var(--color-background-page)]">
|
||||
<Header />
|
||||
|
||||
<main class="container mx-auto px-4 pb-24 pt-4 max-w-lg">
|
||||
<!-- Daily Summary -->
|
||||
<DailySummary />
|
||||
|
||||
<!-- Today's Meals -->
|
||||
<div class="mt-6">
|
||||
<h2 class="text-lg font-semibold text-[var(--color-text-primary)] mb-3">
|
||||
Heutige Mahlzeiten
|
||||
</h2>
|
||||
<MealList />
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- Floating Add Button -->
|
||||
<div class="fixed bottom-6 left-1/2 -translate-x-1/2">
|
||||
<AddMealButton />
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="min-h-screen bg-[var(--color-background-page)] flex items-center justify-center">
|
||||
<div class="animate-pulse text-[var(--color-text-secondary)]">Laden...</div>
|
||||
</div>
|
||||
{/if}
|
||||
287
apps/nutriphi/apps/web/src/routes/add/+page.svelte
Normal file
287
apps/nutriphi/apps/web/src/routes/add/+page.svelte
Normal file
|
|
@ -0,0 +1,287 @@
|
|||
<script lang="ts">
|
||||
import { page } from '$app/stores';
|
||||
import { goto } from '$app/navigation';
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
import { mealsStore } from '$lib/stores/meals.svelte';
|
||||
import { apiClient } from '$lib/api/client';
|
||||
import { suggestMealType, MEAL_TYPE_LABELS } from '@nutriphi/shared';
|
||||
import type { AIAnalysisResult } from '@nutriphi/shared';
|
||||
import { Camera, ArrowLeft, Loader2, Check } from 'lucide-svelte';
|
||||
|
||||
let inputType = $derived($page.url.searchParams.get('type') || 'photo');
|
||||
let mealType = $state(suggestMealType());
|
||||
let textInput = $state('');
|
||||
let imagePreview = $state<string | null>(null);
|
||||
let imageBase64 = $state<string | null>(null);
|
||||
let analyzing = $state(false);
|
||||
let analysisResult = $state<AIAnalysisResult | null>(null);
|
||||
let error = $state('');
|
||||
let saving = $state(false);
|
||||
|
||||
// Redirect if not authenticated
|
||||
$effect(() => {
|
||||
if (!authStore.loading && !authStore.isAuthenticated) {
|
||||
goto('/login');
|
||||
}
|
||||
});
|
||||
|
||||
function handleFileSelect(e: Event) {
|
||||
const input = e.target as HTMLInputElement;
|
||||
const file = input.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
const result = reader.result as string;
|
||||
imagePreview = result;
|
||||
// Extract base64 without data URL prefix
|
||||
imageBase64 = result.split(',')[1];
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
|
||||
async function analyze() {
|
||||
error = '';
|
||||
analyzing = true;
|
||||
|
||||
try {
|
||||
if (inputType === 'photo' && imageBase64) {
|
||||
analysisResult = await apiClient.post<AIAnalysisResult>('/analysis/photo', {
|
||||
imageBase64,
|
||||
mimeType: 'image/jpeg',
|
||||
});
|
||||
} else if (inputType === 'text' && textInput.trim()) {
|
||||
analysisResult = await apiClient.post<AIAnalysisResult>('/analysis/text', {
|
||||
description: textInput,
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
error = err instanceof Error ? err.message : 'Analyse fehlgeschlagen';
|
||||
} finally {
|
||||
analyzing = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function saveMeal() {
|
||||
if (!analysisResult) return;
|
||||
|
||||
saving = true;
|
||||
try {
|
||||
await mealsStore.addMeal({
|
||||
date: new Date().toISOString(),
|
||||
mealType,
|
||||
inputType: inputType as 'photo' | 'text',
|
||||
description: analysisResult.description,
|
||||
confidence: analysisResult.confidence,
|
||||
calories: analysisResult.totalNutrition.calories,
|
||||
protein: analysisResult.totalNutrition.protein,
|
||||
carbohydrates: analysisResult.totalNutrition.carbohydrates,
|
||||
fat: analysisResult.totalNutrition.fat,
|
||||
fiber: analysisResult.totalNutrition.fiber,
|
||||
sugar: analysisResult.totalNutrition.sugar,
|
||||
});
|
||||
goto('/');
|
||||
} catch (err) {
|
||||
error = err instanceof Error ? err.message : 'Speichern fehlgeschlagen';
|
||||
} finally {
|
||||
saving = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="min-h-screen bg-[var(--color-background-page)]">
|
||||
<!-- Header -->
|
||||
<header
|
||||
class="sticky top-0 z-40 bg-[var(--color-background-page)]/95 backdrop-blur border-b border-[var(--color-border)]"
|
||||
>
|
||||
<div class="container mx-auto px-4 max-w-lg">
|
||||
<div class="flex items-center h-14">
|
||||
<button
|
||||
onclick={() => goto('/')}
|
||||
class="p-2 -ml-2 rounded-lg hover:bg-[var(--color-background-card)]"
|
||||
>
|
||||
<ArrowLeft class="w-5 h-5 text-[var(--color-text-secondary)]" />
|
||||
</button>
|
||||
<h1 class="ml-2 font-semibold text-[var(--color-text-primary)]">
|
||||
{inputType === 'photo' ? 'Foto analysieren' : 'Mahlzeit eingeben'}
|
||||
</h1>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="container mx-auto px-4 py-6 max-w-lg">
|
||||
<!-- Meal Type Selector -->
|
||||
<div class="mb-6">
|
||||
<label class="block text-sm font-medium text-[var(--color-text-secondary)] mb-2">
|
||||
Mahlzeit
|
||||
</label>
|
||||
<div class="grid grid-cols-4 gap-2">
|
||||
{#each ['breakfast', 'lunch', 'dinner', 'snack'] as type}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (mealType = type as any)}
|
||||
class="py-2 px-3 rounded-lg text-sm font-medium transition-colors"
|
||||
class:bg-[var(--color-primary)]={mealType === type}
|
||||
class:text-white={mealType === type}
|
||||
class:bg-[var(--color-background-card)]={mealType !== type}
|
||||
class:text-[var(--color-text-secondary)]={mealType !== type}
|
||||
>
|
||||
{MEAL_TYPE_LABELS[type as keyof typeof MEAL_TYPE_LABELS]?.de}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if inputType === 'photo'}
|
||||
<!-- Photo Input -->
|
||||
<div class="mb-6">
|
||||
{#if imagePreview}
|
||||
<img
|
||||
src={imagePreview}
|
||||
alt="Vorschau"
|
||||
class="w-full aspect-square object-cover rounded-xl mb-4"
|
||||
/>
|
||||
{:else}
|
||||
<label
|
||||
class="block w-full aspect-square bg-[var(--color-background-card)] border-2 border-dashed border-[var(--color-border)] rounded-xl cursor-pointer hover:border-[var(--color-primary)] transition-colors"
|
||||
>
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
capture="environment"
|
||||
onchange={handleFileSelect}
|
||||
class="hidden"
|
||||
/>
|
||||
<div
|
||||
class="h-full flex flex-col items-center justify-center text-[var(--color-text-muted)]"
|
||||
>
|
||||
<Camera class="w-12 h-12 mb-2" />
|
||||
<span>Foto aufnehmen oder auswählen</span>
|
||||
</div>
|
||||
</label>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<!-- Text Input -->
|
||||
<div class="mb-6">
|
||||
<label class="block text-sm font-medium text-[var(--color-text-secondary)] mb-2">
|
||||
Was hast du gegessen?
|
||||
</label>
|
||||
<textarea
|
||||
bind:value={textInput}
|
||||
rows="4"
|
||||
placeholder="z.B. Spaghetti Bolognese mit Parmesan und Salat"
|
||||
class="w-full px-4 py-3 bg-[var(--color-background-card)] border border-[var(--color-border)] rounded-xl text-[var(--color-text-primary)] placeholder:text-[var(--color-text-muted)] focus:outline-none focus:border-[var(--color-primary)] resize-none"
|
||||
></textarea>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if error}
|
||||
<p class="text-red-400 text-sm mb-4">{error}</p>
|
||||
{/if}
|
||||
|
||||
{#if !analysisResult}
|
||||
<!-- Analyze Button -->
|
||||
<button
|
||||
onclick={analyze}
|
||||
disabled={analyzing ||
|
||||
(inputType === 'photo' && !imageBase64) ||
|
||||
(inputType === 'text' && !textInput.trim())}
|
||||
class="w-full py-3 bg-[var(--color-primary)] hover:bg-[var(--color-primary-hover)] text-white font-medium rounded-xl transition-colors disabled:opacity-50 flex items-center justify-center gap-2"
|
||||
>
|
||||
{#if analyzing}
|
||||
<Loader2 class="w-5 h-5 animate-spin" />
|
||||
Analysiere...
|
||||
{:else}
|
||||
Analysieren
|
||||
{/if}
|
||||
</button>
|
||||
{:else}
|
||||
<!-- Analysis Result -->
|
||||
<div
|
||||
class="bg-[var(--color-background-card)] rounded-xl p-4 border border-[var(--color-border)] mb-4"
|
||||
>
|
||||
<h3 class="font-semibold text-[var(--color-text-primary)] mb-2">
|
||||
{analysisResult.description}
|
||||
</h3>
|
||||
|
||||
<!-- Detected Foods -->
|
||||
{#if analysisResult.foods.length > 0}
|
||||
<div class="mb-4">
|
||||
<p class="text-sm text-[var(--color-text-muted)] mb-2">Erkannte Lebensmittel:</p>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
{#each analysisResult.foods as food}
|
||||
<span
|
||||
class="px-2 py-1 bg-[var(--color-background-elevated)] rounded-lg text-sm text-[var(--color-text-secondary)]"
|
||||
>
|
||||
{food.name} ({food.quantity})
|
||||
</span>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Nutrition -->
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<div class="text-center p-3 bg-[var(--color-background-elevated)] rounded-lg">
|
||||
<div class="text-xl font-bold text-[var(--color-calories)]">
|
||||
{Math.round(analysisResult.totalNutrition.calories)}
|
||||
</div>
|
||||
<div class="text-xs text-[var(--color-text-muted)]">Kalorien</div>
|
||||
</div>
|
||||
<div class="text-center p-3 bg-[var(--color-background-elevated)] rounded-lg">
|
||||
<div class="text-xl font-bold text-[var(--color-protein)]">
|
||||
{Math.round(analysisResult.totalNutrition.protein)}g
|
||||
</div>
|
||||
<div class="text-xs text-[var(--color-text-muted)]">Protein</div>
|
||||
</div>
|
||||
<div class="text-center p-3 bg-[var(--color-background-elevated)] rounded-lg">
|
||||
<div class="text-xl font-bold text-[var(--color-carbs)]">
|
||||
{Math.round(analysisResult.totalNutrition.carbohydrates)}g
|
||||
</div>
|
||||
<div class="text-xs text-[var(--color-text-muted)]">Carbs</div>
|
||||
</div>
|
||||
<div class="text-center p-3 bg-[var(--color-background-elevated)] rounded-lg">
|
||||
<div class="text-xl font-bold text-[var(--color-fat)]">
|
||||
{Math.round(analysisResult.totalNutrition.fat)}g
|
||||
</div>
|
||||
<div class="text-xs text-[var(--color-text-muted)]">Fett</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Confidence -->
|
||||
<p class="text-xs text-[var(--color-text-muted)] mt-3 text-center">
|
||||
Konfidenz: {Math.round(analysisResult.confidence * 100)}%
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Save Button -->
|
||||
<button
|
||||
onclick={saveMeal}
|
||||
disabled={saving}
|
||||
class="w-full py-3 bg-[var(--color-primary)] hover:bg-[var(--color-primary-hover)] text-white font-medium rounded-xl transition-colors disabled:opacity-50 flex items-center justify-center gap-2"
|
||||
>
|
||||
{#if saving}
|
||||
<Loader2 class="w-5 h-5 animate-spin" />
|
||||
Speichern...
|
||||
{:else}
|
||||
<Check class="w-5 h-5" />
|
||||
Speichern
|
||||
{/if}
|
||||
</button>
|
||||
|
||||
<!-- Reset Button -->
|
||||
<button
|
||||
onclick={() => {
|
||||
analysisResult = null;
|
||||
imagePreview = null;
|
||||
imageBase64 = null;
|
||||
textInput = '';
|
||||
}}
|
||||
class="w-full mt-2 py-3 bg-[var(--color-background-card)] text-[var(--color-text-secondary)] font-medium rounded-xl hover:bg-[var(--color-background-elevated)] transition-colors"
|
||||
>
|
||||
Neu analysieren
|
||||
</button>
|
||||
{/if}
|
||||
</main>
|
||||
</div>
|
||||
14
apps/nutriphi/apps/web/svelte.config.js
Normal file
14
apps/nutriphi/apps/web/svelte.config.js
Normal file
|
|
@ -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;
|
||||
14
apps/nutriphi/apps/web/tsconfig.json
Normal file
14
apps/nutriphi/apps/web/tsconfig.json
Normal file
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
47
apps/nutriphi/apps/web/vite.config.ts
Normal file
47
apps/nutriphi/apps/web/vite.config.ts
Normal file
|
|
@ -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',
|
||||
],
|
||||
},
|
||||
});
|
||||
19
apps/nutriphi/package.json
Normal file
19
apps/nutriphi/package.json
Normal file
|
|
@ -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"
|
||||
}
|
||||
20
apps/nutriphi/packages/shared/package.json
Normal file
20
apps/nutriphi/packages/shared/package.json
Normal file
|
|
@ -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": {}
|
||||
}
|
||||
124
apps/nutriphi/packages/shared/src/constants/index.ts
Normal file
124
apps/nutriphi/packages/shared/src/constants/index.ts
Normal file
|
|
@ -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;
|
||||
8
apps/nutriphi/packages/shared/src/index.ts
Normal file
8
apps/nutriphi/packages/shared/src/index.ts
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
// Types
|
||||
export * from './types';
|
||||
|
||||
// Constants
|
||||
export * from './constants';
|
||||
|
||||
// Utils
|
||||
export * from './utils';
|
||||
185
apps/nutriphi/packages/shared/src/types/index.ts
Normal file
185
apps/nutriphi/packages/shared/src/types/index.ts
Normal file
|
|
@ -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<MealNutrition, 'id' | 'mealId'>;
|
||||
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<MealNutrition, 'id' | 'mealId'>;
|
||||
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<MealNutrition, 'id' | 'mealId'>;
|
||||
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';
|
||||
}
|
||||
174
apps/nutriphi/packages/shared/src/utils/index.ts
Normal file
174
apps/nutriphi/packages/shared/src/utils/index.ts
Normal file
|
|
@ -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<MealNutrition>,
|
||||
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<MealNutrition> | null }>
|
||||
): Partial<MealNutrition> {
|
||||
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<MealNutrition>
|
||||
): 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()
|
||||
);
|
||||
}
|
||||
19
apps/nutriphi/packages/shared/tsconfig.json
Normal file
19
apps/nutriphi/packages/shared/tsconfig.json
Normal file
|
|
@ -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"]
|
||||
}
|
||||
13
package.json
13
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",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue