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

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