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:
Till-JS 2026-01-25 13:19:51 +01:00
parent b77dd4159b
commit b6af01ed67
70 changed files with 4256 additions and 4 deletions

View file

@ -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
View 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 |

View 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,
});

View file

@ -0,0 +1,10 @@
{
"$schema": "https://json.schemastore.org/nest-cli",
"collection": "@nestjs/schematics",
"sourceRoot": "src",
"compilerOptions": {
"deleteOutDir": false,
"assets": [],
"watchAssets": false
}
}

View 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"
}
}

View file

@ -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'}`
);
}
}
}

View 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 {}

View 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);
}
}

View 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;
}
}

View 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 {}

View 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();
}
}

View 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>;

View 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;

View file

@ -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);
}
}

View 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 {}

View file

@ -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;
}
}

View 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);
}
}

View 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 {}

View 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;
}
}

View 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(),
};
}
}

View file

@ -0,0 +1,7 @@
import { Module } from '@nestjs/common';
import { HealthController } from './health.controller';
@Module({
controllers: [HealthController],
})
export class HealthModule {}

View 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();

View 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
);
}
}

View 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 {}

View 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);
}
}

View file

@ -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);
}
}

View file

@ -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 {}

View file

@ -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;
}
}

View 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);
}
}

View 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 {}

View 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),
},
};
}
}

View 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,
};

View 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;
}

View 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"]
}

View 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',
});

View 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"
}
}

View 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>

View 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>

View 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')],
};

View file

@ -0,0 +1,10 @@
{
"extends": "astro/tsconfigs/strict",
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@components/*": ["src/components/*"],
"@layouts/*": ["src/layouts/*"]
}
}
}

View file

@ -0,0 +1,3 @@
name = "nutriphi-landing"
compatibility_date = "2024-12-01"
pages_build_output_dir = "dist"

View 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"
}

View 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;
}

View 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>

View 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();

View file

@ -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>

View file

@ -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>

View 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>

View 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>

View file

@ -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>

View 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();
},
};

View 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();

View file

@ -0,0 +1,5 @@
<script lang="ts">
let { children } = $props();
</script>
{@render children()}

View file

@ -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}
/>

View 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}
/>

View file

@ -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}
/>

View 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()}

View 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}

View 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>

View 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;

View 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"
}
}

View 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',
],
},
});

View 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"
}

View 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": {}
}

View 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;

View file

@ -0,0 +1,8 @@
// Types
export * from './types';
// Constants
export * from './constants';
// Utils
export * from './utils';

View 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';
}

View 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()
);
}

View 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"]
}

View file

@ -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",