diff --git a/NEXT_STEPS.md b/NEXT_STEPS.md index 2741f4d3..6ea7ee1b 100644 --- a/NEXT_STEPS.md +++ b/NEXT_STEPS.md @@ -28,6 +28,14 @@ Receptlistan (`app/recipes/RecipeGrid.tsx`) är en enkel lista. Förbättra pres ### 7. Matplanering Lägg till en enkel veckomenylista: välj ett recept per dag, se en samlad ingredienslista och jämför mot inventariet. Kräver en ny `MealPlan`-modell i Prisma. +### 8. Portionsjustering av recept +Recept lagras utan portionsangivelse. Lägg till ett `servings`-fält (heltal, t.ex. 4) på `Recipe`-modellen och låt användaren ange önskat antal portioner i receptvyn. Alla ingrediensmängder räknas då om proportionellt (t.ex. recept för 4 → 6 pers: × 1,5). Implementationen berör: +- **Databas:** `servings Int?` på `Recipe` i Prisma + migration +- **Backend:** `servings` exponeras i `RecipeDto` och kan sättas vid create/update +- **Frontend (`app/recipes/[id]/`):** räknare för portioner (+ / −) bredvid ingredienslistan — beräkningen sker rent i klientkomponenten utan extra API-anrop +- **Receptskapande (`app/recipes/create/` och `write/`):** lägg till ett fält för grundportioner +- **Matplaneringen (`app/matplan/`):** inköpslistan bör ta hänsyn till önskat portionsantal per dag + --- ## Teknisk skuld och städning diff --git a/backend/prisma/migrations/20260416100000_add_meal_plan/migration.sql b/backend/prisma/migrations/20260416100000_add_meal_plan/migration.sql new file mode 100644 index 00000000..253123b6 --- /dev/null +++ b/backend/prisma/migrations/20260416100000_add_meal_plan/migration.sql @@ -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; diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 2d7dac8c..b22068e1 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -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]) } \ No newline at end of file diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index c40e1f53..a333f53b 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -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 {} \ No newline at end of file diff --git a/backend/src/meal-plan/dto/create-meal-plan-entry.dto.ts b/backend/src/meal-plan/dto/create-meal-plan-entry.dto.ts new file mode 100644 index 00000000..67fc5a84 --- /dev/null +++ b/backend/src/meal-plan/dto/create-meal-plan-entry.dto.ts @@ -0,0 +1,10 @@ +import { IsDateString, IsInt, IsPositive } from 'class-validator'; + +export class CreateMealPlanEntryDto { + @IsDateString() + date: string; // YYYY-MM-DD + + @IsInt() + @IsPositive() + recipeId: number; +} diff --git a/backend/src/meal-plan/meal-plan.controller.ts b/backend/src/meal-plan/meal-plan.controller.ts new file mode 100644 index 00000000..e78584df --- /dev/null +++ b/backend/src/meal-plan/meal-plan.controller.ts @@ -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); + } +} diff --git a/backend/src/meal-plan/meal-plan.module.ts b/backend/src/meal-plan/meal-plan.module.ts new file mode 100644 index 00000000..a179c91b --- /dev/null +++ b/backend/src/meal-plan/meal-plan.module.ts @@ -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 {} diff --git a/backend/src/meal-plan/meal-plan.service.ts b/backend/src/meal-plan/meal-plan.service.ts new file mode 100644 index 00000000..f8d7362e --- /dev/null +++ b/backend/src/meal-plan/meal-plan.service.ts @@ -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(); + 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')); + } +} diff --git a/frontend/app/Navigation.tsx b/frontend/app/Navigation.tsx index f92e005d..95b3632f 100644 --- a/frontend/app/Navigation.tsx +++ b/frontend/app/Navigation.tsx @@ -104,6 +104,21 @@ export default function Navigation() { > ⚡ Snabbimport recept + + 📅 Matplan + ); } diff --git a/frontend/app/api/meal-plan-proxy/route.ts b/frontend/app/api/meal-plan-proxy/route.ts new file mode 100644 index 00000000..50185cd7 --- /dev/null +++ b/frontend/app/api/meal-plan-proxy/route.ts @@ -0,0 +1,40 @@ +import { NextRequest, NextResponse } from 'next/server'; + +const API_BASE = process.env.NEXT_PUBLIC_API_URL_INTERNAL || 'http://recipe-api:8080'; + +export async function GET(request: NextRequest) { + const { searchParams } = request.nextUrl; + const query = searchParams.toString(); + const res = await fetch(`${API_BASE}/api/meal-plan${query ? `?${query}` : ''}`, { + cache: 'no-store', + }); + const text = await res.text(); + return new NextResponse(text, { + status: res.status, + headers: { 'Content-Type': 'application/json' }, + }); +} + +export async function POST(request: NextRequest) { + const body = await request.text(); + const res = await fetch(`${API_BASE}/api/meal-plan`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body, + cache: 'no-store', + }); + const text = await res.text(); + return new NextResponse(text, { + status: res.status, + headers: { 'Content-Type': 'application/json' }, + }); +} + +export async function DELETE(request: NextRequest) { + const date = request.nextUrl.searchParams.get('date'); + const res = await fetch(`${API_BASE}/api/meal-plan/${date}`, { + method: 'DELETE', + cache: 'no-store', + }); + return new NextResponse(null, { status: res.status }); +} diff --git a/frontend/app/api/meal-plan-proxy/shopping/route.ts b/frontend/app/api/meal-plan-proxy/shopping/route.ts new file mode 100644 index 00000000..b7dcc623 --- /dev/null +++ b/frontend/app/api/meal-plan-proxy/shopping/route.ts @@ -0,0 +1,17 @@ +import { NextRequest, NextResponse } from 'next/server'; + +const API_BASE = process.env.NEXT_PUBLIC_API_URL_INTERNAL || 'http://recipe-api:8080'; + +export async function GET(request: NextRequest) { + const { searchParams } = request.nextUrl; + const from = searchParams.get('from'); + const to = searchParams.get('to'); + const res = await fetch(`${API_BASE}/api/meal-plan/shopping-list?from=${from}&to=${to}`, { + cache: 'no-store', + }); + const text = await res.text(); + return new NextResponse(text, { + status: res.status, + headers: { 'Content-Type': 'application/json' }, + }); +} diff --git a/frontend/app/matplan/MealPlanClient.tsx b/frontend/app/matplan/MealPlanClient.tsx new file mode 100644 index 00000000..d1a88163 --- /dev/null +++ b/frontend/app/matplan/MealPlanClient.tsx @@ -0,0 +1,210 @@ +'use client'; + +import { useState, useEffect, useCallback } from 'react'; +import Link from 'next/link'; +import type { Recipe } from '../../features/inventory/types'; + +const DAYS_SV = ['Måndag', 'Tisdag', 'Onsdag', 'Torsdag', 'Fredag', 'Lördag', 'Söndag']; + +type MealPlanEntry = { + id: number; + date: string; + recipe: Pick & { + ingredients: { quantity: string; unit: string; note: string | null; product: { id: number; name: string; canonicalName: string | null } }[]; + }; +}; + +type ShoppingItem = { productId: number; name: string; quantity: number; unit: string }; + +function getWeekDates(offset = 0): string[] { + const now = new Date(); + const day = now.getDay(); + const monday = new Date(now); + monday.setDate(now.getDate() - (day === 0 ? 6 : day - 1) + offset * 7); + return Array.from({ length: 7 }, (_, i) => { + const d = new Date(monday); + d.setDate(monday.getDate() + i); + return d.toISOString().slice(0, 10); + }); +} + +export default function MealPlanClient({ recipes }: { recipes: Recipe[] }) { + const [weekOffset, setWeekOffset] = useState(0); + const [entries, setEntries] = useState([]); + const [shopping, setShopping] = useState([]); + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(null); // date being saved + + const weekDates = getWeekDates(weekOffset); + const from = weekDates[0]; + const to = weekDates[6]; + + const weekLabel = (() => { + const f = new Date(from); + const t = new Date(to); + return `${f.toLocaleDateString('sv-SE', { day: 'numeric', month: 'short' })} – ${t.toLocaleDateString('sv-SE', { day: 'numeric', month: 'short', year: 'numeric' })}`; + })(); + + const load = useCallback(async () => { + setLoading(true); + try { + const [entriesRes, shoppingRes] = await Promise.all([ + fetch(`/api/meal-plan-proxy?from=${from}&to=${to}`), + fetch(`/api/meal-plan-proxy/shopping?from=${from}&to=${to}`), + ]); + const entriesData = await entriesRes.json(); + setEntries(Array.isArray(entriesData) ? entriesData : []); + if (shoppingRes.ok) setShopping(await shoppingRes.json()); + else setShopping([]); + } catch { + setEntries([]); + setShopping([]); + } finally { + setLoading(false); + } + }, [from, to]); + + useEffect(() => { load(); }, [load]); + + const entryForDate = (date: string) => entries.find((e) => e.date.slice(0, 10) === date); + + const handleSelect = async (date: string, recipeId: string) => { + setSaving(date); + try { + if (!recipeId) { + await fetch(`/api/meal-plan-proxy?date=${date}`, { method: 'DELETE' }); + } else { + await fetch('/api/meal-plan-proxy', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ date, recipeId: Number(recipeId) }), + }); + } + await load(); + } finally { + setSaving(null); + } + }; + + const plannedCount = weekDates.filter((d) => entryForDate(d)).length; + + return ( +
+ {/* Veckonavigering */} +
+ + {weekLabel} + + {weekOffset !== 0 && ( + + )} +
+ + {loading ? ( +

Laddar...

+ ) : ( + <> + {/* Veckovy */} +
+ {weekDates.map((date, i) => { + const entry = entryForDate(date); + const isSaving = saving === date; + const isToday = date === new Date().toISOString().slice(0, 10); + return ( +
+
+
{DAYS_SV[i]}
+
+ {new Date(date).toLocaleDateString('sv-SE', { day: 'numeric', month: 'short' })} +
+
+
+ + {entry && ( + + Visa recept → + + )} + {isSaving && Sparar...} +
+
+ ); + })} +
+ + {/* Samlad ingredienslista */} +
+

+ Inköpslista ({plannedCount} {plannedCount === 1 ? 'recept' : 'recept'} planerade) +

+ {plannedCount === 0 ? ( +

Välj recept ovan för att se en samlad ingredienslista.

+ ) : shopping.length === 0 ? ( +

Laddar ingredienser...

+ ) : ( +
    + {shopping.map((item) => ( +
  • + + {item.quantity % 1 === 0 ? item.quantity : item.quantity.toFixed(1)} {item.unit} + + {item.name} +
  • + ))} +
+ )} +
+ + )} +
+ ); +} + +function btnStyle(bg?: string): React.CSSProperties { + return { + padding: '0.45rem 0.9rem', + background: bg || '#f0f0f0', + color: bg ? '#fff' : '#333', + border: '1px solid ' + (bg || '#ccc'), + borderRadius: '6px', + cursor: 'pointer', + fontSize: '0.9rem', + fontWeight: 500, + }; +} diff --git a/frontend/app/matplan/page.tsx b/frontend/app/matplan/page.tsx new file mode 100644 index 00000000..fc143c72 --- /dev/null +++ b/frontend/app/matplan/page.tsx @@ -0,0 +1,18 @@ +import { fetchJson } from '../../../lib/api'; +import type { Recipe } from '../../../features/inventory/types'; +import Navigation from '../../Navigation'; +import MealPlanClient from './MealPlanClient'; + +export default async function MealPlanPage() { + const recipes = await fetchJson('/api/recipes').catch(() => [] as Recipe[]); + return ( +
+ +

Matplanering

+

+ Välj ett recept per dag — se en samlad ingredienslista i slutet. +

+ +
+ ); +}