feat: implement meal planning feature with CRUD operations and UI integration

This commit is contained in:
Nils-Johan Gynther
2026-04-16 19:37:09 +02:00
parent 8b12df4aa4
commit 1b82b02021
13 changed files with 468 additions and 1 deletions
+2
View File
@@ -6,6 +6,7 @@ import { InventoryModule } from './inventory/inventory.module';
import { RecipesModule } from './recipes/recipes.module';
import { QuickImportModule } from './quick-import/quick-import.module';
import { PantryModule } from './pantry/pantry.module';
import { MealPlanModule } from './meal-plan/meal-plan.module';
@Module({
@@ -17,6 +18,7 @@ import { PantryModule } from './pantry/pantry.module';
RecipesModule,
QuickImportModule,
PantryModule,
MealPlanModule,
],
})
export class AppModule {}
@@ -0,0 +1,10 @@
import { IsDateString, IsInt, IsPositive } from 'class-validator';
export class CreateMealPlanEntryDto {
@IsDateString()
date: string; // YYYY-MM-DD
@IsInt()
@IsPositive()
recipeId: number;
}
@@ -0,0 +1,28 @@
import { Body, Controller, Delete, Get, Param, Post, Query } from '@nestjs/common';
import { MealPlanService } from './meal-plan.service';
import { CreateMealPlanEntryDto } from './dto/create-meal-plan-entry.dto';
@Controller('meal-plan')
export class MealPlanController {
constructor(private readonly mealPlanService: MealPlanService) {}
@Get()
findByRange(@Query('from') from: string, @Query('to') to: string) {
return this.mealPlanService.findByRange(from, to);
}
@Get('shopping-list')
shoppingList(@Query('from') from: string, @Query('to') to: string) {
return this.mealPlanService.shoppingList(from, to);
}
@Post()
upsert(@Body() dto: CreateMealPlanEntryDto) {
return this.mealPlanService.upsert(dto);
}
@Delete(':date')
removeByDate(@Param('date') date: string) {
return this.mealPlanService.removeByDate(date);
}
}
+11
View File
@@ -0,0 +1,11 @@
import { Module } from '@nestjs/common';
import { MealPlanController } from './meal-plan.controller';
import { MealPlanService } from './meal-plan.service';
import { PrismaModule } from '../prisma/prisma.module';
@Module({
controllers: [MealPlanController],
providers: [MealPlanService],
imports: [PrismaModule],
})
export class MealPlanModule {}
@@ -0,0 +1,80 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { CreateMealPlanEntryDto } from './dto/create-meal-plan-entry.dto';
const recipeSelect = {
id: true,
name: true,
imageUrl: true,
ingredients: {
select: {
quantity: true,
unit: true,
note: true,
product: { select: { id: true, name: true, canonicalName: true } },
},
},
};
@Injectable()
export class MealPlanService {
constructor(private readonly prisma: PrismaService) {}
/** Hämta matplan för ett datumintervall (default: nuvarande vecka) */
async findByRange(from: string, to: string) {
return this.prisma.mealPlanEntry.findMany({
where: {
date: { gte: new Date(from), lte: new Date(to) },
},
include: { recipe: { select: recipeSelect } },
orderBy: { date: 'asc' },
});
}
/** Sätt recept för ett datum (upsert — ett recept per dag) */
async upsert(dto: CreateMealPlanEntryDto) {
const date = new Date(dto.date);
return this.prisma.mealPlanEntry.upsert({
where: { date },
create: { date, recipeId: dto.recipeId },
update: { recipeId: dto.recipeId },
include: { recipe: { select: recipeSelect } },
});
}
/** Ta bort matplanspost för ett datum */
async removeByDate(date: string) {
const entry = await this.prisma.mealPlanEntry.findUnique({
where: { date: new Date(date) },
});
if (!entry) throw new NotFoundException('Ingen matplanspost för detta datum');
return this.prisma.mealPlanEntry.delete({ where: { id: entry.id } });
}
/** Samlad ingredienslista för ett datumintervall */
async shoppingList(from: string, to: string) {
const entries = await this.findByRange(from, to);
// Summera ingredienser per produkt+enhet
const map = new Map<string, { productId: number; name: string; quantity: number; unit: string }>();
for (const entry of entries) {
for (const ing of entry.recipe.ingredients) {
const key = `${ing.product.id}-${ing.unit}`;
const existing = map.get(key);
const qty = Number(ing.quantity);
if (existing) {
existing.quantity += qty;
} else {
map.set(key, {
productId: ing.product.id,
name: ing.product.canonicalName || ing.product.name,
quantity: qty,
unit: ing.unit,
});
}
}
}
return Array.from(map.values()).sort((a, b) => a.name.localeCompare(b.name, 'sv'));
}
}