Last Used implementiert

This commit is contained in:
2025-09-25 19:52:55 +00:00
parent 0bfb8b2074
commit db431553b9
11 changed files with 270 additions and 7 deletions

View File

@@ -118,6 +118,29 @@ npm run dev
### Recipes ### Recipes
- `GET /api/recipes` - List recipes (pagination, search, filter) - `GET /api/recipes` - List recipes (pagination, search, filter)
- `GET /api/recipes/:id` - Get single recipe - `GET /api/recipes/:id` - Get single recipe
- `lastUsed` wird nur noch gesetzt/aktualisiert wenn ein Rezept aktiv als "zubereitet" markiert wird (siehe Endpoint unten) reines Ansehen ändert den Wert nicht mehr.
### Rezept als zubereitet markieren
POST `/api/recipes/:id/cooked`
Antwort:
```
{ "success": true, "message": "Rezept als zubereitet markiert", "data": { "id": 123, "lastUsed": "2025-09-25T18:47:12.123Z" } }
```
Verwendung: Button "Zubereitet" auf der Detailseite oder manuell per API. Dadurch kann die Liste nach "Zuletzt zubereitet" sortiert werden (`?sortBy=lastUsed&sortOrder=desc`).
### Zuletzt zubereitete Rezepte
`GET /api/recipes/recent?limit=10`
Liefert die zuletzt markierten Rezepte (nur solche mit gesetztem `lastUsed`) absteigend sortiert. `limit` (150) steuert die Anzahl.
Validierungen / Hinweise:
- Zukunftsdaten werden (±1 Minute Toleranz) mit 400 abgelehnt.
- Rezepte ohne `lastUsed` können in der Liste mit einem "Nie" Badge erscheinen.
- Index auf `last_used` verbessert Sortier-Performance (`CREATE INDEX idx_rezepte_last_used ON Rezepte (last_used);`).
- `POST /api/recipes` - Create recipe - `POST /api/recipes` - Create recipe
- `PUT /api/recipes/:id` - Update recipe - `PUT /api/recipes/:id` - Update recipe
- `DELETE /api/recipes/:id` - Delete recipe - `DELETE /api/recipes/:id` - Delete recipe

View File

@@ -1 +1 @@
{"version":3,"file":"recipes.d.ts","sourceRoot":"","sources":["../../src/routes/recipes.ts"],"names":[],"mappings":"AAIA,QAAA,MAAM,MAAM,4CAAW,CAAC;AAoQxB,eAAe,MAAM,CAAC"} {"version":3,"file":"recipes.d.ts","sourceRoot":"","sources":["../../src/routes/recipes.ts"],"names":[],"mappings":"AAIA,QAAA,MAAM,MAAM,4CAAW,CAAC;AA6QxB,eAAe,MAAM,CAAC"}

View File

@@ -109,6 +109,13 @@ router.get('/:id', async (req, res, next) => {
console.log(`Could not load separate ingredients for recipe ${recipe.recipeNumber}:`, ingredientError); console.log(`Could not load separate ingredients for recipe ${recipe.recipeNumber}:`, ingredientError);
} }
} }
const now = new Date();
prisma.recipe.update({
where: { id: recipeId },
data: { lastUsed: now },
select: { id: true }
}).catch(err => console.warn('lastUsed update failed for recipe', recipeId, err.message));
recipe.lastUsed = now;
return res.json({ return res.json({
success: true, success: true,
data: recipe, data: recipe,

File diff suppressed because one or more lines are too long

View File

@@ -22,12 +22,14 @@ model Recipe {
ingredients String? @map("Zutaten") @db.Text ingredients String? @map("Zutaten") @db.Text
instructions String? @map("Zubereitung") @db.Text instructions String? @map("Zubereitung") @db.Text
comment String? @map("Kommentar") @db.Text comment String? @map("Kommentar") @db.Text
lastUsed DateTime? @map("last_used") @db.DateTime(0)
// Relations // Relations
images RecipeImage[] images RecipeImage[]
ingredientsList Ingredient[] ingredientsList Ingredient[]
@@map("Rezepte") @@map("Rezepte")
@@index([lastUsed])
} }
model Ingredient { model Ingredient {

View File

@@ -50,6 +50,10 @@ router.get('/', async (req: Request, res: Response, next: NextFunction) => {
where.category = { contains: category as string }; where.category = { contains: category as string };
} }
// Whitelist allowed sort fields to prevent SQL injection vectors via Prisma
const allowedSortFields = new Set(['title','category','servings','recipeNumber','lastUsed','id']);
const chosenSortField = allowedSortFields.has(String(sortBy)) ? String(sortBy) : 'title';
const [recipes, total] = await Promise.all([ const [recipes, total] = await Promise.all([
prisma.recipe.findMany({ prisma.recipe.findMany({
where, where,
@@ -57,7 +61,8 @@ router.get('/', async (req: Request, res: Response, next: NextFunction) => {
images: true, images: true,
ingredientsList: true, ingredientsList: true,
}, },
orderBy: { [sortBy as string]: sortOrder as 'asc' | 'desc' }, // @ts-ignore lastUsed exists in DB schema; Prisma typing cache issue workaround
orderBy: { [chosenSortField]: sortOrder === 'desc' ? 'desc' : 'asc' },
skip, skip,
take: limitNum, take: limitNum,
}), }),
@@ -79,8 +84,24 @@ router.get('/', async (req: Request, res: Response, next: NextFunction) => {
} }
}); });
// Neueste zubereitete Rezepte (nach lastUsed sortiert) vor :id Route platzieren
router.get('/recent', async (req: Request, res: Response, next: NextFunction) => {
try {
const { limit = '10' } = req.query;
const take = Math.min(Math.max(parseInt(limit as string) || 10, 1), 50);
const recipes = await prisma.recipe.findMany({
// @ts-ignore lastUsed exists
where: { lastUsed: { not: null } },
// @ts-ignore lastUsed exists
orderBy: { lastUsed: 'desc' },
take,
include: { images: true }
});
return res.json({ success: true, data: recipes });
} catch (error) { next(error); }
});
// Get single recipe by ID // Get single recipe by ID
// Get recipe by ID
router.get('/:id', async (req: Request, res: Response, next: NextFunction) => { router.get('/:id', async (req: Request, res: Response, next: NextFunction) => {
try { try {
const { id } = req.params; const { id } = req.params;
@@ -135,6 +156,8 @@ router.get('/:id', async (req: Request, res: Response, next: NextFunction) => {
} }
} }
// lastUsed wird nicht mehr beim Ansehen aktualisiert, sondern explizit über einen "cooked" Endpoint
return res.json({ return res.json({
success: true, success: true,
data: recipe, data: recipe,
@@ -262,4 +285,45 @@ router.delete('/:id', async (req: Request, res: Response, next: NextFunction) =>
} }
}); });
// Endpoint um ein Rezept als "zubereitet" zu markieren -> aktualisiert lastUsed
router.post('/:id/cooked', async (req: Request, res: Response, next: NextFunction) => {
try {
const { id } = req.params;
if (!id) {
return res.status(400).json({ success: false, message: 'Recipe ID is required' });
}
const recipeId = parseInt(id, 10);
if (isNaN(recipeId)) {
return res.status(400).json({ success: false, message: 'Invalid recipe ID format' });
}
let customDate: Date | null = null;
if (req.body && req.body.lastUsed) {
const parsed = new Date(req.body.lastUsed);
if (isNaN(parsed.getTime())) {
return res.status(400).json({ success: false, message: 'Ungültiges Datum (lastUsed)' });
}
customDate = parsed;
}
const targetDate = customDate || new Date();
if (targetDate.getTime() > Date.now() + 60_000) { // 1 Minute Puffer
return res.status(400).json({ success: false, message: 'Datum liegt in der Zukunft' });
}
const updated = await prisma.recipe.update({
where: { id: recipeId },
// @ts-ignore lastUsed exists
data: { lastUsed: targetDate }
});
// @ts-ignore lastUsed field is present in database
return res.json({ success: true, message: 'Rezept als zubereitet markiert', data: { id: updated.id, lastUsed: (updated as any).lastUsed } });
} catch (error) {
if ((error as any).code === 'P2025') {
return res.status(404).json({ success: false, message: 'Recipe not found' });
}
next(error);
}
});
export default router; export default router;

View File

@@ -633,6 +633,42 @@
padding: 40px; padding: 40px;
} }
} }
/* --- Cook Date Modal (appended) --- */
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(0,0,0,0.45);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.modal-dialog {
background: #fff;
padding: 24px 28px;
border-radius: 12px;
width: 100%;
max-width: 420px;
box-shadow: 0 10px 30px -5px rgba(0,0,0,0.35);
animation: fadeIn 0.18s ease-out;
}
.modal-dialog h3 { margin: 0 0 12px; font-size: 1.25rem; }
.modal-field-group { margin-bottom: 16px; }
.modal-field-group label { display:block; font-size: 0.75rem; letter-spacing: .5px; font-weight:600; margin-bottom:4px; color:#555; text-transform: uppercase; }
.modal-field-group input { width: 100%; padding: 8px 10px; border: 1px solid #cbd5e0; border-radius: 6px; font-size: 0.95rem; box-sizing: border-box; }
.modal-field-inline { display: flex; gap: 10px; }
.modal-field-inline .modal-field-group { flex: 1; margin-bottom: 0; }
.modal-actions { display:flex; justify-content:flex-end; gap:10px; margin-top: 18px; }
.modal-actions button { padding:8px 14px; border:none; border-radius:6px; cursor:pointer; font-weight:500; font-size: 0.9rem; }
.modal-actions .primary { background:#2f855a; color:#fff; }
.modal-actions .primary:hover { background:#276749; }
.modal-actions .secondary { background:#e2e8f0; }
.modal-actions .secondary:hover { background:#cbd5e0; }
.modal-actions .danger { background:#dc3545; color:#fff; }
.modal-actions .danger:hover { background:#c82333; }
@media (max-width: 768px) { @media (max-width: 768px) {
.recipe-detail { .recipe-detail {

View File

@@ -36,6 +36,9 @@ const RecipeDetail: React.FC = () => {
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [editingImages, setEditingImages] = useState(false); const [editingImages, setEditingImages] = useState(false);
const [uploadProgress, setUploadProgress] = useState(0); const [uploadProgress, setUploadProgress] = useState(0);
const [markingCooked, setMarkingCooked] = useState(false);
const [showCookModal, setShowCookModal] = useState(false);
const [cookDate, setCookDate] = useState<string>(''); // YYYY-MM-DD
useEffect(() => { useEffect(() => {
const loadRecipe = async () => { const loadRecipe = async () => {
@@ -124,6 +127,43 @@ const RecipeDetail: React.FC = () => {
} }
}; };
const openCookModal = () => {
if (!recipe) return;
const now = new Date();
const isoDate = now.toISOString().slice(0,10); // YYYY-MM-DD
setCookDate(isoDate);
setShowCookModal(true);
};
const submitCooked = async () => {
if (!recipe) return;
if (cookDate) {
const d = new Date(`${cookDate}T00:00:00`);
if (d.getTime() > Date.now() + 60_000) {
setError('Datum liegt in der Zukunft');
return;
}
}
try {
setMarkingCooked(true);
let iso: string | undefined = undefined;
if (cookDate) {
const combined = new Date(`${cookDate}T00:00:00`);
if (!isNaN(combined.getTime())) iso = combined.toISOString();
}
const resp = await recipeApi.markRecipeCooked(recipe.id, iso);
if (resp.success) {
setRecipe({ ...recipe, lastUsed: resp.data.lastUsed || undefined });
setShowCookModal(false);
}
} catch (e) {
console.error('Mark cooked failed', e);
setError('Fehler beim Speichern des Datums');
} finally {
setMarkingCooked(false);
}
};
if (loading) { if (loading) {
return ( return (
<div className="recipe-detail"> <div className="recipe-detail">
@@ -192,6 +232,14 @@ const RecipeDetail: React.FC = () => {
> >
📸 {editingImages ? 'Fertig' : 'Bilder verwalten'} 📸 {editingImages ? 'Fertig' : 'Bilder verwalten'}
</button> </button>
<button
onClick={openCookModal}
className="edit-button"
title="Datum der letzten Zubereitung setzen"
style={{ marginRight: '8px', background: '#2f855a' }}
>
🍳 {recipe.lastUsed ? 'Zubereitet ändern' : 'Zubereitet'}
</button>
<Link to={`/recipes/${recipe.id}/edit`} className="edit-button"> <Link to={`/recipes/${recipe.id}/edit`} className="edit-button">
Bearbeiten Bearbeiten
</Link> </Link>
@@ -200,6 +248,24 @@ const RecipeDetail: React.FC = () => {
</button> </button>
</div> </div>
</div> </div>
{showCookModal && (
<div className="modal-overlay" role="dialog" aria-modal="true">
<div className="modal-dialog">
<h3>Datum der Zubereitung</h3>
<div className="modal-field-group">
<label htmlFor="cook-date">Datum</label>
<input id="cook-date" type="date" value={cookDate} onChange={e => setCookDate(e.target.value)} />
</div>
<div className="modal-actions">
{recipe.lastUsed && (
<button className="danger" disabled={markingCooked} onClick={async () => { try { setMarkingCooked(true); const resp = await recipeApi.markRecipeCooked(recipe.id, undefined); if (resp.success) { setRecipe({ ...recipe, lastUsed: resp.data.lastUsed || undefined }); } } finally { setMarkingCooked(false); setShowCookModal(false);} }}>Jetzt setzen</button>
)}
<button className="secondary" onClick={() => setShowCookModal(false)} disabled={markingCooked}>Abbrechen</button>
<button className="primary" onClick={submitCooked} disabled={markingCooked}>{markingCooked ? 'Speichere...' : 'Speichern'}</button>
</div>
</div>
</div>
)}
{/* Main Content */} {/* Main Content */}
<div className="recipe-content"> <div className="recipe-content">
@@ -249,6 +315,14 @@ const RecipeDetail: React.FC = () => {
<span className="meta-label">Portionen:</span> <span className="meta-label">Portionen:</span>
<span className="meta-value">👥 {recipe.servings}</span> <span className="meta-value">👥 {recipe.servings}</span>
</div> </div>
{recipe.lastUsed && (
<div className="meta-item">
<span className="meta-label">Zuletzt zubereitet:</span>
<span className="meta-value">
{new Date(recipe.lastUsed).toLocaleDateString('de-DE', { year: 'numeric', month: '2-digit', day: '2-digit' })}
</span>
</div>
)}
</div> </div>
</div> </div>

View File

@@ -198,6 +198,29 @@
gap: 0.25rem; gap: 0.25rem;
} }
.recipe-lastused {
background: #2f855a;
color: #fff;
padding: 0.25rem 0.6rem;
border-radius: 16px;
font-size: 0.7rem;
font-weight: 600;
letter-spacing: .5px;
}
.recipe-never {
background: #e53e3e;
color: #fff;
padding: 0.25rem 0.6rem;
border-radius: 16px;
font-size: 0.7rem;
font-weight: 600;
letter-spacing: .5px;
display: inline-flex;
align-items: center;
gap: 0.25rem;
}
.recipe-actions { .recipe-actions {
display: flex; display: flex;
gap: 0.75rem; gap: 0.75rem;

View File

@@ -29,6 +29,8 @@ const RecipeList: React.FC = () => {
const [search, setSearch] = useState(''); const [search, setSearch] = useState('');
const [category, setCategory] = useState(''); const [category, setCategory] = useState('');
const [page, setPage] = useState(1); const [page, setPage] = useState(1);
const [sortBy, setSortBy] = useState<'title' | 'lastUsed'>('title');
const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('asc');
const [pagination, setPagination] = useState({ const [pagination, setPagination] = useState({
page: 1, page: 1,
limit: 10, limit: 10,
@@ -50,8 +52,8 @@ const RecipeList: React.FC = () => {
category: category || undefined, category: category || undefined,
page, page,
limit: 12, limit: 12,
sortBy: 'title', sortBy,
sortOrder: 'asc', sortOrder,
}); });
if (response.success) { if (response.success) {
@@ -71,7 +73,7 @@ const RecipeList: React.FC = () => {
useEffect(() => { useEffect(() => {
loadRecipes(); loadRecipes();
}, [debouncedSearch, category, page]); }, [debouncedSearch, category, page, sortBy, sortOrder]);
const handleSearchSubmit = (e: React.FormEvent) => { const handleSearchSubmit = (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
@@ -140,6 +142,24 @@ const RecipeList: React.FC = () => {
<option value="Fisch">Fisch</option> <option value="Fisch">Fisch</option>
<option value="Dessert">Dessert</option> <option value="Dessert">Dessert</option>
</select> </select>
<select
value={sortBy}
onChange={e => { setSortBy(e.target.value as any); setPage(1); }}
className="category-select"
style={{ marginLeft: '8px' }}
>
<option value="title">Titel</option>
<option value="lastUsed">Zuletzt zubereitet</option>
</select>
<button
onClick={() => { setSortOrder(sortOrder === 'asc' ? 'desc' : 'asc'); setPage(1); }}
className="search-button"
style={{ marginLeft: '4px' }}
title="Sortierreihenfolge umschalten"
>
{sortOrder === 'asc' ? '⬆️' : '⬇️'}
</button>
</div> </div>
</div> </div>
@@ -188,6 +208,13 @@ const RecipeList: React.FC = () => {
<span className="recipe-category">{recipe.category}</span> <span className="recipe-category">{recipe.category}</span>
)} )}
<span className="recipe-servings">👥 {recipe.servings} Portionen</span> <span className="recipe-servings">👥 {recipe.servings} Portionen</span>
{recipe.lastUsed ? (
<span className="recipe-lastused" title={new Date(recipe.lastUsed).toLocaleString('de-DE')}>
🍳 {new Date(recipe.lastUsed).toLocaleDateString('de-DE')}
</span>
) : (
<span className="recipe-never" title="Noch nie zubereitet">🆕 Nie</span>
)}
</div> </div>
<div className="recipe-actions"> <div className="recipe-actions">

View File

@@ -37,6 +37,7 @@ export interface Recipe {
ingredients?: string; ingredients?: string;
instructions?: string; instructions?: string;
comment?: string; comment?: string;
lastUsed?: string; // ISO Datum vom Backend
images?: RecipeImage[]; images?: RecipeImage[];
ingredientsList?: Ingredient[]; ingredientsList?: Ingredient[];
} }
@@ -106,6 +107,12 @@ export const recipeApi = {
const response = await api.delete(`/recipes/${id}`); const response = await api.delete(`/recipes/${id}`);
return response.data; return response.data;
}, },
// Mark recipe as cooked (updates lastUsed)
markRecipeCooked: async (id: number, lastUsed?: string): Promise<ApiResponse<{ id: number; lastUsed: string | null }>> => {
const response = await api.post(`/recipes/${id}/cooked`, lastUsed ? { lastUsed } : {});
return response.data;
},
}; };
// Ingredient API methods // Ingredient API methods