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
+8
View File
@@ -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?``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
@@ -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'));
}
}
+15
View File
@@ -104,6 +104,21 @@ export default function Navigation() {
>
Snabbimport recept
</Link>
<Link
href="/matplan"
style={{
padding: '0.5rem 0.75rem',
background: '#fff',
border: '1px solid #ddd',
borderRadius: '4px',
textDecoration: 'none',
color: '#0070f3',
fontSize: '0.9rem',
fontWeight: 500,
}}
>
📅 Matplan
</Link>
</nav>
);
}
+40
View File
@@ -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 });
}
@@ -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' },
});
}
+210
View File
@@ -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<Recipe, 'id' | 'name' | 'imageUrl'> & {
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<MealPlanEntry[]>([]);
const [shopping, setShopping] = useState<ShoppingItem[]>([]);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState<string | null>(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 (
<div>
{/* Veckonavigering */}
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem', marginBottom: '1.5rem' }}>
<button onClick={() => setWeekOffset((w) => w - 1)} style={btnStyle()}> Förra veckan</button>
<span style={{ fontWeight: 600 }}>{weekLabel}</span>
<button onClick={() => setWeekOffset((w) => w + 1)} style={btnStyle()}>Nästa vecka </button>
{weekOffset !== 0 && (
<button onClick={() => setWeekOffset(0)} style={btnStyle('#0070f3')}>Denna vecka</button>
)}
</div>
{loading ? (
<p style={{ color: '#888' }}>Laddar...</p>
) : (
<>
{/* Veckovy */}
<div style={{ display: 'grid', gap: '0.75rem', marginBottom: '2rem' }}>
{weekDates.map((date, i) => {
const entry = entryForDate(date);
const isSaving = saving === date;
const isToday = date === new Date().toISOString().slice(0, 10);
return (
<div
key={date}
style={{
display: 'grid',
gridTemplateColumns: '100px 1fr',
gap: '1rem',
alignItems: 'center',
padding: '0.75rem 1rem',
border: `1px solid ${isToday ? '#0070f3' : '#dee2e6'}`,
borderRadius: '8px',
background: isToday ? '#f0f7ff' : '#fff',
}}
>
<div>
<div style={{ fontWeight: 700, fontSize: '0.9rem' }}>{DAYS_SV[i]}</div>
<div style={{ fontSize: '0.78rem', color: '#888' }}>
{new Date(date).toLocaleDateString('sv-SE', { day: 'numeric', month: 'short' })}
</div>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem', flexWrap: 'wrap' }}>
<select
value={entry?.recipe.id ?? ''}
onChange={(e) => handleSelect(date, e.target.value)}
disabled={isSaving}
style={{
flex: '1 1 200px',
padding: '0.5rem 0.75rem',
border: '1px solid #ced4da',
borderRadius: '6px',
fontSize: '0.9rem',
background: '#fff',
color: entry ? '#111' : '#888',
}}
>
<option value=""> Inget planerat </option>
{recipes.map((r) => (
<option key={r.id} value={r.id}>{r.name}</option>
))}
</select>
{entry && (
<Link
href={`/recipes/${entry.recipe.id}`}
style={{ fontSize: '0.8rem', color: '#0070f3', whiteSpace: 'nowrap' }}
>
Visa recept
</Link>
)}
{isSaving && <span style={{ fontSize: '0.8rem', color: '#888' }}>Sparar...</span>}
</div>
</div>
);
})}
</div>
{/* Samlad ingredienslista */}
<section style={{ border: '1px solid #dee2e6', borderRadius: '8px', padding: '1rem' }}>
<h2 style={{ margin: '0 0 0.75rem', fontSize: '1.1rem' }}>
Inköpslista ({plannedCount} {plannedCount === 1 ? 'recept' : 'recept'} planerade)
</h2>
{plannedCount === 0 ? (
<p style={{ color: '#888', margin: 0 }}>Välj recept ovan för att se en samlad ingredienslista.</p>
) : shopping.length === 0 ? (
<p style={{ color: '#888', margin: 0 }}>Laddar ingredienser...</p>
) : (
<ul style={{ listStyle: 'none', padding: 0, margin: 0, display: 'grid', gap: '0.35rem' }}>
{shopping.map((item) => (
<li
key={`${item.productId}-${item.unit}`}
style={{ display: 'flex', gap: '0.5rem', alignItems: 'baseline' }}
>
<span style={{ fontWeight: 600, minWidth: '70px', textAlign: 'right' }}>
{item.quantity % 1 === 0 ? item.quantity : item.quantity.toFixed(1)} {item.unit}
</span>
<span>{item.name}</span>
</li>
))}
</ul>
)}
</section>
</>
)}
</div>
);
}
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,
};
}
+18
View File
@@ -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<Recipe[]>('/api/recipes').catch(() => [] as Recipe[]);
return (
<main style={{ padding: '1rem', maxWidth: '900px', margin: '0 auto' }}>
<Navigation />
<h1 style={{ marginBottom: '0.25rem' }}>Matplanering</h1>
<p style={{ color: '#666', marginBottom: '1.5rem' }}>
Välj ett recept per dag se en samlad ingredienslista i slutet.
</p>
<MealPlanClient recipes={recipes} />
</main>
);
}