Refactor: God-Router + Frontend-Lib in Domain-Module aufgeteilt
Some checks failed
CI / Lint, Typecheck, Test, Build (push) Has been cancelled
Some checks failed
CI / Lint, Typecheck, Test, Build (push) Has been cancelled
Router-Split (packages/api/src/trpc/routers/health/): - health.ts (2061 Zeilen Monolith) entfernt und in 6 Sub-Router aufgeteilt: - dashboard.ts (overview, profile, goals, point settings) - foods.ts (foods, nutrition, OpenFoodFacts, food preferences) - recipes.ts (recipes, submissions, reviews, activity feed) - stores.ts (stores, change-requests, shopping lists) - tracking.ts (meals, drinks, weight, mealPlans, activities, leaderboard) - community.ts (club, wearables, coach, stats) - index.ts mergt alle Sub-Router via mergeRouters, sodass Frontend-Aufrufe (trpc.health.listFoods etc.) unveraendert weiterfunktionieren. - t.mergeRouters in init.ts exportiert. Frontend-Lib-Split (apps/admin/lib/hellth/): - hellth-data.ts (1149 Zeilen Monolith) in 5 Domain-Module aufgeteilt: - types.ts (alle TypeScript-Types) - constants.ts (Seeds, Defaults, todayKey, uid) - nutrition.ts (calcRecipe, classifyRecipe, parseNutritionLabel, ...) - storage.ts (load/save HellthData, ClubData, hasCompletedHealthProfile) - stats.ts (Budget, Stats, Coach, Shopping, Weight-Projection) - hellth-data.ts wird zum reinen Re-Export-Index, damit bestehende Imports `from "@/lib/hellth-data"` unveraendert weiterfunktionieren. Funktional identisch zum Original - reine Code-Reorganisation. Verifiziert: 28 API-Tests + 10 Admin-Tests gruen, Typecheck (3 Packages) gruen, Admin-Build (26 Pages) gruen. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
0031268416
commit
8ed7c083c4
15 changed files with 3352 additions and 3211 deletions
File diff suppressed because it is too large
Load diff
184
apps/admin/lib/hellth/constants.ts
Normal file
184
apps/admin/lib/hellth/constants.ts
Normal file
|
|
@ -0,0 +1,184 @@
|
|||
import type {
|
||||
ActivityTemplate,
|
||||
CatalogFood,
|
||||
ClubActivityCategory,
|
||||
ClubProfile,
|
||||
GermanState,
|
||||
HellthGoals,
|
||||
PlannedMeal,
|
||||
Recipe,
|
||||
ReminderSettings,
|
||||
Store,
|
||||
StoreDepartment,
|
||||
} from "./types";
|
||||
|
||||
export const HELLTH_DATA_KEY = "hellth-hub-data-v1";
|
||||
export const HELLTH_CLUB_KEY = "hellth-hub-club-v1";
|
||||
|
||||
export function todayKey() {
|
||||
return new Date().toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
export function uid(prefix = "hh") {
|
||||
return `${prefix}-${Math.random().toString(36).slice(2, 10)}`;
|
||||
}
|
||||
|
||||
export const defaultGoals: HellthGoals = {
|
||||
drinkMl: 2500,
|
||||
calories: 2000,
|
||||
carbs: 200,
|
||||
fat: 67,
|
||||
protein: 150,
|
||||
weightKg: 120,
|
||||
fastingHours: 12,
|
||||
};
|
||||
|
||||
export const defaultReminderSettings: ReminderSettings = {
|
||||
enabled: false,
|
||||
rules: {
|
||||
water: {
|
||||
enabled: true,
|
||||
intervalMinutes: 90,
|
||||
startTime: "08:00",
|
||||
endTime: "20:00",
|
||||
message: "Zeit für ein Glas Wasser.",
|
||||
},
|
||||
snack: {
|
||||
enabled: false,
|
||||
intervalMinutes: 240,
|
||||
startTime: "10:00",
|
||||
endTime: "17:00",
|
||||
message: "Prüfe kurz: brauchst du einen geplanten Snack oder reicht die nächste Mahlzeit?",
|
||||
},
|
||||
movement: {
|
||||
enabled: true,
|
||||
intervalMinutes: 60,
|
||||
startTime: "09:00",
|
||||
endTime: "17:00",
|
||||
message: "Steh kurz auf, streck dich und geh ein paar Schritte.",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const seedActivityTemplates: ActivityTemplate[] = [
|
||||
{ id: "activity-stretch-5", name: "5 Minuten Dehnübungen", minutes: 5, points: 20, calories: 20, category: "mobility", icon: "stretch" },
|
||||
{ id: "activity-desk-walk", name: "Schreibtischpause mit kurzem Spaziergang", minutes: 10, points: 25, calories: 35, category: "habit", icon: "walk" },
|
||||
{ id: "activity-walk-30", name: "30 Minuten Spaziergang", minutes: 30, points: 60, calories: 130, category: "cardio", icon: "walk" },
|
||||
{ id: "activity-run-60", name: "1 Stunde Laufen", minutes: 60, points: 100, calories: 550, category: "cardio", icon: "run" },
|
||||
{ id: "activity-strength-20", name: "20 Minuten Krafttraining", minutes: 20, points: 70, calories: 140, category: "strength", icon: "strength" },
|
||||
];
|
||||
|
||||
export const clubCategories: Array<{ key: ClubActivityCategory; label: string }> = [
|
||||
{ key: "walk", label: "Spazieren" },
|
||||
{ key: "run", label: "Laufen" },
|
||||
{ key: "mobility", label: "Mobility" },
|
||||
{ key: "strength", label: "Kraft" },
|
||||
{ key: "bike", label: "Rad" },
|
||||
{ key: "other", label: "Sonstiges" },
|
||||
];
|
||||
|
||||
export const germanStates: GermanState[] = [
|
||||
"Baden-Württemberg",
|
||||
"Bayern",
|
||||
"Berlin",
|
||||
"Brandenburg",
|
||||
"Bremen",
|
||||
"Hamburg",
|
||||
"Hessen",
|
||||
"Mecklenburg-Vorpommern",
|
||||
"Niedersachsen",
|
||||
"Nordrhein-Westfalen",
|
||||
"Rheinland-Pfalz",
|
||||
"Saarland",
|
||||
"Sachsen",
|
||||
"Sachsen-Anhalt",
|
||||
"Schleswig-Holstein",
|
||||
"Thüringen",
|
||||
];
|
||||
|
||||
export const defaultClubProfile: ClubProfile = {
|
||||
state: "Bayern",
|
||||
city: "Augsburg",
|
||||
privacyMode: "state",
|
||||
};
|
||||
|
||||
const lidlDepartments: StoreDepartment[] = [
|
||||
{ id: "lidl-produce", name: "Obst & Gemüse", sortOrder: 1 },
|
||||
{ id: "lidl-bakery", name: "Brot & Backwaren", sortOrder: 2 },
|
||||
{ id: "lidl-cereals", name: "Cerealien", sortOrder: 3 },
|
||||
{ id: "lidl-dairy", name: "Milchprodukte", sortOrder: 4 },
|
||||
{ id: "lidl-meat", name: "Fleisch/Wurst/Käse", sortOrder: 5 },
|
||||
{ id: "lidl-frozen", name: "Tiefkühlung", sortOrder: 6 },
|
||||
{ id: "lidl-drinks", name: "Getränke", sortOrder: 7 },
|
||||
{ id: "lidl-checkout", name: "Kasse", sortOrder: 8 },
|
||||
];
|
||||
|
||||
function genericDepartments(storeId: string): StoreDepartment[] {
|
||||
return [
|
||||
{ id: `${storeId}-produce`, name: "Obst & Gemüse", sortOrder: 1 },
|
||||
{ id: `${storeId}-bakery`, name: "Brot & Backwaren", sortOrder: 2 },
|
||||
{ id: `${storeId}-dry`, name: "Trockenware", sortOrder: 3 },
|
||||
{ id: `${storeId}-dairy`, name: "Milchprodukte", sortOrder: 4 },
|
||||
{ id: `${storeId}-protein`, name: "Fleisch/Fisch/Protein", sortOrder: 5 },
|
||||
{ id: `${storeId}-frozen`, name: "Tiefkühlung", sortOrder: 6 },
|
||||
{ id: `${storeId}-drinks`, name: "Getränke", sortOrder: 7 },
|
||||
{ id: `${storeId}-other`, name: "Sonstiges", sortOrder: 99 },
|
||||
];
|
||||
}
|
||||
|
||||
export const seedStores: Store[] = [
|
||||
{ id: "lidl", name: "Lidl", favorite: true, departments: lidlDepartments },
|
||||
{ id: "aldi", name: "Aldi", favorite: true, departments: genericDepartments("aldi") },
|
||||
{ id: "edeka", name: "Edeka", favorite: true, departments: genericDepartments("edeka") },
|
||||
{ id: "kaufland", name: "Kaufland", favorite: true, departments: genericDepartments("kaufland") },
|
||||
{ id: "rewe", name: "Rewe", departments: genericDepartments("rewe") },
|
||||
{ id: "dm", name: "DM", departments: genericDepartments("dm") },
|
||||
{ id: "biomarkt", name: "Biomarkt", departments: genericDepartments("biomarkt") },
|
||||
];
|
||||
|
||||
export const seedFoods: CatalogFood[] = [
|
||||
{ id: "food-apple", name: "Apfel", category: "Obst", kcalPer100g: 52, proteinPer100g: 0.3, carbsPer100g: 14, fatPer100g: 0.2, servingG: 150, preferredUnit: "piece", portions: [{ id: "apple-piece", label: "1 Apfel", unit: "piece", amount: 1, gramsEquivalent: 150 }], favorite: true, source: "seed", availableEverywhere: true, availabilityTags: ["basis", "frisch"] },
|
||||
{ id: "food-banana", name: "Banane", category: "Obst", kcalPer100g: 89, proteinPer100g: 1.1, carbsPer100g: 23, fatPer100g: 0.3, servingG: 120, preferredUnit: "piece", portions: [{ id: "banana-piece", label: "1 Banane", unit: "piece", amount: 1, gramsEquivalent: 120 }], favorite: true, source: "seed", availableEverywhere: true, availabilityTags: ["basis", "frisch"] },
|
||||
{ id: "food-oats", name: "Haferflocken", category: "Getreide", kcalPer100g: 372, proteinPer100g: 13.5, carbsPer100g: 58.7, fatPer100g: 7, servingG: 50, preferredUnit: "g", favorite: true, source: "seed", availableEverywhere: true, availabilityTags: ["basis", "vorrat"] },
|
||||
{ id: "food-rice", name: "Reis, trocken", category: "Getreide", kcalPer100g: 350, proteinPer100g: 7.4, carbsPer100g: 78, fatPer100g: 0.6, servingG: 75, source: "seed", availableEverywhere: true, availabilityTags: ["vorrat"] },
|
||||
{ id: "food-potato", name: "Kartoffeln", category: "Gemüse", kcalPer100g: 77, proteinPer100g: 2, carbsPer100g: 17, fatPer100g: 0.1, servingG: 250, source: "seed", availableEverywhere: true, availabilityTags: ["basis", "frisch"] },
|
||||
{ id: "food-egg", name: "Ei", category: "Protein", kcalPer100g: 155, proteinPer100g: 13, carbsPer100g: 1.1, fatPer100g: 11, servingG: 60, preferredUnit: "piece", portions: [{ id: "egg-piece", label: "1 Ei", unit: "piece", amount: 1, gramsEquivalent: 60 }], favorite: true, source: "seed", availableEverywhere: true, availabilityTags: ["basis"] },
|
||||
{ id: "food-chicken", name: "Hähnchenbrust", category: "Protein", kcalPer100g: 110, proteinPer100g: 23, carbsPer100g: 0, fatPer100g: 1.5, servingG: 180, favorite: true, source: "seed", availableEverywhere: true, availabilityTags: ["frisch"] },
|
||||
{ id: "food-quark", name: "Magerquark", category: "Milchprodukte", kcalPer100g: 67, proteinPer100g: 12, carbsPer100g: 4, fatPer100g: 0.2, servingG: 250, preferredUnit: "cup", portions: [{ id: "quark-cup", label: "1 Becher", unit: "cup", amount: 1, gramsEquivalent: 500 }], source: "seed", availableEverywhere: true, availabilityTags: ["protein"] },
|
||||
{ id: "food-paprika", name: "Paprika", category: "Gemüse", kcalPer100g: 43, proteinPer100g: 1.3, carbsPer100g: 6.4, fatPer100g: 0.5, servingG: 100, source: "seed", availableEverywhere: true, availabilityTags: ["frisch"] },
|
||||
{ id: "food-tuna", name: "Thunfisch", category: "Protein", kcalPer100g: 116, proteinPer100g: 26, carbsPer100g: 0, fatPer100g: 1, servingG: 120, source: "seed", storeIds: ["kaufland", "edeka", "rewe"], availabilityTags: ["konserve", "protein"] },
|
||||
{ id: "food-greek-yoghurt-kaufland", name: "Griechischer Joghurt 0,1 % Fett", brand: "K-Classic", category: "Milchprodukte", kcalPer100g: 57, proteinPer100g: 10, carbsPer100g: 3.8, fatPer100g: 0.1, servingG: 250, preferredUnit: "cup", portions: [{ id: "greek-yoghurt-cup", label: "1 Becher", unit: "cup", amount: 1, gramsEquivalent: 500 }], source: "seed", storeIds: ["kaufland"], availabilityTags: ["nur kaufland", "protein"] },
|
||||
{ id: "food-gockentoast-gold", name: "Gockentoast Gold", category: "Brot & Backwaren", kcalPer100g: 245, proteinPer100g: 8, carbsPer100g: 45, fatPer100g: 3.5, servingG: 50, preferredUnit: "slice", portions: [{ id: "toast-slice", label: "1 Scheibe", unit: "slice", amount: 1, gramsEquivalent: 25 }, { id: "toast-pack", label: "1 Packung", unit: "pack", amount: 1, gramsEquivalent: 500 }], source: "seed", storeIds: ["edeka", "kaufland", "lidl"], availabilityTags: ["toast", "supermarkt"] },
|
||||
{ id: "food-oil", name: "Olivenöl", category: "Öl & Fett", kcalPer100g: 884, proteinPer100g: 0, carbsPer100g: 0, fatPer100g: 100, servingG: 15, preferredUnit: "tbsp", densityGPerMl: 0.91, portions: [{ id: "oil-tsp", label: "1 TL", unit: "tsp", amount: 1, gramsEquivalent: 4.6 }, { id: "oil-tbsp", label: "1 EL", unit: "tbsp", amount: 1, gramsEquivalent: 13.7 }], source: "seed", availableEverywhere: true, availabilityTags: ["basis"] },
|
||||
{ id: "food-cream", name: "Sahne", category: "Milchprodukte", kcalPer100g: 292, proteinPer100g: 2.4, carbsPer100g: 3.2, fatPer100g: 30, servingG: 100, preferredUnit: "ml", densityGPerMl: 1, portions: [{ id: "cream-bottle", label: "1 Flasche", unit: "bottle", amount: 1, gramsEquivalent: 250 }], source: "seed", availableEverywhere: true, availabilityTags: ["kühlung"] },
|
||||
];
|
||||
|
||||
export const seedRecipes: Recipe[] = [
|
||||
{
|
||||
id: "recipe-eggs",
|
||||
name: "Eier-Muffins mit Paprika",
|
||||
portions: 2,
|
||||
minutes: 20,
|
||||
ingredients: [
|
||||
{ foodId: "food-egg", amountG: 180 },
|
||||
{ foodId: "food-paprika", amountG: 150 },
|
||||
{ foodId: "food-quark", amountG: 80 },
|
||||
],
|
||||
notes: "Gut für Meal Prep, hält 2 Tage im Kühlschrank.",
|
||||
},
|
||||
{
|
||||
id: "recipe-pita",
|
||||
name: "Mediterrane Thunfisch-Pita",
|
||||
portions: 1,
|
||||
minutes: 20,
|
||||
ingredients: [
|
||||
{ foodId: "food-tuna", amountG: 120 },
|
||||
{ foodId: "food-paprika", amountG: 80 },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export const seedPlannedMeals: PlannedMeal[] = [
|
||||
{ id: "plan-eggs", date: todayKey(), mealType: "Frühstück", recipeId: "recipe-eggs", name: "Eier-Muffins mit Paprika", portions: 1 },
|
||||
{ id: "plan-pita", date: todayKey(), mealType: "Mittagessen", recipeId: "recipe-pita", name: "Mediterrane Thunfisch-Pita", portions: 1 },
|
||||
];
|
||||
146
apps/admin/lib/hellth/nutrition.ts
Normal file
146
apps/admin/lib/hellth/nutrition.ts
Normal file
|
|
@ -0,0 +1,146 @@
|
|||
import type {
|
||||
CatalogFood,
|
||||
PortionUnit,
|
||||
Recipe,
|
||||
RecipeIngredient,
|
||||
RecipeRating,
|
||||
} from "./types";
|
||||
|
||||
export function calcFoodForAmount(food: CatalogFood, amountG: number) {
|
||||
const factor = amountG / 100;
|
||||
return {
|
||||
kcal: Math.round(food.kcalPer100g * factor),
|
||||
protein: Math.round(food.proteinPer100g * factor * 10) / 10,
|
||||
carbs: Math.round(food.carbsPer100g * factor * 10) / 10,
|
||||
fat: Math.round(food.fatPer100g * factor * 10) / 10,
|
||||
};
|
||||
}
|
||||
|
||||
export function unitLabel(unit: PortionUnit) {
|
||||
const labels: Record<PortionUnit, string> = {
|
||||
g: "g",
|
||||
ml: "ml",
|
||||
tsp: "TL",
|
||||
tbsp: "EL",
|
||||
piece: "Stück",
|
||||
pack: "Packung",
|
||||
bottle: "Flasche",
|
||||
cup: "Becher",
|
||||
slice: "Scheibe",
|
||||
};
|
||||
return labels[unit];
|
||||
}
|
||||
|
||||
export function amountToGrams(food: CatalogFood, amount: number, unit: PortionUnit, portionId?: string) {
|
||||
const portion = portionId ? food.portions?.find((item) => item.id === portionId) : undefined;
|
||||
if (portion) return amount * (portion.gramsEquivalent / Math.max(1, portion.amount));
|
||||
if (unit === "g") return amount;
|
||||
if (unit === "ml") return amount * (food.densityGPerMl ?? 1);
|
||||
if (unit === "tsp") return amount * 5 * (food.densityGPerMl ?? 1);
|
||||
if (unit === "tbsp") return amount * 15 * (food.densityGPerMl ?? 1);
|
||||
const matchingPortion = food.portions?.find((item) => item.unit === unit);
|
||||
if (matchingPortion) return amount * (matchingPortion.gramsEquivalent / Math.max(1, matchingPortion.amount));
|
||||
return amount * food.servingG;
|
||||
}
|
||||
|
||||
export function describeIngredientAmount(ingredient: RecipeIngredient) {
|
||||
if (ingredient.amount && ingredient.unit) {
|
||||
return `${ingredient.amount} ${unitLabel(ingredient.unit)}`;
|
||||
}
|
||||
return `${Math.round(ingredient.amountG)} g`;
|
||||
}
|
||||
|
||||
export function calcRecipe(recipe: Recipe, foods: CatalogFood[], portions = recipe.portions) {
|
||||
const byId = new Map(foods.map((food) => [food.id, food]));
|
||||
const totals = recipe.ingredients.reduce(
|
||||
(sum, ingredient) => {
|
||||
const food = byId.get(ingredient.foodId);
|
||||
if (!food) return sum;
|
||||
const values = calcFoodForAmount(food, ingredient.amountG);
|
||||
return {
|
||||
kcal: sum.kcal + values.kcal,
|
||||
protein: sum.protein + values.protein,
|
||||
carbs: sum.carbs + values.carbs,
|
||||
fat: sum.fat + values.fat,
|
||||
};
|
||||
},
|
||||
{ kcal: 0, protein: 0, carbs: 0, fat: 0 },
|
||||
);
|
||||
const divisor = Math.max(1, portions);
|
||||
return {
|
||||
kcal: Math.round(totals.kcal / divisor),
|
||||
protein: Math.round((totals.protein / divisor) * 10) / 10,
|
||||
carbs: Math.round((totals.carbs / divisor) * 10) / 10,
|
||||
fat: Math.round((totals.fat / divisor) * 10) / 10,
|
||||
};
|
||||
}
|
||||
|
||||
export function recipeMacroDistribution(totals: { protein: number; carbs: number; fat: number }) {
|
||||
const proteinCalories = totals.protein * 4;
|
||||
const carbsCalories = totals.carbs * 4;
|
||||
const fatCalories = totals.fat * 9;
|
||||
const total = Math.max(1, proteinCalories + carbsCalories + fatCalories);
|
||||
return {
|
||||
protein: Math.round((proteinCalories / total) * 100),
|
||||
carbs: Math.round((carbsCalories / total) * 100),
|
||||
fat: Math.round((fatCalories / total) * 100),
|
||||
};
|
||||
}
|
||||
|
||||
export function classifyRecipe(recipe: Recipe, foods: CatalogFood[]) {
|
||||
const byId = new Map(foods.map((food) => [food.id, food]));
|
||||
const usedFoods = recipe.ingredients.map((ingredient) => byId.get(ingredient.foodId)).filter(Boolean) as CatalogFood[];
|
||||
const text = usedFoods.map((food) => `${food.name} ${food.category}`).join(" ").toLowerCase();
|
||||
const hasMeatOrFish = /fleisch|fisch|hähnchen|huhn|pute|rind|schwein|hack|wurst|speck|salami|schinken|thunfisch|lachs|garnel|shrimp|kabeljau/.test(text);
|
||||
const hasAnimalProduct = hasMeatOrFish || /milch|joghurt|yoghurt|quark|skyr|käse|sahne|butter|ei\b|eier|honig|molke|whey|gelatine/.test(text);
|
||||
const totals = calcRecipe(recipe, foods);
|
||||
const distribution = recipeMacroDistribution(totals);
|
||||
const tags = [
|
||||
hasMeatOrFish ? "mit Fleisch/Fisch" : "vegetarisch",
|
||||
!hasAnimalProduct ? "vegan" : undefined,
|
||||
totals.protein >= 25 ? "proteinreich" : undefined,
|
||||
totals.kcal <= 500 ? "kalorienbewusst" : undefined,
|
||||
distribution.fat >= 45 ? "fettreich" : undefined,
|
||||
distribution.carbs >= 55 ? "kohlenhydratbetont" : undefined,
|
||||
].filter(Boolean) as string[];
|
||||
const criteria = [
|
||||
{ label: "Protein sinnvoll", ok: totals.protein >= 20, detail: `${totals.protein} g pro Portion` },
|
||||
{ label: "Fett moderat", ok: distribution.fat <= 40, detail: `${distribution.fat} % der Makro-Kalorien` },
|
||||
{ label: "Kalorien alltagstauglich", ok: totals.kcal <= 750, detail: `${totals.kcal} kcal pro Portion` },
|
||||
];
|
||||
return {
|
||||
tags,
|
||||
vegetarian: !hasMeatOrFish,
|
||||
vegan: !hasAnimalProduct,
|
||||
distribution,
|
||||
criteria,
|
||||
};
|
||||
}
|
||||
|
||||
export function parseNutritionLabel(text: string): Partial<CatalogFood> {
|
||||
const normalized = text.replace(/,/g, ".").replace(/\s+/g, " ");
|
||||
const read = (patterns: RegExp[]) => {
|
||||
for (const pattern of patterns) {
|
||||
const match = normalized.match(pattern);
|
||||
if (match?.[1]) return Number(match[1]);
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
return {
|
||||
kcalPer100g: read([/(\d+(?:\.\d+)?)\s*kcal/i, /energie[^0-9]*(\d+(?:\.\d+)?)/i]),
|
||||
proteinPer100g: read([/(?:eiweiss|eiweiß|protein)[^0-9]*(\d+(?:\.\d+)?)/i]),
|
||||
carbsPer100g: read([/(?:kohlenhydrate|carbohydrate)[^0-9]*(\d+(?:\.\d+)?)/i]),
|
||||
fatPer100g: read([/(?:fett|fat)[^0-9]*(\d+(?:\.\d+)?)/i]),
|
||||
};
|
||||
}
|
||||
|
||||
export function recipeRatingSummary(recipeId: string, ratings: RecipeRating[]) {
|
||||
const recipeRatings = ratings.filter((rating) => rating.recipeId === recipeId);
|
||||
if (!recipeRatings.length) return { average: 0, count: 0 };
|
||||
const average = recipeRatings.reduce((sum, rating) => sum + rating.rating, 0) / recipeRatings.length;
|
||||
return {
|
||||
average: Math.round(average * 10) / 10,
|
||||
count: recipeRatings.length,
|
||||
};
|
||||
}
|
||||
390
apps/admin/lib/hellth/stats.ts
Normal file
390
apps/admin/lib/hellth/stats.ts
Normal file
|
|
@ -0,0 +1,390 @@
|
|||
import { todayKey } from "./constants";
|
||||
import { calcRecipe } from "./nutrition";
|
||||
import type {
|
||||
CatalogFood,
|
||||
CoachRecommendation,
|
||||
HealthProfile,
|
||||
HealthProjection,
|
||||
HealthStatsDay,
|
||||
HealthStatsSummary,
|
||||
HellthData,
|
||||
HellthDayStateSnapshot,
|
||||
HellthGoals,
|
||||
ShoppingItem,
|
||||
Store,
|
||||
WeeklyBudgetSummary,
|
||||
} from "./types";
|
||||
import { uid } from "./constants";
|
||||
|
||||
export function startOfWeek(dateKey: string) {
|
||||
const date = new Date(`${dateKey}T12:00:00`);
|
||||
const day = date.getDay() === 0 ? 7 : date.getDay();
|
||||
date.setDate(date.getDate() - day + 1);
|
||||
return date.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
export function weekDates(dateKey: string) {
|
||||
const monday = new Date(`${startOfWeek(dateKey)}T12:00:00`);
|
||||
return Array.from({ length: 7 }, (_, index) => {
|
||||
const date = new Date(monday);
|
||||
date.setDate(monday.getDate() + index);
|
||||
return date.toISOString().slice(0, 10);
|
||||
});
|
||||
}
|
||||
|
||||
export function addDateDays(dateKey: string, days: number) {
|
||||
const date = new Date(`${dateKey}T12:00:00`);
|
||||
date.setDate(date.getDate() + days);
|
||||
return date.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
export function buildWeeklyBudgetSummary(data: HellthData, dateKey = todayKey()): WeeklyBudgetSummary {
|
||||
const dates = weekDates(dateKey);
|
||||
const dailyGoal = data.goals.calories;
|
||||
const planByDate = new Map(data.weeklyCaloriePlans.map((plan) => [plan.date, plan]));
|
||||
const recipeById = new Map(data.recipes.map((recipe) => [recipe.id, recipe]));
|
||||
|
||||
const days = dates.map((date) => {
|
||||
const plannedCalories = planByDate.get(date)?.plannedCalories ?? dailyGoal;
|
||||
const plannedMeals = data.plannedMeals.filter((meal) => meal.date === date);
|
||||
const eatenCalories = plannedMeals.reduce((sum, meal) => {
|
||||
const recipe = meal.recipeId ? recipeById.get(meal.recipeId) : undefined;
|
||||
if (!recipe) return sum;
|
||||
return sum + calcRecipe(recipe, data.foods, recipe.portions).kcal * meal.portions;
|
||||
}, 0);
|
||||
const activityCalories = data.activityLogs
|
||||
.filter((log) => log.date === date)
|
||||
.reduce((sum, log) => sum + (log.calories ?? 0), 0);
|
||||
return {
|
||||
date,
|
||||
plannedCalories,
|
||||
eatenCalories,
|
||||
activityCalories,
|
||||
balance: plannedCalories + activityCalories - eatenCalories,
|
||||
note: planByDate.get(date)?.note,
|
||||
};
|
||||
});
|
||||
|
||||
const plannedBudget = days.reduce((sum, day) => sum + day.plannedCalories, 0);
|
||||
const eatenCalories = days.reduce((sum, day) => sum + day.eatenCalories, 0);
|
||||
const activityCalories = days.reduce((sum, day) => sum + day.activityCalories, 0);
|
||||
const availableCalories = plannedBudget + activityCalories - eatenCalories;
|
||||
|
||||
return {
|
||||
weekStart: dates[0]!,
|
||||
dailyGoal,
|
||||
baseBudget: dailyGoal * 7,
|
||||
plannedBudget,
|
||||
eatenCalories,
|
||||
activityCalories,
|
||||
availableCalories,
|
||||
balance: plannedBudget + activityCalories - eatenCalories,
|
||||
days,
|
||||
};
|
||||
}
|
||||
|
||||
function plannedCaloriesForDate(data: HellthData, date: string) {
|
||||
const recipeById = new Map(data.recipes.map((recipe) => [recipe.id, recipe]));
|
||||
return data.plannedMeals
|
||||
.filter((meal) => meal.date === date)
|
||||
.reduce((sum, meal) => {
|
||||
const recipe = meal.recipeId ? recipeById.get(meal.recipeId) : undefined;
|
||||
if (!recipe) return sum;
|
||||
return sum + calcRecipe(recipe, data.foods, recipe.portions).kcal * meal.portions;
|
||||
}, 0);
|
||||
}
|
||||
|
||||
function latestStreak<T extends HealthStatsDay>(days: T[], predicate: (day: T) => boolean) {
|
||||
let streak = 0;
|
||||
for (const day of [...days].reverse()) {
|
||||
if (!predicate(day)) break;
|
||||
streak += 1;
|
||||
}
|
||||
return streak;
|
||||
}
|
||||
|
||||
function buildWeightProjection(
|
||||
data: HellthData,
|
||||
days: HealthStatsDay[],
|
||||
referenceDate: string,
|
||||
): HealthProjection | undefined {
|
||||
const entries = days
|
||||
.filter((day) => day.weightKg !== undefined)
|
||||
.map((day) => ({ date: day.date, weightKg: day.weightKg! }));
|
||||
if (entries.length < 3) return undefined;
|
||||
|
||||
const first = entries[0]!;
|
||||
const last = entries[entries.length - 1]!;
|
||||
const elapsedDays = Math.max(
|
||||
1,
|
||||
Math.round((new Date(`${last.date}T12:00:00`).getTime() - new Date(`${first.date}T12:00:00`).getTime()) / 86_400_000),
|
||||
);
|
||||
if (elapsedDays < 7) return undefined;
|
||||
|
||||
const trendKgPerWeek = Math.round(((last.weightKg - first.weightKg) / elapsedDays) * 7 * 10) / 10;
|
||||
const targetWeight = data.healthProfile?.targetWeightKg ?? data.goals.weightKg;
|
||||
const wantsLowerWeight = targetWeight < last.weightKg;
|
||||
const movingTowardTarget = wantsLowerWeight ? trendKgPerWeek < -0.05 : trendKgPerWeek > 0.05;
|
||||
|
||||
if (!movingTowardTarget) {
|
||||
return {
|
||||
kind: "weight",
|
||||
trendKgPerWeek,
|
||||
confidence: "low",
|
||||
message: "Noch kein stabiler Zieltrend. Das ist normal, einzelne Wochen schwanken stark.",
|
||||
};
|
||||
}
|
||||
|
||||
const kgPerDay = Math.abs(trendKgPerWeek / 7);
|
||||
const remainingKg = Math.abs(last.weightKg - targetWeight);
|
||||
const estimatedDays = Math.ceil(remainingKg / Math.max(0.01, kgPerDay));
|
||||
const targetDate = addDateDays(referenceDate, estimatedDays);
|
||||
|
||||
return {
|
||||
kind: "weight",
|
||||
trendKgPerWeek,
|
||||
targetDate,
|
||||
confidence: elapsedDays >= 14 && entries.length >= 5 ? "medium" : "low",
|
||||
message: `Aktueller Trend: ${Math.abs(trendKgPerWeek).toLocaleString("de-DE")} kg pro Woche in Richtung Ziel.`,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildHealthStatsSummary(
|
||||
data: HellthData,
|
||||
dayState: HellthDayStateSnapshot = {},
|
||||
referenceDate = todayKey(),
|
||||
): HealthStatsSummary {
|
||||
const days = Array.from({ length: 14 }, (_, index) => addDateDays(referenceDate, index - 13)).map((date) => {
|
||||
const snapshot = dayState.days?.[date];
|
||||
const loggedCalories = snapshot?.foods?.reduce((sum, food) => sum + (Number(food.calories) || 0), 0) ?? 0;
|
||||
const plannedCalories = plannedCaloriesForDate(data, date);
|
||||
const activityCalories = data.activityLogs
|
||||
.filter((log) => log.date === date)
|
||||
.reduce((sum, log) => sum + (log.calories ?? 0), 0);
|
||||
|
||||
return {
|
||||
date,
|
||||
calories: loggedCalories || (date >= referenceDate ? plannedCalories : 0),
|
||||
plannedCalories,
|
||||
activityCalories,
|
||||
protein: snapshot?.foods?.reduce((sum, food) => sum + (Number(food.protein) || 0), 0) ?? 0,
|
||||
drinkMl: snapshot?.drinks?.reduce((sum, drink) => sum + (Number(drink.amountMl) || 0), 0) ?? 0,
|
||||
weightKg: snapshot?.weightKg,
|
||||
};
|
||||
});
|
||||
|
||||
const relevantDays = days.filter((day) => day.date <= referenceDate);
|
||||
const divisor = Math.max(1, relevantDays.length);
|
||||
const averages = {
|
||||
calories: Math.round(relevantDays.reduce((sum, day) => sum + day.calories, 0) / divisor),
|
||||
protein: Math.round(relevantDays.reduce((sum, day) => sum + day.protein, 0) / divisor),
|
||||
drinkMl: Math.round(relevantDays.reduce((sum, day) => sum + day.drinkMl, 0) / divisor),
|
||||
activityCalories: Math.round(relevantDays.reduce((sum, day) => sum + day.activityCalories, 0) / divisor),
|
||||
};
|
||||
|
||||
const streaks = {
|
||||
waterDays: latestStreak(relevantDays, (day) => day.drinkMl >= data.goals.drinkMl),
|
||||
proteinDays: latestStreak(relevantDays, (day) => day.protein >= data.goals.protein * 0.8),
|
||||
activityDays: latestStreak(relevantDays, (day) => day.activityCalories > 0),
|
||||
};
|
||||
|
||||
const wins = [
|
||||
streaks.waterDays > 0 ? `${streaks.waterDays} Tag(e) Trinkziel in Folge` : undefined,
|
||||
streaks.proteinDays > 0 ? `${streaks.proteinDays} Tag(e) Protein gut getroffen` : undefined,
|
||||
streaks.activityDays > 0 ? `${streaks.activityDays} Tag(e) mit Bewegung in Folge` : undefined,
|
||||
averages.activityCalories > 0 ? `Ø ${averages.activityCalories} kcal Sportbonus pro Tag` : undefined,
|
||||
].filter(Boolean) as string[];
|
||||
|
||||
return {
|
||||
days,
|
||||
week: buildWeeklyBudgetSummary(data, referenceDate),
|
||||
averages,
|
||||
streaks,
|
||||
wins: wins.length ? wins : ["Du sammelst gerade Daten. Jeder Eintrag macht die Auswertung besser."],
|
||||
projection: buildWeightProjection(data, days, referenceDate),
|
||||
};
|
||||
}
|
||||
|
||||
export function redistributeWeeklyCalories(
|
||||
data: HellthData,
|
||||
targetDate: string,
|
||||
extraCalories: number,
|
||||
referenceDate = todayKey(),
|
||||
) {
|
||||
const dates = weekDates(referenceDate);
|
||||
const dailyGoal = data.goals.calories;
|
||||
const target = targetDate || referenceDate;
|
||||
const otherDates = dates.filter((date) => date !== target);
|
||||
const savingPerOtherDay = otherDates.length ? Math.ceil(extraCalories / otherDates.length) : 0;
|
||||
return dates.map((date) => ({
|
||||
date,
|
||||
plannedCalories: date === target ? dailyGoal + extraCalories : Math.max(1200, dailyGoal - savingPerOtherDay),
|
||||
note: date === target ? "bewusst höher geplant" : extraCalories > 0 ? "Ausgleichstag" : undefined,
|
||||
}));
|
||||
}
|
||||
|
||||
export function buildGoalsFromProfile(profile: HealthProfile): HellthGoals {
|
||||
const weight = Math.max(45, profile.currentWeightKg || 80);
|
||||
const height = Math.max(130, profile.heightCm || 175);
|
||||
const age = Math.max(18, profile.age || 40);
|
||||
const activityFactor =
|
||||
profile.activityLevel === "active" ? 1.55 : profile.activityLevel === "moderate" ? 1.35 : 1.2;
|
||||
|
||||
// Neutral estimate without sex-specific assumptions. Good enough for an onboarding target,
|
||||
// later refined by real weight trend and adherence.
|
||||
const bmr = 10 * weight + 6.25 * height - 5 * age - 80;
|
||||
const maintenance = Math.round(bmr * activityFactor);
|
||||
const deficit =
|
||||
profile.goal === "lose_weight"
|
||||
? profile.pace === "ambitious"
|
||||
? 700
|
||||
: profile.pace === "normal"
|
||||
? 500
|
||||
: 300
|
||||
: 0;
|
||||
const floor = weight > 90 ? 1600 : 1400;
|
||||
const calories = Math.max(floor, maintenance - deficit);
|
||||
const protein = Math.round(Math.min(220, Math.max(90, weight * (profile.goal === "lose_weight" ? 1.6 : 1.3))));
|
||||
const fat = Math.round((calories * 0.3) / 9);
|
||||
const carbs = Math.round((calories - protein * 4 - fat * 9) / 4);
|
||||
|
||||
return {
|
||||
drinkMl: Math.round(Math.min(3500, Math.max(1800, weight * 35)) / 100) * 100,
|
||||
calories,
|
||||
carbs: Math.max(80, carbs),
|
||||
fat,
|
||||
protein,
|
||||
weightKg: profile.targetWeightKg || profile.currentWeightKg,
|
||||
fastingHours: profile.mealsPerDay <= 3 ? 12 : 10,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildCoachRecommendations(profile: HealthProfile, goals: HellthGoals): CoachRecommendation[] {
|
||||
const recommendations: CoachRecommendation[] = [
|
||||
{
|
||||
id: "calorie-deficit",
|
||||
title: profile.goal === "lose_weight" ? "Moderates Defizit halten" : "Stabile Routine aufbauen",
|
||||
body:
|
||||
profile.goal === "lose_weight"
|
||||
? `Starte mit ca. ${goals.calories} kcal pro Tag. Prüfe nach 2 Wochen den Gewichtstrend statt einzelne Tageswerte zu bewerten.`
|
||||
: `Starte mit ca. ${goals.calories} kcal pro Tag und bewerte Energie, Hunger und Gewichtstrend wöchentlich.`,
|
||||
priority: "high",
|
||||
},
|
||||
{
|
||||
id: "protein",
|
||||
title: "Protein als Sattmacher planen",
|
||||
body: `Plane pro Tag etwa ${goals.protein} g Protein ein. Praktisch: jede Hauptmahlzeit bekommt eine klare Proteinquelle.`,
|
||||
priority: "high",
|
||||
},
|
||||
{
|
||||
id: "water",
|
||||
title: "Trinken sichtbar machen",
|
||||
body: `Dein Trinkziel liegt bei ${goals.drinkMl} ml. Starte mit 500 ml bis Mittag und nutze die Wasserkarte als einfache Erinnerung.`,
|
||||
priority: "medium",
|
||||
},
|
||||
];
|
||||
|
||||
if (profile.biggestChallenge.trim()) {
|
||||
recommendations.push({
|
||||
id: "challenge",
|
||||
title: "Haupt-Hürde zuerst entschärfen",
|
||||
body: `Du hast "${profile.biggestChallenge.trim()}" als Hürde genannt. Plane dafür eine konkrete Standardlösung, z. B. ein schnelles Notfallgericht oder vorbereitete Snacks.`,
|
||||
priority: "high",
|
||||
});
|
||||
}
|
||||
|
||||
if (profile.cookingTime === "low") {
|
||||
recommendations.push({
|
||||
id: "simple-meals",
|
||||
title: "Kurze Rezepte bevorzugen",
|
||||
body: "Nutze Rezepte unter 20 Minuten und wiederhole gute Mahlzeiten bewusst. Abnehmen wird leichter, wenn nicht jede Mahlzeit neu entschieden werden muss.",
|
||||
priority: "medium",
|
||||
});
|
||||
}
|
||||
|
||||
if (profile.budgetFocus) {
|
||||
recommendations.push({
|
||||
id: "budget",
|
||||
title: "Budgetfreundliche Basics",
|
||||
body: "Haferflocken, Kartoffeln, Reis, Eier, Quark, TK-Gemüse und Hülsenfrüchte eignen sich gut für planbare, günstige Mahlzeiten.",
|
||||
priority: "low",
|
||||
});
|
||||
}
|
||||
|
||||
if (profile.medicalNote) {
|
||||
recommendations.unshift({
|
||||
id: "medical",
|
||||
title: "Gesundheitliche Situation beachten",
|
||||
body: "Bei Diagnosen, Medikamenten, Schwangerschaft oder Essstörungs-Vorgeschichte bitte Ziele mit Arzt oder Ernährungsfachkraft abstimmen. Hellth Hub ersetzt keine medizinische Beratung.",
|
||||
priority: "high",
|
||||
});
|
||||
}
|
||||
|
||||
return recommendations;
|
||||
}
|
||||
|
||||
export function isFoodAvailableInStore(food: CatalogFood | undefined, storeId: string) {
|
||||
if (!food) return true;
|
||||
if (food.availableEverywhere) return true;
|
||||
return Boolean(food.storeIds?.includes(storeId));
|
||||
}
|
||||
|
||||
export function departmentForFood(food: CatalogFood | undefined, store: Store) {
|
||||
const explicitId = food?.storeDepartmentIds?.[store.id]?.[0];
|
||||
if (explicitId) {
|
||||
const explicit = store.departments.find((department) => department.id === explicitId);
|
||||
if (explicit) return explicit;
|
||||
}
|
||||
|
||||
const category = food?.category.toLowerCase() ?? "";
|
||||
const match = store.departments.find((department) => {
|
||||
const name = department.name.toLowerCase();
|
||||
return (
|
||||
(category.includes("obst") && name.includes("obst")) ||
|
||||
(category.includes("gemüse") && name.includes("gemüse")) ||
|
||||
(category.includes("brot") && name.includes("brot")) ||
|
||||
(category.includes("getreide") && (name.includes("cerealien") || name.includes("trocken"))) ||
|
||||
(category.includes("milch") && name.includes("milch")) ||
|
||||
(category.includes("protein") && (name.includes("protein") || name.includes("fleisch"))) ||
|
||||
(category.includes("öl") && (name.includes("trocken") || name.includes("sonst")))
|
||||
);
|
||||
});
|
||||
|
||||
return match ?? store.departments.find((department) => department.name.toLowerCase().includes("sonst")) ?? store.departments.at(-1);
|
||||
}
|
||||
|
||||
export function buildShoppingItemsFromPlan(data: HellthData, dates?: string[]) {
|
||||
const dateSet = dates?.length ? new Set(dates) : null;
|
||||
const foodById = new Map(data.foods.map((food) => [food.id, food]));
|
||||
const recipeById = new Map(data.recipes.map((recipe) => [recipe.id, recipe]));
|
||||
const previousByFoodId = new Map(data.shoppingItems.filter((item) => item.foodId).map((item) => [item.foodId!, item]));
|
||||
const previousManualItems = data.shoppingItems.filter((item) => !item.foodId);
|
||||
const generated = new Map<string, ShoppingItem>();
|
||||
|
||||
for (const plan of data.plannedMeals) {
|
||||
if (dateSet && !dateSet.has(plan.date)) continue;
|
||||
const recipe = plan.recipeId ? recipeById.get(plan.recipeId) : undefined;
|
||||
if (!recipe) continue;
|
||||
|
||||
for (const ingredient of recipe.ingredients) {
|
||||
const food = foodById.get(ingredient.foodId);
|
||||
if (!food) continue;
|
||||
|
||||
const previous = previousByFoodId.get(food.id);
|
||||
const current = generated.get(food.id);
|
||||
const amountG = ingredient.amountG * plan.portions;
|
||||
generated.set(food.id, {
|
||||
id: current?.id ?? previous?.id ?? uid("shop"),
|
||||
foodId: food.id,
|
||||
name: food.name,
|
||||
category: food.category,
|
||||
amountG: (current?.amountG ?? 0) + amountG,
|
||||
purchaseAmount: current?.purchaseAmount ?? previous?.purchaseAmount,
|
||||
checked: current?.checked ?? previous?.checked,
|
||||
recipeIds: Array.from(new Set([...(current?.recipeIds ?? []), recipe.id])),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return [...generated.values(), ...previousManualItems];
|
||||
}
|
||||
161
apps/admin/lib/hellth/storage.ts
Normal file
161
apps/admin/lib/hellth/storage.ts
Normal file
|
|
@ -0,0 +1,161 @@
|
|||
import {
|
||||
HELLTH_CLUB_KEY,
|
||||
HELLTH_DATA_KEY,
|
||||
defaultClubProfile,
|
||||
defaultGoals,
|
||||
defaultReminderSettings,
|
||||
seedActivityTemplates,
|
||||
seedFoods,
|
||||
seedPlannedMeals,
|
||||
seedRecipes,
|
||||
seedStores,
|
||||
} from "./constants";
|
||||
import type { ClubData, HellthData } from "./types";
|
||||
|
||||
export function defaultHellthData(): HellthData {
|
||||
return {
|
||||
stores: seedStores,
|
||||
foods: seedFoods,
|
||||
recipes: seedRecipes,
|
||||
recipeRatings: [],
|
||||
plannedMeals: seedPlannedMeals,
|
||||
shoppingItems: [],
|
||||
goals: defaultGoals,
|
||||
coachRecommendations: [],
|
||||
reminders: defaultReminderSettings,
|
||||
activityTemplates: seedActivityTemplates,
|
||||
activityLogs: [],
|
||||
weeklyCaloriePlans: [],
|
||||
};
|
||||
}
|
||||
|
||||
function scopedStorageKey(baseKey: string, userKey?: string | null) {
|
||||
const normalized = userKey?.trim().toLowerCase();
|
||||
return normalized ? `${baseKey}:${normalized}` : baseKey;
|
||||
}
|
||||
|
||||
export function hellthDataStorageKey(userKey?: string | null) {
|
||||
return scopedStorageKey(HELLTH_DATA_KEY, userKey);
|
||||
}
|
||||
|
||||
function migrateGlobalDataToUserKey(userKey: string) {
|
||||
if (typeof window === "undefined") return;
|
||||
const scopedKey = hellthDataStorageKey(userKey);
|
||||
if (window.localStorage.getItem(scopedKey)) return;
|
||||
const globalData = window.localStorage.getItem(HELLTH_DATA_KEY);
|
||||
if (globalData) {
|
||||
window.localStorage.setItem(scopedKey, globalData);
|
||||
}
|
||||
}
|
||||
|
||||
export function loadHellthData(userKey?: string | null): HellthData {
|
||||
if (typeof window === "undefined") return defaultHellthData();
|
||||
try {
|
||||
if (userKey) migrateGlobalDataToUserKey(userKey);
|
||||
const raw = window.localStorage.getItem(hellthDataStorageKey(userKey));
|
||||
if (!raw) return defaultHellthData();
|
||||
const parsed = JSON.parse(raw) as Partial<HellthData>;
|
||||
return {
|
||||
stores: parsed.stores?.length ? parsed.stores : seedStores,
|
||||
foods: parsed.foods?.length ? parsed.foods : seedFoods,
|
||||
recipes: parsed.recipes?.length ? parsed.recipes : seedRecipes,
|
||||
recipeRatings: parsed.recipeRatings ?? [],
|
||||
plannedMeals: parsed.plannedMeals ?? seedPlannedMeals,
|
||||
shoppingItems: parsed.shoppingItems ?? [],
|
||||
goals: { ...defaultGoals, ...parsed.goals },
|
||||
healthProfile: parsed.healthProfile,
|
||||
coachRecommendations: parsed.coachRecommendations ?? [],
|
||||
reminders: {
|
||||
...defaultReminderSettings,
|
||||
...parsed.reminders,
|
||||
rules: {
|
||||
water: { ...defaultReminderSettings.rules.water, ...parsed.reminders?.rules?.water },
|
||||
snack: { ...defaultReminderSettings.rules.snack, ...parsed.reminders?.rules?.snack },
|
||||
movement: { ...defaultReminderSettings.rules.movement, ...parsed.reminders?.rules?.movement },
|
||||
},
|
||||
},
|
||||
activityTemplates: parsed.activityTemplates?.length ? parsed.activityTemplates : seedActivityTemplates,
|
||||
activityLogs: parsed.activityLogs ?? [],
|
||||
weeklyCaloriePlans: parsed.weeklyCaloriePlans ?? [],
|
||||
};
|
||||
} catch {
|
||||
return defaultHellthData();
|
||||
}
|
||||
}
|
||||
|
||||
export function saveHellthData(data: HellthData, userKey?: string | null) {
|
||||
if (typeof window === "undefined") return;
|
||||
window.localStorage.setItem(hellthDataStorageKey(userKey), JSON.stringify(data));
|
||||
}
|
||||
|
||||
export function hasCompletedHealthProfile(userKey?: string | null) {
|
||||
return Boolean(loadHellthData(userKey).healthProfile?.completedAt);
|
||||
}
|
||||
|
||||
import { todayKey } from "./constants";
|
||||
|
||||
export function defaultClubData(): ClubData {
|
||||
const today = todayKey();
|
||||
return {
|
||||
profile: defaultClubProfile,
|
||||
activities: [
|
||||
{
|
||||
id: "club-sebastian-walk",
|
||||
title: "Eine Stunde spazieren gehen",
|
||||
hostUserKey: "seed-sebastian",
|
||||
hostName: "Sebastian",
|
||||
category: "walk",
|
||||
scope: "state",
|
||||
state: "Bayern",
|
||||
city: "Augsburg",
|
||||
locationLabel: "Bayern",
|
||||
startsAt: `${today}T18:00:00`,
|
||||
durationMinutes: 60,
|
||||
points: 100,
|
||||
note: "Lockerer Spaziergang nach Feierabend. Jeder geht für sich, aber wir starten gemeinsam.",
|
||||
participants: [
|
||||
{
|
||||
userKey: "seed-sebastian",
|
||||
displayName: "Sebastian",
|
||||
joinedAt: `${today}T10:00:00`,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "club-mobility",
|
||||
title: "5 Minuten Dehnübungen",
|
||||
hostUserKey: "seed-hellth",
|
||||
hostName: "Hellth Coach",
|
||||
category: "mobility",
|
||||
scope: "nationwide",
|
||||
state: "Bayern",
|
||||
locationLabel: "Deutschlandweit",
|
||||
startsAt: `${today}T15:00:00`,
|
||||
durationMinutes: 5,
|
||||
points: 20,
|
||||
note: "Kurze Schreibtischpause. Matte nicht nötig.",
|
||||
participants: [],
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
export function loadClubData(): ClubData {
|
||||
if (typeof window === "undefined") return defaultClubData();
|
||||
try {
|
||||
const raw = window.localStorage.getItem(HELLTH_CLUB_KEY);
|
||||
if (!raw) return defaultClubData();
|
||||
const parsed = JSON.parse(raw) as Partial<ClubData>;
|
||||
return {
|
||||
profile: { ...defaultClubProfile, ...parsed.profile },
|
||||
activities: parsed.activities?.length ? parsed.activities : defaultClubData().activities,
|
||||
};
|
||||
} catch {
|
||||
return defaultClubData();
|
||||
}
|
||||
}
|
||||
|
||||
export function saveClubData(data: ClubData) {
|
||||
if (typeof window === "undefined") return;
|
||||
window.localStorage.setItem(HELLTH_CLUB_KEY, JSON.stringify(data));
|
||||
}
|
||||
319
apps/admin/lib/hellth/types.ts
Normal file
319
apps/admin/lib/hellth/types.ts
Normal file
|
|
@ -0,0 +1,319 @@
|
|||
export type MealType = "Frühstück" | "Mittagessen" | "Abendessen" | "Snack";
|
||||
export type PortionUnit = "g" | "ml" | "tsp" | "tbsp" | "piece" | "pack" | "bottle" | "cup" | "slice";
|
||||
|
||||
export type FoodPortion = {
|
||||
id: string;
|
||||
label: string;
|
||||
unit: PortionUnit;
|
||||
amount: number;
|
||||
gramsEquivalent: number;
|
||||
};
|
||||
|
||||
export type CatalogFood = {
|
||||
id: string;
|
||||
name: string;
|
||||
brand?: string;
|
||||
category: string;
|
||||
kcalPer100g: number;
|
||||
proteinPer100g: number;
|
||||
carbsPer100g: number;
|
||||
fatPer100g: number;
|
||||
servingG: number;
|
||||
preferredUnit?: PortionUnit;
|
||||
densityGPerMl?: number;
|
||||
portions?: FoodPortion[];
|
||||
favorite?: boolean;
|
||||
source: "seed" | "manual" | "openfoodfacts" | "ocr" | "community";
|
||||
barcode?: string;
|
||||
availableEverywhere?: boolean;
|
||||
storeIds?: string[];
|
||||
availabilityTags?: string[];
|
||||
storeDepartmentIds?: Record<string, string[]>;
|
||||
lastPriceByStore?: Record<string, number>;
|
||||
};
|
||||
|
||||
export type StoreDepartment = {
|
||||
id: string;
|
||||
name: string;
|
||||
sortOrder: number;
|
||||
};
|
||||
|
||||
export type Store = {
|
||||
id: string;
|
||||
name: string;
|
||||
favorite?: boolean;
|
||||
departments: StoreDepartment[];
|
||||
};
|
||||
|
||||
export type RecipeIngredient = {
|
||||
foodId: string;
|
||||
amountG: number;
|
||||
amount?: number;
|
||||
unit?: PortionUnit;
|
||||
portionId?: string;
|
||||
};
|
||||
|
||||
export type Recipe = {
|
||||
id: string;
|
||||
name: string;
|
||||
portions: number;
|
||||
minutes: number;
|
||||
ingredients: RecipeIngredient[];
|
||||
notes?: string;
|
||||
};
|
||||
|
||||
export type RecipeRating = {
|
||||
id: string;
|
||||
recipeId: string;
|
||||
userKey: string;
|
||||
displayName: string;
|
||||
rating: number;
|
||||
comment?: string;
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
export type PlannedMeal = {
|
||||
id: string;
|
||||
date: string;
|
||||
mealType: MealType;
|
||||
recipeId?: string;
|
||||
name: string;
|
||||
portions: number;
|
||||
note?: string;
|
||||
};
|
||||
|
||||
export type ShoppingItem = {
|
||||
id: string;
|
||||
foodId?: string;
|
||||
name: string;
|
||||
category: string;
|
||||
amountG: number;
|
||||
purchaseAmount?: string;
|
||||
checked?: boolean;
|
||||
recipeIds?: string[];
|
||||
};
|
||||
|
||||
export type HellthGoals = {
|
||||
drinkMl: number;
|
||||
calories: number;
|
||||
carbs: number;
|
||||
fat: number;
|
||||
protein: number;
|
||||
weightKg: number;
|
||||
fastingHours: number;
|
||||
};
|
||||
|
||||
export type HealthGoal = "lose_weight" | "maintain" | "healthier";
|
||||
export type ActivityLevel = "low" | "moderate" | "active";
|
||||
export type WeightLossPace = "gentle" | "normal" | "ambitious";
|
||||
|
||||
export type HealthProfile = {
|
||||
completedAt: string;
|
||||
goal: HealthGoal;
|
||||
age: number;
|
||||
heightCm: number;
|
||||
currentWeightKg: number;
|
||||
targetWeightKg?: number;
|
||||
activityLevel: ActivityLevel;
|
||||
pace: WeightLossPace;
|
||||
mealsPerDay: number;
|
||||
dietStyle: "mixed" | "vegetarian" | "vegan" | "low_carb" | "flexible";
|
||||
cookingTime: "low" | "medium" | "high";
|
||||
budgetFocus: boolean;
|
||||
excludedFoods: string;
|
||||
biggestChallenge: string;
|
||||
medicalNote: boolean;
|
||||
};
|
||||
|
||||
export type CoachRecommendation = {
|
||||
id: string;
|
||||
title: string;
|
||||
body: string;
|
||||
priority: "high" | "medium" | "low";
|
||||
};
|
||||
|
||||
export type ReminderKind = "water" | "snack" | "movement";
|
||||
|
||||
export type ReminderRule = {
|
||||
enabled: boolean;
|
||||
intervalMinutes: number;
|
||||
startTime: string;
|
||||
endTime: string;
|
||||
message: string;
|
||||
};
|
||||
|
||||
export type ReminderSettings = {
|
||||
enabled: boolean;
|
||||
permissionRequestedAt?: string;
|
||||
rules: Record<ReminderKind, ReminderRule>;
|
||||
};
|
||||
|
||||
export type ActivityTemplate = {
|
||||
id: string;
|
||||
name: string;
|
||||
minutes: number;
|
||||
points: number;
|
||||
calories: number;
|
||||
category: "mobility" | "cardio" | "strength" | "habit";
|
||||
icon: string;
|
||||
};
|
||||
|
||||
export type ActivityLog = {
|
||||
id: string;
|
||||
templateId?: string;
|
||||
name: string;
|
||||
date: string;
|
||||
minutes: number;
|
||||
points: number;
|
||||
calories?: number;
|
||||
note?: string;
|
||||
};
|
||||
|
||||
export type WeeklyCaloriePlan = {
|
||||
date: string;
|
||||
plannedCalories: number;
|
||||
note?: string;
|
||||
};
|
||||
|
||||
export type ClubActivityCategory = "walk" | "run" | "mobility" | "strength" | "bike" | "other";
|
||||
export type ClubScope = "local" | "state" | "nationwide";
|
||||
export type ClubPrivacyMode = "city" | "state";
|
||||
export type GermanState =
|
||||
| "Baden-Württemberg"
|
||||
| "Bayern"
|
||||
| "Berlin"
|
||||
| "Brandenburg"
|
||||
| "Bremen"
|
||||
| "Hamburg"
|
||||
| "Hessen"
|
||||
| "Mecklenburg-Vorpommern"
|
||||
| "Niedersachsen"
|
||||
| "Nordrhein-Westfalen"
|
||||
| "Rheinland-Pfalz"
|
||||
| "Saarland"
|
||||
| "Sachsen"
|
||||
| "Sachsen-Anhalt"
|
||||
| "Schleswig-Holstein"
|
||||
| "Thüringen";
|
||||
|
||||
export type ClubProfile = {
|
||||
state: GermanState;
|
||||
city?: string;
|
||||
latitude?: number;
|
||||
longitude?: number;
|
||||
privacyMode: ClubPrivacyMode;
|
||||
};
|
||||
|
||||
export type ClubActivityParticipant = {
|
||||
userKey: string;
|
||||
displayName: string;
|
||||
joinedAt: string;
|
||||
completedAt?: string;
|
||||
};
|
||||
|
||||
export type ClubActivity = {
|
||||
id: string;
|
||||
title: string;
|
||||
hostUserKey: string;
|
||||
hostName: string;
|
||||
category: ClubActivityCategory;
|
||||
scope?: ClubScope;
|
||||
state?: GermanState;
|
||||
city?: string;
|
||||
latitude?: number;
|
||||
longitude?: number;
|
||||
locationLabel?: string;
|
||||
startsAt: string;
|
||||
durationMinutes: number;
|
||||
points: number;
|
||||
note?: string;
|
||||
participants: ClubActivityParticipant[];
|
||||
};
|
||||
|
||||
export type ClubData = {
|
||||
profile: ClubProfile;
|
||||
activities: ClubActivity[];
|
||||
};
|
||||
|
||||
export type HellthData = {
|
||||
stores: Store[];
|
||||
foods: CatalogFood[];
|
||||
recipes: Recipe[];
|
||||
recipeRatings: RecipeRating[];
|
||||
plannedMeals: PlannedMeal[];
|
||||
shoppingItems: ShoppingItem[];
|
||||
goals: HellthGoals;
|
||||
healthProfile?: HealthProfile;
|
||||
coachRecommendations?: CoachRecommendation[];
|
||||
reminders: ReminderSettings;
|
||||
activityTemplates: ActivityTemplate[];
|
||||
activityLogs: ActivityLog[];
|
||||
weeklyCaloriePlans: WeeklyCaloriePlan[];
|
||||
};
|
||||
|
||||
export type WeeklyBudgetDay = {
|
||||
date: string;
|
||||
plannedCalories: number;
|
||||
eatenCalories: number;
|
||||
activityCalories: number;
|
||||
balance: number;
|
||||
note?: string;
|
||||
};
|
||||
|
||||
export type WeeklyBudgetSummary = {
|
||||
weekStart: string;
|
||||
dailyGoal: number;
|
||||
baseBudget: number;
|
||||
plannedBudget: number;
|
||||
eatenCalories: number;
|
||||
activityCalories: number;
|
||||
availableCalories: number;
|
||||
balance: number;
|
||||
days: WeeklyBudgetDay[];
|
||||
};
|
||||
|
||||
export type HellthDaySnapshot = {
|
||||
foods?: Array<{ calories?: number; protein?: number }>;
|
||||
drinks?: Array<{ amountMl?: number }>;
|
||||
weightKg?: number;
|
||||
};
|
||||
|
||||
export type HellthDayStateSnapshot = {
|
||||
days?: Record<string, HellthDaySnapshot>;
|
||||
};
|
||||
|
||||
export type HealthStatsDay = {
|
||||
date: string;
|
||||
calories: number;
|
||||
plannedCalories: number;
|
||||
activityCalories: number;
|
||||
protein: number;
|
||||
drinkMl: number;
|
||||
weightKg?: number;
|
||||
};
|
||||
|
||||
export type HealthProjection = {
|
||||
kind: "weight";
|
||||
trendKgPerWeek: number;
|
||||
targetDate?: string;
|
||||
message: string;
|
||||
confidence: "low" | "medium";
|
||||
};
|
||||
|
||||
export type HealthStatsSummary = {
|
||||
days: HealthStatsDay[];
|
||||
week: WeeklyBudgetSummary;
|
||||
averages: {
|
||||
calories: number;
|
||||
protein: number;
|
||||
drinkMl: number;
|
||||
activityCalories: number;
|
||||
};
|
||||
streaks: {
|
||||
waterDays: number;
|
||||
proteinDays: number;
|
||||
activityDays: number;
|
||||
};
|
||||
wins: string[];
|
||||
projection?: HealthProjection;
|
||||
};
|
||||
|
|
@ -9,6 +9,7 @@ const t = initTRPC.context<Context>().create({
|
|||
});
|
||||
|
||||
export const router = t.router;
|
||||
export const mergeRouters = t.mergeRouters;
|
||||
export const publicProcedure = t.procedure;
|
||||
const TWO_FACTOR_BYPASS_PATHS = new Set([
|
||||
"security.status",
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
448
packages/api/src/trpc/routers/health/community.ts
Normal file
448
packages/api/src/trpc/routers/health/community.ts
Normal file
|
|
@ -0,0 +1,448 @@
|
|||
import { schema } from "@hellth/db";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { z } from "zod";
|
||||
import { buildDailyCoachSignals } from "../../../lib/health-coach";
|
||||
import { protectedProcedure, router } from "../../init";
|
||||
import {
|
||||
addDays,
|
||||
auditHealthMutation,
|
||||
calcRecipeCalories,
|
||||
clubActivityInput,
|
||||
clubProfileInput,
|
||||
dateKeySchema,
|
||||
dateRange,
|
||||
id,
|
||||
numberString,
|
||||
startOfWeek,
|
||||
} from "./_shared";
|
||||
|
||||
export const communityRouter = router({
|
||||
clubProfile: protectedProcedure.query(async ({ ctx }) => {
|
||||
const profile = await ctx.db.query.clubProfiles.findFirst({
|
||||
where: (row, { eq: rowEq }) => rowEq(row.userId, ctx.user.id),
|
||||
});
|
||||
return profile ?? {
|
||||
userId: ctx.user.id,
|
||||
state: "Bayern",
|
||||
city: "Augsburg",
|
||||
latitude: null,
|
||||
longitude: null,
|
||||
privacyMode: "state",
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
}),
|
||||
|
||||
upsertClubProfile: protectedProcedure.input(clubProfileInput).mutation(async ({ ctx, input }) => {
|
||||
await ctx.db
|
||||
.insert(schema.clubProfiles)
|
||||
.values({
|
||||
userId: ctx.user.id,
|
||||
state: input.state,
|
||||
city: input.city || null,
|
||||
latitude: input.latitude == null ? null : numberString(input.latitude),
|
||||
longitude: input.longitude == null ? null : numberString(input.longitude),
|
||||
privacyMode: input.privacyMode,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: schema.clubProfiles.userId,
|
||||
set: {
|
||||
state: input.state,
|
||||
city: input.city || null,
|
||||
latitude: input.latitude == null ? null : numberString(input.latitude),
|
||||
longitude: input.longitude == null ? null : numberString(input.longitude),
|
||||
privacyMode: input.privacyMode,
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
});
|
||||
auditHealthMutation(ctx, "club-profile.upsert", { state: input.state, privacyMode: input.privacyMode });
|
||||
return { ok: true };
|
||||
}),
|
||||
|
||||
clubActivities: protectedProcedure
|
||||
.input(z.object({ startsOn: dateKeySchema.optional(), endsOn: dateKeySchema.optional() }).optional())
|
||||
.query(async ({ ctx, input }) => {
|
||||
const startsOn = input?.startsOn ?? addDays(new Date().toISOString().slice(0, 10), -7);
|
||||
const endsOn = input?.endsOn ?? addDays(new Date().toISOString().slice(0, 10), 30);
|
||||
const activities = await ctx.db.query.clubActivities.findMany({
|
||||
where: (row, { and: andWhere, gte, lte }) =>
|
||||
andWhere(gte(row.startsAt, new Date(`${startsOn}T00:00:00`)), lte(row.startsAt, new Date(`${endsOn}T23:59:59`))),
|
||||
orderBy: (row, { asc }) => [asc(row.startsAt)],
|
||||
});
|
||||
const participants = activities.length
|
||||
? await ctx.db.query.clubActivityParticipants.findMany({
|
||||
where: (row, { inArray: rowInArray }) => rowInArray(row.activityId, activities.map((activity) => activity.id)),
|
||||
})
|
||||
: [];
|
||||
const userIds = [...new Set([...activities.map((activity) => activity.hostUserId), ...participants.map((participant) => participant.userId)])];
|
||||
const users = userIds.length
|
||||
? await ctx.db.query.user.findMany({
|
||||
where: (row, { inArray: rowInArray }) => rowInArray(row.id, userIds),
|
||||
columns: { id: true, name: true, email: true },
|
||||
})
|
||||
: [];
|
||||
const userById = new Map(users.map((user) => [user.id, user]));
|
||||
const participantsByActivity = new Map<string, typeof participants>();
|
||||
for (const participant of participants) {
|
||||
const current = participantsByActivity.get(participant.activityId) ?? [];
|
||||
current.push(participant);
|
||||
participantsByActivity.set(participant.activityId, current);
|
||||
}
|
||||
return activities.map((activity) => {
|
||||
const host = userById.get(activity.hostUserId);
|
||||
return {
|
||||
...activity,
|
||||
startsAt: activity.startsAt.toISOString(),
|
||||
latitude: activity.latitude == null ? null : Number(activity.latitude),
|
||||
longitude: activity.longitude == null ? null : Number(activity.longitude),
|
||||
hostName: host?.name || host?.email || "Hellth User",
|
||||
participants: (participantsByActivity.get(activity.id) ?? []).map((participant) => {
|
||||
const participantUser = userById.get(participant.userId);
|
||||
return {
|
||||
...participant,
|
||||
displayName: participant.displayName || participantUser?.name || participantUser?.email || "Hellth User",
|
||||
joinedAt: participant.joinedAt.toISOString(),
|
||||
completedAt: participant.completedAt?.toISOString() ?? null,
|
||||
};
|
||||
}),
|
||||
};
|
||||
});
|
||||
}),
|
||||
|
||||
createClubActivity: protectedProcedure.input(clubActivityInput).mutation(async ({ ctx, input }) => {
|
||||
const activityId = id("club");
|
||||
await ctx.db.transaction(async (tx) => {
|
||||
await tx.insert(schema.clubActivities).values({
|
||||
id: activityId,
|
||||
hostUserId: ctx.user.id,
|
||||
title: input.title,
|
||||
category: input.category,
|
||||
scope: input.scope,
|
||||
state: input.state || null,
|
||||
city: input.city || null,
|
||||
latitude: input.latitude == null ? null : numberString(input.latitude),
|
||||
longitude: input.longitude == null ? null : numberString(input.longitude),
|
||||
locationLabel: input.locationLabel || null,
|
||||
startsAt: new Date(input.startsAt),
|
||||
durationMinutes: input.durationMinutes,
|
||||
points: input.points,
|
||||
note: input.note || null,
|
||||
updatedAt: new Date(),
|
||||
});
|
||||
await tx.insert(schema.clubActivityParticipants).values({
|
||||
activityId,
|
||||
userId: ctx.user.id,
|
||||
displayName: ctx.user.name || ctx.user.email || "Du",
|
||||
joinedAt: new Date(),
|
||||
});
|
||||
});
|
||||
auditHealthMutation(ctx, "club-activity.create", { activityId, scope: input.scope, category: input.category });
|
||||
return { id: activityId };
|
||||
}),
|
||||
|
||||
joinClubActivity: protectedProcedure.input(z.object({ id: z.string().min(1) })).mutation(async ({ ctx, input }) => {
|
||||
const activity = await ctx.db.query.clubActivities.findFirst({
|
||||
where: (row, { eq: rowEq }) => rowEq(row.id, input.id),
|
||||
});
|
||||
if (!activity) throw new TRPCError({ code: "NOT_FOUND", message: "Aktivität wurde nicht gefunden." });
|
||||
await ctx.db.insert(schema.clubActivityParticipants).values({
|
||||
activityId: input.id,
|
||||
userId: ctx.user.id,
|
||||
displayName: ctx.user.name || ctx.user.email || "Du",
|
||||
joinedAt: new Date(),
|
||||
}).onConflictDoNothing();
|
||||
auditHealthMutation(ctx, "club-activity.join", { activityId: input.id });
|
||||
return { ok: true };
|
||||
}),
|
||||
|
||||
completeClubActivity: protectedProcedure.input(z.object({ id: z.string().min(1) })).mutation(async ({ ctx, input }) => {
|
||||
const activity = await ctx.db.query.clubActivities.findFirst({
|
||||
where: (row, { eq: rowEq }) => rowEq(row.id, input.id),
|
||||
});
|
||||
if (!activity) throw new TRPCError({ code: "NOT_FOUND", message: "Aktivität wurde nicht gefunden." });
|
||||
await ctx.db.transaction(async (tx) => {
|
||||
await tx.insert(schema.clubActivityParticipants).values({
|
||||
activityId: input.id,
|
||||
userId: ctx.user.id,
|
||||
displayName: ctx.user.name || ctx.user.email || "Du",
|
||||
joinedAt: new Date(),
|
||||
completedAt: new Date(),
|
||||
}).onConflictDoUpdate({
|
||||
target: [schema.clubActivityParticipants.activityId, schema.clubActivityParticipants.userId],
|
||||
set: { completedAt: new Date() },
|
||||
});
|
||||
await tx.insert(schema.activityLogs).values({
|
||||
id: id("activity-log"),
|
||||
userId: ctx.user.id,
|
||||
date: activity.startsAt.toISOString().slice(0, 10),
|
||||
name: `Hellfireclub: ${activity.title}`,
|
||||
minutes: activity.durationMinutes,
|
||||
points: activity.points,
|
||||
calories: Math.round(activity.durationMinutes * 4),
|
||||
source: "system",
|
||||
note: "Gemeinsam getrennt im Hellfireclub",
|
||||
});
|
||||
});
|
||||
auditHealthMutation(ctx, "club-activity.complete", { activityId: input.id, points: activity.points });
|
||||
return { ok: true };
|
||||
}),
|
||||
|
||||
wearableConnections: protectedProcedure.query(async ({ ctx }) => {
|
||||
const rows = await ctx.db.query.wearableConnections.findMany({
|
||||
where: (row, { eq: rowEq }) => rowEq(row.userId, ctx.user.id),
|
||||
});
|
||||
return rows.map((row) => ({
|
||||
id: row.id,
|
||||
provider: row.provider,
|
||||
providerUserId: row.providerUserId,
|
||||
scopes: row.scopes,
|
||||
expiresAt: row.expiresAt,
|
||||
lastSyncAt: row.lastSyncAt,
|
||||
syncEnabled: row.syncEnabled,
|
||||
}));
|
||||
}),
|
||||
|
||||
statsSummary: protectedProcedure
|
||||
.input(z.object({ referenceDate: dateKeySchema }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
const userId = ctx.user.id;
|
||||
const days = dateRange(input.referenceDate, 14);
|
||||
const startsOn = days[0]!;
|
||||
const endsOn = days[days.length - 1]!;
|
||||
const weekStart = startOfWeek(input.referenceDate);
|
||||
const weekDays = dateRange(addDays(weekStart, 6), 7);
|
||||
|
||||
const [profile, goals, mealLogs, drinkLogs, weightLogs, activityLogs, mealPlans, weeklyPlans] = await Promise.all([
|
||||
ctx.db.query.healthProfiles.findFirst({ where: (row, { eq: rowEq }) => rowEq(row.userId, userId) }),
|
||||
ctx.db.query.healthGoals.findFirst({ where: (row, { eq: rowEq }) => rowEq(row.userId, userId) }),
|
||||
ctx.db.query.mealLogs.findMany({
|
||||
where: (row, { and: andWhere, eq: rowEq, gte, lte }) =>
|
||||
andWhere(rowEq(row.userId, userId), gte(row.date, startsOn), lte(row.date, endsOn)),
|
||||
}),
|
||||
ctx.db.query.drinkLogs.findMany({
|
||||
where: (row, { and: andWhere, eq: rowEq, gte, lte }) =>
|
||||
andWhere(rowEq(row.userId, userId), gte(row.date, startsOn), lte(row.date, endsOn)),
|
||||
}),
|
||||
ctx.db.query.weightLogs.findMany({
|
||||
where: (row, { and: andWhere, eq: rowEq, gte, lte }) =>
|
||||
andWhere(rowEq(row.userId, userId), gte(row.date, startsOn), lte(row.date, endsOn)),
|
||||
orderBy: (row, { asc }) => [asc(row.date)],
|
||||
}),
|
||||
ctx.db.query.activityLogs.findMany({
|
||||
where: (row, { and: andWhere, eq: rowEq, gte, lte }) =>
|
||||
andWhere(rowEq(row.userId, userId), gte(row.date, startsOn), lte(row.date, endsOn)),
|
||||
}),
|
||||
ctx.db.query.mealPlans.findMany({
|
||||
where: (row, { and: andWhere, eq: rowEq, gte, lte }) =>
|
||||
andWhere(rowEq(row.userId, userId), gte(row.date, weekStart), lte(row.date, addDays(weekStart, 6))),
|
||||
}),
|
||||
ctx.db.query.weeklyCaloriePlans.findMany({
|
||||
where: (row, { and: andWhere, eq: rowEq, gte, lte }) =>
|
||||
andWhere(rowEq(row.userId, userId), gte(row.date, weekStart), lte(row.date, addDays(weekStart, 6))),
|
||||
}),
|
||||
]);
|
||||
|
||||
const recipeIds = [...new Set(mealPlans.map((plan) => plan.recipeId).filter(Boolean))] as string[];
|
||||
const recipes = recipeIds.length
|
||||
? await ctx.db.query.recipes.findMany({ where: (row, { inArray: rowInArray }) => rowInArray(row.id, recipeIds) })
|
||||
: [];
|
||||
const ingredients = recipeIds.length
|
||||
? await ctx.db.query.recipeIngredients.findMany({ where: (row, { inArray: rowInArray }) => rowInArray(row.recipeId, recipeIds) })
|
||||
: [];
|
||||
const foodIds = [...new Set(ingredients.map((ingredient) => ingredient.foodId))];
|
||||
const foods = foodIds.length
|
||||
? await ctx.db.query.foods.findMany({ where: (row, { inArray: rowInArray }) => rowInArray(row.id, foodIds) })
|
||||
: [];
|
||||
|
||||
const recipeCalories = new Map(recipes.map((recipe) => [recipe.id, calcRecipeCalories(recipe, ingredients, foods)]));
|
||||
const plannedCaloriesByDate = new Map<string, number>();
|
||||
for (const plan of mealPlans) {
|
||||
const explicitCalories = Number(plan.plannedCalories ?? 0);
|
||||
const recipeCaloriesForPlan = plan.recipeId ? recipeCalories.get(plan.recipeId) ?? 0 : 0;
|
||||
const kcal = explicitCalories > 0 ? explicitCalories : recipeCaloriesForPlan * Number(plan.portions ?? 1);
|
||||
plannedCaloriesByDate.set(plan.date, (plannedCaloriesByDate.get(plan.date) ?? 0) + kcal);
|
||||
}
|
||||
|
||||
const dayRows = days.map((date) => {
|
||||
const loggedMeals = mealLogs.filter((meal) => meal.date === date);
|
||||
const loggedCalories = loggedMeals.reduce((sum, meal) => sum + meal.calories, 0);
|
||||
return {
|
||||
date,
|
||||
calories: loggedCalories || (date >= input.referenceDate ? Math.round(plannedCaloriesByDate.get(date) ?? 0) : 0),
|
||||
plannedCalories: Math.round(plannedCaloriesByDate.get(date) ?? 0),
|
||||
activityCalories: activityLogs.filter((log) => log.date === date).reduce((sum, log) => sum + log.calories, 0),
|
||||
protein: Math.round(loggedMeals.reduce((sum, meal) => sum + Number(meal.proteinG ?? 0), 0)),
|
||||
drinkMl: drinkLogs.filter((drink) => drink.date === date).reduce((sum, drink) => sum + drink.amountMl, 0),
|
||||
weightKg: (() => {
|
||||
const weight = weightLogs.find((log) => log.date === date);
|
||||
return weight ? Number(weight.weightKg) : undefined;
|
||||
})(),
|
||||
};
|
||||
});
|
||||
|
||||
const relevantDays = dayRows.filter((day) => day.date <= input.referenceDate);
|
||||
const divisor = Math.max(1, relevantDays.length);
|
||||
const averages = {
|
||||
calories: Math.round(relevantDays.reduce((sum, day) => sum + day.calories, 0) / divisor),
|
||||
protein: Math.round(relevantDays.reduce((sum, day) => sum + day.protein, 0) / divisor),
|
||||
drinkMl: Math.round(relevantDays.reduce((sum, day) => sum + day.drinkMl, 0) / divisor),
|
||||
activityCalories: Math.round(relevantDays.reduce((sum, day) => sum + day.activityCalories, 0) / divisor),
|
||||
};
|
||||
|
||||
const streak = (predicate: (day: typeof dayRows[number]) => boolean) => {
|
||||
let count = 0;
|
||||
for (const day of [...relevantDays].reverse()) {
|
||||
if (!predicate(day)) break;
|
||||
count += 1;
|
||||
}
|
||||
return count;
|
||||
};
|
||||
const calorieGoal = goals?.calories ?? 2000;
|
||||
const drinkGoal = goals?.drinkMl ?? 2500;
|
||||
const proteinGoal = goals?.proteinG ?? 150;
|
||||
const weeklyPlanByDate = new Map(weeklyPlans.map((plan) => [plan.date, plan]));
|
||||
const weekDayRows = weekDays.map((date) => {
|
||||
const plannedCalories = weeklyPlanByDate.get(date)?.plannedCalories ?? calorieGoal;
|
||||
const eatenCalories = Math.round(plannedCaloriesByDate.get(date) ?? 0);
|
||||
const activityCalories = activityLogs.filter((log) => log.date === date).reduce((sum, log) => sum + log.calories, 0);
|
||||
return {
|
||||
date,
|
||||
plannedCalories,
|
||||
eatenCalories,
|
||||
activityCalories,
|
||||
balance: plannedCalories + activityCalories - eatenCalories,
|
||||
note: weeklyPlanByDate.get(date)?.note ?? undefined,
|
||||
};
|
||||
});
|
||||
const plannedBudget = weekDayRows.reduce((sum, day) => sum + day.plannedCalories, 0);
|
||||
const eatenCalories = weekDayRows.reduce((sum, day) => sum + day.eatenCalories, 0);
|
||||
const activityCalories = weekDayRows.reduce((sum, day) => sum + day.activityCalories, 0);
|
||||
|
||||
const weightEntries = dayRows.filter((day) => day.weightKg !== undefined);
|
||||
let projection: { kind: "weight"; trendKgPerWeek: number; targetDate?: string; message: string; confidence: "low" | "medium" } | undefined;
|
||||
if (weightEntries.length >= 3) {
|
||||
const first = weightEntries[0]!;
|
||||
const last = weightEntries[weightEntries.length - 1]!;
|
||||
const elapsedDays = Math.max(1, Math.round((new Date(`${last.date}T12:00:00`).getTime() - new Date(`${first.date}T12:00:00`).getTime()) / 86_400_000));
|
||||
if (elapsedDays >= 7) {
|
||||
const trendKgPerWeek = Math.round(((last.weightKg! - first.weightKg!) / elapsedDays) * 7 * 10) / 10;
|
||||
const targetWeight = profile?.targetWeightKg ? Number(profile.targetWeightKg) : goals?.weightKg ? Number(goals.weightKg) : undefined;
|
||||
if (targetWeight && ((targetWeight < last.weightKg! && trendKgPerWeek < -0.05) || (targetWeight > last.weightKg! && trendKgPerWeek > 0.05))) {
|
||||
const estimatedDays = Math.ceil(Math.abs(last.weightKg! - targetWeight) / Math.max(0.01, Math.abs(trendKgPerWeek / 7)));
|
||||
projection = {
|
||||
kind: "weight",
|
||||
trendKgPerWeek,
|
||||
targetDate: addDays(input.referenceDate, estimatedDays),
|
||||
confidence: elapsedDays >= 14 && weightEntries.length >= 5 ? "medium" : "low",
|
||||
message: `Aktueller Trend: ${Math.abs(trendKgPerWeek).toLocaleString("de-DE")} kg pro Woche in Richtung Ziel.`,
|
||||
};
|
||||
} else {
|
||||
projection = {
|
||||
kind: "weight",
|
||||
trendKgPerWeek,
|
||||
confidence: "low",
|
||||
message: "Noch kein stabiler Zieltrend. Das ist normal, einzelne Wochen schwanken stark.",
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const streaks = {
|
||||
waterDays: streak((day) => day.drinkMl >= drinkGoal),
|
||||
proteinDays: streak((day) => day.protein >= proteinGoal * 0.8),
|
||||
activityDays: streak((day) => day.activityCalories > 0),
|
||||
};
|
||||
const wins = [
|
||||
streaks.waterDays > 0 ? `${streaks.waterDays} Tag(e) Trinkziel in Folge` : undefined,
|
||||
streaks.proteinDays > 0 ? `${streaks.proteinDays} Tag(e) Protein gut getroffen` : undefined,
|
||||
streaks.activityDays > 0 ? `${streaks.activityDays} Tag(e) mit Bewegung in Folge` : undefined,
|
||||
averages.activityCalories > 0 ? `Ø ${averages.activityCalories} kcal Sportbonus pro Tag` : undefined,
|
||||
].filter(Boolean);
|
||||
|
||||
return {
|
||||
days: dayRows,
|
||||
week: {
|
||||
weekStart,
|
||||
dailyGoal: calorieGoal,
|
||||
baseBudget: calorieGoal * 7,
|
||||
plannedBudget,
|
||||
eatenCalories,
|
||||
activityCalories,
|
||||
availableCalories: plannedBudget + activityCalories - eatenCalories,
|
||||
balance: plannedBudget + activityCalories - eatenCalories,
|
||||
days: weekDayRows,
|
||||
},
|
||||
averages,
|
||||
streaks,
|
||||
wins: wins.length ? wins : ["Du sammelst gerade Daten. Jeder Eintrag macht die Auswertung besser."],
|
||||
projection,
|
||||
};
|
||||
}),
|
||||
|
||||
generateCoachRecommendations: protectedProcedure
|
||||
.input(z.object({ date: dateKeySchema }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const userId = ctx.user.id;
|
||||
const [profile, goals, mealLogs, drinkLogs, activityLogs] = await Promise.all([
|
||||
ctx.db.query.healthProfiles.findFirst({ where: (row, { eq: rowEq }) => rowEq(row.userId, userId) }),
|
||||
ctx.db.query.healthGoals.findFirst({ where: (row, { eq: rowEq }) => rowEq(row.userId, userId) }),
|
||||
ctx.db.query.mealLogs.findMany({ where: (row, { and: andWhere, eq: rowEq }) => andWhere(rowEq(row.userId, userId), rowEq(row.date, input.date)) }),
|
||||
ctx.db.query.drinkLogs.findMany({ where: (row, { and: andWhere, eq: rowEq }) => andWhere(rowEq(row.userId, userId), rowEq(row.date, input.date)) }),
|
||||
ctx.db.query.activityLogs.findMany({ where: (row, { and: andWhere, eq: rowEq }) => andWhere(rowEq(row.userId, userId), rowEq(row.date, input.date)) }),
|
||||
]);
|
||||
|
||||
const calories = mealLogs.reduce((sum, meal) => sum + (meal.calories ?? 0), 0);
|
||||
const proteinG = mealLogs.reduce((sum, meal) => sum + Number(meal.proteinG ?? 0), 0);
|
||||
const drinkMl = drinkLogs.reduce((sum, drink) => sum + (drink.amountMl ?? 0), 0);
|
||||
const activityMinutes = activityLogs.reduce((sum, activity) => sum + (activity.minutes ?? 0), 0);
|
||||
const activityCalories = activityLogs.reduce((sum, activity) => sum + (activity.calories ?? 0), 0);
|
||||
|
||||
const signals = buildDailyCoachSignals({
|
||||
date: input.date,
|
||||
calorieGoal: goals?.calories ?? 2000,
|
||||
proteinGoal: goals?.proteinG ?? 150,
|
||||
drinkGoalMl: goals?.drinkMl ?? 2500,
|
||||
calories,
|
||||
proteinG,
|
||||
drinkMl,
|
||||
activityMinutes,
|
||||
activityCalories,
|
||||
medicalNote: profile?.medicalNote ?? false,
|
||||
});
|
||||
|
||||
await ctx.db.transaction(async (tx) => {
|
||||
await tx
|
||||
.delete(schema.coachRecommendations)
|
||||
.where(and(eq(schema.coachRecommendations.userId, userId), eq(schema.coachRecommendations.source, "rule")));
|
||||
await tx.insert(schema.coachRecommendations).values(signals.map((signal) => ({
|
||||
id: id("coach"),
|
||||
userId,
|
||||
title: signal.title,
|
||||
body: signal.body,
|
||||
priority: signal.priority,
|
||||
category: signal.category,
|
||||
source: "rule",
|
||||
validFrom: input.date,
|
||||
validTo: input.date,
|
||||
})));
|
||||
});
|
||||
|
||||
auditHealthMutation(ctx, "coach-recommendations.generate", {
|
||||
date: input.date,
|
||||
count: signals.length,
|
||||
});
|
||||
return { recommendations: signals };
|
||||
}),
|
||||
|
||||
dismissCoachRecommendation: protectedProcedure
|
||||
.input(z.object({ id: z.string().min(1) }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
await ctx.db
|
||||
.update(schema.coachRecommendations)
|
||||
.set({ dismissedAt: new Date() })
|
||||
.where(and(eq(schema.coachRecommendations.id, input.id), eq(schema.coachRecommendations.userId, ctx.user.id)));
|
||||
auditHealthMutation(ctx, "coach-recommendation.dismiss", { recommendationId: input.id });
|
||||
return { ok: true };
|
||||
}),
|
||||
});
|
||||
169
packages/api/src/trpc/routers/health/dashboard.ts
Normal file
169
packages/api/src/trpc/routers/health/dashboard.ts
Normal file
|
|
@ -0,0 +1,169 @@
|
|||
import { schema } from "@hellth/db";
|
||||
import { and, eq, isNull } from "drizzle-orm";
|
||||
import { z } from "zod";
|
||||
import { platformProcedure, protectedProcedure, router } from "../../init";
|
||||
import {
|
||||
auditHealthMutation,
|
||||
dateKeySchema,
|
||||
getPointSettings,
|
||||
goalsInput,
|
||||
numberString,
|
||||
pointSettingsInput,
|
||||
profileInput,
|
||||
} from "./_shared";
|
||||
|
||||
export const dashboardRouter = router({
|
||||
pointDefaults: protectedProcedure.query(async ({ ctx }) => getPointSettings(ctx.db)),
|
||||
|
||||
pointSettings: platformProcedure.query(async ({ ctx }) => {
|
||||
const settings = await getPointSettings(ctx.db);
|
||||
const templates = await ctx.db.query.activityTemplates.findMany({
|
||||
where: (row, { isNull: rowIsNull }) => rowIsNull(row.ownerUserId),
|
||||
orderBy: (row, { asc }) => [asc(row.category), asc(row.name)],
|
||||
});
|
||||
return {
|
||||
...settings,
|
||||
activityTemplates: templates,
|
||||
};
|
||||
}),
|
||||
|
||||
updatePointSettings: platformProcedure.input(pointSettingsInput).mutation(async ({ ctx, input }) => {
|
||||
await ctx.db.transaction(async (tx) => {
|
||||
await tx
|
||||
.insert(schema.platformSettings)
|
||||
.values({
|
||||
key: "hellth.points",
|
||||
value: JSON.stringify({
|
||||
recipeSubmissionPoints: input.recipeSubmissionPoints,
|
||||
clubActivityDefaultPoints: input.clubActivityDefaultPoints,
|
||||
}),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: schema.platformSettings.key,
|
||||
set: {
|
||||
value: JSON.stringify({
|
||||
recipeSubmissionPoints: input.recipeSubmissionPoints,
|
||||
clubActivityDefaultPoints: input.clubActivityDefaultPoints,
|
||||
}),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
for (const template of input.activityTemplates) {
|
||||
await tx.update(schema.activityTemplates)
|
||||
.set({ defaultPoints: template.defaultPoints })
|
||||
.where(and(eq(schema.activityTemplates.id, template.id), isNull(schema.activityTemplates.ownerUserId)));
|
||||
}
|
||||
});
|
||||
auditHealthMutation(ctx, "point-settings.update", {
|
||||
recipeSubmissionPoints: input.recipeSubmissionPoints,
|
||||
clubActivityDefaultPoints: input.clubActivityDefaultPoints,
|
||||
templates: input.activityTemplates.length,
|
||||
});
|
||||
return { ok: true };
|
||||
}),
|
||||
|
||||
overview: protectedProcedure
|
||||
.input(z.object({ date: dateKeySchema }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
const userId = ctx.user.id;
|
||||
const [
|
||||
profile,
|
||||
goals,
|
||||
mealLogs,
|
||||
drinkLogs,
|
||||
weightLog,
|
||||
mealPlans,
|
||||
activityLogs,
|
||||
coachRecommendations,
|
||||
wearableConnections,
|
||||
] = await Promise.all([
|
||||
ctx.db.query.healthProfiles.findFirst({ where: (row, { eq: rowEq }) => rowEq(row.userId, userId) }),
|
||||
ctx.db.query.healthGoals.findFirst({ where: (row, { eq: rowEq }) => rowEq(row.userId, userId) }),
|
||||
ctx.db.query.mealLogs.findMany({ where: (row, { and: andWhere, eq: rowEq }) => andWhere(rowEq(row.userId, userId), rowEq(row.date, input.date)), orderBy: (row, { desc: rowDesc }) => [rowDesc(row.createdAt)] }),
|
||||
ctx.db.query.drinkLogs.findMany({ where: (row, { and: andWhere, eq: rowEq }) => andWhere(rowEq(row.userId, userId), rowEq(row.date, input.date)), orderBy: (row, { desc: rowDesc }) => [rowDesc(row.createdAt)] }),
|
||||
ctx.db.query.weightLogs.findFirst({ where: (row, { and: andWhere, eq: rowEq }) => andWhere(rowEq(row.userId, userId), rowEq(row.date, input.date)) }),
|
||||
ctx.db.query.mealPlans.findMany({ where: (row, { and: andWhere, eq: rowEq }) => andWhere(rowEq(row.userId, userId), rowEq(row.date, input.date)) }),
|
||||
ctx.db.query.activityLogs.findMany({ where: (row, { and: andWhere, eq: rowEq }) => andWhere(rowEq(row.userId, userId), rowEq(row.date, input.date)), orderBy: (row, { desc: rowDesc }) => [rowDesc(row.createdAt)] }),
|
||||
ctx.db.query.coachRecommendations.findMany({ where: (row, { and: andWhere, eq: rowEq, isNull: rowIsNull }) => andWhere(rowEq(row.userId, userId), rowIsNull(row.dismissedAt)), orderBy: (row, { desc: rowDesc }) => [rowDesc(row.createdAt)], limit: 5 }),
|
||||
ctx.db.query.wearableConnections.findMany({ where: (row, { eq: rowEq }) => rowEq(row.userId, userId) }),
|
||||
]);
|
||||
|
||||
return {
|
||||
profile,
|
||||
goals,
|
||||
day: {
|
||||
date: input.date,
|
||||
mealLogs,
|
||||
drinkLogs,
|
||||
weightLog,
|
||||
mealPlans,
|
||||
activityLogs,
|
||||
},
|
||||
coachRecommendations,
|
||||
wearableConnections: wearableConnections.map((connection) => ({
|
||||
id: connection.id,
|
||||
provider: connection.provider,
|
||||
providerUserId: connection.providerUserId,
|
||||
scopes: connection.scopes,
|
||||
lastSyncAt: connection.lastSyncAt,
|
||||
syncEnabled: connection.syncEnabled,
|
||||
})),
|
||||
};
|
||||
}),
|
||||
|
||||
upsertProfile: protectedProcedure.input(profileInput).mutation(async ({ ctx, input }) => {
|
||||
const userId = ctx.user.id;
|
||||
await ctx.db
|
||||
.insert(schema.healthProfiles)
|
||||
.values({
|
||||
userId,
|
||||
...input,
|
||||
heightCm: numberString(input.heightCm, 0),
|
||||
currentWeightKg: numberString(input.currentWeightKg, 0),
|
||||
targetWeightKg: input.targetWeightKg ? numberString(input.targetWeightKg) : null,
|
||||
completedAt: input.completed ? new Date() : null,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: schema.healthProfiles.userId,
|
||||
set: {
|
||||
...input,
|
||||
heightCm: numberString(input.heightCm, 0),
|
||||
currentWeightKg: numberString(input.currentWeightKg, 0),
|
||||
targetWeightKg: input.targetWeightKg ? numberString(input.targetWeightKg) : null,
|
||||
completedAt: input.completed ? new Date() : null,
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
});
|
||||
auditHealthMutation(ctx, "profile.upsert", { goal: input.goal, completed: input.completed });
|
||||
return { ok: true };
|
||||
}),
|
||||
|
||||
upsertGoals: protectedProcedure.input(goalsInput).mutation(async ({ ctx, input }) => {
|
||||
const userId = ctx.user.id;
|
||||
await ctx.db
|
||||
.insert(schema.healthGoals)
|
||||
.values({
|
||||
userId,
|
||||
...input,
|
||||
weightKg: input.weightKg ? numberString(input.weightKg) : null,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: schema.healthGoals.userId,
|
||||
set: {
|
||||
...input,
|
||||
weightKg: input.weightKg ? numberString(input.weightKg) : null,
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
});
|
||||
auditHealthMutation(ctx, "goals.upsert", {
|
||||
calories: input.calories,
|
||||
proteinG: input.proteinG,
|
||||
weeklyCalories: input.weeklyCalories ?? null,
|
||||
});
|
||||
return { ok: true };
|
||||
}),
|
||||
});
|
||||
247
packages/api/src/trpc/routers/health/foods.ts
Normal file
247
packages/api/src/trpc/routers/health/foods.ts
Normal file
|
|
@ -0,0 +1,247 @@
|
|||
import { schema } from "@hellth/db";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { and, desc, eq, isNull, or } from "drizzle-orm";
|
||||
import { z } from "zod";
|
||||
import { protectedProcedure, router } from "../../init";
|
||||
import {
|
||||
auditHealthMutation,
|
||||
averageNutritionSources,
|
||||
foodInput,
|
||||
foodPreferenceInput,
|
||||
id,
|
||||
normalizeOpenFoodFactsProduct,
|
||||
numberString,
|
||||
nutritionSourceInput,
|
||||
} from "./_shared";
|
||||
|
||||
export const foodsRouter = router({
|
||||
listFoods: protectedProcedure
|
||||
.input(z.object({ query: z.string().trim().max(120).optional().default("") }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
const userId = ctx.user.id;
|
||||
const rows = await ctx.db.query.foods.findMany({
|
||||
where: or(isNull(schema.foods.ownerUserId), eq(schema.foods.ownerUserId, userId)),
|
||||
orderBy: [desc(schema.foods.updatedAt)],
|
||||
limit: input.query ? 200 : 100,
|
||||
});
|
||||
const needle = input.query.toLowerCase();
|
||||
const filtered = needle
|
||||
? rows.filter((food) => [food.name, food.brand, food.category, food.barcode].filter(Boolean).some((value) => value!.toLowerCase().includes(needle)))
|
||||
: rows;
|
||||
const portions = filtered.length
|
||||
? await ctx.db.query.foodPortions.findMany({
|
||||
where: (row, { inArray: rowInArray }) => rowInArray(row.foodId, filtered.map((food) => food.id)),
|
||||
})
|
||||
: [];
|
||||
const nutritionSources = filtered.length
|
||||
? await ctx.db.query.foodNutritionSources.findMany({
|
||||
where: (row, { inArray: rowInArray }) => rowInArray(row.foodId, filtered.map((food) => food.id)),
|
||||
})
|
||||
: [];
|
||||
const preferences = filtered.length
|
||||
? await ctx.db.query.foodUserPreferences.findMany({
|
||||
where: (row, { and: andWhere, eq: rowEq, inArray: rowInArray }) =>
|
||||
andWhere(rowEq(row.userId, userId), rowInArray(row.foodId, filtered.map((food) => food.id))),
|
||||
})
|
||||
: [];
|
||||
const portionsByFood = new Map<string, typeof portions>();
|
||||
for (const portion of portions) {
|
||||
const current = portionsByFood.get(portion.foodId) ?? [];
|
||||
current.push(portion);
|
||||
portionsByFood.set(portion.foodId, current);
|
||||
}
|
||||
const sourcesByFood = new Map<string, typeof nutritionSources>();
|
||||
for (const source of nutritionSources) {
|
||||
const current = sourcesByFood.get(source.foodId) ?? [];
|
||||
current.push(source);
|
||||
sourcesByFood.set(source.foodId, current);
|
||||
}
|
||||
const preferencesByFood = new Map(preferences.map((preference) => [preference.foodId, preference]));
|
||||
return filtered.map((food) => ({
|
||||
...food,
|
||||
portions: portionsByFood.get(food.id) ?? [],
|
||||
nutritionSources: sourcesByFood.get(food.id) ?? [],
|
||||
userVote: preferencesByFood.get(food.id)?.vote ?? 0,
|
||||
}));
|
||||
}),
|
||||
|
||||
setFoodPreference: protectedProcedure.input(foodPreferenceInput).mutation(async ({ ctx, input }) => {
|
||||
const food = await ctx.db.query.foods.findFirst({
|
||||
where: (row, { eq: rowEq }) => rowEq(row.id, input.foodId),
|
||||
});
|
||||
if (!food || (food.ownerUserId && food.ownerUserId !== ctx.user.id)) {
|
||||
throw new TRPCError({ code: "NOT_FOUND", message: "Lebensmittel wurde nicht gefunden." });
|
||||
}
|
||||
if (input.vote === 0) {
|
||||
await ctx.db
|
||||
.delete(schema.foodUserPreferences)
|
||||
.where(and(eq(schema.foodUserPreferences.userId, ctx.user.id), eq(schema.foodUserPreferences.foodId, input.foodId)));
|
||||
} else {
|
||||
await ctx.db
|
||||
.insert(schema.foodUserPreferences)
|
||||
.values({
|
||||
userId: ctx.user.id,
|
||||
foodId: input.foodId,
|
||||
vote: input.vote,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: [schema.foodUserPreferences.userId, schema.foodUserPreferences.foodId],
|
||||
set: {
|
||||
vote: input.vote,
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
});
|
||||
}
|
||||
auditHealthMutation(ctx, "food-preference.set", { foodId: input.foodId, vote: input.vote });
|
||||
return { ok: true };
|
||||
}),
|
||||
|
||||
saveFood: protectedProcedure.input(foodInput).mutation(async ({ ctx, input }) => {
|
||||
const userId = ctx.user.id;
|
||||
const foodId = input.id ?? id("food");
|
||||
const existing = input.id
|
||||
? await ctx.db.query.foods.findFirst({ where: (row, { eq: rowEq }) => rowEq(row.id, input.id!) })
|
||||
: null;
|
||||
|
||||
if (existing?.ownerUserId && existing.ownerUserId !== userId) {
|
||||
throw new TRPCError({ code: "FORBIDDEN", message: "Dieses Lebensmittel gehört einem anderen Benutzer." });
|
||||
}
|
||||
|
||||
await ctx.db.transaction(async (tx) => {
|
||||
await tx
|
||||
.insert(schema.foods)
|
||||
.values({
|
||||
id: foodId,
|
||||
ownerUserId: existing?.ownerUserId ?? userId,
|
||||
name: input.name,
|
||||
brand: input.brand || null,
|
||||
barcode: input.barcode || null,
|
||||
category: input.category,
|
||||
kcalPer100g: numberString(input.kcalPer100g),
|
||||
proteinPer100g: numberString(input.proteinPer100g),
|
||||
carbsPer100g: numberString(input.carbsPer100g),
|
||||
fatPer100g: numberString(input.fatPer100g),
|
||||
servingG: numberString(input.servingG, 100),
|
||||
preferredUnit: input.preferredUnit,
|
||||
densityGPerMl: input.densityGPerMl ? numberString(input.densityGPerMl) : null,
|
||||
source: input.source,
|
||||
availableEverywhere: input.availableEverywhere,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: schema.foods.id,
|
||||
set: {
|
||||
name: input.name,
|
||||
brand: input.brand || null,
|
||||
barcode: input.barcode || null,
|
||||
category: input.category,
|
||||
kcalPer100g: numberString(input.kcalPer100g),
|
||||
proteinPer100g: numberString(input.proteinPer100g),
|
||||
carbsPer100g: numberString(input.carbsPer100g),
|
||||
fatPer100g: numberString(input.fatPer100g),
|
||||
servingG: numberString(input.servingG, 100),
|
||||
preferredUnit: input.preferredUnit,
|
||||
densityGPerMl: input.densityGPerMl ? numberString(input.densityGPerMl) : null,
|
||||
source: input.source,
|
||||
availableEverywhere: input.availableEverywhere,
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
await tx.delete(schema.foodPortions).where(eq(schema.foodPortions.foodId, foodId));
|
||||
if (input.portions.length) {
|
||||
await tx.insert(schema.foodPortions).values(input.portions.map((portion) => ({
|
||||
id: portion.id ?? id("portion"),
|
||||
foodId,
|
||||
label: portion.label,
|
||||
unit: portion.unit,
|
||||
amount: numberString(portion.amount, 1),
|
||||
gramsEquivalent: numberString(portion.gramsEquivalent, 1),
|
||||
})));
|
||||
}
|
||||
});
|
||||
|
||||
auditHealthMutation(ctx, input.id ? "food.update" : "food.create", {
|
||||
foodId,
|
||||
name: input.name,
|
||||
portions: input.portions.length,
|
||||
});
|
||||
return { id: foodId };
|
||||
}),
|
||||
|
||||
searchOpenFoodFacts: protectedProcedure
|
||||
.input(z.object({
|
||||
query: z.string().trim().min(2).max(120),
|
||||
pageSize: z.number().int().min(1).max(25).default(10),
|
||||
}))
|
||||
.query(async ({ input }) => {
|
||||
const url = new URL("https://world.openfoodfacts.org/cgi/search.pl");
|
||||
url.searchParams.set("search_terms", input.query);
|
||||
url.searchParams.set("search_simple", "1");
|
||||
url.searchParams.set("action", "process");
|
||||
url.searchParams.set("json", "1");
|
||||
url.searchParams.set("page_size", String(input.pageSize));
|
||||
url.searchParams.set("fields", "code,product_name,brands,categories,serving_quantity,nutriments");
|
||||
|
||||
const response = await fetch(url, {
|
||||
headers: {
|
||||
"User-Agent": "HellthHub/0.1 (local-home-lab)",
|
||||
},
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new TRPCError({ code: "BAD_GATEWAY", message: "Open Food Facts konnte nicht abgefragt werden." });
|
||||
}
|
||||
const payload = await response.json() as { products?: Array<Parameters<typeof normalizeOpenFoodFactsProduct>[0]> };
|
||||
return (payload.products ?? [])
|
||||
.map(normalizeOpenFoodFactsProduct)
|
||||
.filter((product) => product.name && product.kcalPer100g >= 0);
|
||||
}),
|
||||
|
||||
addNutritionSource: protectedProcedure.input(nutritionSourceInput).mutation(async ({ ctx, input }) => {
|
||||
const existing = await ctx.db.query.foods.findFirst({
|
||||
where: (row, { eq: rowEq }) => rowEq(row.id, input.foodId),
|
||||
});
|
||||
if (!existing || (existing.ownerUserId && existing.ownerUserId !== ctx.user.id)) {
|
||||
throw new TRPCError({ code: "NOT_FOUND", message: "Lebensmittel wurde nicht gefunden." });
|
||||
}
|
||||
|
||||
await ctx.db.insert(schema.foodNutritionSources).values({
|
||||
id: id("nutrition-source"),
|
||||
foodId: input.foodId,
|
||||
provider: input.provider,
|
||||
providerFoodId: input.providerFoodId || null,
|
||||
name: input.name,
|
||||
brand: input.brand || null,
|
||||
url: input.url || null,
|
||||
kcalPer100g: numberString(input.kcalPer100g),
|
||||
proteinPer100g: numberString(input.proteinPer100g),
|
||||
carbsPer100g: numberString(input.carbsPer100g),
|
||||
fatPer100g: numberString(input.fatPer100g),
|
||||
servingG: input.servingG ? numberString(input.servingG) : null,
|
||||
confidence: input.confidence,
|
||||
raw: input.raw,
|
||||
updatedAt: new Date(),
|
||||
});
|
||||
|
||||
const sources = await ctx.db.query.foodNutritionSources.findMany({
|
||||
where: (row, { eq: rowEq }) => rowEq(row.foodId, input.foodId),
|
||||
});
|
||||
const average = averageNutritionSources(sources);
|
||||
await ctx.db.update(schema.foods).set({
|
||||
kcalPer100g: numberString(average.kcalPer100g),
|
||||
proteinPer100g: numberString(average.proteinPer100g),
|
||||
carbsPer100g: numberString(average.carbsPer100g),
|
||||
fatPer100g: numberString(average.fatPer100g),
|
||||
source: "community",
|
||||
updatedAt: new Date(),
|
||||
}).where(eq(schema.foods.id, input.foodId));
|
||||
|
||||
auditHealthMutation(ctx, "food-nutrition-source.add", {
|
||||
foodId: input.foodId,
|
||||
provider: input.provider,
|
||||
sources: sources.length,
|
||||
});
|
||||
return { ok: true, average, sourceCount: sources.length };
|
||||
}),
|
||||
});
|
||||
18
packages/api/src/trpc/routers/health/index.ts
Normal file
18
packages/api/src/trpc/routers/health/index.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
import { mergeRouters } from "../../init";
|
||||
import { communityRouter } from "./community";
|
||||
import { dashboardRouter } from "./dashboard";
|
||||
import { foodsRouter } from "./foods";
|
||||
import { recipesRouter } from "./recipes";
|
||||
import { storesRouter } from "./stores";
|
||||
import { trackingRouter } from "./tracking";
|
||||
|
||||
// Flach zusammengefuehrt, damit Frontend-Aufrufe weiterhin als
|
||||
// `trpc.health.listFoods` etc. funktionieren (keine BREAKING CHANGE).
|
||||
export const healthRouter = mergeRouters(
|
||||
dashboardRouter,
|
||||
foodsRouter,
|
||||
recipesRouter,
|
||||
storesRouter,
|
||||
trackingRouter,
|
||||
communityRouter,
|
||||
);
|
||||
423
packages/api/src/trpc/routers/health/recipes.ts
Normal file
423
packages/api/src/trpc/routers/health/recipes.ts
Normal file
|
|
@ -0,0 +1,423 @@
|
|||
import { schema } from "@hellth/db";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { z } from "zod";
|
||||
import { platformProcedure, protectedProcedure, router } from "../../init";
|
||||
import {
|
||||
auditHealthMutation,
|
||||
classifyFoodsForDiet,
|
||||
getPointSettings,
|
||||
id,
|
||||
numberString,
|
||||
recipeInput,
|
||||
recipeNutrition,
|
||||
recipeRecommendationInput,
|
||||
recipeSubmissionReviewInput,
|
||||
recipeSubmissionStatusSchema,
|
||||
} from "./_shared";
|
||||
|
||||
export const recipesRouter = router({
|
||||
listRecipes: protectedProcedure
|
||||
.input(z.object({ query: z.string().trim().max(120).optional().default("") }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
const userId = ctx.user.id;
|
||||
const recipes = await ctx.db.query.recipes.findMany({
|
||||
where: (row, { or: orWhere, eq: rowEq }) => orWhere(rowEq(row.ownerUserId, userId), rowEq(row.isPublic, true)),
|
||||
orderBy: (row, { desc: rowDesc }) => [rowDesc(row.updatedAt)],
|
||||
limit: input.query ? 200 : 100,
|
||||
});
|
||||
const needle = input.query.toLowerCase();
|
||||
const filtered = needle
|
||||
? recipes.filter((recipe) => [recipe.name, recipe.notes].filter(Boolean).some((value) => value!.toLowerCase().includes(needle)))
|
||||
: recipes;
|
||||
const ingredients = await ctx.db.query.recipeIngredients.findMany({
|
||||
where: (row, { inArray: rowInArray }) => rowInArray(row.recipeId, filtered.map((recipe) => recipe.id)),
|
||||
orderBy: (row, { asc: rowAsc }) => [rowAsc(row.sortOrder)],
|
||||
});
|
||||
const ingredientsByRecipe = new Map<string, typeof ingredients>();
|
||||
for (const ingredient of ingredients) {
|
||||
const current = ingredientsByRecipe.get(ingredient.recipeId) ?? [];
|
||||
current.push(ingredient);
|
||||
ingredientsByRecipe.set(ingredient.recipeId, current);
|
||||
}
|
||||
return filtered.map((recipe) => ({
|
||||
...recipe,
|
||||
ingredients: ingredientsByRecipe.get(recipe.id) ?? [],
|
||||
}));
|
||||
}),
|
||||
|
||||
recipeRecommendations: protectedProcedure.input(recipeRecommendationInput).query(async ({ ctx, input }) => {
|
||||
const userId = ctx.user.id;
|
||||
const profile = await ctx.db.query.healthProfiles.findFirst({
|
||||
where: (row, { eq: rowEq }) => rowEq(row.userId, userId),
|
||||
});
|
||||
const preferredMealPrepDays = input.mealPrepDays ?? profile?.preferredMealPrepDays ?? 0;
|
||||
const cooksPerDay = input.cooksPerDay ?? profile?.cooksPerDay ?? 1;
|
||||
const dietPreferences = new Set(profile?.dietPreferences?.length ? profile.dietPreferences : ["vegan", "vegetarian", "meat", "fish"]);
|
||||
const meatFishFrequency = profile?.meatFishFrequency ?? "flexible";
|
||||
|
||||
const recipes = await ctx.db.query.recipes.findMany({
|
||||
where: (row, { or: orWhere, eq: rowEq }) => orWhere(rowEq(row.ownerUserId, userId), rowEq(row.isPublic, true)),
|
||||
orderBy: (row, { desc: rowDesc }) => [rowDesc(row.updatedAt)],
|
||||
limit: 250,
|
||||
});
|
||||
const needle = input.query.toLowerCase();
|
||||
const filteredRecipes = needle
|
||||
? recipes.filter((recipe) => [recipe.name, recipe.notes].filter(Boolean).some((value) => value!.toLowerCase().includes(needle)))
|
||||
: recipes;
|
||||
if (!filteredRecipes.length) return [];
|
||||
|
||||
const ingredients = await ctx.db.query.recipeIngredients.findMany({
|
||||
where: (row, { inArray: rowInArray }) => rowInArray(row.recipeId, filteredRecipes.map((recipe) => recipe.id)),
|
||||
orderBy: (row, { asc }) => [asc(row.sortOrder)],
|
||||
});
|
||||
const foodIds = [...new Set(ingredients.map((ingredient) => ingredient.foodId))];
|
||||
const [foods, preferences] = await Promise.all([
|
||||
foodIds.length ? ctx.db.query.foods.findMany({ where: (row, { inArray: rowInArray }) => rowInArray(row.id, foodIds) }) : [],
|
||||
foodIds.length
|
||||
? ctx.db.query.foodUserPreferences.findMany({
|
||||
where: (row, { and: andWhere, eq: rowEq, inArray: rowInArray }) =>
|
||||
andWhere(rowEq(row.userId, userId), rowInArray(row.foodId, foodIds)),
|
||||
})
|
||||
: [],
|
||||
]);
|
||||
const foodById = new Map(foods.map((food) => [food.id, food]));
|
||||
const voteByFood = new Map(preferences.map((preference) => [preference.foodId, preference.vote]));
|
||||
const ingredientsByRecipe = new Map<string, typeof ingredients>();
|
||||
for (const ingredient of ingredients) {
|
||||
const current = ingredientsByRecipe.get(ingredient.recipeId) ?? [];
|
||||
current.push(ingredient);
|
||||
ingredientsByRecipe.set(ingredient.recipeId, current);
|
||||
}
|
||||
|
||||
return filteredRecipes
|
||||
.map((recipe) => {
|
||||
const recipeIngredients = ingredientsByRecipe.get(recipe.id) ?? [];
|
||||
const recipeFoods = recipeIngredients.map((ingredient) => foodById.get(ingredient.foodId)).filter(Boolean) as typeof foods;
|
||||
const diet = classifyFoodsForDiet(recipeFoods);
|
||||
const containsDislikedFood = recipeIngredients.some((ingredient) => voteByFood.get(ingredient.foodId) === -1);
|
||||
const likedCount = recipeIngredients.filter((ingredient) => voteByFood.get(ingredient.foodId) === 1).length;
|
||||
const blockedByDiet =
|
||||
(diet.hasAnimal && !dietPreferences.has("vegetarian") && !dietPreferences.has("meat") && !dietPreferences.has("fish")) ||
|
||||
(diet.hasMeat && !dietPreferences.has("meat")) ||
|
||||
(diet.hasFish && !dietPreferences.has("fish")) ||
|
||||
(!diet.hasAnimal && !dietPreferences.has("vegan"));
|
||||
if (blockedByDiet || containsDislikedFood) return null;
|
||||
|
||||
const nutrition = recipeNutrition(recipe, recipeIngredients, foods);
|
||||
const mealPrepFit = preferredMealPrepDays === 0
|
||||
? recipe.mealPrepDays <= 1 ? 0 : 1
|
||||
: Math.abs(recipe.mealPrepDays - preferredMealPrepDays);
|
||||
let score = 70;
|
||||
score += likedCount * 12;
|
||||
score += Math.max(0, 18 - mealPrepFit * 6);
|
||||
score += recipe.minutes <= 25 ? 10 : recipe.minutes <= 45 ? 4 : -4;
|
||||
score += nutrition.protein >= 25 ? 8 : nutrition.protein >= 15 ? 4 : 0;
|
||||
score += nutrition.kcal <= 750 ? 6 : -4;
|
||||
if (meatFishFrequency === "none" && (diet.hasMeat || diet.hasFish)) score -= 50;
|
||||
if (meatFishFrequency === "once_per_day" && (diet.hasMeat || diet.hasFish)) score -= 8;
|
||||
if (cooksPerDay === 1 && recipe.mealPrepDays >= 2) score += 8;
|
||||
if (cooksPerDay >= 2 && recipe.mealPrepDays <= 1) score += 4;
|
||||
|
||||
return {
|
||||
...recipe,
|
||||
ingredients: recipeIngredients.map((ingredient) => ({
|
||||
...ingredient,
|
||||
food: foodById.get(ingredient.foodId) ?? null,
|
||||
userVote: voteByFood.get(ingredient.foodId) ?? 0,
|
||||
})),
|
||||
nutrition,
|
||||
tags: diet.tags,
|
||||
score: Math.max(0, Math.round(score)),
|
||||
reasons: [
|
||||
preferredMealPrepDays > 0 ? `passt zu Mealprep ${preferredMealPrepDays} Tage` : "passt zu frischer Tagesplanung",
|
||||
cooksPerDay === 1 ? "für 1x Kochen am Tag priorisiert" : "für flexible Kochfenster priorisiert",
|
||||
likedCount > 0 ? `${likedCount} bevorzugte Lebensmittel enthalten` : undefined,
|
||||
nutrition.protein >= 25 ? "proteinreich" : undefined,
|
||||
].filter(Boolean) as string[],
|
||||
};
|
||||
})
|
||||
.filter((recommendation): recommendation is Exclude<typeof recommendation, null> => recommendation !== null)
|
||||
.sort((a, b) => b!.score - a!.score)
|
||||
.slice(0, input.limit);
|
||||
}),
|
||||
|
||||
saveRecipe: protectedProcedure.input(recipeInput).mutation(async ({ ctx, input }) => {
|
||||
const userId = ctx.user.id;
|
||||
const recipeId = input.id ?? id("recipe");
|
||||
const existing = input.id
|
||||
? await ctx.db.query.recipes.findFirst({ where: (row, { eq: rowEq }) => rowEq(row.id, input.id!) })
|
||||
: null;
|
||||
|
||||
if (existing && existing.ownerUserId !== userId) {
|
||||
throw new TRPCError({ code: "FORBIDDEN", message: "Dieses Rezept gehört einem anderen Benutzer." });
|
||||
}
|
||||
|
||||
await ctx.db.transaction(async (tx) => {
|
||||
await tx
|
||||
.insert(schema.recipes)
|
||||
.values({
|
||||
id: recipeId,
|
||||
ownerUserId: userId,
|
||||
name: input.name,
|
||||
portions: input.portions,
|
||||
minutes: input.minutes,
|
||||
notes: input.notes || null,
|
||||
mealPrepDays: input.mealPrepDays,
|
||||
isPublic: input.isPublic,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: schema.recipes.id,
|
||||
set: {
|
||||
name: input.name,
|
||||
portions: input.portions,
|
||||
minutes: input.minutes,
|
||||
notes: input.notes || null,
|
||||
mealPrepDays: input.mealPrepDays,
|
||||
isPublic: input.isPublic,
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
await tx.delete(schema.recipeIngredients).where(eq(schema.recipeIngredients.recipeId, recipeId));
|
||||
await tx.insert(schema.recipeIngredients).values(input.ingredients.map((ingredient, index) => ({
|
||||
id: ingredient.id ?? id("ingredient"),
|
||||
recipeId,
|
||||
foodId: ingredient.foodId,
|
||||
amountG: numberString(ingredient.amountG),
|
||||
displayAmount: ingredient.displayAmount ? numberString(ingredient.displayAmount) : null,
|
||||
displayUnit: ingredient.displayUnit ?? null,
|
||||
portionId: ingredient.portionId ?? null,
|
||||
sortOrder: ingredient.sortOrder ?? index,
|
||||
})));
|
||||
});
|
||||
|
||||
auditHealthMutation(ctx, input.id ? "recipe.update" : "recipe.create", {
|
||||
recipeId,
|
||||
ingredients: input.ingredients.length,
|
||||
mealPrepDays: input.mealPrepDays,
|
||||
});
|
||||
return { id: recipeId };
|
||||
}),
|
||||
|
||||
submitRecipeSuggestion: protectedProcedure.input(recipeInput.omit({ id: true, isPublic: true })).mutation(async ({ ctx, input }) => {
|
||||
const submissionId = id("recipe-submission");
|
||||
const pointSettings = await getPointSettings(ctx.db);
|
||||
const recipeSubmissionPoints = pointSettings.recipeSubmissionPoints;
|
||||
await ctx.db.transaction(async (tx) => {
|
||||
await tx.insert(schema.recipeSubmissions).values({
|
||||
id: submissionId,
|
||||
userId: ctx.user.id,
|
||||
name: input.name,
|
||||
portions: input.portions,
|
||||
minutes: input.minutes,
|
||||
notes: input.notes || null,
|
||||
mealPrepDays: input.mealPrepDays,
|
||||
status: "pending",
|
||||
updatedAt: new Date(),
|
||||
});
|
||||
await tx.insert(schema.recipeSubmissionIngredients).values(input.ingredients.map((ingredient, index) => ({
|
||||
id: ingredient.id ?? id("submission-ingredient"),
|
||||
submissionId,
|
||||
foodId: ingredient.foodId,
|
||||
amountG: numberString(ingredient.amountG),
|
||||
displayAmount: ingredient.displayAmount ? numberString(ingredient.displayAmount) : null,
|
||||
displayUnit: ingredient.displayUnit ?? null,
|
||||
portionId: ingredient.portionId ?? null,
|
||||
sortOrder: ingredient.sortOrder ?? index,
|
||||
})));
|
||||
await tx.insert(schema.activityLogs).values({
|
||||
id: id("activity-log"),
|
||||
userId: ctx.user.id,
|
||||
date: new Date().toISOString().slice(0, 10),
|
||||
name: `Rezeptvorschlag: ${input.name}`,
|
||||
minutes: 0,
|
||||
points: recipeSubmissionPoints,
|
||||
calories: 0,
|
||||
source: "system",
|
||||
externalId: `recipe-submission:${submissionId}`,
|
||||
note: "Punkte für einen eingereichten Rezeptvorschlag",
|
||||
}).onConflictDoNothing();
|
||||
});
|
||||
auditHealthMutation(ctx, "recipe-submission.create", {
|
||||
submissionId,
|
||||
ingredients: input.ingredients.length,
|
||||
points: recipeSubmissionPoints,
|
||||
});
|
||||
return { id: submissionId, points: recipeSubmissionPoints };
|
||||
}),
|
||||
|
||||
myRecipeSubmissions: protectedProcedure.query(async ({ ctx }) => {
|
||||
const submissions = await ctx.db.query.recipeSubmissions.findMany({
|
||||
where: (row, { eq: rowEq }) => rowEq(row.userId, ctx.user.id),
|
||||
orderBy: (row, { desc: rowDesc }) => [rowDesc(row.createdAt)],
|
||||
limit: 50,
|
||||
});
|
||||
const ingredients = submissions.length
|
||||
? await ctx.db.query.recipeSubmissionIngredients.findMany({
|
||||
where: (row, { inArray: rowInArray }) => rowInArray(row.submissionId, submissions.map((submission) => submission.id)),
|
||||
orderBy: (row, { asc }) => [asc(row.sortOrder)],
|
||||
})
|
||||
: [];
|
||||
const ingredientsBySubmission = new Map<string, typeof ingredients>();
|
||||
for (const ingredient of ingredients) {
|
||||
const current = ingredientsBySubmission.get(ingredient.submissionId) ?? [];
|
||||
current.push(ingredient);
|
||||
ingredientsBySubmission.set(ingredient.submissionId, current);
|
||||
}
|
||||
return submissions.map((submission) => ({
|
||||
...submission,
|
||||
ingredients: ingredientsBySubmission.get(submission.id) ?? [],
|
||||
}));
|
||||
}),
|
||||
|
||||
recipeSubmissionsForReview: platformProcedure
|
||||
.input(z.object({ status: recipeSubmissionStatusSchema.optional().default("pending") }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
const submissions = await ctx.db.query.recipeSubmissions.findMany({
|
||||
where: (row, { eq: rowEq }) => rowEq(row.status, input.status),
|
||||
orderBy: (row, { asc }) => [asc(row.createdAt)],
|
||||
limit: 100,
|
||||
});
|
||||
const ingredients = submissions.length
|
||||
? await ctx.db.query.recipeSubmissionIngredients.findMany({
|
||||
where: (row, { inArray: rowInArray }) => rowInArray(row.submissionId, submissions.map((submission) => submission.id)),
|
||||
orderBy: (row, { asc }) => [asc(row.sortOrder)],
|
||||
})
|
||||
: [];
|
||||
const foodIds = [...new Set(ingredients.map((ingredient) => ingredient.foodId))];
|
||||
const foods = foodIds.length
|
||||
? await ctx.db.query.foods.findMany({ where: (row, { inArray: rowInArray }) => rowInArray(row.id, foodIds) })
|
||||
: [];
|
||||
const foodById = new Map(foods.map((food) => [food.id, food]));
|
||||
const ingredientsBySubmission = new Map<string, typeof ingredients>();
|
||||
for (const ingredient of ingredients) {
|
||||
const current = ingredientsBySubmission.get(ingredient.submissionId) ?? [];
|
||||
current.push(ingredient);
|
||||
ingredientsBySubmission.set(ingredient.submissionId, current);
|
||||
}
|
||||
return submissions.map((submission) => ({
|
||||
...submission,
|
||||
ingredients: (ingredientsBySubmission.get(submission.id) ?? []).map((ingredient) => ({
|
||||
...ingredient,
|
||||
food: foodById.get(ingredient.foodId) ?? null,
|
||||
})),
|
||||
}));
|
||||
}),
|
||||
|
||||
adminActivityFeed: platformProcedure.query(async ({ ctx }) => {
|
||||
const [recipeSubmissions, storeChangeRequests] = await Promise.all([
|
||||
ctx.db.query.recipeSubmissions.findMany({
|
||||
orderBy: (row, { desc: rowDesc }) => [rowDesc(row.createdAt)],
|
||||
limit: 50,
|
||||
}),
|
||||
ctx.db.query.storeChangeRequests.findMany({
|
||||
orderBy: (row, { desc: rowDesc }) => [rowDesc(row.createdAt)],
|
||||
limit: 50,
|
||||
}),
|
||||
]);
|
||||
const userIds = [...new Set([
|
||||
...recipeSubmissions.map((item) => item.userId),
|
||||
...storeChangeRequests.map((item) => item.userId),
|
||||
])];
|
||||
const users = userIds.length
|
||||
? await ctx.db.query.user.findMany({
|
||||
where: (row, { inArray: rowInArray }) => rowInArray(row.id, userIds),
|
||||
columns: { id: true, name: true, email: true },
|
||||
})
|
||||
: [];
|
||||
const userById = new Map(users.map((user) => [user.id, user]));
|
||||
const recipeItems = recipeSubmissions.map((submission) => {
|
||||
const actor = userById.get(submission.userId);
|
||||
return {
|
||||
id: submission.id,
|
||||
type: "recipe_submission" as const,
|
||||
title: submission.name,
|
||||
status: submission.status,
|
||||
actorName: actor?.name || actor?.email || "Unbekannter Benutzer",
|
||||
actorEmail: actor?.email ?? null,
|
||||
createdAt: submission.createdAt.toISOString(),
|
||||
href: "/rezepte/pruefung",
|
||||
detail: `${submission.minutes} min · ${submission.portions} Portion(en)`,
|
||||
};
|
||||
});
|
||||
const storeItems = storeChangeRequests.map((request) => {
|
||||
const actor = userById.get(request.userId);
|
||||
return {
|
||||
id: request.id,
|
||||
type: "store_change" as const,
|
||||
title: request.title,
|
||||
status: request.status,
|
||||
actorName: actor?.name || actor?.email || "Unbekannter Benutzer",
|
||||
actorEmail: actor?.email ?? null,
|
||||
createdAt: request.createdAt.toISOString(),
|
||||
href: "/maerkte",
|
||||
detail: request.note || request.requestType,
|
||||
};
|
||||
});
|
||||
return [...recipeItems, ...storeItems]
|
||||
.sort((a, b) => b.createdAt.localeCompare(a.createdAt))
|
||||
.slice(0, 80);
|
||||
}),
|
||||
|
||||
reviewRecipeSubmission: platformProcedure.input(recipeSubmissionReviewInput).mutation(async ({ ctx, input }) => {
|
||||
const submission = await ctx.db.query.recipeSubmissions.findFirst({
|
||||
where: (row, { eq: rowEq }) => rowEq(row.id, input.id),
|
||||
});
|
||||
if (!submission) throw new TRPCError({ code: "NOT_FOUND", message: "Rezeptvorschlag wurde nicht gefunden." });
|
||||
if (submission.status !== "pending") {
|
||||
throw new TRPCError({ code: "BAD_REQUEST", message: "Dieser Rezeptvorschlag wurde bereits geprüft." });
|
||||
}
|
||||
|
||||
let acceptedRecipeId: string | null = null;
|
||||
await ctx.db.transaction(async (tx) => {
|
||||
if (input.action === "accept") {
|
||||
const ingredients = await tx.query.recipeSubmissionIngredients.findMany({
|
||||
where: (row, { eq: rowEq }) => rowEq(row.submissionId, input.id),
|
||||
orderBy: (row, { asc }) => [asc(row.sortOrder)],
|
||||
});
|
||||
acceptedRecipeId = id("recipe");
|
||||
await tx.insert(schema.recipes).values({
|
||||
id: acceptedRecipeId,
|
||||
ownerUserId: submission.userId,
|
||||
name: submission.name,
|
||||
portions: submission.portions,
|
||||
minutes: submission.minutes,
|
||||
notes: submission.notes,
|
||||
mealPrepDays: submission.mealPrepDays,
|
||||
isPublic: true,
|
||||
updatedAt: new Date(),
|
||||
});
|
||||
await tx.insert(schema.recipeIngredients).values(ingredients.map((ingredient, index) => ({
|
||||
id: id("ingredient"),
|
||||
recipeId: acceptedRecipeId!,
|
||||
foodId: ingredient.foodId,
|
||||
amountG: ingredient.amountG,
|
||||
displayAmount: ingredient.displayAmount,
|
||||
displayUnit: ingredient.displayUnit,
|
||||
portionId: ingredient.portionId,
|
||||
sortOrder: ingredient.sortOrder ?? index,
|
||||
})));
|
||||
}
|
||||
|
||||
await tx.update(schema.recipeSubmissions)
|
||||
.set({
|
||||
status: input.action === "accept" ? "accepted" : "rejected",
|
||||
reviewNote: input.reviewNote || null,
|
||||
reviewedBy: ctx.user.id,
|
||||
reviewedAt: new Date(),
|
||||
acceptedRecipeId,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(schema.recipeSubmissions.id, input.id));
|
||||
});
|
||||
|
||||
auditHealthMutation(ctx, "recipe-submission.review", {
|
||||
submissionId: input.id,
|
||||
action: input.action,
|
||||
acceptedRecipeId,
|
||||
});
|
||||
return { ok: true, recipeId: acceptedRecipeId };
|
||||
}),
|
||||
});
|
||||
375
packages/api/src/trpc/routers/health/stores.ts
Normal file
375
packages/api/src/trpc/routers/health/stores.ts
Normal file
|
|
@ -0,0 +1,375 @@
|
|||
import { schema } from "@hellth/db";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { and, eq, isNull, or } from "drizzle-orm";
|
||||
import { z } from "zod";
|
||||
import { platformProcedure, protectedProcedure, router } from "../../init";
|
||||
import {
|
||||
auditHealthMutation,
|
||||
dateKeySchema,
|
||||
id,
|
||||
numberString,
|
||||
storeChangeRequestInput,
|
||||
storeChangeReviewInput,
|
||||
storeFavoriteInput,
|
||||
storeInput,
|
||||
} from "./_shared";
|
||||
|
||||
export const storesRouter = router({
|
||||
listStores: protectedProcedure.query(async ({ ctx }) => {
|
||||
const stores = await ctx.db.query.stores.findMany({
|
||||
where: or(isNull(schema.stores.ownerUserId), eq(schema.stores.ownerUserId, ctx.user.id)),
|
||||
orderBy: (row, { desc: rowDesc, asc }) => [rowDesc(row.favorite), asc(row.name)],
|
||||
});
|
||||
const preferences = stores.length
|
||||
? await ctx.db.query.storeUserPreferences.findMany({
|
||||
where: (row, { and: andWhere, eq: rowEq, inArray: rowInArray }) =>
|
||||
andWhere(rowEq(row.userId, ctx.user.id), rowInArray(row.storeId, stores.map((store) => store.id))),
|
||||
})
|
||||
: [];
|
||||
const departments = stores.length
|
||||
? await ctx.db.query.storeDepartments.findMany({
|
||||
where: (row, { inArray: rowInArray }) => rowInArray(row.storeId, stores.map((store) => store.id)),
|
||||
orderBy: (row, { asc }) => [asc(row.sortOrder), asc(row.name)],
|
||||
})
|
||||
: [];
|
||||
const departmentsByStore = new Map<string, typeof departments>();
|
||||
for (const department of departments) {
|
||||
const current = departmentsByStore.get(department.storeId) ?? [];
|
||||
current.push(department);
|
||||
departmentsByStore.set(department.storeId, current);
|
||||
}
|
||||
const preferencesByStore = new Map(preferences.map((preference) => [preference.storeId, preference]));
|
||||
return stores
|
||||
.map((store) => ({
|
||||
...store,
|
||||
favorite: preferencesByStore.get(store.id)?.favorite ?? store.favorite,
|
||||
latitude: store.latitude == null ? null : Number(store.latitude),
|
||||
longitude: store.longitude == null ? null : Number(store.longitude),
|
||||
departments: departmentsByStore.get(store.id) ?? [],
|
||||
}))
|
||||
.sort((a, b) => Number(b.favorite) - Number(a.favorite) || a.name.localeCompare(b.name, "de-DE"));
|
||||
}),
|
||||
|
||||
setStoreFavorite: protectedProcedure.input(storeFavoriteInput).mutation(async ({ ctx, input }) => {
|
||||
const store = await ctx.db.query.stores.findFirst({
|
||||
where: (row, { eq: rowEq }) => rowEq(row.id, input.storeId),
|
||||
});
|
||||
if (!store || (store.ownerUserId && store.ownerUserId !== ctx.user.id)) {
|
||||
throw new TRPCError({ code: "NOT_FOUND", message: "Markt wurde nicht gefunden." });
|
||||
}
|
||||
await ctx.db
|
||||
.insert(schema.storeUserPreferences)
|
||||
.values({
|
||||
userId: ctx.user.id,
|
||||
storeId: input.storeId,
|
||||
favorite: input.favorite,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: [schema.storeUserPreferences.userId, schema.storeUserPreferences.storeId],
|
||||
set: {
|
||||
favorite: input.favorite,
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
});
|
||||
auditHealthMutation(ctx, "store.favorite", { storeId: input.storeId, favorite: input.favorite });
|
||||
return { ok: true };
|
||||
}),
|
||||
|
||||
saveStore: protectedProcedure.input(storeInput).mutation(async ({ ctx, input }) => {
|
||||
const storeId = input.id ?? id("store");
|
||||
const existing = input.id
|
||||
? await ctx.db.query.stores.findFirst({ where: (row, { eq: rowEq }) => rowEq(row.id, input.id!) })
|
||||
: null;
|
||||
|
||||
if (existing && existing.ownerUserId !== ctx.user.id) {
|
||||
throw new TRPCError({ code: "FORBIDDEN", message: "Globale Märkte und Märkte anderer Benutzer können nicht direkt geändert werden." });
|
||||
}
|
||||
|
||||
await ctx.db.transaction(async (tx) => {
|
||||
await tx
|
||||
.insert(schema.stores)
|
||||
.values({
|
||||
id: storeId,
|
||||
ownerUserId: existing?.ownerUserId ?? ctx.user.id,
|
||||
name: input.name,
|
||||
address: input.address || null,
|
||||
placeId: input.placeId || null,
|
||||
latitude: input.latitude == null ? null : numberString(input.latitude),
|
||||
longitude: input.longitude == null ? null : numberString(input.longitude),
|
||||
favorite: input.favorite,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: schema.stores.id,
|
||||
set: {
|
||||
name: input.name,
|
||||
address: input.address || null,
|
||||
placeId: input.placeId || null,
|
||||
latitude: input.latitude == null ? null : numberString(input.latitude),
|
||||
longitude: input.longitude == null ? null : numberString(input.longitude),
|
||||
favorite: input.favorite,
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
await tx.delete(schema.storeDepartments).where(eq(schema.storeDepartments.storeId, storeId));
|
||||
await tx.insert(schema.storeDepartments).values(input.departments.map((department) => ({
|
||||
id: department.id ?? id("department"),
|
||||
storeId,
|
||||
name: department.name,
|
||||
sortOrder: department.sortOrder,
|
||||
})));
|
||||
});
|
||||
|
||||
auditHealthMutation(ctx, input.id ? "store.update" : "store.create", {
|
||||
storeId,
|
||||
departments: input.departments.length,
|
||||
});
|
||||
return { id: storeId };
|
||||
}),
|
||||
|
||||
submitStoreChangeRequest: protectedProcedure.input(storeChangeRequestInput).mutation(async ({ ctx, input }) => {
|
||||
const requestId = id("store-change");
|
||||
if (input.storeId) {
|
||||
const store = await ctx.db.query.stores.findFirst({
|
||||
where: (row, { eq: rowEq }) => rowEq(row.id, input.storeId!),
|
||||
});
|
||||
if (!store) throw new TRPCError({ code: "NOT_FOUND", message: "Markt wurde nicht gefunden." });
|
||||
}
|
||||
await ctx.db.insert(schema.storeChangeRequests).values({
|
||||
id: requestId,
|
||||
userId: ctx.user.id,
|
||||
storeId: input.storeId || null,
|
||||
requestType: input.requestType,
|
||||
status: "pending",
|
||||
title: input.title,
|
||||
note: input.note || null,
|
||||
proposedStore: input.store,
|
||||
proposedDepartments: input.departments,
|
||||
updatedAt: new Date(),
|
||||
});
|
||||
auditHealthMutation(ctx, "store-change.submit", {
|
||||
requestId,
|
||||
storeId: input.storeId ?? null,
|
||||
requestType: input.requestType,
|
||||
});
|
||||
return { id: requestId };
|
||||
}),
|
||||
|
||||
myStoreChangeRequests: protectedProcedure.query(async ({ ctx }) => {
|
||||
return ctx.db.query.storeChangeRequests.findMany({
|
||||
where: (row, { eq: rowEq }) => rowEq(row.userId, ctx.user.id),
|
||||
orderBy: (row, { desc: rowDesc }) => [rowDesc(row.createdAt)],
|
||||
limit: 50,
|
||||
});
|
||||
}),
|
||||
|
||||
reviewStoreChangeRequest: platformProcedure.input(storeChangeReviewInput).mutation(async ({ ctx, input }) => {
|
||||
const request = await ctx.db.query.storeChangeRequests.findFirst({
|
||||
where: (row, { eq: rowEq }) => rowEq(row.id, input.id),
|
||||
});
|
||||
if (!request) throw new TRPCError({ code: "NOT_FOUND", message: "Änderungsantrag wurde nicht gefunden." });
|
||||
if (request.status !== "pending") throw new TRPCError({ code: "BAD_REQUEST", message: "Dieser Antrag wurde bereits geprüft." });
|
||||
|
||||
let acceptedStoreId: string | null = null;
|
||||
await ctx.db.transaction(async (tx) => {
|
||||
if (input.action === "accept") {
|
||||
const proposedStore = request.proposedStore as { name?: string; address?: string | null; placeId?: string | null; latitude?: number | null; longitude?: number | null };
|
||||
const proposedDepartments = request.proposedDepartments as Array<{ id?: string; name?: string; sortOrder?: number }>;
|
||||
acceptedStoreId = request.storeId ?? id("store-global");
|
||||
await tx.insert(schema.stores).values({
|
||||
id: acceptedStoreId,
|
||||
ownerUserId: null,
|
||||
name: proposedStore.name || request.title,
|
||||
address: proposedStore.address || null,
|
||||
placeId: proposedStore.placeId || null,
|
||||
latitude: proposedStore.latitude == null ? null : numberString(proposedStore.latitude),
|
||||
longitude: proposedStore.longitude == null ? null : numberString(proposedStore.longitude),
|
||||
favorite: false,
|
||||
updatedAt: new Date(),
|
||||
}).onConflictDoUpdate({
|
||||
target: schema.stores.id,
|
||||
set: {
|
||||
ownerUserId: null,
|
||||
name: proposedStore.name || request.title,
|
||||
address: proposedStore.address || null,
|
||||
placeId: proposedStore.placeId || null,
|
||||
latitude: proposedStore.latitude == null ? null : numberString(proposedStore.latitude),
|
||||
longitude: proposedStore.longitude == null ? null : numberString(proposedStore.longitude),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
});
|
||||
await tx.delete(schema.storeDepartments).where(eq(schema.storeDepartments.storeId, acceptedStoreId));
|
||||
await tx.insert(schema.storeDepartments).values(proposedDepartments.map((department, index) => ({
|
||||
id: department.id ?? id("department"),
|
||||
storeId: acceptedStoreId!,
|
||||
name: department.name || `Abteilung ${index + 1}`,
|
||||
sortOrder: department.sortOrder ?? index + 1,
|
||||
})));
|
||||
}
|
||||
await tx.update(schema.storeChangeRequests)
|
||||
.set({
|
||||
status: input.action === "accept" ? "accepted" : "rejected",
|
||||
reviewedBy: ctx.user.id,
|
||||
reviewedAt: new Date(),
|
||||
reviewNote: input.reviewNote || null,
|
||||
acceptedStoreId,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(schema.storeChangeRequests.id, input.id));
|
||||
});
|
||||
auditHealthMutation(ctx, "store-change.review", { requestId: input.id, action: input.action, acceptedStoreId });
|
||||
return { ok: true, storeId: acceptedStoreId };
|
||||
}),
|
||||
|
||||
deleteStore: protectedProcedure.input(z.object({ id: z.string().min(1) })).mutation(async ({ ctx, input }) => {
|
||||
const existing = await ctx.db.query.stores.findFirst({
|
||||
where: (row, { eq: rowEq }) => rowEq(row.id, input.id),
|
||||
});
|
||||
if (!existing) throw new TRPCError({ code: "NOT_FOUND", message: "Markt wurde nicht gefunden." });
|
||||
if (existing.ownerUserId !== ctx.user.id) {
|
||||
throw new TRPCError({ code: "FORBIDDEN", message: "Globale Märkte können nicht gelöscht werden." });
|
||||
}
|
||||
await ctx.db.delete(schema.stores).where(eq(schema.stores.id, input.id));
|
||||
auditHealthMutation(ctx, "store.delete", { storeId: input.id });
|
||||
return { ok: true };
|
||||
}),
|
||||
|
||||
generateShoppingList: protectedProcedure
|
||||
.input(z.object({
|
||||
startsOn: dateKeySchema,
|
||||
endsOn: dateKeySchema,
|
||||
storeId: z.string().optional().nullable(),
|
||||
name: z.string().trim().max(180).optional(),
|
||||
}))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const userId = ctx.user.id;
|
||||
const plans = await ctx.db.query.mealPlans.findMany({
|
||||
where: (row, { and: andWhere, eq: rowEq, gte, lte }) =>
|
||||
andWhere(rowEq(row.userId, userId), gte(row.date, input.startsOn), lte(row.date, input.endsOn)),
|
||||
});
|
||||
const recipeIds = [...new Set(plans.map((plan) => plan.recipeId).filter(Boolean))] as string[];
|
||||
const ingredients = recipeIds.length
|
||||
? await ctx.db.query.recipeIngredients.findMany({
|
||||
where: (row, { inArray: rowInArray }) => rowInArray(row.recipeId, recipeIds),
|
||||
})
|
||||
: [];
|
||||
const foods = ingredients.length
|
||||
? await ctx.db.query.foods.findMany({
|
||||
where: (row, { inArray: rowInArray }) => rowInArray(row.id, [...new Set(ingredients.map((ingredient) => ingredient.foodId))]),
|
||||
})
|
||||
: [];
|
||||
const foodById = new Map(foods.map((food) => [food.id, food]));
|
||||
const planPortionsByRecipeId = new Map<string, number>();
|
||||
for (const plan of plans) {
|
||||
if (!plan.recipeId) continue;
|
||||
planPortionsByRecipeId.set(
|
||||
plan.recipeId,
|
||||
(planPortionsByRecipeId.get(plan.recipeId) ?? 0) + Number(plan.portions ?? 1),
|
||||
);
|
||||
}
|
||||
const amountByFood = new Map<string, { foodId: string; name: string; category: string; amountG: number }>();
|
||||
for (const ingredient of ingredients) {
|
||||
const food = foodById.get(ingredient.foodId);
|
||||
const portions = planPortionsByRecipeId.get(ingredient.recipeId) ?? 1;
|
||||
const current = amountByFood.get(ingredient.foodId) ?? {
|
||||
foodId: ingredient.foodId,
|
||||
name: food?.name ?? "Unbekanntes Lebensmittel",
|
||||
category: food?.category ?? "Sonstiges",
|
||||
amountG: 0,
|
||||
};
|
||||
current.amountG += Number(ingredient.amountG ?? 0) * portions;
|
||||
amountByFood.set(ingredient.foodId, current);
|
||||
}
|
||||
|
||||
const shoppingListId = id("shopping");
|
||||
await ctx.db.transaction(async (tx) => {
|
||||
await tx.insert(schema.shoppingLists).values({
|
||||
id: shoppingListId,
|
||||
userId,
|
||||
storeId: input.storeId ?? null,
|
||||
name: input.name || `Einkauf ${input.startsOn} bis ${input.endsOn}`,
|
||||
startsOn: input.startsOn,
|
||||
endsOn: input.endsOn,
|
||||
});
|
||||
const items = [...amountByFood.values()];
|
||||
if (items.length) {
|
||||
await tx.insert(schema.shoppingListItems).values(items.map((item, index) => ({
|
||||
id: id("shopping-item"),
|
||||
shoppingListId,
|
||||
foodId: item.foodId,
|
||||
name: item.name,
|
||||
category: item.category,
|
||||
amountG: numberString(Math.round(item.amountG)),
|
||||
purchaseAmount: `${Math.round(item.amountG)} g`,
|
||||
checked: false,
|
||||
sortOrder: index,
|
||||
})));
|
||||
}
|
||||
});
|
||||
auditHealthMutation(ctx, "shopping-list.generate", {
|
||||
shoppingListId,
|
||||
startsOn: input.startsOn,
|
||||
endsOn: input.endsOn,
|
||||
itemCount: amountByFood.size,
|
||||
});
|
||||
return { id: shoppingListId, itemCount: amountByFood.size };
|
||||
}),
|
||||
|
||||
shoppingLists: protectedProcedure
|
||||
.input(z.object({ limit: z.number().int().min(1).max(20).default(5) }).optional())
|
||||
.query(async ({ ctx, input }) => {
|
||||
const lists = await ctx.db.query.shoppingLists.findMany({
|
||||
where: (row, { eq: rowEq }) => rowEq(row.userId, ctx.user.id),
|
||||
orderBy: (row, { desc: rowDesc }) => [rowDesc(row.createdAt)],
|
||||
limit: input?.limit ?? 5,
|
||||
});
|
||||
const items = lists.length
|
||||
? await ctx.db.query.shoppingListItems.findMany({
|
||||
where: (row, { inArray: rowInArray }) => rowInArray(row.shoppingListId, lists.map((list) => list.id)),
|
||||
orderBy: (row, { asc }) => [asc(row.sortOrder), asc(row.name)],
|
||||
})
|
||||
: [];
|
||||
const itemsByList = new Map<string, typeof items>();
|
||||
for (const item of items) {
|
||||
const current = itemsByList.get(item.shoppingListId) ?? [];
|
||||
current.push(item);
|
||||
itemsByList.set(item.shoppingListId, current);
|
||||
}
|
||||
return lists.map((list) => ({ ...list, items: itemsByList.get(list.id) ?? [] }));
|
||||
}),
|
||||
|
||||
updateShoppingListItem: protectedProcedure
|
||||
.input(z.object({
|
||||
id: z.string().min(1),
|
||||
checked: z.boolean().optional(),
|
||||
purchaseAmount: z.string().trim().max(120).optional().nullable(),
|
||||
}))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const existing = await ctx.db
|
||||
.select({
|
||||
id: schema.shoppingListItems.id,
|
||||
listUserId: schema.shoppingLists.userId,
|
||||
})
|
||||
.from(schema.shoppingListItems)
|
||||
.innerJoin(schema.shoppingLists, eq(schema.shoppingListItems.shoppingListId, schema.shoppingLists.id))
|
||||
.where(eq(schema.shoppingListItems.id, input.id))
|
||||
.limit(1);
|
||||
if (!existing[0] || existing[0].listUserId !== ctx.user.id) {
|
||||
throw new TRPCError({ code: "NOT_FOUND", message: "Einkaufslisten-Eintrag wurde nicht gefunden." });
|
||||
}
|
||||
await ctx.db
|
||||
.update(schema.shoppingListItems)
|
||||
.set({
|
||||
...(input.checked !== undefined ? { checked: input.checked } : {}),
|
||||
...(input.purchaseAmount !== undefined ? { purchaseAmount: input.purchaseAmount || null } : {}),
|
||||
})
|
||||
.where(eq(schema.shoppingListItems.id, input.id));
|
||||
auditHealthMutation(ctx, "shopping-list-item.update", { itemId: input.id });
|
||||
return { ok: true };
|
||||
}),
|
||||
});
|
||||
|
||||
// Note: `and` is imported but currently not used by procedures here.
|
||||
void and;
|
||||
462
packages/api/src/trpc/routers/health/tracking.ts
Normal file
462
packages/api/src/trpc/routers/health/tracking.ts
Normal file
|
|
@ -0,0 +1,462 @@
|
|||
import { schema } from "@hellth/db";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { z } from "zod";
|
||||
import { protectedProcedure, router } from "../../init";
|
||||
import {
|
||||
addDays,
|
||||
auditHealthMutation,
|
||||
dateKeySchema,
|
||||
id,
|
||||
mealTypeSchema,
|
||||
numberString,
|
||||
startOfWeek,
|
||||
} from "./_shared";
|
||||
|
||||
export const trackingRouter = router({
|
||||
addDrinkLog: protectedProcedure
|
||||
.input(z.object({ date: dateKeySchema, amountMl: z.number().int().positive().max(5000) }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
await ctx.db.insert(schema.drinkLogs).values({
|
||||
id: id("drink"),
|
||||
userId: ctx.user.id,
|
||||
date: input.date,
|
||||
amountMl: input.amountMl,
|
||||
});
|
||||
auditHealthMutation(ctx, "drink.add", { date: input.date, amountMl: input.amountMl });
|
||||
return { ok: true };
|
||||
}),
|
||||
|
||||
upsertWeightLog: protectedProcedure
|
||||
.input(z.object({ date: dateKeySchema, weightKg: z.number().positive().max(500), note: z.string().max(1000).optional().nullable() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
await ctx.db
|
||||
.insert(schema.weightLogs)
|
||||
.values({
|
||||
id: id("weight"),
|
||||
userId: ctx.user.id,
|
||||
date: input.date,
|
||||
weightKg: numberString(input.weightKg),
|
||||
note: input.note || null,
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: [schema.weightLogs.userId, schema.weightLogs.date],
|
||||
set: {
|
||||
weightKg: numberString(input.weightKg),
|
||||
note: input.note || null,
|
||||
createdAt: new Date(),
|
||||
},
|
||||
});
|
||||
auditHealthMutation(ctx, "weight.upsert", { date: input.date, weightKg: input.weightKg });
|
||||
return { ok: true };
|
||||
}),
|
||||
|
||||
weightHistory: protectedProcedure
|
||||
.input(z.object({
|
||||
startsOn: dateKeySchema.optional(),
|
||||
endsOn: dateKeySchema.optional(),
|
||||
limit: z.number().int().min(1).max(365).default(90),
|
||||
}).optional())
|
||||
.query(async ({ ctx, input }) => {
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
const endsOn = input?.endsOn ?? today;
|
||||
const startsOn = input?.startsOn ?? addDays(endsOn, -(input?.limit ?? 90) + 1);
|
||||
const [profile, goals, rows] = await Promise.all([
|
||||
ctx.db.query.healthProfiles.findFirst({ where: (row, { eq: rowEq }) => rowEq(row.userId, ctx.user.id) }),
|
||||
ctx.db.query.healthGoals.findFirst({ where: (row, { eq: rowEq }) => rowEq(row.userId, ctx.user.id) }),
|
||||
ctx.db.query.weightLogs.findMany({
|
||||
where: (row, { and: andWhere, eq: rowEq, gte, lte }) =>
|
||||
andWhere(rowEq(row.userId, ctx.user.id), gte(row.date, startsOn), lte(row.date, endsOn)),
|
||||
orderBy: (row, { asc }) => [asc(row.date)],
|
||||
limit: input?.limit ?? 90,
|
||||
}),
|
||||
]);
|
||||
|
||||
const entries = rows.map((row) => ({
|
||||
id: row.id,
|
||||
date: row.date,
|
||||
weightKg: Number(row.weightKg),
|
||||
note: row.note,
|
||||
createdAt: row.createdAt,
|
||||
}));
|
||||
const first = entries[0];
|
||||
const latest = entries.at(-1);
|
||||
const previous = entries.at(-2);
|
||||
const targetWeightKg = profile?.targetWeightKg ? Number(profile.targetWeightKg) : goals?.weightKg ? Number(goals.weightKg) : null;
|
||||
const deltaFromFirst = first && latest ? Math.round((latest.weightKg - first.weightKg) * 10) / 10 : 0;
|
||||
const deltaFromPrevious = previous && latest ? Math.round((latest.weightKg - previous.weightKg) * 10) / 10 : 0;
|
||||
|
||||
let trendKgPerWeek: number | null = null;
|
||||
let targetDate: string | null = null;
|
||||
let projectionMessage = "Noch zu wenig Daten für eine faire Prognose.";
|
||||
let confidence: "none" | "low" | "medium" = "none";
|
||||
|
||||
if (entries.length >= 3 && first && latest) {
|
||||
const elapsedDays = Math.max(
|
||||
1,
|
||||
Math.round((new Date(`${latest.date}T12:00:00`).getTime() - new Date(`${first.date}T12:00:00`).getTime()) / 86_400_000),
|
||||
);
|
||||
if (elapsedDays >= 7) {
|
||||
trendKgPerWeek = Math.round(((latest.weightKg - first.weightKg) / elapsedDays) * 7 * 10) / 10;
|
||||
confidence = elapsedDays >= 14 && entries.length >= 5 ? "medium" : "low";
|
||||
if (
|
||||
targetWeightKg &&
|
||||
((targetWeightKg < latest.weightKg && trendKgPerWeek < -0.05) ||
|
||||
(targetWeightKg > latest.weightKg && trendKgPerWeek > 0.05))
|
||||
) {
|
||||
const estimatedDays = Math.ceil(Math.abs(latest.weightKg - targetWeightKg) / Math.max(0.01, Math.abs(trendKgPerWeek / 7)));
|
||||
targetDate = addDays(endsOn, estimatedDays);
|
||||
projectionMessage = `Aktueller Trend: ${Math.abs(trendKgPerWeek).toLocaleString("de-DE")} kg pro Woche in Richtung Ziel.`;
|
||||
} else {
|
||||
projectionMessage = "Noch kein stabiler Zieltrend. Das ist normal, einzelne Wochen schwanken stark.";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
startsOn,
|
||||
endsOn,
|
||||
entries,
|
||||
latest,
|
||||
targetWeightKg,
|
||||
deltaFromFirst,
|
||||
deltaFromPrevious,
|
||||
trendKgPerWeek,
|
||||
targetDate,
|
||||
projectionMessage,
|
||||
confidence,
|
||||
};
|
||||
}),
|
||||
|
||||
deleteWeightLog: protectedProcedure
|
||||
.input(z.object({ id: z.string().min(1) }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const existing = await ctx.db.query.weightLogs.findFirst({
|
||||
where: (row, { eq: rowEq }) => rowEq(row.id, input.id),
|
||||
});
|
||||
if (!existing || existing.userId !== ctx.user.id) {
|
||||
throw new TRPCError({ code: "NOT_FOUND", message: "Gewichtseintrag wurde nicht gefunden." });
|
||||
}
|
||||
await ctx.db.delete(schema.weightLogs).where(eq(schema.weightLogs.id, input.id));
|
||||
auditHealthMutation(ctx, "weight.delete", { id: input.id, date: existing.date });
|
||||
return { ok: true };
|
||||
}),
|
||||
|
||||
addMealLog: protectedProcedure
|
||||
.input(z.object({
|
||||
date: dateKeySchema,
|
||||
mealType: mealTypeSchema,
|
||||
name: z.string().trim().min(1).max(180),
|
||||
amountLabel: z.string().trim().max(120).optional().nullable(),
|
||||
calories: z.number().int().min(0).max(10000),
|
||||
proteinG: z.number().min(0).max(1000).default(0),
|
||||
carbsG: z.number().min(0).max(1000).default(0),
|
||||
fatG: z.number().min(0).max(1000).default(0),
|
||||
foodId: z.string().optional().nullable(),
|
||||
recipeId: z.string().optional().nullable(),
|
||||
}))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
await ctx.db.insert(schema.mealLogs).values({
|
||||
id: id("meal"),
|
||||
userId: ctx.user.id,
|
||||
date: input.date,
|
||||
mealType: input.mealType,
|
||||
foodId: input.foodId || null,
|
||||
recipeId: input.recipeId || null,
|
||||
name: input.name,
|
||||
amountLabel: input.amountLabel || null,
|
||||
calories: input.calories,
|
||||
proteinG: numberString(input.proteinG),
|
||||
carbsG: numberString(input.carbsG),
|
||||
fatG: numberString(input.fatG),
|
||||
});
|
||||
auditHealthMutation(ctx, "meal.add", { date: input.date, mealType: input.mealType, calories: input.calories });
|
||||
return { ok: true };
|
||||
}),
|
||||
|
||||
deleteMealLog: protectedProcedure
|
||||
.input(z.object({ id: z.string().min(1) }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const existing = await ctx.db.query.mealLogs.findFirst({
|
||||
where: (row, { eq: rowEq }) => rowEq(row.id, input.id),
|
||||
});
|
||||
if (!existing || existing.userId !== ctx.user.id) {
|
||||
throw new TRPCError({ code: "NOT_FOUND", message: "Der Eintrag wurde nicht gefunden." });
|
||||
}
|
||||
await ctx.db.delete(schema.mealLogs).where(eq(schema.mealLogs.id, input.id));
|
||||
auditHealthMutation(ctx, "meal.delete", { id: input.id, date: existing.date });
|
||||
return { ok: true };
|
||||
}),
|
||||
|
||||
deleteDrinkLog: protectedProcedure
|
||||
.input(z.object({ id: z.string().min(1) }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const existing = await ctx.db.query.drinkLogs.findFirst({
|
||||
where: (row, { eq: rowEq }) => rowEq(row.id, input.id),
|
||||
});
|
||||
if (!existing || existing.userId !== ctx.user.id) {
|
||||
throw new TRPCError({ code: "NOT_FOUND", message: "Der Eintrag wurde nicht gefunden." });
|
||||
}
|
||||
await ctx.db.delete(schema.drinkLogs).where(eq(schema.drinkLogs.id, input.id));
|
||||
auditHealthMutation(ctx, "drink.delete", { id: input.id, date: existing.date });
|
||||
return { ok: true };
|
||||
}),
|
||||
|
||||
upsertMealPlan: protectedProcedure
|
||||
.input(z.object({
|
||||
id: z.string().optional(),
|
||||
date: dateKeySchema,
|
||||
mealType: mealTypeSchema,
|
||||
recipeId: z.string().optional().nullable(),
|
||||
name: z.string().trim().min(1).max(180),
|
||||
portions: z.number().positive().max(100).default(1),
|
||||
plannedCalories: z.number().int().min(0).max(20000).default(0),
|
||||
note: z.string().max(1000).optional().nullable(),
|
||||
}))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const userId = ctx.user.id;
|
||||
const planId = input.id ?? id("meal-plan");
|
||||
const existing = input.id
|
||||
? await ctx.db.query.mealPlans.findFirst({ where: (row, { eq: rowEq }) => rowEq(row.id, input.id!) })
|
||||
: null;
|
||||
if (existing && existing.userId !== userId) {
|
||||
throw new TRPCError({ code: "FORBIDDEN", message: "Diese Planung gehört einem anderen Benutzer." });
|
||||
}
|
||||
await ctx.db
|
||||
.insert(schema.mealPlans)
|
||||
.values({
|
||||
id: planId,
|
||||
userId,
|
||||
date: input.date,
|
||||
mealType: input.mealType,
|
||||
recipeId: input.recipeId || null,
|
||||
name: input.name,
|
||||
portions: numberString(input.portions),
|
||||
plannedCalories: input.plannedCalories,
|
||||
note: input.note || null,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: schema.mealPlans.id,
|
||||
set: {
|
||||
date: input.date,
|
||||
mealType: input.mealType,
|
||||
recipeId: input.recipeId || null,
|
||||
name: input.name,
|
||||
portions: numberString(input.portions),
|
||||
plannedCalories: input.plannedCalories,
|
||||
note: input.note || null,
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
});
|
||||
auditHealthMutation(ctx, input.id ? "meal-plan.update" : "meal-plan.create", {
|
||||
planId,
|
||||
date: input.date,
|
||||
mealType: input.mealType,
|
||||
});
|
||||
return { id: planId };
|
||||
}),
|
||||
|
||||
deleteMealPlan: protectedProcedure
|
||||
.input(z.object({ id: z.string().min(1) }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const existing = await ctx.db.query.mealPlans.findFirst({
|
||||
where: (row, { eq: rowEq }) => rowEq(row.id, input.id),
|
||||
});
|
||||
if (!existing || existing.userId !== ctx.user.id) {
|
||||
throw new TRPCError({ code: "NOT_FOUND", message: "Die Planung wurde nicht gefunden." });
|
||||
}
|
||||
await ctx.db.delete(schema.mealPlans).where(eq(schema.mealPlans.id, input.id));
|
||||
auditHealthMutation(ctx, "meal-plan.delete", { id: input.id, date: existing.date });
|
||||
return { ok: true };
|
||||
}),
|
||||
|
||||
mealPlansRange: protectedProcedure
|
||||
.input(z.object({ startsOn: dateKeySchema, endsOn: dateKeySchema }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
return ctx.db.query.mealPlans.findMany({
|
||||
where: (row, { and: andWhere, eq: rowEq, gte, lte }) =>
|
||||
andWhere(rowEq(row.userId, ctx.user.id), gte(row.date, input.startsOn), lte(row.date, input.endsOn)),
|
||||
orderBy: (row, { asc }) => [asc(row.date), asc(row.mealType), asc(row.createdAt)],
|
||||
});
|
||||
}),
|
||||
|
||||
upsertWeeklyCaloriePlan: protectedProcedure
|
||||
.input(z.object({ date: dateKeySchema, plannedCalories: z.number().int().min(800).max(6000), note: z.string().max(500).optional().nullable() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
await ctx.db
|
||||
.insert(schema.weeklyCaloriePlans)
|
||||
.values({
|
||||
userId: ctx.user.id,
|
||||
date: input.date,
|
||||
plannedCalories: input.plannedCalories,
|
||||
note: input.note || null,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: [schema.weeklyCaloriePlans.userId, schema.weeklyCaloriePlans.date],
|
||||
set: {
|
||||
plannedCalories: input.plannedCalories,
|
||||
note: input.note || null,
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
});
|
||||
auditHealthMutation(ctx, "weekly-calorie-plan.upsert", {
|
||||
date: input.date,
|
||||
plannedCalories: input.plannedCalories,
|
||||
});
|
||||
return { ok: true };
|
||||
}),
|
||||
|
||||
addActivityLog: protectedProcedure
|
||||
.input(z.object({
|
||||
date: dateKeySchema,
|
||||
name: z.string().trim().min(1).max(180),
|
||||
templateId: z.string().max(180).optional().nullable(),
|
||||
minutes: z.number().int().min(1).max(1440),
|
||||
points: z.number().int().min(0).max(10000),
|
||||
calories: z.number().int().min(0).max(10000).default(0),
|
||||
source: z.enum(["manual", "fitbit", "health_connect", "system"]).default("manual"),
|
||||
externalId: z.string().max(180).optional().nullable(),
|
||||
note: z.string().max(1000).optional().nullable(),
|
||||
}))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
await ctx.db.insert(schema.activityLogs).values({
|
||||
id: id("activity-log"),
|
||||
userId: ctx.user.id,
|
||||
templateId: input.templateId || null,
|
||||
date: input.date,
|
||||
name: input.name,
|
||||
minutes: input.minutes,
|
||||
points: input.points,
|
||||
calories: input.calories,
|
||||
source: input.source,
|
||||
externalId: input.externalId || null,
|
||||
note: input.note || null,
|
||||
}).onConflictDoNothing();
|
||||
auditHealthMutation(ctx, "activity.add", {
|
||||
date: input.date,
|
||||
source: input.source,
|
||||
points: input.points,
|
||||
calories: input.calories,
|
||||
});
|
||||
return { ok: true };
|
||||
}),
|
||||
|
||||
activityWeek: protectedProcedure
|
||||
.input(z.object({ date: dateKeySchema }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
const weekStart = startOfWeek(input.date);
|
||||
const weekEnd = addDays(weekStart, 6);
|
||||
const logs = await ctx.db.query.activityLogs.findMany({
|
||||
where: (row, { and: andWhere, eq: rowEq, gte, lte }) =>
|
||||
andWhere(rowEq(row.userId, ctx.user.id), gte(row.date, weekStart), lte(row.date, weekEnd)),
|
||||
orderBy: (row, { desc: rowDesc }) => [rowDesc(row.createdAt)],
|
||||
});
|
||||
return {
|
||||
weekStart,
|
||||
weekEnd,
|
||||
logs,
|
||||
};
|
||||
}),
|
||||
|
||||
clubLeaderboard: protectedProcedure
|
||||
.input(z.object({ days: z.number().int().min(7).max(365).default(90) }).optional())
|
||||
.query(async ({ ctx, input }) => {
|
||||
const startsOn = addDays(new Date().toISOString().slice(0, 10), -(input?.days ?? 90) + 1);
|
||||
const logs = await ctx.db.query.activityLogs.findMany({
|
||||
where: (row, { gte }) => gte(row.date, startsOn),
|
||||
orderBy: (row, { desc: rowDesc }) => [rowDesc(row.createdAt)],
|
||||
});
|
||||
const userIds = [...new Set(logs.map((log) => log.userId))];
|
||||
const users = userIds.length
|
||||
? await ctx.db.query.user.findMany({
|
||||
where: (row, { inArray: rowInArray }) => rowInArray(row.id, userIds),
|
||||
columns: { id: true, name: true, email: true },
|
||||
})
|
||||
: [];
|
||||
const userById = new Map(users.map((user) => [user.id, user]));
|
||||
const scores = new Map<string, { userKey: string; name: string; points: number; completed: number }>();
|
||||
for (const log of logs) {
|
||||
if (log.points <= 0) continue;
|
||||
const user = userById.get(log.userId);
|
||||
const current = scores.get(log.userId) ?? {
|
||||
userKey: log.userId,
|
||||
name: user?.name || user?.email || "Hellth User",
|
||||
points: 0,
|
||||
completed: 0,
|
||||
};
|
||||
scores.set(log.userId, {
|
||||
...current,
|
||||
points: current.points + log.points,
|
||||
completed: current.completed + 1,
|
||||
});
|
||||
}
|
||||
return [...scores.values()]
|
||||
.sort((a, b) => b.points - a.points || b.completed - a.completed)
|
||||
.slice(0, 20);
|
||||
}),
|
||||
|
||||
deleteActivityLog: protectedProcedure
|
||||
.input(z.object({ id: z.string().min(1) }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
await ctx.db
|
||||
.delete(schema.activityLogs)
|
||||
.where(and(eq(schema.activityLogs.id, input.id), eq(schema.activityLogs.userId, ctx.user.id)));
|
||||
auditHealthMutation(ctx, "activity.delete", { activityLogId: input.id });
|
||||
return { ok: true };
|
||||
}),
|
||||
|
||||
listActivityTemplates: protectedProcedure.query(async ({ ctx }) => {
|
||||
const userId = ctx.user.id;
|
||||
return ctx.db.query.activityTemplates.findMany({
|
||||
where: (row, { or: orWhere, eq: rowEq, isNull: rowIsNull }) => orWhere(rowIsNull(row.ownerUserId), rowEq(row.ownerUserId, userId)),
|
||||
orderBy: (row, { asc: rowAsc }) => [rowAsc(row.name)],
|
||||
});
|
||||
}),
|
||||
|
||||
saveActivityTemplate: protectedProcedure
|
||||
.input(z.object({
|
||||
id: z.string().optional(),
|
||||
name: z.string().trim().min(1).max(180),
|
||||
category: z.string().trim().min(1).max(80).default("habit"),
|
||||
defaultMinutes: z.number().int().min(1).max(1440),
|
||||
defaultPoints: z.number().int().min(0).max(10000),
|
||||
defaultCalories: z.number().int().min(0).max(10000).default(0),
|
||||
icon: z.string().trim().max(80).default("custom"),
|
||||
}))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const templateId = input.id ?? id("activity");
|
||||
const existing = input.id
|
||||
? await ctx.db.query.activityTemplates.findFirst({ where: (row, { eq: rowEq }) => rowEq(row.id, input.id!) })
|
||||
: null;
|
||||
if (existing?.ownerUserId && existing.ownerUserId !== ctx.user.id) {
|
||||
throw new TRPCError({ code: "FORBIDDEN", message: "Diese Aktivitätsvorlage gehört einem anderen Benutzer." });
|
||||
}
|
||||
await ctx.db
|
||||
.insert(schema.activityTemplates)
|
||||
.values({
|
||||
id: templateId,
|
||||
ownerUserId: existing?.ownerUserId ?? ctx.user.id,
|
||||
name: input.name,
|
||||
category: input.category,
|
||||
defaultMinutes: input.defaultMinutes,
|
||||
defaultPoints: input.defaultPoints,
|
||||
defaultCalories: input.defaultCalories,
|
||||
icon: input.icon,
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: schema.activityTemplates.id,
|
||||
set: {
|
||||
name: input.name,
|
||||
category: input.category,
|
||||
defaultMinutes: input.defaultMinutes,
|
||||
defaultPoints: input.defaultPoints,
|
||||
defaultCalories: input.defaultCalories,
|
||||
icon: input.icon,
|
||||
},
|
||||
});
|
||||
auditHealthMutation(ctx, input.id ? "activity-template.update" : "activity-template.create", { templateId });
|
||||
return { id: templateId };
|
||||
}),
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue