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
@@ -0,0 +1,15 @@
-- CreateTable
CREATE TABLE `MealPlanEntry` (
`id` INTEGER NOT NULL AUTO_INCREMENT,
`date` DATE NOT NULL,
`recipeId` INTEGER NOT NULL,
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updatedAt` DATETIME(3) NOT NULL,
UNIQUE INDEX `MealPlanEntry_date_key`(`date`),
INDEX `MealPlanEntry_date_idx`(`date`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- AddForeignKey
ALTER TABLE `MealPlanEntry` ADD CONSTRAINT `MealPlanEntry_recipeId_fkey` FOREIGN KEY (`recipeId`) REFERENCES `Recipe`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
+14 -1
View File
@@ -69,7 +69,8 @@ model Recipe {
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
ingredients RecipeIngredient[]
ingredients RecipeIngredient[]
mealPlanEntries MealPlanEntry[]
}
model RecipeIngredient {
@@ -92,4 +93,16 @@ model PantryItem {
product Product @relation(fields: [productId], references: [id], onDelete: Cascade)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model MealPlanEntry {
id Int @id @default(autoincrement())
date DateTime @db.Date
recipe Recipe @relation(fields: [recipeId], references: [id], onDelete: Cascade)
recipeId Int
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([date])
@@index([date])
}
+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'));
}
}