* Extended functionalities

* Added different UIs
This commit is contained in:
2026-06-24 21:43:40 +02:00
parent 282f4864c4
commit f15bb7a916
348 changed files with 59057 additions and 498 deletions

View 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(['/']);
};

View File

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

View File

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

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

View File

@@ -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: '',
};

View File

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

View File

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

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

View File

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

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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: '🛒',
};

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

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