mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-21 22:46:41 +02:00
✨ feat(nutriphi): add AI-powered nutrition tracking app
- NestJS backend with Gemini AI for food photo analysis - SvelteKit web app with Svelte 5 runes - Drizzle ORM schema for meals, goals, favorites, recommendations - Unified auth pages using shared-auth-ui components - Landing page with Astro - Shared types and utilities package
This commit is contained in:
parent
b77dd4159b
commit
b6af01ed67
70 changed files with 4256 additions and 4 deletions
124
apps/nutriphi/packages/shared/src/constants/index.ts
Normal file
124
apps/nutriphi/packages/shared/src/constants/index.ts
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
// Default daily recommended values (based on 2000 kcal diet)
|
||||
export const DEFAULT_DAILY_VALUES = {
|
||||
calories: 2000,
|
||||
protein: 50, // grams
|
||||
carbohydrates: 275, // grams
|
||||
fat: 78, // grams
|
||||
fiber: 28, // grams
|
||||
sugar: 50, // grams (max)
|
||||
saturatedFat: 20, // grams (max)
|
||||
// Vitamins
|
||||
vitaminA: 900, // µg RAE
|
||||
vitaminB1: 1.2, // mg
|
||||
vitaminB2: 1.3, // mg
|
||||
vitaminB3: 16, // mg
|
||||
vitaminB5: 5, // mg
|
||||
vitaminB6: 1.7, // mg
|
||||
vitaminB7: 30, // µg
|
||||
vitaminB9: 400, // µg
|
||||
vitaminB12: 2.4, // µg
|
||||
vitaminC: 90, // mg
|
||||
vitaminD: 20, // µg
|
||||
vitaminE: 15, // mg
|
||||
vitaminK: 120, // µg
|
||||
// Minerals
|
||||
calcium: 1000, // mg
|
||||
iron: 18, // mg
|
||||
magnesium: 420, // mg
|
||||
phosphorus: 700, // mg
|
||||
potassium: 4700, // mg
|
||||
sodium: 2300, // mg (max)
|
||||
zinc: 11, // mg
|
||||
copper: 0.9, // mg
|
||||
manganese: 2.3, // mg
|
||||
selenium: 55, // µg
|
||||
} as const;
|
||||
|
||||
// Meal type labels
|
||||
export const MEAL_TYPE_LABELS = {
|
||||
breakfast: {
|
||||
de: 'Frühstück',
|
||||
en: 'Breakfast',
|
||||
},
|
||||
lunch: {
|
||||
de: 'Mittagessen',
|
||||
en: 'Lunch',
|
||||
},
|
||||
dinner: {
|
||||
de: 'Abendessen',
|
||||
en: 'Dinner',
|
||||
},
|
||||
snack: {
|
||||
de: 'Snack',
|
||||
en: 'Snack',
|
||||
},
|
||||
} as const;
|
||||
|
||||
// Nutrient categories for UI grouping
|
||||
export const NUTRIENT_CATEGORIES = {
|
||||
macros: ['calories', 'protein', 'carbohydrates', 'fat', 'fiber', 'sugar'],
|
||||
vitamins: [
|
||||
'vitaminA',
|
||||
'vitaminB1',
|
||||
'vitaminB2',
|
||||
'vitaminB3',
|
||||
'vitaminB5',
|
||||
'vitaminB6',
|
||||
'vitaminB7',
|
||||
'vitaminB9',
|
||||
'vitaminB12',
|
||||
'vitaminC',
|
||||
'vitaminD',
|
||||
'vitaminE',
|
||||
'vitaminK',
|
||||
],
|
||||
minerals: [
|
||||
'calcium',
|
||||
'iron',
|
||||
'magnesium',
|
||||
'phosphorus',
|
||||
'potassium',
|
||||
'sodium',
|
||||
'zinc',
|
||||
'copper',
|
||||
'manganese',
|
||||
'selenium',
|
||||
],
|
||||
} as const;
|
||||
|
||||
// Nutrient display info
|
||||
export const NUTRIENT_INFO = {
|
||||
calories: { label: 'Kalorien', unit: 'kcal', color: '#F59E0B' },
|
||||
protein: { label: 'Protein', unit: 'g', color: '#EF4444' },
|
||||
carbohydrates: { label: 'Kohlenhydrate', unit: 'g', color: '#3B82F6' },
|
||||
fat: { label: 'Fett', unit: 'g', color: '#8B5CF6' },
|
||||
fiber: { label: 'Ballaststoffe', unit: 'g', color: '#10B981' },
|
||||
sugar: { label: 'Zucker', unit: 'g', color: '#EC4899' },
|
||||
vitaminA: { label: 'Vitamin A', unit: 'µg', color: '#F97316' },
|
||||
vitaminC: { label: 'Vitamin C', unit: 'mg', color: '#FBBF24' },
|
||||
vitaminD: { label: 'Vitamin D', unit: 'µg', color: '#A3E635' },
|
||||
calcium: { label: 'Calcium', unit: 'mg', color: '#E5E7EB' },
|
||||
iron: { label: 'Eisen', unit: 'mg', color: '#78716C' },
|
||||
magnesium: { label: 'Magnesium', unit: 'mg', color: '#06B6D4' },
|
||||
} as const;
|
||||
|
||||
// Credit costs per action
|
||||
export const CREDIT_COSTS = {
|
||||
photoAnalysis: 5,
|
||||
textAnalysis: 2,
|
||||
aiCoaching: 10,
|
||||
} as const;
|
||||
|
||||
// Theme colors
|
||||
export const NUTRIPHI_COLORS = {
|
||||
primary: '#22C55E', // Green 500
|
||||
primaryHover: '#16A34A', // Green 600
|
||||
primaryLight: '#86EFAC', // Green 300
|
||||
secondary: '#F97316', // Orange 500
|
||||
accent: '#14B8A6', // Teal 500
|
||||
background: '#0F1F0F', // Dark green tinted
|
||||
backgroundCard: '#1A2F1A',
|
||||
textPrimary: '#F0FDF4', // Green 50
|
||||
textSecondary: '#BBF7D0', // Green 200
|
||||
border: '#22543D', // Green 800
|
||||
} as const;
|
||||
8
apps/nutriphi/packages/shared/src/index.ts
Normal file
8
apps/nutriphi/packages/shared/src/index.ts
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
// Types
|
||||
export * from './types';
|
||||
|
||||
// Constants
|
||||
export * from './constants';
|
||||
|
||||
// Utils
|
||||
export * from './utils';
|
||||
185
apps/nutriphi/packages/shared/src/types/index.ts
Normal file
185
apps/nutriphi/packages/shared/src/types/index.ts
Normal file
|
|
@ -0,0 +1,185 @@
|
|||
// User Goals
|
||||
export interface UserGoals {
|
||||
id: string;
|
||||
userId: string;
|
||||
dailyCalories: number;
|
||||
dailyProtein?: number | null; // in grams
|
||||
dailyCarbs?: number | null;
|
||||
dailyFat?: number | null;
|
||||
dailyFiber?: number | null;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
export interface CreateUserGoalsDto {
|
||||
dailyCalories: number;
|
||||
dailyProtein?: number;
|
||||
dailyCarbs?: number;
|
||||
dailyFat?: number;
|
||||
dailyFiber?: number;
|
||||
}
|
||||
|
||||
// Meal Types
|
||||
export type MealType = 'breakfast' | 'lunch' | 'dinner' | 'snack';
|
||||
export type InputType = 'photo' | 'text';
|
||||
|
||||
// Meal
|
||||
export interface Meal {
|
||||
id: string;
|
||||
userId: string;
|
||||
date: Date;
|
||||
mealType: MealType;
|
||||
inputType: InputType;
|
||||
description: string; // AI-generated description of the meal
|
||||
portionSize?: string; // e.g., "small", "medium", "large" or grams
|
||||
confidence: number; // AI confidence score 0-1
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
export interface CreateMealDto {
|
||||
mealType: MealType;
|
||||
inputType: InputType;
|
||||
description?: string; // For text input
|
||||
imageBase64?: string; // For photo input
|
||||
portionSize?: string;
|
||||
}
|
||||
|
||||
// Nutrition Data
|
||||
export interface MealNutrition {
|
||||
id: string;
|
||||
mealId: string;
|
||||
// Macros
|
||||
calories: number;
|
||||
protein: number;
|
||||
carbohydrates: number;
|
||||
fat: number;
|
||||
fiber: number;
|
||||
sugar: number;
|
||||
saturatedFat?: number | null;
|
||||
unsaturatedFat?: number | null;
|
||||
// Vitamins (in mg or µg as appropriate)
|
||||
vitaminA?: number | null; // µg RAE
|
||||
vitaminB1?: number | null; // mg (Thiamin)
|
||||
vitaminB2?: number | null; // mg (Riboflavin)
|
||||
vitaminB3?: number | null; // mg (Niacin)
|
||||
vitaminB5?: number | null; // mg (Pantothenic acid)
|
||||
vitaminB6?: number | null; // mg
|
||||
vitaminB7?: number | null; // µg (Biotin)
|
||||
vitaminB9?: number | null; // µg (Folate)
|
||||
vitaminB12?: number | null; // µg
|
||||
vitaminC?: number | null; // mg
|
||||
vitaminD?: number | null; // µg
|
||||
vitaminE?: number | null; // mg
|
||||
vitaminK?: number | null; // µg
|
||||
// Minerals (in mg)
|
||||
calcium?: number | null;
|
||||
iron?: number | null;
|
||||
magnesium?: number | null;
|
||||
phosphorus?: number | null;
|
||||
potassium?: number | null;
|
||||
sodium?: number | null;
|
||||
zinc?: number | null;
|
||||
copper?: number | null;
|
||||
manganese?: number | null;
|
||||
selenium?: number | null; // µg
|
||||
// Water
|
||||
water?: number | null; // ml
|
||||
}
|
||||
|
||||
// Favorite Meals
|
||||
export interface FavoriteMeal {
|
||||
id: string;
|
||||
userId: string;
|
||||
name: string;
|
||||
description: string;
|
||||
mealType: MealType;
|
||||
nutrition: Omit<MealNutrition, 'id' | 'mealId'>;
|
||||
usageCount: number;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
export interface CreateFavoriteMealDto {
|
||||
name: string;
|
||||
mealId?: string; // Create from existing meal
|
||||
description?: string;
|
||||
mealType?: MealType;
|
||||
}
|
||||
|
||||
// Daily Summary
|
||||
export interface DailySummary {
|
||||
date: Date;
|
||||
meals: Meal[];
|
||||
totalNutrition: Omit<MealNutrition, 'id' | 'mealId'>;
|
||||
goals?: UserGoals;
|
||||
progress: NutritionProgress;
|
||||
}
|
||||
|
||||
export interface NutritionProgress {
|
||||
calories: { current: number; target: number; percentage: number };
|
||||
protein?: { current: number; target: number; percentage: number };
|
||||
carbs?: { current: number; target: number; percentage: number };
|
||||
fat?: { current: number; target: number; percentage: number };
|
||||
}
|
||||
|
||||
// Recommendations
|
||||
export type RecommendationType = 'hint' | 'coaching';
|
||||
export type RecommendationPriority = 'low' | 'medium' | 'high';
|
||||
|
||||
export interface Recommendation {
|
||||
id: string;
|
||||
userId: string;
|
||||
date: Date;
|
||||
type: RecommendationType;
|
||||
priority: RecommendationPriority;
|
||||
message: string;
|
||||
nutrient?: string; // e.g., 'protein', 'vitaminC'
|
||||
actionable?: string; // e.g., "Add more leafy greens"
|
||||
dismissed: boolean;
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
// Weekly Stats
|
||||
export interface WeeklyStats {
|
||||
startDate: Date;
|
||||
endDate: Date;
|
||||
days: DailyStats[];
|
||||
averages: {
|
||||
calories: number;
|
||||
protein: number;
|
||||
carbs: number;
|
||||
fat: number;
|
||||
};
|
||||
trends: {
|
||||
caloriesTrend: 'up' | 'down' | 'stable';
|
||||
proteinTrend: 'up' | 'down' | 'stable';
|
||||
};
|
||||
}
|
||||
|
||||
export interface DailyStats {
|
||||
date: Date;
|
||||
totalCalories: number;
|
||||
totalProtein: number;
|
||||
totalCarbs: number;
|
||||
totalFat: number;
|
||||
mealCount: number;
|
||||
goalsMet: boolean;
|
||||
}
|
||||
|
||||
// AI Analysis Response
|
||||
export interface AIAnalysisResult {
|
||||
foods: DetectedFood[];
|
||||
totalNutrition: Omit<MealNutrition, 'id' | 'mealId'>;
|
||||
description: string;
|
||||
confidence: number;
|
||||
warnings?: string[]; // e.g., "Could not identify one item"
|
||||
suggestions?: string[]; // e.g., "Consider adding more vegetables"
|
||||
}
|
||||
|
||||
export interface DetectedFood {
|
||||
name: string;
|
||||
quantity: string; // e.g., "150g", "1 cup"
|
||||
calories: number;
|
||||
confidence: number;
|
||||
source?: 'usda' | 'openfoodfacts' | 'ai_estimate';
|
||||
}
|
||||
174
apps/nutriphi/packages/shared/src/utils/index.ts
Normal file
174
apps/nutriphi/packages/shared/src/utils/index.ts
Normal file
|
|
@ -0,0 +1,174 @@
|
|||
import { DEFAULT_DAILY_VALUES, NUTRIENT_INFO } from '../constants';
|
||||
import type { MealNutrition, NutritionProgress, UserGoals } from '../types';
|
||||
|
||||
/**
|
||||
* Calculate nutrition progress towards daily goals
|
||||
*/
|
||||
export function calculateProgress(
|
||||
totalNutrition: Partial<MealNutrition>,
|
||||
goals?: UserGoals
|
||||
): NutritionProgress {
|
||||
const targetCalories = goals?.dailyCalories ?? DEFAULT_DAILY_VALUES.calories;
|
||||
const targetProtein = goals?.dailyProtein ?? DEFAULT_DAILY_VALUES.protein;
|
||||
const targetCarbs = goals?.dailyCarbs ?? DEFAULT_DAILY_VALUES.carbohydrates;
|
||||
const targetFat = goals?.dailyFat ?? DEFAULT_DAILY_VALUES.fat;
|
||||
|
||||
return {
|
||||
calories: {
|
||||
current: totalNutrition.calories ?? 0,
|
||||
target: targetCalories,
|
||||
percentage: Math.min(
|
||||
100,
|
||||
Math.round(((totalNutrition.calories ?? 0) / targetCalories) * 100)
|
||||
),
|
||||
},
|
||||
protein: {
|
||||
current: totalNutrition.protein ?? 0,
|
||||
target: targetProtein,
|
||||
percentage: Math.min(100, Math.round(((totalNutrition.protein ?? 0) / targetProtein) * 100)),
|
||||
},
|
||||
carbs: {
|
||||
current: totalNutrition.carbohydrates ?? 0,
|
||||
target: targetCarbs,
|
||||
percentage: Math.min(
|
||||
100,
|
||||
Math.round(((totalNutrition.carbohydrates ?? 0) / targetCarbs) * 100)
|
||||
),
|
||||
},
|
||||
fat: {
|
||||
current: totalNutrition.fat ?? 0,
|
||||
target: targetFat,
|
||||
percentage: Math.min(100, Math.round(((totalNutrition.fat ?? 0) / targetFat) * 100)),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Sum up nutrition from multiple meals
|
||||
*/
|
||||
export function sumNutrition(
|
||||
meals: Array<{ nutrition?: Partial<MealNutrition> | null }>
|
||||
): Partial<MealNutrition> {
|
||||
const sum = {
|
||||
calories: 0,
|
||||
protein: 0,
|
||||
carbohydrates: 0,
|
||||
fat: 0,
|
||||
fiber: 0,
|
||||
sugar: 0,
|
||||
};
|
||||
|
||||
for (const meal of meals) {
|
||||
if (!meal.nutrition) continue;
|
||||
const n = meal.nutrition;
|
||||
if (typeof n.calories === 'number') sum.calories += n.calories;
|
||||
if (typeof n.protein === 'number') sum.protein += n.protein;
|
||||
if (typeof n.carbohydrates === 'number') sum.carbohydrates += n.carbohydrates;
|
||||
if (typeof n.fat === 'number') sum.fat += n.fat;
|
||||
if (typeof n.fiber === 'number') sum.fiber += n.fiber;
|
||||
if (typeof n.sugar === 'number') sum.sugar += n.sugar;
|
||||
}
|
||||
|
||||
return sum;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format nutrient value with unit
|
||||
*/
|
||||
export function formatNutrient(
|
||||
nutrient: keyof typeof NUTRIENT_INFO,
|
||||
value: number | undefined
|
||||
): string {
|
||||
if (value === undefined) return '-';
|
||||
const info = NUTRIENT_INFO[nutrient];
|
||||
if (!info) return `${value}`;
|
||||
|
||||
if (nutrient === 'calories') {
|
||||
return `${Math.round(value)} ${info.unit}`;
|
||||
}
|
||||
|
||||
return `${value.toFixed(1)} ${info.unit}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get color for progress percentage
|
||||
*/
|
||||
export function getProgressColor(percentage: number): string {
|
||||
if (percentage < 50) return '#EF4444'; // Red
|
||||
if (percentage < 80) return '#F59E0B'; // Orange
|
||||
if (percentage <= 100) return '#22C55E'; // Green
|
||||
return '#EF4444'; // Red (over target)
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect deficiencies based on daily values
|
||||
*/
|
||||
export function detectDeficiencies(
|
||||
totalNutrition: Partial<MealNutrition>
|
||||
): Array<{ nutrient: string; percentage: number; label: string }> {
|
||||
const deficiencies: Array<{ nutrient: string; percentage: number; label: string }> = [];
|
||||
|
||||
const checks = [
|
||||
{ key: 'protein', threshold: 0.5 },
|
||||
{ key: 'fiber', threshold: 0.5 },
|
||||
{ key: 'vitaminC', threshold: 0.5 },
|
||||
{ key: 'vitaminD', threshold: 0.5 },
|
||||
{ key: 'iron', threshold: 0.5 },
|
||||
{ key: 'calcium', threshold: 0.5 },
|
||||
] as const;
|
||||
|
||||
for (const check of checks) {
|
||||
const value = totalNutrition[check.key as keyof typeof totalNutrition];
|
||||
const dailyValue = DEFAULT_DAILY_VALUES[check.key as keyof typeof DEFAULT_DAILY_VALUES];
|
||||
|
||||
if (
|
||||
typeof value === 'number' &&
|
||||
typeof dailyValue === 'number' &&
|
||||
value < dailyValue * check.threshold
|
||||
) {
|
||||
const info = NUTRIENT_INFO[check.key as keyof typeof NUTRIENT_INFO];
|
||||
deficiencies.push({
|
||||
nutrient: check.key,
|
||||
percentage: Math.round((value / dailyValue) * 100),
|
||||
label: info?.label ?? check.key,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return deficiencies;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get meal type based on current time
|
||||
*/
|
||||
export function suggestMealType(): 'breakfast' | 'lunch' | 'dinner' | 'snack' {
|
||||
const hour = new Date().getHours();
|
||||
|
||||
if (hour >= 5 && hour < 11) return 'breakfast';
|
||||
if (hour >= 11 && hour < 14) return 'lunch';
|
||||
if (hour >= 17 && hour < 21) return 'dinner';
|
||||
return 'snack';
|
||||
}
|
||||
|
||||
/**
|
||||
* Format date for display
|
||||
*/
|
||||
export function formatDateForDisplay(date: Date, locale = 'de-DE'): string {
|
||||
return new Intl.DateTimeFormat(locale, {
|
||||
weekday: 'long',
|
||||
day: 'numeric',
|
||||
month: 'long',
|
||||
}).format(date);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if date is today
|
||||
*/
|
||||
export function isToday(date: Date): boolean {
|
||||
const today = new Date();
|
||||
return (
|
||||
date.getDate() === today.getDate() &&
|
||||
date.getMonth() === today.getMonth() &&
|
||||
date.getFullYear() === today.getFullYear()
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue