* Extended functionalities
* Added different UIs
This commit is contained in:
21
meal-plan-frontend-angular/src/app/core/guards/auth.guard.ts
Normal file
21
meal-plan-frontend-angular/src/app/core/guards/auth.guard.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { inject } from '@angular/core';
|
||||
import { CanActivateFn, Router } from '@angular/router';
|
||||
import { AuthService } from '../services/auth.service';
|
||||
|
||||
export const authGuard: CanActivateFn = () => {
|
||||
const auth = inject(AuthService);
|
||||
const router = inject(Router);
|
||||
if (auth.isAuthenticated()) {
|
||||
return true;
|
||||
}
|
||||
return router.createUrlTree(['/login']);
|
||||
};
|
||||
|
||||
export const guestGuard: CanActivateFn = () => {
|
||||
const auth = inject(AuthService);
|
||||
const router = inject(Router);
|
||||
if (!auth.isAuthenticated()) {
|
||||
return true;
|
||||
}
|
||||
return router.createUrlTree(['/']);
|
||||
};
|
||||
@@ -0,0 +1,118 @@
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import {
|
||||
HttpBackend,
|
||||
HttpClient,
|
||||
HttpErrorResponse,
|
||||
HttpEvent,
|
||||
HttpHandler,
|
||||
HttpInterceptor,
|
||||
HttpRequest,
|
||||
} from '@angular/common/http';
|
||||
import { Router } from '@angular/router';
|
||||
import { Observable, catchError, firstValueFrom, from, switchMap, throwError } from 'rxjs';
|
||||
import { AuthService } from '../services/auth.service';
|
||||
import { TokenResponse } from '../models/auth.models';
|
||||
|
||||
@Injectable()
|
||||
export class AuthInterceptor implements HttpInterceptor {
|
||||
private isRefreshing = false;
|
||||
private queue: Array<(token: string | null) => void> = [];
|
||||
private readonly refreshClient: HttpClient;
|
||||
|
||||
private readonly auth = inject(AuthService);
|
||||
private readonly router = inject(Router);
|
||||
|
||||
constructor(httpBackend: HttpBackend) {
|
||||
this.refreshClient = new HttpClient(httpBackend);
|
||||
}
|
||||
|
||||
intercept(req: HttpRequest<unknown>, next: HttpHandler): Observable<HttpEvent<unknown>> {
|
||||
const token = this.auth.getAccessToken();
|
||||
const authReq = token
|
||||
? req.clone({ setHeaders: { Authorization: `Bearer ${token}` } })
|
||||
: req;
|
||||
|
||||
return next.handle(authReq).pipe(
|
||||
catchError((error: HttpErrorResponse) => {
|
||||
const original = authReq;
|
||||
const isAuthEndpoint =
|
||||
original.url.includes('/auth/login') ||
|
||||
original.url.includes('/auth/register') ||
|
||||
original.url.includes('/auth/refresh');
|
||||
|
||||
if (error.status !== 401 || isAuthEndpoint || original.headers.has('X-Retry')) {
|
||||
return throwError(() => error);
|
||||
}
|
||||
|
||||
if (this.isRefreshing) {
|
||||
return new Observable<HttpEvent<unknown>>((subscriber) => {
|
||||
this.queue.push((newToken) => {
|
||||
if (!newToken) {
|
||||
subscriber.error(error);
|
||||
return;
|
||||
}
|
||||
next
|
||||
.handle(
|
||||
original.clone({
|
||||
setHeaders: {
|
||||
Authorization: `Bearer ${newToken}`,
|
||||
'X-Retry': 'true',
|
||||
},
|
||||
}),
|
||||
)
|
||||
.subscribe(subscriber);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
this.isRefreshing = true;
|
||||
return from(this.performRefresh()).pipe(
|
||||
switchMap((newToken) => {
|
||||
this.isRefreshing = false;
|
||||
this.flushQueue(newToken);
|
||||
if (!newToken) {
|
||||
this.redirectToLogin();
|
||||
return throwError(() => error);
|
||||
}
|
||||
return next.handle(
|
||||
original.clone({
|
||||
setHeaders: {
|
||||
Authorization: `Bearer ${newToken}`,
|
||||
'X-Retry': 'true',
|
||||
},
|
||||
}),
|
||||
);
|
||||
}),
|
||||
);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
private async performRefresh(): Promise<string | null> {
|
||||
const refreshToken = this.auth.getRefreshToken();
|
||||
if (!refreshToken) return null;
|
||||
try {
|
||||
const tokens = await firstValueFrom(
|
||||
this.refreshClient.post<TokenResponse>('/api/auth/refresh', { refreshToken }),
|
||||
);
|
||||
if (!tokens) return null;
|
||||
this.auth.setTokens(tokens);
|
||||
return tokens.accessToken;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private flushQueue(token: string | null): void {
|
||||
this.queue.forEach((resolve) => resolve(token));
|
||||
this.queue = [];
|
||||
}
|
||||
|
||||
private redirectToLogin(): void {
|
||||
this.auth.clearAuth();
|
||||
const path = this.router.url.split('?')[0];
|
||||
if (path !== '/login' && path !== '/register') {
|
||||
void this.router.navigateByUrl('/login');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
export interface TokenResponse {
|
||||
accessToken: string;
|
||||
refreshToken: string;
|
||||
expiresInSeconds: number;
|
||||
tokenType: string;
|
||||
}
|
||||
|
||||
export interface UserInfo {
|
||||
id: string;
|
||||
userName: string | null;
|
||||
email: string | null;
|
||||
}
|
||||
|
||||
export interface LoginRequest {
|
||||
email: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
export interface RegisterRequest {
|
||||
email: string;
|
||||
password: string;
|
||||
userName?: string;
|
||||
}
|
||||
237
meal-plan-frontend-angular/src/app/core/models/diet.models.ts
Normal file
237
meal-plan-frontend-angular/src/app/core/models/diet.models.ts
Normal file
@@ -0,0 +1,237 @@
|
||||
export interface UserDailyGoals {
|
||||
calorieGoal?: number | null;
|
||||
proteinGoalG?: number | null;
|
||||
fatGoalG?: number | null;
|
||||
carbsGoalG?: number | null;
|
||||
waterGoalMl?: number | null;
|
||||
}
|
||||
|
||||
export interface MacroRemaining {
|
||||
goal?: number | null;
|
||||
consumed: number;
|
||||
remaining?: number | null;
|
||||
progressPercent?: number | null;
|
||||
}
|
||||
|
||||
export interface DecimalMacroRemaining {
|
||||
goal?: number | null;
|
||||
consumed: number;
|
||||
remaining?: number | null;
|
||||
progressPercent?: number | null;
|
||||
}
|
||||
|
||||
export interface MealConsumption {
|
||||
id: number;
|
||||
recipeId?: number | null;
|
||||
mealCategory: number;
|
||||
logDate: string;
|
||||
consumedAt: string;
|
||||
name: string;
|
||||
portions: number;
|
||||
calories?: number | null;
|
||||
proteinG?: number | null;
|
||||
fatG?: number | null;
|
||||
carbsG?: number | null;
|
||||
notes?: string | null;
|
||||
}
|
||||
|
||||
export interface DrinkCatalogItem {
|
||||
id: number;
|
||||
code: string;
|
||||
name: string;
|
||||
defaultVolumeMl: number;
|
||||
caloriesPer100Ml?: number | null;
|
||||
iconEmoji?: string | null;
|
||||
}
|
||||
|
||||
export interface DrinkConsumption {
|
||||
id: number;
|
||||
drinkCatalogId?: number | null;
|
||||
logDate: string;
|
||||
consumedAt: string;
|
||||
name: string;
|
||||
volumeMl: number;
|
||||
calories?: number | null;
|
||||
}
|
||||
|
||||
export interface DailySummary {
|
||||
date: string;
|
||||
goals?: UserDailyGoals | null;
|
||||
calories: MacroRemaining;
|
||||
proteinG: DecimalMacroRemaining;
|
||||
fatG: DecimalMacroRemaining;
|
||||
carbsG: DecimalMacroRemaining;
|
||||
waterMl: MacroRemaining;
|
||||
meals: MealConsumption[];
|
||||
drinks: DrinkConsumption[];
|
||||
}
|
||||
|
||||
export interface LogMealInput {
|
||||
recipeId?: number;
|
||||
catalogItemId?: number;
|
||||
grams?: number;
|
||||
name?: string;
|
||||
mealCategory: number;
|
||||
logDate?: string;
|
||||
portions?: number;
|
||||
calories?: number;
|
||||
proteinG?: number;
|
||||
fatG?: number;
|
||||
carbsG?: number;
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
export interface MealPreview {
|
||||
meal: {
|
||||
name: string;
|
||||
portions: number;
|
||||
calories?: number | null;
|
||||
proteinG?: number | null;
|
||||
fatG?: number | null;
|
||||
carbsG?: number | null;
|
||||
};
|
||||
remainingAfter: {
|
||||
calories?: number | null;
|
||||
proteinG?: number | null;
|
||||
fatG?: number | null;
|
||||
carbsG?: number | null;
|
||||
};
|
||||
}
|
||||
|
||||
export interface DietSuggestion {
|
||||
catalogItemId: number;
|
||||
name: string;
|
||||
namePl?: string | null;
|
||||
categoryCode: string;
|
||||
suggestedGrams: number;
|
||||
calories?: number | null;
|
||||
proteinG?: number | null;
|
||||
fatG?: number | null;
|
||||
carbsG?: number | null;
|
||||
reasonKey: string;
|
||||
}
|
||||
|
||||
export interface LogDrinkInput {
|
||||
drinkCatalogId?: number;
|
||||
name?: string;
|
||||
volumeMl: number;
|
||||
logDate?: string;
|
||||
calories?: number;
|
||||
}
|
||||
|
||||
export interface DietReminder {
|
||||
id: number;
|
||||
reminderType: number;
|
||||
title: string;
|
||||
message?: string | null;
|
||||
timeOfDay: string;
|
||||
daysOfWeekMask: number;
|
||||
mealCategory?: number | null;
|
||||
isEnabled: boolean;
|
||||
}
|
||||
|
||||
export interface CreateDietReminderInput {
|
||||
reminderType: number;
|
||||
title: string;
|
||||
message?: string;
|
||||
timeOfDay: string;
|
||||
daysOfWeekMask?: number;
|
||||
mealCategory?: number;
|
||||
isEnabled?: boolean;
|
||||
}
|
||||
|
||||
export type UpdateDietReminderInput = CreateDietReminderInput;
|
||||
|
||||
export interface DueReminder {
|
||||
id: number;
|
||||
reminderType: number;
|
||||
title: string;
|
||||
message?: string | null;
|
||||
timeOfDay: string;
|
||||
mealCategory?: number | null;
|
||||
}
|
||||
|
||||
export interface HistoryDay {
|
||||
date: string;
|
||||
calories: number;
|
||||
proteinG: number;
|
||||
fatG: number;
|
||||
carbsG: number;
|
||||
waterMl: number;
|
||||
mealCount: number;
|
||||
}
|
||||
|
||||
export interface RecentMeal {
|
||||
recipeId?: number | null;
|
||||
catalogItemId?: number | null;
|
||||
name: string;
|
||||
mealCategory: number;
|
||||
portions: number;
|
||||
grams?: number | null;
|
||||
calories?: number | null;
|
||||
proteinG?: number | null;
|
||||
fatG?: number | null;
|
||||
carbsG?: number | null;
|
||||
lastLoggedAt: string;
|
||||
}
|
||||
|
||||
export interface FavoriteRecipe {
|
||||
recipeId: number;
|
||||
name: string;
|
||||
mealCategory: number;
|
||||
calories: number | null;
|
||||
}
|
||||
|
||||
export interface FavoriteCatalogItem {
|
||||
catalogItemId: number;
|
||||
name: string;
|
||||
namePl?: string | null;
|
||||
categoryCode: string;
|
||||
}
|
||||
|
||||
export interface CopyMealsResult {
|
||||
sourceDate: string;
|
||||
targetDate: string;
|
||||
copiedCount: number;
|
||||
}
|
||||
|
||||
export enum DietReminderType {
|
||||
Water = 0,
|
||||
Meal = 1,
|
||||
Snack = 2,
|
||||
Custom = 3,
|
||||
}
|
||||
|
||||
export const CONSUMPTION_CATEGORIES = [
|
||||
{ value: 0, labelKey: 'categories.breakfast' },
|
||||
{ value: 1, labelKey: 'categories.secondBreakfast' },
|
||||
{ value: 2, labelKey: 'categories.lunch' },
|
||||
{ value: 3, labelKey: 'categories.dinner' },
|
||||
{ value: 4, labelKey: 'diet.snack' },
|
||||
] as const;
|
||||
|
||||
export const REMINDER_DAYS_MASK_ALL = 127;
|
||||
|
||||
function todayIso(): string {
|
||||
return new Date().toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
function addDaysIso(iso: string, days: number): string {
|
||||
const d = new Date(`${iso}T12:00:00`);
|
||||
d.setDate(d.getDate() + days);
|
||||
return d.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
function startOfWeekIso(iso: string): string {
|
||||
const d = new Date(`${iso}T12:00:00`);
|
||||
const day = d.getDay();
|
||||
const diff = (day === 0 ? -6 : 1) - day;
|
||||
d.setDate(d.getDate() + diff);
|
||||
return d.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
function endOfWeekIso(iso: string): string {
|
||||
return addDaysIso(startOfWeekIso(iso), 6);
|
||||
}
|
||||
|
||||
export { todayIso, addDaysIso, startOfWeekIso, endOfWeekIso };
|
||||
@@ -0,0 +1,149 @@
|
||||
export interface DietUserProfile {
|
||||
heightCm: number;
|
||||
weightKg: number;
|
||||
age: number;
|
||||
sex: 'male' | 'female';
|
||||
activityLevel: 'sedentary' | 'light' | 'moderate' | 'active' | 'very_active';
|
||||
goal: 'lose_weight' | 'maintain' | 'gain_muscle' | 'recomp';
|
||||
allergiesNotes?: string | null;
|
||||
dislikedFoods?: string | null;
|
||||
mealsPerDayMask: number;
|
||||
}
|
||||
|
||||
export interface MacroProposal {
|
||||
calories: number;
|
||||
proteinG: number;
|
||||
fatG: number;
|
||||
carbsG: number;
|
||||
rationale?: string | null;
|
||||
}
|
||||
|
||||
export interface DietGeneratorDraftMealIngredient {
|
||||
id: number;
|
||||
name: string;
|
||||
amountGrams?: number | null;
|
||||
unit?: string | null;
|
||||
sortOrder: number;
|
||||
sourceIngredientId?: number | null;
|
||||
}
|
||||
|
||||
export interface DietGeneratorDraftMealStep {
|
||||
id: number;
|
||||
stepNumber: number;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export interface DietGeneratorDraftMeal {
|
||||
id: number;
|
||||
planDate: string;
|
||||
mealCategory: number;
|
||||
recipeId: number;
|
||||
recipeName: string;
|
||||
portions: number;
|
||||
calories?: number | null;
|
||||
proteinG?: number | null;
|
||||
fatG?: number | null;
|
||||
carbsG?: number | null;
|
||||
isLocked: boolean;
|
||||
prepTimeMinutes?: number | null;
|
||||
ingredients: DietGeneratorDraftMealIngredient[];
|
||||
steps: DietGeneratorDraftMealStep[];
|
||||
}
|
||||
|
||||
export interface DietGeneratorDaySummary {
|
||||
planDate: string;
|
||||
totalCalories: number;
|
||||
targetCalories: number;
|
||||
withinTolerance: boolean;
|
||||
}
|
||||
|
||||
export interface DietGeneratorSession {
|
||||
id: number;
|
||||
status: string;
|
||||
proposedMacros?: MacroProposal | null;
|
||||
planStartDate?: string | null;
|
||||
planDayCount: number;
|
||||
calorieToleranceKcal: number;
|
||||
draftMeals: DietGeneratorDraftMeal[];
|
||||
daySummaries: DietGeneratorDaySummary[];
|
||||
}
|
||||
|
||||
export interface CommitDietPlanResult {
|
||||
sessionId: number;
|
||||
planStartDate: string;
|
||||
planEndDate: string;
|
||||
mealsWritten: number;
|
||||
}
|
||||
|
||||
export interface RecipeGeneratorConstraints {
|
||||
prompt: string;
|
||||
mealCategory: number;
|
||||
targetCalories?: number | null;
|
||||
calorieToleranceKcal?: number;
|
||||
maxPrepTimeMinutes?: number | null;
|
||||
cuisineStyle?: string | null;
|
||||
dietStyle?: string | null;
|
||||
exclusions?: string | null;
|
||||
}
|
||||
|
||||
export interface GeneratedIngredient {
|
||||
name: string;
|
||||
amountGrams?: number | null;
|
||||
unit?: string | null;
|
||||
catalogItemId?: number | null;
|
||||
catalogName?: string | null;
|
||||
isLinked: boolean;
|
||||
}
|
||||
|
||||
export interface GeneratedStep {
|
||||
stepNumber: number;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export interface GeneratedRecipeDraft {
|
||||
draftId: string;
|
||||
name: string;
|
||||
mealCategory: number;
|
||||
prepTimeMinutes?: number | null;
|
||||
calories?: number | null;
|
||||
protein?: number | null;
|
||||
fat?: number | null;
|
||||
carbs?: number | null;
|
||||
ingredients: GeneratedIngredient[];
|
||||
steps: GeneratedStep[];
|
||||
unlinkedIngredientCount: number;
|
||||
}
|
||||
|
||||
export interface RecipeGeneratorSession {
|
||||
id: number;
|
||||
status: string;
|
||||
constraints: RecipeGeneratorConstraints;
|
||||
draft?: GeneratedRecipeDraft | null;
|
||||
drafts: GeneratedRecipeDraft[];
|
||||
savedRecipeId?: number | null;
|
||||
generationVersion: number;
|
||||
}
|
||||
|
||||
export interface CommitRecipeResult {
|
||||
sessionId: number;
|
||||
recipeId: number;
|
||||
recipeName: string;
|
||||
draftId: string;
|
||||
}
|
||||
|
||||
export interface CommitRecipesResult {
|
||||
sessionId: number;
|
||||
saved: CommitRecipeResult[];
|
||||
}
|
||||
|
||||
export const DEFAULT_DIET_PROFILE: DietUserProfile = {
|
||||
heightCm: 175,
|
||||
weightKg: 75,
|
||||
age: 30,
|
||||
sex: 'male',
|
||||
activityLevel: 'moderate',
|
||||
goal: 'maintain',
|
||||
mealsPerDayMask: 15,
|
||||
allergiesNotes: '',
|
||||
dislikedFoods: '',
|
||||
};
|
||||
@@ -0,0 +1,33 @@
|
||||
export interface IngredientCategory {
|
||||
id: number;
|
||||
code: string;
|
||||
name: string;
|
||||
itemCount: number;
|
||||
}
|
||||
|
||||
export interface IngredientNutritionItem {
|
||||
id: number;
|
||||
categoryId: number;
|
||||
categoryCode: string;
|
||||
categoryName: string;
|
||||
name: string;
|
||||
namePl?: string | null;
|
||||
caloriesPer100G: number;
|
||||
proteinGPer100G: number;
|
||||
fatGPer100G: number;
|
||||
carbsGPer100G: number;
|
||||
fiberGPer100G: number | null;
|
||||
}
|
||||
|
||||
export interface UpsertIngredientNutritionItemInput {
|
||||
categoryId: number;
|
||||
name: string;
|
||||
namePl?: string | null;
|
||||
caloriesPer100G: number;
|
||||
proteinGPer100G: number;
|
||||
fatGPer100G: number;
|
||||
carbsGPer100G: number;
|
||||
fiberGPer100G?: number | null;
|
||||
sortOrder?: number;
|
||||
isActive?: boolean;
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
export interface MealPlanEntry {
|
||||
id: number;
|
||||
planDate: string;
|
||||
mealCategory: number;
|
||||
recipeId: number;
|
||||
recipeName: string;
|
||||
portions: number;
|
||||
calories: number | null;
|
||||
}
|
||||
|
||||
export interface UpsertMealPlanEntryInput {
|
||||
planDate: string;
|
||||
mealCategory: number;
|
||||
recipeId: number;
|
||||
portions?: number;
|
||||
}
|
||||
|
||||
export interface ShoppingListItem {
|
||||
name: string;
|
||||
unit: string | null;
|
||||
totalGrams: number | null;
|
||||
totalAmount: number | null;
|
||||
breakdown: string[];
|
||||
}
|
||||
100
meal-plan-frontend-angular/src/app/core/models/recipe.models.ts
Normal file
100
meal-plan-frontend-angular/src/app/core/models/recipe.models.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
export enum MealCategory {
|
||||
Breakfast = 0,
|
||||
SecondBreakfast = 1,
|
||||
Lunch = 2,
|
||||
Dinner = 3,
|
||||
}
|
||||
|
||||
export const MEAL_CATEGORY_ROUTES: Record<MealCategory, string> = {
|
||||
[MealCategory.Breakfast]: '/breakfast',
|
||||
[MealCategory.SecondBreakfast]: '/second-breakfast',
|
||||
[MealCategory.Lunch]: '/lunch',
|
||||
[MealCategory.Dinner]: '/dinner',
|
||||
};
|
||||
|
||||
export const MEAL_CATEGORY_I18N_KEYS: Record<MealCategory, string> = {
|
||||
[MealCategory.Breakfast]: 'categories.breakfast',
|
||||
[MealCategory.SecondBreakfast]: 'categories.secondBreakfast',
|
||||
[MealCategory.Lunch]: 'categories.lunch',
|
||||
[MealCategory.Dinner]: 'categories.dinner',
|
||||
};
|
||||
|
||||
export interface RecipeListItem {
|
||||
id: number;
|
||||
name: string;
|
||||
mealCategory: MealCategory;
|
||||
calories: number | null;
|
||||
prepTimeMinutes: number | null;
|
||||
}
|
||||
|
||||
export interface Ingredient {
|
||||
id: number;
|
||||
name: string;
|
||||
amountGrams: number | null;
|
||||
unit: string | null;
|
||||
sortOrder: number;
|
||||
catalogItemId?: number | null;
|
||||
}
|
||||
|
||||
export interface RecipeStep {
|
||||
id: number;
|
||||
stepNumber: number;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export interface RecipeDetail {
|
||||
id: number;
|
||||
name: string;
|
||||
mealCategory: MealCategory;
|
||||
calories: number | null;
|
||||
protein: number | null;
|
||||
fat: number | null;
|
||||
carbs: number | null;
|
||||
prepTimeMinutes: number | null;
|
||||
ingredients: Ingredient[];
|
||||
steps: RecipeStep[];
|
||||
}
|
||||
|
||||
export interface CategoryCount {
|
||||
category: MealCategory;
|
||||
name: string;
|
||||
count: number;
|
||||
}
|
||||
|
||||
export interface CreateIngredientInput {
|
||||
name: string;
|
||||
amountGrams: number | null;
|
||||
unit: string;
|
||||
sortOrder: number;
|
||||
catalogItemId?: number | null;
|
||||
}
|
||||
|
||||
export interface CreateStepInput {
|
||||
stepNumber: number;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export interface CreateRecipeInput {
|
||||
name: string;
|
||||
mealCategory: MealCategory;
|
||||
calories: number | null;
|
||||
protein: number | null;
|
||||
fat: number | null;
|
||||
carbs: number | null;
|
||||
prepTimeMinutes: number | null;
|
||||
ingredients: CreateIngredientInput[];
|
||||
steps: CreateStepInput[];
|
||||
}
|
||||
|
||||
export interface RecipeImportResult {
|
||||
createdCount: number;
|
||||
skippedCount: number;
|
||||
errors: string[];
|
||||
createdRecipeIds: number[];
|
||||
}
|
||||
|
||||
export interface RecipeMacroRecalcResult {
|
||||
recipesProcessed: number;
|
||||
recipesUpdated: number;
|
||||
unlinkedIngredientRows: number;
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { Injectable, signal } from '@angular/core';
|
||||
import {
|
||||
AppearanceId,
|
||||
IconStyleId,
|
||||
THEME_PROPOSALS,
|
||||
ThemeProposal,
|
||||
} from '../theme/appearance.config';
|
||||
|
||||
const STORAGE_KEY = 'dailymeals.appearance';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class AppearanceService {
|
||||
readonly proposal = signal<ThemeProposal>(this.load());
|
||||
|
||||
readonly iconStyle = signal<IconStyleId>(this.proposal().iconStyle);
|
||||
|
||||
init(): void {
|
||||
this.apply(this.proposal());
|
||||
}
|
||||
|
||||
setProposal(id: AppearanceId): void {
|
||||
const next = THEME_PROPOSALS.find((p) => p.id === id) ?? THEME_PROPOSALS[0];
|
||||
this.proposal.set(next);
|
||||
this.iconStyle.set(next.iconStyle);
|
||||
localStorage.setItem(STORAGE_KEY, id);
|
||||
this.apply(next);
|
||||
}
|
||||
|
||||
proposals(): ThemeProposal[] {
|
||||
return THEME_PROPOSALS;
|
||||
}
|
||||
|
||||
strokeWidth(): string {
|
||||
return this.iconStyle() === 'bold' ? '2.5' : '2';
|
||||
}
|
||||
|
||||
useEmojiIcons(): boolean {
|
||||
return this.iconStyle() === 'emoji';
|
||||
}
|
||||
|
||||
private load(): ThemeProposal {
|
||||
const stored = localStorage.getItem(STORAGE_KEY) as AppearanceId | null;
|
||||
const found = THEME_PROPOSALS.find((p) => p.id === stored);
|
||||
const proposal = found ?? THEME_PROPOSALS[0];
|
||||
this.apply(proposal);
|
||||
return proposal;
|
||||
}
|
||||
|
||||
private apply(proposal: ThemeProposal): void {
|
||||
const root = document.documentElement;
|
||||
root.dataset['colorTheme'] = proposal.colorTheme;
|
||||
root.dataset['iconStyle'] = proposal.iconStyle;
|
||||
}
|
||||
}
|
||||
111
meal-plan-frontend-angular/src/app/core/services/auth.service.ts
Normal file
111
meal-plan-frontend-angular/src/app/core/services/auth.service.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
import { Injectable, signal } from '@angular/core';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
import {
|
||||
LoginRequest,
|
||||
RegisterRequest,
|
||||
TokenResponse,
|
||||
UserInfo,
|
||||
} from '../models/auth.models';
|
||||
|
||||
const REFRESH_TOKEN_KEY = 'dm_refresh_token';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class AuthService {
|
||||
private readonly accessToken = signal<string | null>(null);
|
||||
private readonly refreshToken = signal<string | null>(null);
|
||||
readonly user = signal<UserInfo | null>(null);
|
||||
|
||||
constructor(private readonly http: HttpClient) {
|
||||
const stored = localStorage.getItem(REFRESH_TOKEN_KEY);
|
||||
if (stored) {
|
||||
this.refreshToken.set(stored);
|
||||
}
|
||||
}
|
||||
|
||||
isAuthenticated(): boolean {
|
||||
return Boolean(this.accessToken());
|
||||
}
|
||||
|
||||
getAccessToken(): string | null {
|
||||
return this.accessToken();
|
||||
}
|
||||
|
||||
getRefreshToken(): string | null {
|
||||
return this.refreshToken();
|
||||
}
|
||||
|
||||
setTokens(tokens: TokenResponse): void {
|
||||
this.accessToken.set(tokens.accessToken);
|
||||
this.refreshToken.set(tokens.refreshToken);
|
||||
localStorage.setItem(REFRESH_TOKEN_KEY, tokens.refreshToken);
|
||||
}
|
||||
|
||||
clearAuth(): void {
|
||||
this.accessToken.set(null);
|
||||
this.refreshToken.set(null);
|
||||
this.user.set(null);
|
||||
localStorage.removeItem(REFRESH_TOKEN_KEY);
|
||||
}
|
||||
|
||||
async login(payload: LoginRequest): Promise<UserInfo> {
|
||||
const tokens = await firstValueFrom(this.http.post<TokenResponse>('/api/auth/login', payload));
|
||||
this.setTokens(tokens);
|
||||
return this.loadCurrentUser();
|
||||
}
|
||||
|
||||
async register(payload: RegisterRequest): Promise<UserInfo> {
|
||||
const tokens = await firstValueFrom(this.http.post<TokenResponse>('/api/auth/register', payload));
|
||||
this.setTokens(tokens);
|
||||
return this.loadCurrentUser();
|
||||
}
|
||||
|
||||
async loadCurrentUser(): Promise<UserInfo> {
|
||||
const me = await firstValueFrom(this.http.get<UserInfo>('/api/auth/me'));
|
||||
this.user.set(me);
|
||||
return me;
|
||||
}
|
||||
|
||||
async restoreSession(): Promise<boolean> {
|
||||
const refresh = this.refreshToken();
|
||||
if (!refresh) return false;
|
||||
const token = await this.refreshAccessToken();
|
||||
if (!token) {
|
||||
this.clearAuth();
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
await this.loadCurrentUser();
|
||||
return true;
|
||||
} catch {
|
||||
this.clearAuth();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async logout(): Promise<void> {
|
||||
const refresh = this.refreshToken();
|
||||
try {
|
||||
if (refresh) {
|
||||
await firstValueFrom(this.http.post('/api/auth/logout', { refreshToken: refresh }));
|
||||
}
|
||||
} catch {
|
||||
// best effort
|
||||
}
|
||||
this.clearAuth();
|
||||
}
|
||||
|
||||
async refreshAccessToken(): Promise<string | null> {
|
||||
const refresh = this.refreshToken();
|
||||
if (!refresh) return null;
|
||||
try {
|
||||
const tokens = await firstValueFrom(
|
||||
this.http.post<TokenResponse>('/api/auth/refresh', { refreshToken: refresh }),
|
||||
);
|
||||
this.setTokens(tokens);
|
||||
return tokens.accessToken;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { Observable, map } from 'rxjs';
|
||||
import {
|
||||
CommitDietPlanResult,
|
||||
DietGeneratorSession,
|
||||
DietUserProfile,
|
||||
MacroProposal,
|
||||
} from '../models/generator.models';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class DietGeneratorService {
|
||||
constructor(private readonly http: HttpClient) {}
|
||||
|
||||
fetchProfile(): Observable<DietUserProfile | null> {
|
||||
return this.http.get<DietUserProfile>('/api/diet-generator/profile').pipe(
|
||||
map((data) => (data.heightCm ? data : null)),
|
||||
);
|
||||
}
|
||||
|
||||
saveProfile(profile: DietUserProfile): Observable<DietUserProfile> {
|
||||
return this.http.put<DietUserProfile>('/api/diet-generator/profile', profile);
|
||||
}
|
||||
|
||||
createSession(input: {
|
||||
planStartDate?: string;
|
||||
planDayCount?: number;
|
||||
calorieToleranceKcal?: number;
|
||||
}): Observable<DietGeneratorSession> {
|
||||
return this.http.post<DietGeneratorSession>('/api/diet-generator/sessions', input);
|
||||
}
|
||||
|
||||
fetchSession(id: number): Observable<DietGeneratorSession> {
|
||||
return this.http.get<DietGeneratorSession>(`/api/diet-generator/sessions/${id}`);
|
||||
}
|
||||
|
||||
proposeMacros(sessionId: number): Observable<DietGeneratorSession> {
|
||||
return this.http.post<DietGeneratorSession>(
|
||||
`/api/diet-generator/sessions/${sessionId}/propose-macros`,
|
||||
null,
|
||||
);
|
||||
}
|
||||
|
||||
acceptMacros(sessionId: number, macros?: Partial<MacroProposal>): Observable<DietGeneratorSession> {
|
||||
return this.http.post<DietGeneratorSession>(
|
||||
`/api/diet-generator/sessions/${sessionId}/accept-macros`,
|
||||
{
|
||||
calories: macros?.calories,
|
||||
proteinG: macros?.proteinG,
|
||||
fatG: macros?.fatG,
|
||||
carbsG: macros?.carbsG,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
generatePlan(sessionId: number): Observable<DietGeneratorSession> {
|
||||
return this.http.post<DietGeneratorSession>(
|
||||
`/api/diet-generator/sessions/${sessionId}/generate-plan`,
|
||||
null,
|
||||
);
|
||||
}
|
||||
|
||||
regenerateMeal(
|
||||
sessionId: number,
|
||||
planDate: string,
|
||||
mealCategory: number,
|
||||
): Observable<DietGeneratorSession> {
|
||||
return this.http.post<DietGeneratorSession>(
|
||||
`/api/diet-generator/sessions/${sessionId}/regenerate-meal`,
|
||||
{ planDate, mealCategory },
|
||||
);
|
||||
}
|
||||
|
||||
toggleMealLock(sessionId: number, mealId: number, isLocked: boolean): Observable<DietGeneratorSession> {
|
||||
return this.http.patch<DietGeneratorSession>(
|
||||
`/api/diet-generator/sessions/${sessionId}/meals/${mealId}`,
|
||||
{ isLocked },
|
||||
);
|
||||
}
|
||||
|
||||
commitPlan(sessionId: number): Observable<CommitDietPlanResult> {
|
||||
return this.http.post<CommitDietPlanResult>(
|
||||
`/api/diet-generator/sessions/${sessionId}/commit`,
|
||||
null,
|
||||
);
|
||||
}
|
||||
}
|
||||
129
meal-plan-frontend-angular/src/app/core/services/diet.service.ts
Normal file
129
meal-plan-frontend-angular/src/app/core/services/diet.service.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { Observable, map } from 'rxjs';
|
||||
import {
|
||||
CopyMealsResult,
|
||||
CreateDietReminderInput,
|
||||
DailySummary,
|
||||
DietReminder,
|
||||
DietSuggestion,
|
||||
DrinkCatalogItem,
|
||||
DueReminder,
|
||||
FavoriteCatalogItem,
|
||||
FavoriteRecipe,
|
||||
HistoryDay,
|
||||
LogDrinkInput,
|
||||
LogMealInput,
|
||||
MealConsumption,
|
||||
MealPreview,
|
||||
RecentMeal,
|
||||
UpdateDietReminderInput,
|
||||
UserDailyGoals,
|
||||
todayIso,
|
||||
} from '../models/diet.models';
|
||||
import { normalizeDailySummary } from '../utils/diet-normalize.util';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class DietService {
|
||||
constructor(private readonly http: HttpClient) {}
|
||||
|
||||
getSummary(date = todayIso()): Observable<DailySummary> {
|
||||
return this.http
|
||||
.get<unknown>('/api/diet/summary', { params: { date } })
|
||||
.pipe(map(normalizeDailySummary));
|
||||
}
|
||||
|
||||
getGoals(): Observable<UserDailyGoals> {
|
||||
return this.http.get<UserDailyGoals>('/api/diet/goals');
|
||||
}
|
||||
|
||||
saveGoals(goals: UserDailyGoals): Observable<UserDailyGoals> {
|
||||
return this.http.put<UserDailyGoals>('/api/diet/goals', goals);
|
||||
}
|
||||
|
||||
logMeal(input: LogMealInput): Observable<MealConsumption> {
|
||||
return this.http.post<MealConsumption>('/api/diet/meals', input);
|
||||
}
|
||||
|
||||
previewMeal(input: LogMealInput, date = todayIso()): Observable<MealPreview> {
|
||||
return this.http.post<MealPreview>('/api/diet/meals/preview', input, { params: { date } });
|
||||
}
|
||||
|
||||
getSuggestions(date = todayIso(), limit = 5): Observable<DietSuggestion[]> {
|
||||
return this.http.get<DietSuggestion[]>('/api/diet/suggestions', { params: { date, limit } });
|
||||
}
|
||||
|
||||
deleteMeal(id: number): Observable<void> {
|
||||
return this.http.delete<void>(`/api/diet/meals/${id}`);
|
||||
}
|
||||
|
||||
getDrinkCatalog(): Observable<DrinkCatalogItem[]> {
|
||||
return this.http.get<DrinkCatalogItem[]>('/api/diet/drinks/catalog');
|
||||
}
|
||||
|
||||
logDrink(input: LogDrinkInput): Observable<void> {
|
||||
return this.http.post<void>('/api/diet/drinks', input);
|
||||
}
|
||||
|
||||
deleteDrink(id: number): Observable<void> {
|
||||
return this.http.delete<void>(`/api/diet/drinks/${id}`);
|
||||
}
|
||||
|
||||
getReminders(): Observable<DietReminder[]> {
|
||||
return this.http.get<DietReminder[]>('/api/diet/reminders');
|
||||
}
|
||||
|
||||
getDueReminders(date = todayIso(), time?: string): Observable<DueReminder[]> {
|
||||
const params: Record<string, string> = { date };
|
||||
if (time) params['time'] = time;
|
||||
return this.http.get<DueReminder[]>('/api/diet/reminders/due', { params });
|
||||
}
|
||||
|
||||
createReminder(input: CreateDietReminderInput): Observable<DietReminder> {
|
||||
return this.http.post<DietReminder>('/api/diet/reminders', input);
|
||||
}
|
||||
|
||||
updateReminder(id: number, input: UpdateDietReminderInput): Observable<DietReminder> {
|
||||
return this.http.put<DietReminder>(`/api/diet/reminders/${id}`, input);
|
||||
}
|
||||
|
||||
deleteReminder(id: number): Observable<void> {
|
||||
return this.http.delete<void>(`/api/diet/reminders/${id}`);
|
||||
}
|
||||
|
||||
getHistory(from: string, to: string): Observable<HistoryDay[]> {
|
||||
return this.http.get<HistoryDay[]>('/api/diet/history', { params: { from, to } });
|
||||
}
|
||||
|
||||
getRecentMeals(limit = 10): Observable<RecentMeal[]> {
|
||||
return this.http.get<RecentMeal[]>('/api/diet/meals/recent', { params: { limit } });
|
||||
}
|
||||
|
||||
copyYesterdayMeals(date = todayIso()): Observable<CopyMealsResult> {
|
||||
return this.http.post<CopyMealsResult>('/api/diet/meals/copy-yesterday', null, { params: { date } });
|
||||
}
|
||||
|
||||
getFavoriteRecipes(): Observable<FavoriteRecipe[]> {
|
||||
return this.http.get<FavoriteRecipe[]>('/api/diet/favorites/recipes');
|
||||
}
|
||||
|
||||
addFavoriteRecipe(recipeId: number): Observable<FavoriteRecipe> {
|
||||
return this.http.post<FavoriteRecipe>(`/api/diet/favorites/recipes/${recipeId}`, null);
|
||||
}
|
||||
|
||||
removeFavoriteRecipe(recipeId: number): Observable<void> {
|
||||
return this.http.delete<void>(`/api/diet/favorites/recipes/${recipeId}`);
|
||||
}
|
||||
|
||||
getFavoriteCatalogItems(): Observable<FavoriteCatalogItem[]> {
|
||||
return this.http.get<FavoriteCatalogItem[]>('/api/diet/favorites/catalog');
|
||||
}
|
||||
|
||||
addFavoriteCatalogItem(catalogItemId: number): Observable<FavoriteCatalogItem> {
|
||||
return this.http.post<FavoriteCatalogItem>(`/api/diet/favorites/catalog/${catalogItemId}`, null);
|
||||
}
|
||||
|
||||
removeFavoriteCatalogItem(catalogItemId: number): Observable<void> {
|
||||
return this.http.delete<void>(`/api/diet/favorites/catalog/${catalogItemId}`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { HttpClient, HttpParams } from '@angular/common/http';
|
||||
import { Observable } from 'rxjs';
|
||||
import {
|
||||
IngredientCategory,
|
||||
IngredientNutritionItem,
|
||||
UpsertIngredientNutritionItemInput,
|
||||
} from '../models/ingredient-catalog.models';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class IngredientCatalogService {
|
||||
constructor(private readonly http: HttpClient) {}
|
||||
|
||||
getCategories(): Observable<IngredientCategory[]> {
|
||||
return this.http.get<IngredientCategory[]>('/api/ingredient-catalog/categories');
|
||||
}
|
||||
|
||||
getItems(categoryCode?: string, search?: string): Observable<IngredientNutritionItem[]> {
|
||||
let params = new HttpParams();
|
||||
if (categoryCode) params = params.set('category', categoryCode);
|
||||
if (search) params = params.set('search', search);
|
||||
return this.http.get<IngredientNutritionItem[]>('/api/ingredient-catalog', { params });
|
||||
}
|
||||
|
||||
getItem(id: number): Observable<IngredientNutritionItem> {
|
||||
return this.http.get<IngredientNutritionItem>(`/api/ingredient-catalog/${id}`);
|
||||
}
|
||||
|
||||
createItem(input: UpsertIngredientNutritionItemInput): Observable<IngredientNutritionItem> {
|
||||
return this.http.post<IngredientNutritionItem>('/api/ingredient-catalog', input);
|
||||
}
|
||||
|
||||
updateItem(id: number, input: UpsertIngredientNutritionItemInput): Observable<IngredientNutritionItem> {
|
||||
return this.http.put<IngredientNutritionItem>(`/api/ingredient-catalog/${id}`, input);
|
||||
}
|
||||
|
||||
deleteItem(id: number): Observable<void> {
|
||||
return this.http.delete<void>(`/api/ingredient-catalog/${id}`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { TranslateService } from '@ngx-translate/core';
|
||||
|
||||
const STORAGE_KEY = 'dailymeals.lang';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class LanguageService {
|
||||
constructor(private readonly translate: TranslateService) {}
|
||||
|
||||
init(): void {
|
||||
const stored = localStorage.getItem(STORAGE_KEY);
|
||||
const lang =
|
||||
stored === 'en' || stored === 'pl'
|
||||
? stored
|
||||
: navigator.language.toLowerCase().startsWith('pl')
|
||||
? 'pl'
|
||||
: 'en';
|
||||
this.translate.addLangs(['en', 'pl']);
|
||||
this.translate.setFallbackLang('en');
|
||||
this.setLanguage(lang);
|
||||
}
|
||||
|
||||
setLanguage(lang: 'en' | 'pl'): void {
|
||||
this.translate.use(lang);
|
||||
localStorage.setItem(STORAGE_KEY, lang);
|
||||
document.documentElement.lang = lang;
|
||||
}
|
||||
|
||||
currentLanguage(): 'en' | 'pl' {
|
||||
const current = this.translate.getCurrentLang();
|
||||
return current?.startsWith('pl') ? 'pl' : 'en';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { Injectable, signal } from '@angular/core';
|
||||
|
||||
export type LayoutId = 'sidebar' | 'topnav' | 'compact' | 'wide';
|
||||
|
||||
const STORAGE_KEY = 'dailymeals.layout';
|
||||
const VALID: LayoutId[] = ['sidebar', 'topnav', 'compact', 'wide'];
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class LayoutService {
|
||||
readonly layout = signal<LayoutId>(this.load());
|
||||
|
||||
setLayout(id: LayoutId): void {
|
||||
this.layout.set(id);
|
||||
localStorage.setItem(STORAGE_KEY, id);
|
||||
document.documentElement.dataset['layout'] = id;
|
||||
}
|
||||
|
||||
private load(): LayoutId {
|
||||
const stored = localStorage.getItem(STORAGE_KEY);
|
||||
const id = VALID.includes(stored as LayoutId) ? (stored as LayoutId) : 'sidebar';
|
||||
document.documentElement.dataset['layout'] = id;
|
||||
return id;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { Observable } from 'rxjs';
|
||||
import {
|
||||
MealPlanEntry,
|
||||
ShoppingListItem,
|
||||
UpsertMealPlanEntryInput,
|
||||
} from '../models/meal-plan.models';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class MealPlanService {
|
||||
constructor(private readonly http: HttpClient) {}
|
||||
|
||||
getEntries(from: string, to: string): Observable<MealPlanEntry[]> {
|
||||
return this.http.get<MealPlanEntry[]>('/api/meal-plan', { params: { from, to } });
|
||||
}
|
||||
|
||||
upsertEntry(input: UpsertMealPlanEntryInput): Observable<MealPlanEntry> {
|
||||
return this.http.put<MealPlanEntry>('/api/meal-plan', input);
|
||||
}
|
||||
|
||||
deleteEntry(id: number): Observable<void> {
|
||||
return this.http.delete<void>(`/api/meal-plan/${id}`);
|
||||
}
|
||||
|
||||
getShoppingList(from: string, to: string): Observable<ShoppingListItem[]> {
|
||||
return this.http.get<ShoppingListItem[]>('/api/meal-plan/shopping-list', { params: { from, to } });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { Observable } from 'rxjs';
|
||||
import {
|
||||
CommitRecipesResult,
|
||||
GeneratedRecipeDraft,
|
||||
RecipeGeneratorConstraints,
|
||||
RecipeGeneratorSession,
|
||||
} from '../models/generator.models';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class RecipeGeneratorService {
|
||||
constructor(private readonly http: HttpClient) {}
|
||||
|
||||
createSession(constraints: RecipeGeneratorConstraints): Observable<RecipeGeneratorSession> {
|
||||
return this.http.post<RecipeGeneratorSession>('/api/recipe-generator/sessions', constraints);
|
||||
}
|
||||
|
||||
fetchSession(id: number): Observable<RecipeGeneratorSession> {
|
||||
return this.http.get<RecipeGeneratorSession>(`/api/recipe-generator/sessions/${id}`);
|
||||
}
|
||||
|
||||
generateDrafts(sessionId: number, count = 3): Observable<RecipeGeneratorSession> {
|
||||
return this.http.post<RecipeGeneratorSession>(`/api/recipe-generator/sessions/${sessionId}/generate`, {
|
||||
count,
|
||||
});
|
||||
}
|
||||
|
||||
regeneratePart(
|
||||
sessionId: number,
|
||||
draftId: string,
|
||||
mode: 'ingredients' | 'steps' | 'full',
|
||||
instruction?: string,
|
||||
): Observable<RecipeGeneratorSession> {
|
||||
return this.http.post<RecipeGeneratorSession>(`/api/recipe-generator/sessions/${sessionId}/regenerate`, {
|
||||
draftId,
|
||||
mode,
|
||||
instruction,
|
||||
});
|
||||
}
|
||||
|
||||
removeDraft(sessionId: number, draftId: string): Observable<RecipeGeneratorSession> {
|
||||
return this.http.delete<RecipeGeneratorSession>(
|
||||
`/api/recipe-generator/sessions/${sessionId}/drafts/${encodeURIComponent(draftId)}`,
|
||||
);
|
||||
}
|
||||
|
||||
updateDraft(sessionId: number, draft: GeneratedRecipeDraft): Observable<RecipeGeneratorSession> {
|
||||
return this.http.put<RecipeGeneratorSession>(`/api/recipe-generator/sessions/${sessionId}/draft`, draft);
|
||||
}
|
||||
|
||||
commitRecipes(sessionId: number, draftIds?: string[]): Observable<CommitRecipesResult> {
|
||||
return this.http.post<CommitRecipesResult>(`/api/recipe-generator/sessions/${sessionId}/commit`, {
|
||||
draftIds: draftIds?.length ? draftIds : undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { Observable } from 'rxjs';
|
||||
import {
|
||||
CreateRecipeInput,
|
||||
RecipeDetail,
|
||||
RecipeImportResult,
|
||||
RecipeMacroRecalcResult,
|
||||
} from '../models/recipe.models';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class RecipeManagementService {
|
||||
constructor(private readonly http: HttpClient) {}
|
||||
|
||||
createRecipe(input: CreateRecipeInput): Observable<RecipeDetail> {
|
||||
return this.http.post<RecipeDetail>('/api/recipes', this.toPayload(input));
|
||||
}
|
||||
|
||||
updateRecipe(id: number, input: CreateRecipeInput): Observable<RecipeDetail> {
|
||||
return this.http.put<RecipeDetail>(`/api/recipes/${id}`, this.toPayload(input));
|
||||
}
|
||||
|
||||
downloadImportTemplate(): Observable<Blob> {
|
||||
return this.http.get('/api/recipes/import/template', { responseType: 'blob' });
|
||||
}
|
||||
|
||||
importFromExcel(file: File): Observable<RecipeImportResult> {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
return this.http.post<RecipeImportResult>('/api/recipes/import/excel', formData);
|
||||
}
|
||||
|
||||
recalculateAllMacros(): Observable<RecipeMacroRecalcResult> {
|
||||
return this.http.post<RecipeMacroRecalcResult>('/api/recipes/recalculate-macros', {});
|
||||
}
|
||||
|
||||
private toPayload(input: CreateRecipeInput) {
|
||||
return {
|
||||
name: input.name,
|
||||
mealCategory: input.mealCategory,
|
||||
calories: input.calories,
|
||||
protein: input.protein,
|
||||
fat: input.fat,
|
||||
carbs: input.carbs,
|
||||
prepTimeMinutes: input.prepTimeMinutes,
|
||||
ingredients: input.ingredients.map((i, index) => ({
|
||||
name: i.name,
|
||||
amountGrams: i.amountGrams,
|
||||
unit: i.unit.trim() || null,
|
||||
sortOrder: i.sortOrder || index,
|
||||
catalogItemId: i.catalogItemId ?? null,
|
||||
})),
|
||||
steps: input.steps.map((s) => ({
|
||||
stepNumber: s.stepNumber,
|
||||
description: s.description,
|
||||
})),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { HttpClient, HttpParams } from '@angular/common/http';
|
||||
import { Observable } from 'rxjs';
|
||||
import {
|
||||
CategoryCount,
|
||||
MealCategory,
|
||||
RecipeDetail,
|
||||
RecipeListItem,
|
||||
} from '../models/recipe.models';
|
||||
|
||||
export interface RecipeQuery {
|
||||
category?: MealCategory;
|
||||
search?: string;
|
||||
ingredient?: string;
|
||||
}
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class RecipeService {
|
||||
constructor(private readonly http: HttpClient) {}
|
||||
|
||||
getRecipes(query: RecipeQuery = {}): Observable<RecipeListItem[]> {
|
||||
let params = new HttpParams();
|
||||
if (query.category != null) params = params.set('category', query.category);
|
||||
if (query.search) params = params.set('search', query.search);
|
||||
if (query.ingredient) params = params.set('ingredient', query.ingredient);
|
||||
return this.http.get<RecipeListItem[]>('/api/recipes', { params });
|
||||
}
|
||||
|
||||
getRecipeById(id: number): Observable<RecipeDetail> {
|
||||
return this.http.get<RecipeDetail>(`/api/recipes/${id}`);
|
||||
}
|
||||
|
||||
getCategories(): Observable<CategoryCount[]> {
|
||||
return this.http.get<CategoryCount[]>('/api/recipes/categories');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { Injectable, OnDestroy, inject } from '@angular/core';
|
||||
import { interval, Subscription } from 'rxjs';
|
||||
import { DietService } from './diet.service';
|
||||
import { AuthService } from './auth.service';
|
||||
import { DueReminder } from '../models/diet.models';
|
||||
import { todayIso } from '../models/diet.models';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class ReminderNotificationService implements OnDestroy {
|
||||
private readonly diet = inject(DietService);
|
||||
private readonly auth = inject(AuthService);
|
||||
private sub: Subscription | null = null;
|
||||
private readonly notifiedIds = new Set<number>();
|
||||
|
||||
start(): void {
|
||||
if (this.sub || !this.auth.isAuthenticated()) return;
|
||||
this.requestPermission();
|
||||
this.poll();
|
||||
this.sub = interval(60_000).subscribe(() => this.poll());
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
this.sub?.unsubscribe();
|
||||
this.sub = null;
|
||||
this.notifiedIds.clear();
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.stop();
|
||||
}
|
||||
|
||||
private requestPermission(): void {
|
||||
if (typeof Notification === 'undefined') return;
|
||||
if (Notification.permission === 'default') {
|
||||
void Notification.requestPermission();
|
||||
}
|
||||
}
|
||||
|
||||
private poll(): void {
|
||||
if (!this.auth.isAuthenticated()) return;
|
||||
const now = new Date();
|
||||
const time = `${String(now.getHours()).padStart(2, '0')}:${String(now.getMinutes()).padStart(2, '0')}`;
|
||||
this.diet.getDueReminders(todayIso(), time).subscribe({
|
||||
next: (due) => this.showNotifications(due),
|
||||
error: () => {},
|
||||
});
|
||||
}
|
||||
|
||||
private showNotifications(due: DueReminder[]): void {
|
||||
if (typeof Notification === 'undefined' || Notification.permission !== 'granted') return;
|
||||
for (const r of due) {
|
||||
if (this.notifiedIds.has(r.id)) continue;
|
||||
this.notifiedIds.add(r.id);
|
||||
const body = r.message ?? undefined;
|
||||
new Notification(r.title, { body, tag: `reminder-${r.id}` });
|
||||
}
|
||||
if (this.notifiedIds.size > 100) {
|
||||
this.notifiedIds.clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { Injectable, signal } from '@angular/core';
|
||||
|
||||
export type Theme = 'light' | 'dark';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class ThemeService {
|
||||
readonly theme = signal<Theme>(this.detectInitial());
|
||||
|
||||
constructor() {
|
||||
this.apply(this.theme());
|
||||
}
|
||||
|
||||
toggle(): void {
|
||||
const next = this.theme() === 'dark' ? 'light' : 'dark';
|
||||
this.theme.set(next);
|
||||
this.apply(next);
|
||||
}
|
||||
|
||||
private detectInitial(): Theme {
|
||||
if (typeof window === 'undefined') return 'light';
|
||||
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
||||
}
|
||||
|
||||
private apply(theme: Theme): void {
|
||||
document.documentElement.classList.toggle('dark', theme === 'dark');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
export type ColorThemeId = 'forest' | 'ocean' | 'sunset' | 'berry';
|
||||
export type IconStyleId = 'outline' | 'emoji' | 'bold';
|
||||
export type AppearanceId = ColorThemeId;
|
||||
|
||||
export interface ThemeProposal {
|
||||
id: AppearanceId;
|
||||
labelKey: string;
|
||||
descriptionKey: string;
|
||||
colorTheme: ColorThemeId;
|
||||
iconStyle: IconStyleId;
|
||||
/** Preview swatch for UI */
|
||||
swatch: string;
|
||||
}
|
||||
|
||||
export const THEME_PROPOSALS: ThemeProposal[] = [
|
||||
{
|
||||
id: 'forest',
|
||||
labelKey: 'appearance.forest',
|
||||
descriptionKey: 'appearance.forestDesc',
|
||||
colorTheme: 'forest',
|
||||
iconStyle: 'outline',
|
||||
swatch: '#16774c',
|
||||
},
|
||||
{
|
||||
id: 'ocean',
|
||||
labelKey: 'appearance.ocean',
|
||||
descriptionKey: 'appearance.oceanDesc',
|
||||
colorTheme: 'ocean',
|
||||
iconStyle: 'outline',
|
||||
swatch: '#2563eb',
|
||||
},
|
||||
{
|
||||
id: 'sunset',
|
||||
labelKey: 'appearance.sunset',
|
||||
descriptionKey: 'appearance.sunsetDesc',
|
||||
colorTheme: 'sunset',
|
||||
iconStyle: 'emoji',
|
||||
swatch: '#d97706',
|
||||
},
|
||||
{
|
||||
id: 'berry',
|
||||
labelKey: 'appearance.berry',
|
||||
descriptionKey: 'appearance.berryDesc',
|
||||
colorTheme: 'berry',
|
||||
iconStyle: 'bold',
|
||||
swatch: '#7c3aed',
|
||||
},
|
||||
];
|
||||
|
||||
export const NAV_ICON_EMOJI: Record<string, string> = {
|
||||
dashboard: '📊',
|
||||
coffee: '☕',
|
||||
croissant: '🥐',
|
||||
soup: '🍲',
|
||||
moon: '🌙',
|
||||
list: '📋',
|
||||
book: '✏️',
|
||||
leaf: '🥬',
|
||||
diet: '🥗',
|
||||
calendar: '📅',
|
||||
cart: '🛒',
|
||||
};
|
||||
@@ -0,0 +1,19 @@
|
||||
import { HttpErrorResponse } from '@angular/common/http';
|
||||
|
||||
const GENERIC_API_TITLES = new Set([
|
||||
'An unexpected error occurred.',
|
||||
'Internal Server Error',
|
||||
]);
|
||||
|
||||
export function extractApiError(error: unknown, fallback = 'Something went wrong.'): string {
|
||||
if (error instanceof HttpErrorResponse) {
|
||||
const data = error.error as { error?: string; title?: string } | undefined;
|
||||
if (data?.error) return data.error;
|
||||
if (data?.title && !GENERIC_API_TITLES.has(data.title)) return data.title;
|
||||
return fallback;
|
||||
}
|
||||
if (error instanceof Error) {
|
||||
return error.message;
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import { MacroRemaining, DecimalMacroRemaining, DailySummary } from '../models/diet.models';
|
||||
|
||||
const EMPTY_INT_MACRO: MacroRemaining = {
|
||||
consumed: 0,
|
||||
goal: null,
|
||||
remaining: null,
|
||||
progressPercent: null,
|
||||
};
|
||||
|
||||
const EMPTY_DECIMAL_MACRO: DecimalMacroRemaining = {
|
||||
consumed: 0,
|
||||
goal: null,
|
||||
remaining: null,
|
||||
progressPercent: null,
|
||||
};
|
||||
|
||||
function pick<T>(raw: Record<string, unknown>, camel: string, pascal: string): T | undefined {
|
||||
return (raw[camel] ?? raw[pascal]) as T | undefined;
|
||||
}
|
||||
|
||||
function normalizeIntMacro(raw: unknown): MacroRemaining {
|
||||
if (!raw || typeof raw !== 'object') return { ...EMPTY_INT_MACRO };
|
||||
const obj = raw as Record<string, unknown>;
|
||||
return {
|
||||
goal: (pick<number | null>(obj, 'goal', 'Goal') ?? null) as number | null,
|
||||
consumed: Number(pick<number>(obj, 'consumed', 'Consumed') ?? 0),
|
||||
remaining: (pick<number | null>(obj, 'remaining', 'Remaining') ?? null) as number | null,
|
||||
progressPercent: (pick<number | null>(obj, 'progressPercent', 'ProgressPercent') ?? null) as
|
||||
| number
|
||||
| null,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeDecimalMacro(raw: unknown): DecimalMacroRemaining {
|
||||
if (!raw || typeof raw !== 'object') return { ...EMPTY_DECIMAL_MACRO };
|
||||
const obj = raw as Record<string, unknown>;
|
||||
return {
|
||||
goal: (pick<number | null>(obj, 'goal', 'Goal') ?? null) as number | null,
|
||||
consumed: Number(pick<number>(obj, 'consumed', 'Consumed') ?? 0),
|
||||
remaining: (pick<number | null>(obj, 'remaining', 'Remaining') ?? null) as number | null,
|
||||
progressPercent: (pick<number | null>(obj, 'progressPercent', 'ProgressPercent') ?? null) as
|
||||
| number
|
||||
| null,
|
||||
};
|
||||
}
|
||||
|
||||
/** Normalizes API payloads so templates never crash on missing nested fields. */
|
||||
export function normalizeDailySummary(raw: unknown): DailySummary {
|
||||
if (!raw || typeof raw !== 'object') {
|
||||
return {
|
||||
date: '',
|
||||
goals: null,
|
||||
calories: { ...EMPTY_INT_MACRO },
|
||||
proteinG: { ...EMPTY_DECIMAL_MACRO },
|
||||
fatG: { ...EMPTY_DECIMAL_MACRO },
|
||||
carbsG: { ...EMPTY_DECIMAL_MACRO },
|
||||
waterMl: { ...EMPTY_INT_MACRO },
|
||||
meals: [],
|
||||
drinks: [],
|
||||
};
|
||||
}
|
||||
|
||||
const obj = raw as Record<string, unknown>;
|
||||
const meals = pick<unknown[]>(obj, 'meals', 'Meals');
|
||||
const drinks = pick<unknown[]>(obj, 'drinks', 'Drinks');
|
||||
|
||||
return {
|
||||
date: String(pick<string>(obj, 'date', 'Date') ?? ''),
|
||||
goals: (pick(obj, 'goals', 'Goals') as DailySummary['goals']) ?? null,
|
||||
calories: normalizeIntMacro(pick(obj, 'calories', 'Calories')),
|
||||
proteinG: normalizeDecimalMacro(pick(obj, 'proteinG', 'ProteinG')),
|
||||
fatG: normalizeDecimalMacro(pick(obj, 'fatG', 'FatG')),
|
||||
carbsG: normalizeDecimalMacro(pick(obj, 'carbsG', 'CarbsG')),
|
||||
waterMl: normalizeIntMacro(pick(obj, 'waterMl', 'WaterMl')),
|
||||
meals: Array.isArray(meals) ? (meals as DailySummary['meals']) : [],
|
||||
drinks: Array.isArray(drinks) ? (drinks as DailySummary['drinks']) : [],
|
||||
};
|
||||
}
|
||||
|
||||
export function formatReminderTime(value: string | null | undefined): string {
|
||||
if (!value) return '';
|
||||
return value.length >= 5 ? value.slice(0, 5) : value;
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
const COUNT_AMOUNT_THRESHOLD = 10;
|
||||
|
||||
const NON_QUANTIFIED_UNITS = new Set([
|
||||
'to taste',
|
||||
'for serving',
|
||||
'optional',
|
||||
'do smaku',
|
||||
'do podania',
|
||||
'opcjonalnie',
|
||||
'garnish',
|
||||
'szczypta',
|
||||
]);
|
||||
|
||||
function normalizeUnit(unit: string): string {
|
||||
return unit.trim().toLowerCase().replace(/\.$/, '');
|
||||
}
|
||||
|
||||
export function isNonQuantifiedUnit(unit: string | null | undefined): boolean {
|
||||
if (!unit?.trim()) return false;
|
||||
return NON_QUANTIFIED_UNITS.has(normalizeUnit(unit));
|
||||
}
|
||||
|
||||
function isCountUnit(unit: string | null | undefined): boolean {
|
||||
if (!unit?.trim()) return false;
|
||||
const u = normalizeUnit(unit);
|
||||
return (
|
||||
u === 'szt' ||
|
||||
u === 'sztuka' ||
|
||||
u === 'sztuki' ||
|
||||
u === 'ząbek' ||
|
||||
u === 'zabek' ||
|
||||
u === 'zabki' ||
|
||||
u === 'clove' ||
|
||||
u === 'cloves' ||
|
||||
u === 'łyżka' ||
|
||||
u === 'lyzka' ||
|
||||
u === 'łyżeczka' ||
|
||||
u === 'lyzeczka' ||
|
||||
u === 'jajko' ||
|
||||
u === 'jajka' ||
|
||||
u === 'egg' ||
|
||||
u === 'eggs'
|
||||
);
|
||||
}
|
||||
|
||||
function gramsPerCountUnit(unit: string): number {
|
||||
const u = normalizeUnit(unit);
|
||||
switch (u) {
|
||||
case 'ząbek':
|
||||
case 'zabek':
|
||||
case 'zabki':
|
||||
case 'clove':
|
||||
case 'cloves':
|
||||
return 4;
|
||||
case 'łyżka':
|
||||
case 'lyzka':
|
||||
return 15;
|
||||
case 'łyżeczka':
|
||||
case 'lyzeczka':
|
||||
return 5;
|
||||
default:
|
||||
return 60;
|
||||
}
|
||||
}
|
||||
|
||||
function roundAmount(value: number): number {
|
||||
return Math.round(value * 10) / 10;
|
||||
}
|
||||
|
||||
export function normalizeIngredientQuantity(
|
||||
amountGrams: number | null | undefined,
|
||||
unit: string | null | undefined,
|
||||
): { amountGrams: number | null; unit: string | null } {
|
||||
const unitRaw = unit?.trim() || null;
|
||||
|
||||
if (isNonQuantifiedUnit(unitRaw)) {
|
||||
return { amountGrams: amountGrams ?? null, unit: unitRaw };
|
||||
}
|
||||
|
||||
if (amountGrams == null || amountGrams <= 0) {
|
||||
return { amountGrams: null, unit: unitRaw };
|
||||
}
|
||||
|
||||
if (isCountUnit(unitRaw) && unitRaw) {
|
||||
if (amountGrams <= COUNT_AMOUNT_THRESHOLD) {
|
||||
return {
|
||||
amountGrams: roundAmount(amountGrams * gramsPerCountUnit(unitRaw)),
|
||||
unit: null,
|
||||
};
|
||||
}
|
||||
return { amountGrams: roundAmount(amountGrams), unit: null };
|
||||
}
|
||||
|
||||
return { amountGrams: roundAmount(amountGrams), unit: null };
|
||||
}
|
||||
|
||||
function formatNumber(value: number): string {
|
||||
return Number.isInteger(value) ? value.toString() : value.toFixed(1).replace(/\.0$/, '');
|
||||
}
|
||||
|
||||
export function formatIngredientAmount(amountGrams: number | null, unit: string | null): string {
|
||||
if (isNonQuantifiedUnit(unit)) {
|
||||
return unit?.trim() ?? '';
|
||||
}
|
||||
|
||||
const normalized = normalizeIngredientQuantity(amountGrams, unit);
|
||||
if (normalized.amountGrams == null || normalized.amountGrams <= 0) {
|
||||
return normalized.unit ?? '';
|
||||
}
|
||||
|
||||
return `${formatNumber(normalized.amountGrams)} g`;
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import { MealPlanEntry } from '../models/meal-plan.models';
|
||||
import { MealCategory, RecipeListItem } from '../models/recipe.models';
|
||||
|
||||
export const PLANNER_MEAL_CATEGORIES = [
|
||||
MealCategory.Breakfast,
|
||||
MealCategory.SecondBreakfast,
|
||||
MealCategory.Lunch,
|
||||
MealCategory.Dinner,
|
||||
] as const;
|
||||
|
||||
export interface DayMealPlanning {
|
||||
dailyTarget: number;
|
||||
logged: number;
|
||||
remaining: number;
|
||||
slotTarget: number;
|
||||
openSlots: number;
|
||||
pendingCalories: number;
|
||||
}
|
||||
|
||||
export interface PendingMealSelection {
|
||||
category: MealCategory;
|
||||
calories: number;
|
||||
}
|
||||
|
||||
export function entryCaloriesTotal(entry: MealPlanEntry): number {
|
||||
if (entry.calories == null) return 0;
|
||||
return Math.round(entry.calories * entry.portions);
|
||||
}
|
||||
|
||||
export function computeDayMealPlanning(
|
||||
date: string,
|
||||
category: MealCategory,
|
||||
entries: MealPlanEntry[],
|
||||
dailyTarget: number,
|
||||
pending?: PendingMealSelection | null,
|
||||
): DayMealPlanning {
|
||||
const dayEntries = entries.filter((e) => e.planDate === date);
|
||||
const categoriesWithSaved = new Set(dayEntries.map((e) => e.mealCategory));
|
||||
const existingInSlot = dayEntries.find((e) => e.mealCategory === category);
|
||||
|
||||
let logged = dayEntries.reduce((sum, e) => sum + entryCaloriesTotal(e), 0);
|
||||
|
||||
const pendingCalories =
|
||||
pending && pending.category === category && pending.calories > 0 ? pending.calories : 0;
|
||||
|
||||
if (pendingCalories > 0) {
|
||||
if (existingInSlot) {
|
||||
logged = logged - entryCaloriesTotal(existingInSlot) + pendingCalories;
|
||||
} else {
|
||||
logged += pendingCalories;
|
||||
}
|
||||
}
|
||||
|
||||
const remaining = dailyTarget - logged;
|
||||
|
||||
const emptyCategories = PLANNER_MEAL_CATEGORIES.filter((cat) => !categoriesWithSaved.has(cat));
|
||||
const currentSlotSaved = categoriesWithSaved.has(category);
|
||||
|
||||
let unfilled = [...emptyCategories];
|
||||
if (pendingCalories > 0 && !currentSlotSaved) {
|
||||
unfilled = unfilled.filter((cat) => cat !== category);
|
||||
}
|
||||
|
||||
let openSlots: number;
|
||||
if (currentSlotSaved) {
|
||||
openSlots = Math.max(1, unfilled.length + 1);
|
||||
} else if (pendingCalories > 0) {
|
||||
openSlots = Math.max(1, unfilled.length);
|
||||
} else if (unfilled.includes(category)) {
|
||||
openSlots = Math.max(1, unfilled.length);
|
||||
} else {
|
||||
openSlots = Math.max(1, unfilled.length + 1);
|
||||
}
|
||||
|
||||
const slotTarget = dailyTarget > 0 ? Math.round(Math.max(0, remaining) / openSlots) : 0;
|
||||
|
||||
return { dailyTarget, logged, remaining, slotTarget, openSlots, pendingCalories };
|
||||
}
|
||||
|
||||
export function rankRecipeSuggestions(
|
||||
recipes: RecipeListItem[],
|
||||
category: MealCategory,
|
||||
slotTarget: number,
|
||||
limit = 6,
|
||||
): { recipe: RecipeListItem; diff: number }[] {
|
||||
if (slotTarget <= 0) return [];
|
||||
|
||||
return recipes
|
||||
.filter((r) => r.mealCategory === category && r.calories != null && r.calories > 0)
|
||||
.map((recipe) => ({ recipe, diff: Math.abs(recipe.calories! - slotTarget) }))
|
||||
.sort((a, b) => a.diff - b.diff || a.recipe.name.localeCompare(b.recipe.name))
|
||||
.slice(0, limit);
|
||||
}
|
||||
|
||||
export function estimateEntryCalories(recipe: RecipeListItem | null, portions: number): number | null {
|
||||
if (!recipe?.calories) return null;
|
||||
return Math.round(recipe.calories * portions);
|
||||
}
|
||||
159
meal-plan-frontend-angular/src/app/core/utils/pdf-export.util.ts
Normal file
159
meal-plan-frontend-angular/src/app/core/utils/pdf-export.util.ts
Normal file
@@ -0,0 +1,159 @@
|
||||
import jsPDF from 'jspdf';
|
||||
import html2canvas from 'html2canvas';
|
||||
import { TranslateService } from '@ngx-translate/core';
|
||||
import { RecipeDetail } from '../models/recipe.models';
|
||||
import { isNonQuantifiedUnit, normalizeIngredientQuantity } from './ingredient-quantity.util';
|
||||
|
||||
export interface ShoppingItem {
|
||||
name: string;
|
||||
totalGrams: number | null;
|
||||
totalAmount: number | null;
|
||||
unit: string | null;
|
||||
quantified: boolean;
|
||||
sources: string[];
|
||||
contributions: Array<{ recipe: string; amount: number | null }>;
|
||||
}
|
||||
|
||||
function roundMacro(value: number): number {
|
||||
return Math.round(value * 10) / 10;
|
||||
}
|
||||
|
||||
export function scaleRecipeDetail(recipe: RecipeDetail, portions: number): RecipeDetail {
|
||||
return {
|
||||
...recipe,
|
||||
calories: recipe.calories != null ? Math.round(recipe.calories * portions) : null,
|
||||
protein: recipe.protein != null ? roundMacro(recipe.protein * portions) : null,
|
||||
fat: recipe.fat != null ? roundMacro(recipe.fat * portions) : null,
|
||||
carbs: recipe.carbs != null ? roundMacro(recipe.carbs * portions) : null,
|
||||
ingredients: recipe.ingredients.map((ing) => ({
|
||||
...ing,
|
||||
amountGrams: ing.amountGrams != null ? roundMacro(ing.amountGrams * portions) : null,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
export function buildShoppingList(recipes: RecipeDetail[]): ShoppingItem[] {
|
||||
const buckets = new Map<string, ShoppingItem>();
|
||||
|
||||
for (const recipe of recipes) {
|
||||
for (const ing of recipe.ingredients) {
|
||||
const displayName = ing.name.trim();
|
||||
const normName = displayName.toLowerCase();
|
||||
const normalized = normalizeIngredientQuantity(ing.amountGrams, ing.unit);
|
||||
const unitRaw = normalized.unit;
|
||||
const nonQuantified =
|
||||
isNonQuantifiedUnit(unitRaw) || (unitRaw === null && normalized.amountGrams === null);
|
||||
|
||||
const key = nonQuantified ? `nq:${normName}` : `g:${normName}`;
|
||||
|
||||
let bucket = buckets.get(key);
|
||||
if (!bucket) {
|
||||
bucket = {
|
||||
name: displayName,
|
||||
totalGrams: null,
|
||||
totalAmount: null,
|
||||
unit: nonQuantified ? unitRaw : null,
|
||||
quantified: !nonQuantified,
|
||||
sources: [],
|
||||
contributions: [],
|
||||
};
|
||||
buckets.set(key, bucket);
|
||||
}
|
||||
|
||||
if (!bucket.sources.includes(recipe.name)) {
|
||||
bucket.sources.push(recipe.name);
|
||||
}
|
||||
|
||||
if (nonQuantified) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const amount = normalized.amountGrams;
|
||||
if (amount == null || amount <= 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
bucket.contributions.push({ recipe: recipe.name, amount });
|
||||
bucket.totalGrams = (bucket.totalGrams ?? 0) + amount;
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(buckets.values()).sort((a, b) =>
|
||||
a.name.localeCompare(b.name, undefined, { sensitivity: 'base' }),
|
||||
);
|
||||
}
|
||||
|
||||
function formatNumber(value: number): string {
|
||||
return Number.isInteger(value) ? value.toString() : value.toFixed(2).replace(/\.?0+$/, '');
|
||||
}
|
||||
|
||||
export function formatQuantity(item: ShoppingItem, toTasteLabel: string): string {
|
||||
if (!item.quantified) {
|
||||
return item.unit ? item.unit : toTasteLabel;
|
||||
}
|
||||
return `${formatNumber(item.totalGrams ?? 0)} g`;
|
||||
}
|
||||
|
||||
export function formatBreakdown(item: ShoppingItem): string | null {
|
||||
if (!item.quantified || item.sources.length <= 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const amounts = item.contributions.map((c) => c.amount).filter((a): a is number => a !== null);
|
||||
if (amounts.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const unitLabel = 'g';
|
||||
const allEqual = amounts.every((a) => a === amounts[0]);
|
||||
|
||||
if (allEqual && amounts.length > 1) {
|
||||
return `${amounts.length} × ${formatNumber(amounts[0])} ${unitLabel}`;
|
||||
}
|
||||
|
||||
return amounts.map((a) => `${formatNumber(a)} ${unitLabel}`).join(' + ');
|
||||
}
|
||||
|
||||
export async function generatePdf(
|
||||
translate: TranslateService,
|
||||
fileName = 'meal-plan.pdf',
|
||||
containerId = 'pdf-render-area',
|
||||
): Promise<void> {
|
||||
const container = document.getElementById(containerId);
|
||||
if (!container) {
|
||||
throw new Error(translate.instant('errors.pdfRenderAreaMissing'));
|
||||
}
|
||||
|
||||
const pages = Array.from(container.querySelectorAll<HTMLElement>('.pdf-page'));
|
||||
if (pages.length === 0) {
|
||||
throw new Error(translate.instant('errors.pdfNothingToExport'));
|
||||
}
|
||||
|
||||
const pdf = new jsPDF({ unit: 'pt', format: 'a4', orientation: 'portrait' });
|
||||
const pageWidth = pdf.internal.pageSize.getWidth();
|
||||
const pageHeight = pdf.internal.pageSize.getHeight();
|
||||
|
||||
for (let i = 0; i < pages.length; i += 1) {
|
||||
const canvas = await html2canvas(pages[i], {
|
||||
scale: 2,
|
||||
backgroundColor: '#ffffff',
|
||||
useCORS: true,
|
||||
logging: false,
|
||||
});
|
||||
|
||||
const imgData = canvas.toDataURL('image/jpeg', 0.92);
|
||||
const imgWidth = pageWidth;
|
||||
const imgHeight = (canvas.height * imgWidth) / canvas.width;
|
||||
|
||||
if (i > 0) {
|
||||
pdf.addPage();
|
||||
}
|
||||
|
||||
const finalHeight = Math.min(imgHeight, pageHeight);
|
||||
const finalWidth =
|
||||
finalHeight === imgHeight ? imgWidth : (canvas.width * finalHeight) / canvas.height;
|
||||
pdf.addImage(imgData, 'JPEG', (pageWidth - finalWidth) / 2, 0, finalWidth, finalHeight);
|
||||
}
|
||||
|
||||
pdf.save(fileName);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { TranslateService } from '@ngx-translate/core';
|
||||
|
||||
function polishPluralSuffix(count: number): string {
|
||||
const mod10 = count % 10;
|
||||
const mod100 = count % 100;
|
||||
if (count === 1) return 'one';
|
||||
if (mod10 >= 2 && mod10 <= 4 && (mod100 < 12 || mod100 > 14)) return 'few';
|
||||
return 'many';
|
||||
}
|
||||
|
||||
export function translatePlural(
|
||||
translate: TranslateService,
|
||||
key: string,
|
||||
count: number,
|
||||
): string {
|
||||
const lang = translate.getCurrentLang() ?? translate.getFallbackLang() ?? 'en';
|
||||
let suffix: string;
|
||||
if (lang.startsWith('pl')) {
|
||||
suffix = polishPluralSuffix(count);
|
||||
} else {
|
||||
suffix = count === 1 ? 'one' : 'other';
|
||||
}
|
||||
const fullKey = `${key}_${suffix}`;
|
||||
const translated = translate.instant(fullKey, { count });
|
||||
if (translated === fullKey) {
|
||||
return translate.instant(`${key}_other`, { count });
|
||||
}
|
||||
return translated;
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { IngredientNutritionItem } from '../models/ingredient-catalog.models';
|
||||
|
||||
export interface MacroTotals {
|
||||
calories: number;
|
||||
protein: number;
|
||||
fat: number;
|
||||
carbs: number;
|
||||
}
|
||||
|
||||
export interface MacroIngredientInput {
|
||||
catalogItemId?: number | null;
|
||||
amountGrams?: number | null;
|
||||
unit?: string | null;
|
||||
name?: string;
|
||||
}
|
||||
|
||||
export function hasNonMacroUnit(unit?: string | null): boolean {
|
||||
if (!unit?.trim()) return false;
|
||||
const u = unit.trim().toLowerCase();
|
||||
return u === 'do smaku' || u === 'optional' || u === 'opcjonalnie' || u === 'szczypta';
|
||||
}
|
||||
|
||||
export function shouldIncludeIngredientInMacroSum(ing: MacroIngredientInput): boolean {
|
||||
return Boolean(
|
||||
ing.catalogItemId && ing.amountGrams != null && ing.amountGrams > 0 && !hasNonMacroUnit(ing.unit),
|
||||
);
|
||||
}
|
||||
|
||||
export function isUnlinkedMacroIngredient(ing: MacroIngredientInput): boolean {
|
||||
if (!ing.name?.trim()) return false;
|
||||
if (hasNonMacroUnit(ing.unit)) return false;
|
||||
return Boolean(ing.amountGrams != null && ing.amountGrams > 0 && !ing.catalogItemId);
|
||||
}
|
||||
|
||||
export function countUnlinkedMacroIngredients(ingredients: MacroIngredientInput[]): number {
|
||||
return ingredients.filter(isUnlinkedMacroIngredient).length;
|
||||
}
|
||||
|
||||
export function estimateMacrosFromCatalog(
|
||||
ingredients: MacroIngredientInput[],
|
||||
catalogItems: IngredientNutritionItem[],
|
||||
): MacroTotals | null {
|
||||
let calories = 0;
|
||||
let protein = 0;
|
||||
let fat = 0;
|
||||
let carbs = 0;
|
||||
let hasAny = false;
|
||||
|
||||
for (const ing of ingredients) {
|
||||
if (!shouldIncludeIngredientInMacroSum(ing)) continue;
|
||||
const cat = catalogItems.find((c) => c.id === ing.catalogItemId);
|
||||
if (!cat) continue;
|
||||
const scale = Number(ing.amountGrams) / 100;
|
||||
calories += Math.round(cat.caloriesPer100G * scale);
|
||||
protein += cat.proteinGPer100G * scale;
|
||||
fat += cat.fatGPer100G * scale;
|
||||
carbs += cat.carbsGPer100G * scale;
|
||||
hasAny = true;
|
||||
}
|
||||
|
||||
return hasAny ? { calories, protein, fat, carbs } : null;
|
||||
}
|
||||
53
meal-plan-frontend-angular/src/app/core/utils/sort.util.ts
Normal file
53
meal-plan-frontend-angular/src/app/core/utils/sort.util.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
export type SortDirection = 'asc' | 'desc';
|
||||
|
||||
export function nextSort<T extends string>(
|
||||
current: { column: T; direction: SortDirection } | null,
|
||||
column: T,
|
||||
): { column: T; direction: SortDirection } {
|
||||
if (current?.column === column) {
|
||||
return { column, direction: current.direction === 'asc' ? 'desc' : 'asc' };
|
||||
}
|
||||
return { column, direction: 'asc' };
|
||||
}
|
||||
|
||||
export function sortIndicator(
|
||||
current: { column: string; direction: SortDirection } | null,
|
||||
column: string,
|
||||
): string {
|
||||
if (current?.column !== column) return '';
|
||||
return current.direction === 'asc' ? ' ↑' : ' ↓';
|
||||
}
|
||||
|
||||
export function compareText(a: string, b: string, direction: SortDirection): number {
|
||||
const result = a.localeCompare(b, undefined, { sensitivity: 'base', numeric: true });
|
||||
return direction === 'asc' ? result : -result;
|
||||
}
|
||||
|
||||
export function compareNumber(
|
||||
a: number | null | undefined,
|
||||
b: number | null | undefined,
|
||||
direction: SortDirection,
|
||||
): number {
|
||||
const aMissing = a == null || Number.isNaN(a);
|
||||
const bMissing = b == null || Number.isNaN(b);
|
||||
if (aMissing && bMissing) return 0;
|
||||
if (aMissing) return 1;
|
||||
if (bMissing) return -1;
|
||||
const result = a - b;
|
||||
return direction === 'asc' ? result : -result;
|
||||
}
|
||||
|
||||
export function sortBy<T>(
|
||||
items: readonly T[],
|
||||
direction: SortDirection,
|
||||
getValue: (item: T) => string | number | null | undefined,
|
||||
): T[] {
|
||||
return [...items].sort((left, right) => {
|
||||
const a = getValue(left);
|
||||
const b = getValue(right);
|
||||
if (typeof a === 'number' || typeof b === 'number') {
|
||||
return compareNumber(typeof a === 'number' ? a : null, typeof b === 'number' ? b : null, direction);
|
||||
}
|
||||
return compareText(String(a ?? ''), String(b ?? ''), direction);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user